왜 투표 앱을 만들까?
여러분이 회사에서 점심 메뉴를 정해야 한다고 상상해 보세요. 슬랙에 "짜장면 vs 짬뽕 vs 탕수육" 투표를 올렸는데, 50명이 동시에 투표합니다.

이 상황에서 기술적으로 중요한 것은 무엇일까요?
- 동시성: 50명이 동시에 투표해도 정확한 결과가 나와야 합니다
- 원자적 카운터: "현재 값을 읽고 → 1 더하고 → 다시 저장" 하는 사이에 다른 투표가 끼어들면 카운트가 틀려집니다
- 실시간 결과: 투표 후 바로 현재 현황을 볼 수 있어야 합니다
이 작은 프로젝트에서 배울 수 있는 핵심 개념들:
| 개념 | 실무 활용 |
|---|---|
| DynamoDB Atomic Counter | 좋아요 수, 조회수, 재고 수량 등 동시 업데이트가 필요한 모든 카운터 |
| REST API 설계 | 프론트엔드와 백엔드 분리, 모바일 앱과 웹 앱에서 동일 API 사용 |
| CORS 설정 | 브라우저에서 다른 도메인의 API를 호출할 때 필수 보안 설정 |
| 서버리스 패턴 | 서버 관리 없이 API를 배포하고 자동 스케일링 |
회사 내부 투표, 이벤트 설문, 실시간 퀴즈 앱 등 다양하게 확장할 수 있는 기본기를 다져 봅시다.
실습을 시작하기 전에 AWS 콘솔에 로그인되어 있는지 확인하세요. 리전은 ap-northeast-2 (서울) 을 사용합니다.
아키텍처 개요

