커피빵(CoffeeShout) 분산환경 구축

@MJ · 13 min read
Created Date · 2025년 10월 13일 09:10
Updated Date· 2025년 10월 15일 09:10

단일 인스턴스 목표 TPS

커피빵은 게임 기반 실시간 서비스이기 때문에 유저 간의 양방향 통신을 위해 웹소켓 통신을 이용하고 있다. 웹소켓 통신의 경우 서버가 각 클라이언트의 구독 상태를 세션별로 메모리에서 관리하고 있는데, REST API와는 다르게 연결이 지속적으로 유지되면서 이벤트 브로드캐스팅과 메시지 라우팅을 처리해야 한다. 이 과정에서 동시 접속자 수가 증가하면 메시지 처리 대기열이 쌓이게 되고, 결과적으로 응답 지연이 발생하는 메시지 처리 병목 현상이 생기게 된다.

단일 인스턴스로 운영하던 서비스 초반, 하나의 EC2(AWS t4g.small)로 버틸 수 있는 처리량을 측정하기 위해 부하 테스트를 진행했다. 웹소켓 요청 처리(Inbound)와 응답 전송(Outbound) 각각의 스레드 풀 크기와 TPS를 점진적으로 증가시키며 테스트했고, 컨텍스트 스위칭 오버헤드로 인한 레이턴시 증가가 발생하기 직전 시점을 기준으로 아래와 같은 최적값을 도출했다.

Inbound 스레드 풀

  • 코어, 최대 스레드 32개
  • 큐 크기 2048
  • TPS 373 기준 P99 레이턴시 113ms

Outbound 스레드 풀

  • 코어, 최대 스레드 16개
  • 큐 크기 4096
  • TPS 2800 기준 P99 레이턴시 92ms

이런 설정값을 유지했을 때 250명 정도가 동시 접속 가능한데, 더 많은 유저들이 몰리게 되면 어떻게 될까?

분산환경 구축 옵션

예산이 제한된 상황에서는 스케일 업(Scale-up)보다 스케일 아웃(Scale-out)을 우선 고려하게 된다. 그런데 여기서 추가적인 문제가 발생한다. 현재 이 서비스는 게임 세션 데이터를 DB가 아닌 인메모리(in-memory) 방식으로만 관리하고 있다. 즉, 같은 게임 세션에 참여하는 모든 유저는 동일한 인스턴스에 웹소켓 연결을 유지해야 한다.

만약 아무 고려 없이 단순 스케일 아웃을 진행하면, 같은 게임 세션의 유저들이 서로 다른 인스턴스에 분산 연결될 수 있고, 이 경우 각 인스턴스가 서로 다른 게임 상태를 가지게 되어 게임 동기화가 깨지는 문제가 발생한다.

이를 해결하기 위해 고려할 수 있는 방안은 다음과 같다.

  1. MySQL을 활용한 실시간 상태 동기화: 모든 게임 상태 변경을 즉시 MySQL에 저장하고, 각 인스턴스가 DB를 조회하며 게임 진행
  2. Redis를 원격 캐시로 사용: Redis를 공유 세션 저장소로 활용하여 모든 인스턴스가 동일한 게임 상태 참조
  3. 로컬 캐시 + Redis Pub/Sub 동기화: 각 인스턴스가 로컬 메모리에 캐시를 유지하되, Redis Pub/Sub을 통해 상태 변경 이벤트를 브로드캐스팅하여 동기화

각 방안의 장단점을 비교하면 다음과 같다.

1. MySQL을 활용한 실시간 상태 동기화

장점

  • 데이터 영속성 보장. 서버 재시작 시에도 게임 상태 복구 가능
  • 별도 학습 곡선 없이 해결 가능
  • 트랜잭션 지원으로 데이터 정합성 확보

단점

  • 매 액션마다 디스크 I/O 발생으로 레이턴시 급증. 실시간 게임에선 치명적
  • 동시 접속자 증가 시 DB 커넥션 풀 고갈 및 병목 발생
  • DB 부하 분산을 위해 결국 Read Replica나 샤딩 필요 → 복잡도 상승

2. Redis를 원격 캐시로 사용

장점

  • 디스크 I/O 없이 메모리에서 바로 조회하므로 MySQL 대비 빠름
  • 모든 인스턴스가 단일 Redis를 바라보므로 상태 일관성 자동 보장
  • 세션 데이터 TTL 설정으로 자동 만료 처리 가능

단점

  • 모든 요청이 네트워크를 타므로 로컬 메모리 대비 네트워크 레이턴시 존재
  • 매 요청마다 직렬화/역직렬화 오버헤드 발생. 특히 복잡한 객체 구조일수록 성능 저하 심각
  • Redis 장애 시 전체 서비스 마비 → SPOF(Single Point of Failure) 위험
  • Redis Cluster 구성 시 추가 비용 및 운영 복잡도 증가

3. 로컬 캐시 + Redis Pub/Sub 동기화

장점

  • 읽기는 로컬 메모리에서 처리 → 가장 빠른 응답속도. 네트워크 통신 및 직렬화/역직렬화 불필요
  • Redis는 동기화 메시지 전파 용도로만 사용하므로 부하 최소화
  • Redis 일시 장애 시에도 로컬 캐시로 서비스 지속 가능

