📊 서버리스 투표/설문 앱

60

왜 투표 앱을 만들까?

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

서버리스 투표 앱 아키텍처 — 원자적 카운터 기반 실시간 집계

이 상황에서 기술적으로 중요한 것은 무엇일까요?

  • 동시성: 50명이 동시에 투표해도 정확한 결과가 나와야 합니다
  • 원자적 카운터: "현재 값을 읽고 → 1 더하고 → 다시 저장" 하는 사이에 다른 투표가 끼어들면 카운트가 틀려집니다
  • 실시간 결과: 투표 후 바로 현재 현황을 볼 수 있어야 합니다

이 작은 프로젝트에서 배울 수 있는 핵심 개념들:

개념실무 활용
DynamoDB Atomic Counter좋아요 수, 조회수, 재고 수량 등 동시 업데이트가 필요한 모든 카운터
REST API 설계프론트엔드와 백엔드 분리, 모바일 앱과 웹 앱에서 동일 API 사용
CORS 설정브라우저에서 다른 도메인의 API를 호출할 때 필수 보안 설정
서버리스 패턴서버 관리 없이 API를 배포하고 자동 스케일링

회사 내부 투표, 이벤트 설문, 실시간 퀴즈 앱 등 다양하게 확장할 수 있는 기본기를 다져 봅시다.

실습을 시작하기 전에 AWS 콘솔에 로그인되어 있는지 확인하세요. 리전은 ap-northeast-2 (서울) 을 사용합니다.

아키텍처 개요

DynamoDB 원자적 카운터 — 동시 투표에도 정확한 집계

투표 처리 흐름

다이어그램 로딩 중...
투표 등록 및 결과 조회 흐름

비용 예측

비용 계산기

1시간
0h24h
API Gateway

1,000 요청당 $3.50

$0.0040
Lambda 호출

100만 요청당 $0.20 + 실행시간

$0.0020
DynamoDB

쓰기 $1.25/백만, 읽기 $0.25/백만

$0.0030
예상 총 비용$0.0090

* 실제 비용은 AWS 요금 정책에 따라 달라질 수 있습니다.

Step 1: DynamoDB 테이블 생성

투표 항목과 카운트를 저장할 테이블을 만듭니다. 각 투표 옵션(예: "짜장면", "짬뽕")이 하나의 항목이 됩니다.

  1. AWS 콘솔 → DynamoDB 검색 → 테이블테이블 생성 클릭
  2. 테이블 이름: voting-app-votes
  3. 파티션 키: optionId (문자열) — 각 투표 옵션의 고유 식별자
  4. 설정: 온디맨드 용량 모드 선택
  5. 테이블 생성 클릭
  6. 테이블이 활성 상태가 되면, 항목 탐색 탭 클릭 → 항목 생성 버튼으로 초기 투표 옵션을 등록합니다:
    • 항목 1: optionId = option-A, label = 짜장면, voteCount = 0 (숫자 타입)
    • 항목 2: optionId = option-B, label = 짬뽕, voteCount = 0
    • 항목 3: optionId = option-C, label = 탕수육, voteCount = 0
코드
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 함수 두 개를 만듭니다.

진행률 0/5
  1. 1Lambda 콘솔 → 함수 생성 → 이름: voting-cast-vote, 런타임: Python 3.12
  2. 2아래 투표 등록 코드를 붙여넣고 Deploy 클릭
  3. 3voting-get-results 함수 생성 (Python 3.12) → 결과 조회 코드 붙여넣기 → Deploy
  4. 4두 함수 모두 실행 역할에 AmazonDynamoDBFullAccess 정책 연결
  5. 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 엔드포인트로 노출합니다.

진행률 0/8
  1. 1API Gateway 콘솔 → REST API 카드에서 빌드 클릭
  2. 2새 API 선택, API 이름: voting-api → API 생성 클릭
  3. 3리소스 생성: 리소스 이름 vote, 경로 /vote → 리소스 생성 클릭
  4. 4/vote 선택 → 메서드 생성 → POST → Lambda 프록시 통합 → voting-cast-vote 선택 → 메서드 생성 클릭
  5. 5리소스 생성: 리소스 이름 results, 경로 /results → 리소스 생성 클릭
  6. 6/results 선택 → 메서드 생성 → GET → Lambda 프록시 통합 → voting-get-results 선택 → 메서드 생성 클릭
  7. 7API 배포 → 새 스테이지 → 스테이지 이름: prod → 배포
  8. 8배포 후 제공되는 호출 URL을 복사하여 테스트에 사용합니다

Step 4: CORS 설정 및 테스트

브라우저에서 API를 호출하려면 CORS 설정이 필요합니다. 그 후 curl로 전체 동작을 테스트합니다.

  1. API Gateway 콘솔 → /vote 리소스 선택 → CORS 활성화 클릭
  2. Access-Control-Allow-Origin: *
  3. Access-Control-Allow-Methods: POST, OPTIONS
  4. Access-Control-Allow-Headers: Content-Type
  5. 저장 클릭
  6. /results 리소스에도 동일하게 CORS 설정 (GET, OPTIONS)
  7. 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: 완성 후 통합 테스트

전체 시스템이 기대한 대로 동작하는지 체계적으로 검증합니다.

진행률 0/5
  1. 1기본 투표 테스트: curl로 각 옵션에 투표 → 응답에서 newCount 값이 올바른지 확인
  2. 2결과 조회 테스트: GET /results → 각 옵션의 voteCount 합계가 총 투표 수와 일치하는지 확인
  3. 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. 4잘못된 입력 테스트: optionId 없이 요청 → 400 에러 반환 확인
  5. 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":

  1. API Gateway에서 CORS 활성화 후 반드시 API를 재배포했는지 확인
  2. Lambda 응답에도 Access-Control-Allow-Origin: * 헤더가 포함되어야 합니다 (프록시 통합에서는 Lambda가 헤더를 직접 반환)
  3. 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)을 빠뜨리지 않았는지도 확인합니다.

핵심 개념 확인

확장 아이디어

기본 투표 앱이 완성되었으니, 더 발전시켜 보세요:

  1. 중복 투표 방지: IP 주소 또는 사용자 ID를 DynamoDB에 저장하여 1인 1표 제한. ConditionExpression으로 구현
  2. 투표 마감 시간: DynamoDB TTL 또는 Lambda에서 현재 시간 체크로 마감 후 투표 거부
  3. 실시간 결과 갱신: WebSocket API를 추가하여 투표가 들어올 때마다 접속 중인 모든 브라우저에 결과 자동 업데이트
  4. 투표 항목 동적 추가: POST /poll 엔드포인트로 새 투표 주제와 옵션을 동적으로 생성하는 관리 API
  5. 투표 결과 시각화: S3 정적 웹사이트 호스팅으로 차트(Chart.js)를 이용한 결과 페이지 배포

학습 정리

핵심 치트시트

리소스 정리

실습 완료 후 반드시 아래 순서대로 리소스를 정리하여 불필요한 과금을 방지하세요.

  1. API Gateway → voting-api 삭제
  2. Lambda 함수 삭제 (voting-cast-vote, voting-get-results)
  3. IAM 역할 삭제 (Lambda 실행 역할)
  4. DynamoDB → voting-app-votes 테이블 삭제
  5. CloudWatch 로그 그룹 삭제