투표 처리 흐름
비용 예측
비용 계산기
1,000 요청당 $3.50
100만 요청당 $0.20 + 실행시간
쓰기 $1.25/백만, 읽기 $0.25/백만
* 실제 비용은 AWS 요금 정책에 따라 달라질 수 있습니다.
Step 1: DynamoDB 테이블 생성
투표 항목과 카운트를 저장할 테이블을 만듭니다. 각 투표 옵션(예: "짜장면", "짬뽕")이 하나의 항목이 됩니다.
- AWS 콘솔 → DynamoDB 검색 → 테이블 → 테이블 생성 클릭
- 테이블 이름:
voting-app-votes - 파티션 키:
optionId(문자열) — 각 투표 옵션의 고유 식별자 - 설정: 온디맨드 용량 모드 선택
- 테이블 생성 클릭
- 테이블이 활성 상태가 되면, 항목 탐색 탭 클릭 → 항목 생성 버튼으로 초기 투표 옵션을 등록합니다:
- 항목 1:
optionId=option-A,label=짜장면,voteCount=0(숫자 타입) - 항목 2:
optionId=option-B,label=짬뽕,voteCount=0 - 항목 3:
optionId=option-C,label=탕수육,voteCount=0
- 항목 1:
aws dynamodb create-table \
--table-name voting-app-votes \
--attribute-definitions AttributeName=optionId,AttributeType=S \
--key-schema AttributeName=optionId,KeyType=HASH \
--billing-mode PAY_PER_REQUEST
# 초기 투표 항목 등록
aws dynamodb put-item --table-name voting-app-votes \
--item '{"optionId":{"S":"option-A"},"label":{"S":"짜장면"},"voteCount":{"N":"0"}}'
aws dynamodb put-item --table-name voting-app-votes \
--item '{"optionId":{"S":"option-B"},"label":{"S":"짬뽕"},"voteCount":{"N":"0"}}'
aws dynamodb put-item --table-name voting-app-votes \
--item '{"optionId":{"S":"option-C"},"label":{"S":"탕수육"},"voteCount":{"N":"0"}}'항목 생성 시 voteCount를 숫자(N) 타입으로 설정해야 합니다.
DynamoDB 콘솔에서 속성을 추가할 때 타입 드롭다운에서 Number를 선택하세요.
문자열(S) 타입으로 넣으면 나중에 ADD 연산이 동작하지 않습니다.
Step 2: Lambda 함수 작성 (투표 등록 / 결과 조회)
투표를 처리하는 Lambda 함수와 결과를 조회하는 Lambda 함수 두 개를 만듭니다.
- 1Lambda 콘솔 → 함수 생성 → 이름: voting-cast-vote, 런타임: Python 3.12
- 2아래 투표 등록 코드를 붙여넣고 Deploy 클릭
- 3voting-get-results 함수 생성 (Python 3.12) → 결과 조회 코드 붙여넣기 → Deploy
- 4두 함수 모두 실행 역할에 AmazonDynamoDBFullAccess 정책 연결
- 5테스트 탭에서 테스트 이벤트를 생성하여 각 함수가 정상 동작하는지 확인: voting-cast-vote 테스트 입력: {"body": "{\"optionId\": \"option-A\"}"} voting-get-results 테스트 입력: {} (빈 JSON)
# voting-cast-vote Lambda (Python 3.12)
import json, boto3
ddb = boto3.resource('dynamodb')
table = ddb.Table('voting-app-votes')
def lambda_handler(event, context):
try:
body = json.loads(event.get('body', '{}'))
option_id = body.get('optionId')
if not option_id:
return response(400, {'error': 'optionId가 필요합니다'})
# Atomic Counter — 동시 요청에서도 정확한 카운트 보장
result = table.update_item(
Key={'optionId': option_id},
UpdateExpression='ADD voteCount :inc',
ExpressionAttributeValues={':inc': 1},
ReturnValues='UPDATED_NEW'
)
new_count = int(result['Attributes']['voteCount'])
return response(200, {
'message': f'{option_id}에 투표 완료!',
'optionId': option_id,
'newCount': new_count
})
except Exception as e:
return response(500, {'error': str(e)})
def response(status_code, body):
return {
'statusCode': status_code,
'headers': {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'POST, OPTIONS',
},
'body': json.dumps(body, ensure_ascii=False)
}# voting-get-results Lambda (Python 3.12)
import json, boto3
from decimal import Decimal
ddb = boto3.resource('dynamodb')
table = ddb.Table('voting-app-votes')
class DecimalEncoder(json.JSONEncoder):
def default(self, obj):
if isinstance(obj, Decimal):
return int(obj)
return super().default(obj)
def lambda_handler(event, context):
try:
result = table.scan()
items = sorted(result['Items'], key=lambda x: x.get('voteCount', 0), reverse=True)
total = sum(item.get('voteCount', 0) for item in items)
return {
'statusCode': 200,
'headers': {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, OPTIONS',
},
'body': json.dumps({
'results': items,
'totalVotes': total
}, cls=DecimalEncoder, ensure_ascii=False)
}
except Exception as e:
return {
'statusCode': 500,
'headers': {'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*'},
'body': json.dumps({'error': str(e)})
}DynamoDB의 Atomic Counter 패턴은 UpdateExpression: "ADD voteCount :inc"와
ExpressionAttributeValues: {":inc": 1}을 사용합니다.
동시 요청에서도 정확한 카운트를 보장하는 핵심 기법입니다.
ReturnValues='UPDATED_NEW'를 사용하면 업데이트 후의 새 값을 즉시 받을 수 있습니다.
Python에서 DynamoDB 숫자 타입 주의: DynamoDB는 숫자를 Decimal 타입으로 반환합니다.
Python의 기본 json.dumps()는 Decimal을 처리하지 못하므로, 위 코드처럼 DecimalEncoder
커스텀 인코더를 사용하거나 int()로 변환해야 합니다.
Step 3: API Gateway REST API 생성
Lambda 함수를 HTTP 엔드포인트로 노출합니다.
- 1API Gateway 콘솔 → REST API 카드에서 빌드 클릭
- 2새 API 선택, API 이름: voting-api → API 생성 클릭
- 3리소스 생성: 리소스 이름 vote, 경로 /vote → 리소스 생성 클릭
- 4/vote 선택 → 메서드 생성 → POST → Lambda 프록시 통합 → voting-cast-vote 선택 → 메서드 생성 클릭
- 5리소스 생성: 리소스 이름 results, 경로 /results → 리소스 생성 클릭
- 6/results 선택 → 메서드 생성 → GET → Lambda 프록시 통합 → voting-get-results 선택 → 메서드 생성 클릭
- 7API 배포 → 새 스테이지 → 스테이지 이름: prod → 배포
- 8배포 후 제공되는 호출 URL을 복사하여 테스트에 사용합니다
Step 4: CORS 설정 및 테스트
브라우저에서 API를 호출하려면 CORS 설정이 필요합니다. 그 후 curl로 전체 동작을 테스트합니다.
- API Gateway 콘솔 →
/vote리소스 선택 → CORS 활성화 클릭 - Access-Control-Allow-Origin:
* - Access-Control-Allow-Methods:
POST, OPTIONS - Access-Control-Allow-Headers:
Content-Type - 저장 클릭
/results리소스에도 동일하게 CORS 설정 (GET, OPTIONS)- API 재배포 (prod 스테이지) — 재배포하지 않으면 변경사항이 적용되지 않습니다!
# 투표 등록 테스트 (3번 실행하여 카운트 확인)
curl -X POST https://{api-id}.execute-api.ap-northeast-2.amazonaws.com/prod/vote \
-H "Content-Type: application/json" \
-d '{"optionId": "option-A"}'
curl -X POST https://{api-id}.execute-api.ap-northeast-2.amazonaws.com/prod/vote \
-H "Content-Type: application/json" \
-d '{"optionId": "option-B"}'
curl -X POST https://{api-id}.execute-api.ap-northeast-2.amazonaws.com/prod/vote \
-H "Content-Type: application/json" \
-d '{"optionId": "option-A"}'
# 결과 조회 테스트
curl https://{api-id}.execute-api.ap-northeast-2.amazonaws.com/prod/results
# 예상 결과: option-A: 2표, option-B: 1표, option-C: 0표Step 5: 완성 후 통합 테스트
전체 시스템이 기대한 대로 동작하는지 체계적으로 검증합니다.
- 1기본 투표 테스트: curl로 각 옵션에 투표 → 응답에서 newCount 값이 올바른지 확인
- 2결과 조회 테스트: GET /results → 각 옵션의 voteCount 합계가 총 투표 수와 일치하는지 확인
- 3동시 투표 테스트: 터미널 여러 개에서 동시에 투표를 보내고 Atomic Counter가 정확한지 확인: # 5개의 동시 투표 (백그라운드 실행) for i in {1..5}; do curl -s -X POST https://YOUR_API_URL/prod/vote \ -H "Content-Type: application/json" \ -d '{"optionId": "option-A"}' & done wait # 결과 조회 — option-A의 카운트가 정확히 5 증가했는지 확인 curl https://YOUR_API_URL/prod/results
- 4잘못된 입력 테스트: optionId 없이 요청 → 400 에러 반환 확인
- 5DynamoDB 직접 확인: 콘솔에서 voting-app-votes 테이블의 항목 탐색 → 각 항목의 voteCount가 API 결과와 일치하는지 확인
트러블슈팅
"Internal Server Error" (500) — Lambda 로그에 "ValidationException":
DynamoDB에서 voteCount 속성이 문자열(S) 타입으로 저장되어 있으면 ADD 연산이 실패합니다.
항목 생성 시 voteCount를 반드시 숫자(N) 타입으로 만들어야 합니다.
이미 문자열로 만들었다면 항목을 삭제하고 숫자 타입으로 다시 생성하세요.
CORS 에러 — "No 'Access-Control-Allow-Origin' header is present":
- API Gateway에서 CORS 활성화 후 반드시 API를 재배포했는지 확인
- Lambda 응답에도
Access-Control-Allow-Origin: *헤더가 포함되어야 합니다 (프록시 통합에서는 Lambda가 헤더를 직접 반환) - OPTIONS 메서드(프리플라이트 요청)도 정상 응답하는지 확인
"Object of type Decimal is not JSON serializable" 에러:
Python의 json.dumps()는 DynamoDB의 Decimal 타입을 직접 처리할 수 없습니다.
위 코드의 DecimalEncoder 클래스를 사용하거나, 각 숫자 필드를 int(item['voteCount'])로 명시적 변환하세요.
API Gateway에서 "Missing Authentication Token" 에러:
이 에러는 존재하지 않는 리소스 경로에 요청한 경우에도 발생합니다.
URL 경로가 /prod/vote (POST) 또는 /prod/results (GET)인지 다시 확인하세요.
스테이지 이름(prod)을 빠뜨리지 않았는지도 확인합니다.
핵심 개념 확인
확장 아이디어
기본 투표 앱이 완성되었으니, 더 발전시켜 보세요:
- 중복 투표 방지: IP 주소 또는 사용자 ID를 DynamoDB에 저장하여 1인 1표 제한. ConditionExpression으로 구현
- 투표 마감 시간: DynamoDB TTL 또는 Lambda에서 현재 시간 체크로 마감 후 투표 거부
- 실시간 결과 갱신: WebSocket API를 추가하여 투표가 들어올 때마다 접속 중인 모든 브라우저에 결과 자동 업데이트
- 투표 항목 동적 추가: POST /poll 엔드포인트로 새 투표 주제와 옵션을 동적으로 생성하는 관리 API
- 투표 결과 시각화: S3 정적 웹사이트 호스팅으로 차트(Chart.js)를 이용한 결과 페이지 배포
학습 정리
핵심 치트시트
리소스 정리
실습 완료 후 반드시 아래 순서대로 리소스를 정리하여 불필요한 과금을 방지하세요.
- API Gateway → voting-api 삭제
- Lambda 함수 삭제 (voting-cast-vote, voting-get-results)
- IAM 역할 삭제 (Lambda 실행 역할)
- DynamoDB → voting-app-votes 테이블 삭제
- CloudWatch 로그 그룹 삭제