단점

  • Pub/Sub 메시지 전파 지연으로 일시적 데이터 불일치(Eventual Consistency) 발생 가능
  • 구현 복잡도 높음. 캐시 무효화(invalidation) 로직 정교하게 설계 필요
  • 메시지 유실 시 동기화 깨질 위험 존재

Redis를 원격 캐시로 사용

게임 특성상 유저 간 인터랙션이 빈번하게 발생하는데, 이를 MySQL 같은 RDBMS로 처리하기엔 레이턴시 측면에서 무리가 있었다. 또한 팀 내에서 Redis에 대한 사전 지식이 없어서, 학습 목적도 겸해 Redis 도입을 결정했다.

2번과 3번 옵션 중 무엇을 선택할지 고민하다가, 직접 구현해서 성능을 비교해보기로 하고 2번 옵션부터 적용했다.

동작 방식

모든 게임 세션 데이터를 중앙 집중식 Redis에 저장하고, 각 서버 인스턴스는 로컬 메모리에 데이터를 보관하지 않는 방식이다. 대신 게임 로직 실행 시 매번 Redis에서 데이터를 조회하고 수정한다.

처리 흐름 예시

1. 유저 A가 "카드 1번" 선택 액션 전송
2. 서버 1이 Redis에서 게임 세션 데이터 조회 후 역직렬화 → Java 객체 변환
3. 비즈니스 로직 처리 (카드 효과 적용, 게임 상태 업데이트 등)
4. 변경된 객체를 직렬화 → Redis에 저장
5. 모든 서버 인스턴스가 변경된 Redis 데이터를 조회 및 역직렬화 후 연결된 유저들에게 브로드캐스팅

문제점

이 방식의 가장 큰 문제는 직렬화/역직렬화가 빈번하게 발생한다는 점이다. 게임 로직 실행 중 매번 Redis를 거쳐야 하므로, 다음과 같은 오버헤드가 발생한다:

  • 읽기 작업: Redis 조회 → 역직렬화 → 비즈니스 로직 실행
  • 쓰기 작업: Redis 조회 → 비즈니스 로직 실행 → 직렬화 → Redis 저장

특히 도메인 객체가 복잡할수록(중첩된 객체, 컬렉션 등) 직렬화 비용이 급격히 증가한다. 실제로 게임 세션 객체는 플레이어 리스트, 선택된 메뉴, 미니게임 상태 등 여러 계층의 데이터를 포함하고 있어, 한 번의 직렬화/역직렬화에 수 밀리초가 소요됐다.

모든 구현을 완료하고 로컬 환경에서 약 300개의 테스트를 실행한 결과, 기존 인메모리 방식 대비 2배 이상의 실행 시간이 소요되는 것을 확인했다. 실시간 게임에서 이 정도 성능 저하는 유저 경험에 직접적인 영향을 미치는 치명적인 문제였다.

또한 이 구조는 Redis에 과도한 부하를 가한다는 문제도 있다. 모든 게임 로직이 Redis를 거쳐야 하므로, 동시 접속자가 증가하면 Redis가 애플리케이션 서버보다 먼저 병목이 될 가능성이 높다.

로컬 캐시 + Redis Pub/Sub 동기화

결국 우리는 3번으로 구현 방식을 바꿨다.

동작 방식

각 서버 인스턴스가 게임 세션 데이터를 로컬 메모리(인메모리 캐시)에 보관하고, 상태 변경이 발생하면 Redis Pub/Sub을 통해 다른 인스턴스들에게 변경 이벤트를 브로드캐스팅하는 방식이다. 각 인스턴스는 메시지를 수신하면 자신의 로컬 캐시를 업데이트한다.

처리 흐름 예시

1. 유저 A가 "카드 1번" 선택 액션 전송 (서버 1에 연결)
2. 서버 1이 로컬 메모리에서 게임 세션 데이터 조회 (역직렬화 불필요)
3. 비즈니스 로직 처리 (카드 효과 적용, 게임 상태 업데이트)
4. 서버 1이 변경 이벤트를 Redis Pub/Sub으로 발행 (Publish)
5. 같은 게임 세션을 구독(Subscribe)하고 있는 서버 2, 3이 메시지 수신
6. 각 서버가 자신의 로컬 캐시 업데이트 후 연결된 유저들에게 브로드캐스팅

2번 방식과의 차이점

가장 큰 차이는 읽기 작업이 로컬 메모리에서 처리된다는 점이다.

  • 2번 방식: 매번 네트워크 통신 + 직렬화/역직렬화 필요
  • 3번 방식: 읽기는 로컬 메모리에서 즉시 처리, 쓰기 시에만 Pub/Sub 메시지 발행

2번 방식은 게임 상태를 읽을 때도, 쓸 때도 항상 Redis를 거쳐야 한다. 반면 3번 방식은 데이터가 이미 로컬에 있기 때문에 비즈니스 로직 실행 중 발생하는 모든 읽기 작업이 네트워크 없이 바로 처리된다.

성능 개선 결과

동일한 300개 테스트 실행 결과, 기존 인메모리 단일 인스턴스 방식과 거의 동일한 성능을 확인했다. 이벤트 발행과 읽는 시점에서 직렬화/역직렬화가 필요했지만, 그 크기가 2번의 상황보다 훨씬 적기 때문에 오버헤드가 적었다.

참고자료

Redis 공식 자료