왜 실시간 채팅 앱을 만들까?
카카오톡이나 슬랙 같은 채팅 앱을 사용할 때, 메시지를 보내면 상대방에게 즉시 도착합니다. 새로고침을 누르지 않아도, 별도 조작 없이, 화면에 바로 메시지가 나타나죠.

이것이 가능한 이유는 WebSocket 프로토콜 덕분입니다.
일반적인 HTTP는 "요청-응답" 구조입니다. 클라이언트가 물어봐야 서버가 대답합니다. 새 메시지가 있는지 확인하려면 1초마다 "새 메시지 있나요?"라고 계속 물어봐야 합니다 (폴링). 사용자가 1,000명이면 1초에 1,000번의 불필요한 요청이 발생합니다.
WebSocket은 다릅니다. 한 번 연결하면 서버와 클라이언트가 양방향으로 자유롭게 데이터를 주고받습니다. 서버가 새 메시지를 받으면, 연결된 모든 클라이언트에게 즉시 푸시합니다.
이 실습에서 구축할 채팅 앱의 동작 방식:
| 이벤트 | 처리 |
|---|---|
| 사용자가 접속 | $connect → connectionId를 DynamoDB에 저장 |
| 메시지 전송 | sendMessage → 모든 연결에 메시지 브로드캐스트 |
| 사용자가 떠남 | $disconnect → connectionId를 DynamoDB에서 삭제 |
서버 없이, API Gateway WebSocket API + Lambda + DynamoDB로 완전한 실시간 채팅을 만들어 보겠습니다.
실습을 시작하기 전에 AWS 콘솔에 로그인되어 있는지 확인하세요. 리전은 ap-northeast-2 (서울) 을 사용합니다.
아키텍처 개요

