GraphQL를 활용한 실시간 다대다 채팅 서비스입니다.
사용자는 채팅 방을 생성하고, 다른 사용자와 동시에 메시지를 주고받으며,
타이핑 상태 알림으로 대화 몰입도를 높일 수 있습니다.
모든 메시지와 이벤트는 Redis에 저장 후 즉시 소비되어, 초저지연 실시간 통신을 제공합니다.
또한, 처리된 메시지와 이벤트는 DynamoDB에 영구 저장되어
필요 시 애플리케이션에서 이전 대화 내역이나 상태를 조회할 수 있습니다.
이를 통해 실시간성과 데이터 영속성을 모두 만족합니다.
chat-es.mp4
![]() |
![]() |
|---|
| 주요 기능 | 내용 |
|---|---|
| WebSocket을 통한 실시간 채팅 | WebSocket을 사용하여 사용자 간에 실시간으로 채팅할 수 있습니다. |
| 다대다 채팅 기능 | 여러 사용자가 동시에 참여하여 채팅을 진행할 수 있는 다대다 채팅 기능을 제공합니다. |
| 채팅 방 생성 및 삭제 | 사용자가 새로운 채팅 방을 생성하고, 필요에 따라 방을 삭제할 수 있습니다. |
| 채팅 방 내 사용자 추가 | 채팅 방 생성 후, 다른 사용자를 해당 방에 추가할 수 있는 기능을 제공합니다. |
| 입력 타이핑 상태 표시 | 사용자가 타이핑 중일 때 다른 사용자에게 그 상태를 실시간으로 표시해줍니다. |
| 채팅방 내용 키워드 검색 | 채팅방 내의 메시지를 키워드를 통해 쉽게 검색할 수 있는 기능을 제공합니다. |
| 채팅 대화 디스크 저장 | 모든 채팅 내용을 디스크에 저장하여 나중에 다시 볼 수 있게 합니다. |
- 백엔드
- Apollo Server: Express 플러그인으로 GraphQL Query / Mutation / Subscription / Resolver 처리
- Kong API Gateway: 클라이언트 요청 라우팅 및 로드밸런싱, WebSocket 업그레이드 지원
- NestJS 서버: 이벤트 처리, 비즈니스 로직 실행
- Business: 클라이언트 요청 처리, 레플리카 간 Redis Adapter를 통해 세션 동기화
- Consumers: Redis Streams를 구독하여 이벤트를 처리하고, DynamoDB와 Elasticsearch로 데이터 동기화
- Redis
- Pub/Sub: 서버 레플리카 간 메시지 동기화
- Streams: 레디스 스트림에 메시지 저장 후 소비
- Storage: 캐싱 및 데이터 저장소 역할
- DynamoDB: Redis Streams에서 전달된 이벤트 데이터를 영구 저장
- Elasticsearch: Redis Streams에서 전달된 이벤트 데이터를 색인하여, 검색어 기반 대화 내역 조회 지원
- 프론트엔드
- Apollo Client: GraphQL Query/Mutation/Subscription 처리, 클라이언트 캐싱, 데이터 페칭
- NGINX: 프론트엔드 애플리케이션 정적 파일 서빙
- Vue: UI 렌더링 및 상태 관리
- Quasar: 웹 UI 구성 및 스타일링
- 데이터 흐름
- 클라이언트(Vue)에서 Apollo Client로 GraphQL Query / Mutation / Subscription 요청 전송
- Kong API Gateway가 WebSocket 업그레이드를 처리하고 요청을 NestJS 서버 레플리카로 전달
- Apollo Server + NestJS에서 요청 처리 후 비즈니스 로직 실행
- Query / Mutation: 비즈니스 로직 실행 후 응답 반환
- Subscription: Redis Pub/Sub를 통해 실시간 이벤트 브로드캐스트
- Redis Storage에서 데이터 조회/저장
- Consumer가 이벤트를 읽어 DynamoDB에 데이터 저장
- Consumer가 이벤트를 읽어 Elasticsearch에 색인
- 클라이언트는 Subscription을 구독하고, 서버에서 발행된 메시지를 실시간 수신
- 화면에 실시간 업데이트 (채팅 메시지, 타이핑 상태, 방 생성)
classDiagram
direction RL
class ApolloClient {
+currentUser // 현재 사용자
+currentRoom // 현재 방
+messages // 수신한 메시지 목록
+subscribeSystem() // 시스템 메시지 구독
+subscribeRoomCreated() // 방 생성 구독
+subscribeMessage() // 채팅 메시지 구독
+subscribeTyping() // 타이핑 상태 구독
+subscribeUserPresence() // 사용자 상태 구독
+queryGetHistory() // 메시지 기록 요청
+mutationCreateRoom() // 방 생성 요청
+mutationJoinRoom() // 방 참가 요청
+mutationLeaveRoom() // 방 퇴장 요청
+mutationSendMessage() // 메시지 전송
+mutationTyping() // 타이핑 상태 전송
}
class ApolloServer {
+schema // Query, Mutation, Subscription 정의
+Query.getChatHistory() : [Message!]!
+Mutation.createRoom() : String!
+Mutation.joinRoom() : Boolean!
+Mutation.leaveRoom() : Boolean!
+Mutation.message() : Boolean!
+Mutation.typing() : Boolean!
+Subscription.message(roomId) : Message!
+Subscription.roomCreated(userId) : Room!
+Subscription.system(input) : Message!
+Subscription.typing(roomId) : Message!
+Subscription.userPresence() : [String!]!
}
ApolloClient --> ApolloServer : HTTP (Query/Mutation)
ApolloClient --|> ApolloServer : WebSocket (Subscription)
GraphQL Voyager는 GraphQL 스키마를 시각적으로 탐색하고 구조를 이해할 수 있도록 돕는 정적/인터랙티브 시각화 도구
타입과 타입 간 참조를 그래프 형태로 표현
| GraphQL Voyager |
classDiagram
direction LR
class Message {
+content : String
+roomId : String!
+userId : String!
}
class Room {
+participants : [String!]!
+roomId : String!
}
class SystemInput {
+roomId : String
+userId : String
}
class Query {
+users() : [String!]!
+history(roomId: String!) : [Message!]!
+search(keyword: String!, userId: String!) : [Message!]!
}
class Mutation {
+createRoom(hostId: String!, participants: [String!]!) : String!
+joinRoom(roomId: String!, userId: String!) : Boolean!
+leaveRoom(roomId: String!, userId: String!) : Boolean!
+message(content: String!, roomId: String!, userId: String!) : Boolean!
+setUser(id: String!) : Boolean!
+typing(roomId: String!, userId: String!) : Boolean!
}
class Subscription {
+message(roomId: String!) : Message!
+roomCreated(userId: String!) : Room!
+system(input: SystemInput!) : Message!
+typing(roomId: String!) : Message!
+userPresence : [String!]!
}
%% 관계
Mutation --> Room : create/join/leave
Mutation --> Message : send
Subscription --> Message : publishes
Subscription --> Room : publishes
Subscription --> SystemInput : uses
Message --> Room : belongs to
Query --> Message : get
sequenceDiagram
participant Web1
participant Web2
participant APIGateway@{ "type" : "queue" }
participant Servers@{ "type" : "collections" }
participant RedisStore@{ "type": "database" }
participant RedisPubSub@{ "type" : "queue" }
participant RedisStreams@{ "type" : "queue" }
participant DynamoDB@{ "type" : "database" }
participant Elasticsearch@{ "type" : "database"}
activate Servers
%% muation %%
rect rgba(155, 198, 142, 0.7)
Web1 ->> APIGateway: mutation setUser(id)
APIGateway ->> Servers: forward
Servers ->> RedisStore: store
end
%% 구독 %%
rect rgb(191, 223, 255, 0.7)
Web1 ->> APIGateway: subscription roomCreated(userId)
APIGateway ->> Servers:
Servers ->> RedisPubSub: subscribe
end
par For each room
rect rgba(155, 198, 142, 0.7)
Web1 ->> APIGateway: mutation createRoom(hostId, participants)
APIGateway ->> Servers:
Servers ->> RedisStore: [roomId, members]
Servers ->> RedisPubSub: publish roomCreated(room)
RedisPubSub ->> RedisPubSub: publish
end
opt Room created
rect rgba(155, 198, 142, 0.7)
Web2 ->> APIGateway: mutation joinRoom(roomId, userId)
APIGateway ->> Servers:
Servers ->> RedisPubSub: publish roomCreated(room)
end
rect rgb(233, 191, 201, 0.7)
Web2 ->> APIGateway: query history(roomId)
APIGateway ->> Servers:
Servers ->> DynamoDB: getMessageHistory()
DynamoDB -->> Servers: MessageHistory
Servers -->> APIGateway:
APIGateway -->> Web2: [messages]
end
rect rgb(191, 223, 255, 0.7)
Web1 ->> APIGateway: subscription message(roomId)
Web2 ->> APIGateway:
APIGateway ->> Servers:
Servers ->> RedisStreams: subscribe
Web1 ->> APIGateway: subscription typing(roomId)
Web1 ->> APIGateway: subscription system(input)
APIGateway ->> Servers:
Servers ->> RedisPubSub: subscribe
end
rect rgba(155, 198, 142, 0.7)
loop Multiple events
Web1 ->> APIGateway: mutation message(roomId, userId, content)
APIGateway ->> Servers:
Servers ->> RedisStreams: publish message(message)
RedisStreams ->> RedisStreams: publish
rect rgba(255,235,200,0.7)
loop Sync Consumer
RedisStreams ->> DynamoDB: store
RedisStreams ->> Elasticsearch: indexing
end
end
Web1 ->> APIGateway: mutation typing(roomId, userId)
APIGateway ->> Servers:
Servers ->> RedisPubSub: publish typing(ping)
RedisPubSub ->> RedisPubSub: publish
end
end
end
and Search
rect rgb(233, 191, 201, 0.7)
Web1 ->> APIGateway: query search(userId, keyword)
APIGateway ->> Servers:
Servers ->> Elasticsearch: searchByKeyword()
Elasticsearch -->> Servers: search result
Servers -->> APIGateway:
APIGateway -->> Web1: [messages]
end
and Sync GraphQL Subscriptions
rect rgba(255,235,200,0.7)
RedisStreams -->> Servers: asyncIterator
RedisPubSub -->> Servers: asyncIterator
Servers -->> APIGateway: push
APIGateway -->> Web1: [roomCreated| message | typing | system]
APIGateway -->> Web2:
end
end
deactivate Servers
열기
Chat-Service
├─ infra
│ └─ docker-compose.yml
├─ project
│ ├─ client
│ │ ├─ Dockerfile
│ │ ├─ nginx.conf
│ │ └─ codegen.yml
│ ├─ server
│ │ ├─ Dockerfile
│ │ └─ graphql
│ │ └─ schema.gql
│ └─ consumers
│ ├─ stream-dynamo-consumer
│ │ └─ Dockerfile
│ └─ docker-compose.yml
├─ .env
├─ .prettierrc
├─ docker-compose.yml
└─ README.md
| 프로젝트 | 저장소 | 설명 | 브랜치/버전 |
|---|---|---|---|
| Backend | /Chat-Service--Backend/tree/graphql | GraphQL + Redis 기반 실시간 채팅 서버 | graphql / v2.3.0 |
| Frontend | /Chat-Service--Frontend/tree/graphql | Vue + Vite 클라이언트 | graphql / v2.2.0 |
| Consumers | /Chat-GraphQL/tree/consumers | Redis-Streams에서 DynamoDB/Elasticsearch로 동기화 | main / v2.0.0 |
| Infra | /Chat-GraphQL/tree/infra | 인프라 정의 | main / v1.3.0 |
$ git clone https://github.com/NarciSource/Chat-GraphQL.git
$ cd Chat-GraphQL
$ docker-compose up -d| 환경 | URL |
|---|---|
| web | http://localhost:80 |
| server healthcheck | http://localhost:3000 |
| graphql schema | http://localhost:3000/voyager |
| graphql playground | http://localhost:3000/graphql |
| api gateway 대시보드 | http://localhost:3002 |
| elasticsearch 대시보드 | http://localhost:5601 |