WebSocket 통신 흐름
비용 예측
비용 계산기
1,000 요청당 $3.50
100만 요청당 $0.20 + 실행시간
쓰기 $1.25/백만, 읽기 $0.25/백만
* 실제 비용은 AWS 요금 정책에 따라 달라질 수 있습니다.
Step 1: DynamoDB 연결 테이블 생성
WebSocket 연결 정보를 저장할 테이블을 만듭니다. 사용자가 접속하면 connectionId를 저장하고, 떠나면 삭제합니다.
- AWS 콘솔 → DynamoDB → 테이블 생성 클릭
- 테이블 이름:
chat-connections - 파티션 키:
connectionId(문자열) — API Gateway가 각 WebSocket 연결에 부여하는 고유 ID - 설정: 온디맨드 용량 모드 선택
- 테이블 생성 클릭
- 상태가 활성이 될 때까지 대기합니다 (약 10-20초)
aws dynamodb create-table \
--table-name chat-connections \
--attribute-definitions AttributeName=connectionId,AttributeType=S \
--key-schema AttributeName=connectionId,KeyType=HASH \
--billing-mode PAY_PER_REQUEST채팅 앱에서는 메시지를 보낼 때 모든 활성 연결에 브로드캐스트해야 하므로, DynamoDB의 Scan 연산으로 전체 connectionId를 조회합니다. 사용자 수가 수천 명 이상이 되면 Scan 대신 GSI(글로벌 보조 인덱스)나 채팅방별 파티셔닝을 고려해야 합니다.
Step 2: Lambda 핸들러 작성
WebSocket의 세 가지 이벤트($connect, $disconnect, sendMessage)를 처리할 Lambda 함수 3개를 만듭니다.
- 1Lambda 콘솔 → 함수 생성 → 이름: chat-connect, 런타임: Node.js 20.x
- 2chat-connect 코드: connectionId를 DynamoDB에 저장하고 statusCode 200 반환 import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; import { PutCommand, DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb'; const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({})); export const handler = async (event) => { await ddb.send(new PutCommand({ TableName: 'chat-connections', Item: { connectionId: event.requestContext.connectionId, connectedAt: new Date
- 3chat-disconnect 함수 생성: connectionId를 DynamoDB에서 삭제 import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; import { DeleteCommand, DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb'; const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({})); export const handler = async (event) => { await ddb.send(new DeleteCommand({ TableName: 'chat-connections', Key: { connectionId: event.requestContext.connectionId } })); return { statusCode: 200 };
- 4chat-sendmessage 함수 생성: 아래 전체 코드를 붙여넣습니다
- 5세 함수 모두 실행 역할에 DynamoDB 읽기/쓰기 권한 부여 (AmazonDynamoDBFullAccess)
- 6chat-sendmessage 역할에 추가로 API Gateway 관리 연결 권한 부여 (AmazonAPIGatewayInvokeFullAccess)
아래는 핵심인 chat-sendmessage Lambda의 전체 코드입니다. 모든 연결에 메시지를 브로드캐스트하며, 끊어진 연결은 자동으로 정리합니다.
// sendMessage Lambda — 메시지 브로드캐스트 함수 (Node.js 20.x 런타임)
import { DynamoDBClient } from '@aws-sdk/client-dynamodb';
import {
ScanCommand,
DeleteCommand,
DynamoDBDocumentClient,
} from '@aws-sdk/lib-dynamodb';
import {
ApiGatewayManagementApiClient,
PostToConnectionCommand,
} from '@aws-sdk/client-apigatewaymanagementapi';
const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({}));
const TABLE = process.env.CONNECTIONS_TABLE || 'chat-connections';
export const handler = async (event) => {
const { connectionId, domainName, stage } = event.requestContext;
const endpoint = `https://${domainName}/${stage}`;
const apigw = new ApiGatewayManagementApiClient({ endpoint });
const { message } = JSON.parse(event.body);
const payload = JSON.stringify({
action: 'message',
sender: connectionId.slice(-6), // 발신자 식별용 짧은 ID
message,
timestamp: new Date().toISOString(),
});
// 모든 활성 연결 조회
const { Items } = await ddb.send(new ScanCommand({ TableName: TABLE }));
// 각 연결에 메시지 브로드캐스트
const broadcasts = Items.map(async ({ connectionId: id }) => {
try {
await apigw.send(
new PostToConnectionCommand({
ConnectionId: id,
Data: payload,
})
);
} catch (err) {
if (err.statusCode === 410) {
// 410 Gone — 이미 끊어진 연결은 DynamoDB에서 정리
await ddb.send(
new DeleteCommand({
TableName: TABLE,
Key: { connectionId: id },
})
);
}
}
});
await Promise.all(broadcasts);
return { statusCode: 200 };
};postToConnection 호출 시 410 상태 코드가 반환되면 해당 연결이 이미 끊어졌다는
의미입니다. 이런 "stale connection"을 DynamoDB에서 삭제하여 불필요한 전송 시도를
방지하세요. 이 패턴은 프로덕션 채팅 서비스에서도 동일하게 사용됩니다.
Step 3: WebSocket API 생성 및 라우트 설정
WebSocket API를 만들고 각 이벤트를 Lambda 함수에 연결합니다.
- 1API Gateway 콘솔 → WebSocket API 카드에서 빌드 클릭
- 2API 이름: chat-websocket-api
- 3라우트 선택 표현식: $request.body.action (클라이언트가 보내는 JSON의 action 필드로 라우팅)
- 4라우트 설정 화면: $connect 라우트가 자동 생성됨 → 통합 대상: chat-connect Lambda 선택 $disconnect 라우트 → 통합 대상: chat-disconnect Lambda 선택 라우트 추가 클릭 → 라우트 키: sendMessage → 통합 대상: chat-sendmessage Lambda 선택
- 5스테이지 이름: production → 배포 클릭
- 6배포 완료 후 화면에 표시되는 WebSocket URL 복사 (형식: wss://{api-id}.execute-api.ap-northeast-2.amazonaws.com/production)
라우트 선택 표현식 $request.body.action은 클라이언트가 보내는 JSON 메시지에서
action 필드 값을 읽어 해당 라우트로 라우팅합니다.
예: {"action": "sendMessage", "message": "안녕!"}은 sendMessage 라우트로 전달됩니다.
Step 4: wscat으로 테스트
두 개의 터미널을 열어 실시간 채팅을 테스트합니다.
# wscat 설치 (Node.js 필요)
npm install -g wscat
# WebSocket 연결 (URL을 실제 값으로 교체)
wscat -c wss://{api-id}.execute-api.ap-northeast-2.amazonaws.com/production
# 연결 성공 메시지가 나타나면 아래 명령으로 메시지 전송:
> {"action": "sendMessage", "message": "안녕하세요! 저는 사용자 A입니다."}
# 사용자 B가 보낸 메시지가 여기에 수신됩니다
< {"action":"message","sender":"abc123","message":"반갑습니다!","timestamp":"..."}# 두 번째 터미널에서 동일한 WebSocket에 연결
wscat -c wss://{api-id}.execute-api.ap-northeast-2.amazonaws.com/production
# 사용자 A가 보낸 메시지가 여기에 수신됩니다
< {"action":"message","sender":"def456","message":"안녕하세요! 저는 사용자 A입니다.","timestamp":"..."}
# 사용자 B도 메시지를 보내봅니다
> {"action": "sendMessage", "message": "반갑습니다!"}Step 5: 완성 후 통합 테스트
전체 시스템의 동작을 체계적으로 검증합니다.
- 1연결 테스트: wscat으로 연결 → DynamoDB 콘솔에서 chat-connections 테이블에 connectionId가 저장되었는지 확인
- 2브로드캐스트 테스트: 터미널 2~3개를 동시에 연결 → 한 곳에서 메시지 전송 → 모든 터미널에서 수신되는지 확인
- 3연결 해제 테스트: wscat에서 Ctrl+C로 종료 → DynamoDB에서 해당 connectionId가 삭제되었는지 확인
- 4Stale 연결 정리 테스트: 네트워크를 강제 종료(랜 케이블 뽑기 시뮬레이션)하여 $disconnect가 호출되지 않는 상황 만들기 → 다른 사용자가 메시지 보내면 410 처리로 자동 정리되는지 확인
- 5동시 접속 테스트: 3개 이상의 터미널에서 동시에 메시지를 주고받으며 순서와 안정성 확인
- 6CloudWatch Logs 확인: 각 Lambda 함수의 로그에서 정상 동작 여부 확인
트러블슈팅
wscat 연결 시 "Unexpected server response: 500" 에러:
chat-connect Lambda 함수에 오류가 있을 가능성이 높습니다. CloudWatch Logs에서 해당 함수의 에러를 확인하세요.
가장 흔한 원인은 DynamoDB 권한 부족 또는 테이블 이름 오타입니다.
메시지를 보냈는데 다른 터미널에 수신되지 않으면:
chat-sendmessageLambda의 CloudWatch 로그를 확인합니다ApiGatewayManagementApiClient의 endpoint가 올바른지 확인 —https://{domainName}/{stage}형식이어야 합니다- Lambda 실행 역할에
execute-api:ManageConnections권한이 있는지 확인합니다
"ForbiddenException" 에러 — postToConnection 호출 시:
Lambda 실행 역할에 API Gateway 연결 관리 권한이 필요합니다.
IAM 정책에 다음 Action을 추가하세요: execute-api:ManageConnections, 리소스: arn:aws:execute-api:ap-northeast-2:ACCOUNT_ID:{api-id}/*
wscat이 설치되지 않으면:
Node.js 18+ 이 설치되어 있는지 확인하세요. npm install -g wscat 실패 시 npx wscat -c wss://... 으로 직접 실행할 수도 있습니다.
핵심 개념 확인
확장 아이디어
기본 채팅 앱이 완성되었으니, 더 발전시켜 보세요:
- 채팅방 기능: DynamoDB에 roomId 필드를 추가하여 방별로 메시지를 분리. GSI로 roomId별 연결 조회
- 메시지 저장: DynamoDB Messages 테이블을 추가하여 채팅 이력 저장 및 조회 기능
- 타이핑 표시:
typing액션을 추가하여 "상대방이 입력 중입니다..." 표시 - Cognito 인증: $connect 라우트에 Cognito 인증을 연동하여 사용자 이름으로 메시지 표시
- 파일 공유: S3 Presigned URL을 생성하여 이미지/파일 공유 기능 구현
학습 정리
핵심 치트시트
리소스 정리
실습 완료 후 반드시 아래 순서대로 리소스를 정리하여 불필요한 과금을 방지하세요.
- API Gateway → WebSocket API 삭제
- Lambda 함수 삭제 (chat-connect, chat-disconnect, chat-sendmessage)
- IAM 역할 삭제 (Lambda 실행 역할)
- DynamoDB → chat-connections 테이블 삭제
- CloudWatch 로그 그룹 삭제