기술적 해결 기록
레거시 서버 전면 재설계
합류 당시 서버는 Node.js 단일 코드베이스에 API 로직과 게임 소켓 로직이 섞여 있었고, 도메인 구조도 없이 기능이 계속 쌓여온 상태였습니다. 새 기능을 추가할 때마다 기존 로직에 영향이 가고, 장애가 나도 원인을 추적할 방법이 없었습니다. 이 구조 위에 계속 쌓아가는 것은 불가능하다고 판단하고 전면 재설계를 결정했습니다. API 서버는 10개 이상의 도메인을 체계적으로 설계하고 장기적으로 유지보수해야 했기 때문에, 타입 시스템이 탄탄하고 JPA, Security, Batch 등 필요한 생태계가 갖춰진 Kotlin + Spring Boot 3를 선택했습니다. 소켓 서버는 기존 Node.js의 가장 큰 문제였던 타입 부재를 해결하면서도 Socket.IO 생태계의 이점을 유지하기 위해 TypeScript로 전면 재작성했습니다. 이벤트 루프 기반의 비동기 I/O가 실시간 양방향 통신에 적합하다고 판단했습니다. 결과적으로 40만 MAU 서비스를 멈추지 않고 완전 전환에 성공했으며, 하나의 서비스를 처음부터 다시 설계하고 이관하는 전체 과정을 경험하면서 기술 선택의 근거를 세우고, 운영 중인 서비스를 중단 없이 전환하는 전략을 세우는 능력을 쌓을 수 있었습니다.
무중단 마이그레이션 전략
40만 MAU 서비스를 멈추지 않고 전환해야 했습니다. 새로운 서버를 만드는 것과 그것을 운영 중인 서비스에 적용하는 것은 완전히 다른 문제였습니다. API 서버는 stateless한 요청-응답 구조이기 때문에, 구서버와 신서버의 Endpoint를 동일하게 맞추고 포트만 다르게 띄운 뒤 Nginx에서 라우팅을 전환하는 방식으로 처리했습니다. 유저는 전환이 일어났다는 것을 인지하지 못했습니다. 소켓 서버는 연결이 유지되는 특성상 즉시 교체가 불가능했습니다. 구소켓 서버와 신소켓 서버를 동시에 띄우고, 앱 버전에 따라 접속 대상 서버를 분기하는 방식으로 설계했습니다. 구버전 앱 사용자는 기존 소켓 서버에, 신버전 앱 사용자는 새 소켓 서버에 접속하도록 하여 두 서버를 병행 운영했고, 강제 업데이트를 통해 모든 사용자가 신버전으로 이동한 시점에 구서버를 자연스럽게 종료하며 무중단 전환을 완료했습니다. 이 과정에서 "잘 만드는 것"만큼 "안전하게 옮기는 것"이 중요하다는 것을 배웠고, 전환 전략을 설계하고 실행에 옮기며, 기술적 판단뿐 아니라 전환 시점과 순서를 결정하는 것도 엔지니어의 역할이라는 것을 배웠습니다.
인프라 / Observability / 배포
합류 당시에는 모니터링, 로깅 시스템, 배포 파이프라인이 전무했습니다. 배포는 물리 서버에 SSH로 접속해서 명령어를 치는 방식이었고, 장애가 나도 원인을 추적할 방법이 없었습니다. 대표님께 더 나은 운영을 약속드리며 데스크탑을 구매해 사내에 우분투 서버를 설치하고, 오픈소스 기반으로 인프라를 구축했습니다. 유료 APM 대신 Pinpoint를 선택한 이유는 비용이었습니다. 스타트업에서 월 수십만원의 APM 구독료를 정당화하기 어려웠고, Pinpoint는 오픈소스이면서도 트랜잭션 추적, 쿼리 병목 분석, 서버 메트릭 수집이 모두 가능했습니다. Apache Pinot, Kafka, Telegraf 등을 추가 설치하여 URL Statistic, Infrastructure, Error Analysis 기능을 활성화했고, 단순 트랜잭션 추적뿐 아니라 API별 응답 시간 추이, 서버 인프라 메트릭, 에러 발생 패턴까지 관측 가능하게 되어 운영 가시성을 확보했습니다. ELK 스택으로는 API 서버와 소켓 서버의 로그를 한곳에 모아서, 한 유저의 전체 흐름을 시간 순서대로 추적할 수 있게 했습니다. Docker로 API, 소켓, 이미지, 링크 등 서버를 컨테이너화하고, Jenkins 기반 CI/CD와 Nginx 무중단 배포를 구축했습니다. 소켓 서버는 게이트웨이 방식으로 서버를 순차적으로 빼고 배포하는 구조를 만들어, 배포 중에도 유저의 끊김 없이 운영할 수 있게 됐습니다. 모니터링 없이 감으로 운영하던 서비스가, 수치와 로그 기반으로 판단하고 대응할 수 있는 서비스로 바뀌었습니다.
도메인 설계 및 레이어드 아키텍처
기존 Node.js 코드베이스에서는 도메인 간 경계 없이 모든 로직이 뒤섞여 있어, 결제 로직을 수정하면 유저 쪽이 깨지고, 게임 로직을 추가하면 점수 계산이 틀어지는 일이 반복됐습니다. 이 문제를 해결하기 위해 각 도메인을 Presentation, Application, Domain, Infrastructure 4개 레이어로 분리했습니다. Layered Architecture의 명확한 상하 의존 방향과 Clean Architecture의 도메인 독립성 원칙을 차용해서, Domain 레이어를 중심으로 Presentation → Application → Domain ← Infrastructure 방향으로 모듈을 구분하여 의존 관계를 명확히 하고, 프레임워크에 의존하지 않는 도메인 레이어를 중심에 두는 구조를 만들었습니다. 도메인 레이어는 Spring 어노테이션이나 JPA 의존 없이 비즈니스 로직만 집약된 순수 Kotlin 클래스로 구성했습니다. 이렇게 한 이유는 프레임워크와 분리된 도메인 로직은 DB도 Spring 컨텍스트도 없이 밀리초 단위로 단위 테스트가 가능해지기 때문입니다. 실제로 테스트 작성이 수월해지면서 코드를 고치기 전에 테스트를 먼저 돌리는 흐름이 자연스럽게 자리잡았습니다. 도메인 간 참조는 Provider 인터페이스 모듈을 별도로 두고, 도메인 간에는 인터페이스로만 접근하도록 설계했습니다. 예를 들어 점수 도메인이 미션 도메인의 데이터가 필요할 때, 미션의 구현체를 직접 참조하는 것이 아니라 MissionProvider 인터페이스에 의존하고, 실제 구현체는 Presentation 레이어에 두고, 다른 Controller처럼 동작하게끔 했습니다. 이 구조의 장점은 서버 분리 시에도 드러납니다. 도메인 간 모듈 분리가 서버 분리로 전환되더라도, 참조하는 쪽(점수)에서는 Provider 구현체를 HTTP 요청을 보내는 구현체로 갈아끼우기만 하면 되고, 참조당하는 쪽(미션)에서는 Presentation 레이어에 있던 Provider 구현체가 그대로 Controller 역할을 하는 구조이기 때문에 도메인 로직의 변경 없이 전환이 가능합니다. 단일 모듈의 토이 프로젝트만 경험해본 상태에서 시작해 서툴렀지만, 지금은 새로운 도메인 추가나 서버 분리 시에도 구현체만 교체하여 확장 가능한 아키텍처가 완성됐습니다. 여러 아키텍처와 상황을 고려하며, 누가 와도 이해할 수 있고 확장시킬 수 있는 구조를 만들어내고자 했고, 지금은 그 구조 위에서 자신 있게 새로운 기능을 올릴 수 있는 상태가 됐습니다.
점수/랭킹 시스템 재설계 — 3,000ms → 32ms
기존 점수 테이블은 월 단위로 update하는 방식이었습니다. 한 유저의 점수 row를 여러 요청이 동시에 update하면 충돌이 발생하는 동시성 문제가 있었고, 5,000만건이 쌓이니 랭킹 조회 한 번에 3,000ms 이상의 레이턴시가 발생했습니다. 랭킹을 보여줄수록 쿼리 부하가 서비스 전체에 영향을 주는 구조적 문제였습니다. update 방식을 insert-only로 전환하여 동시성 문제를 근본적으로 해결했습니다. 같은 row를 경쟁하는 구조에서, 각 플레이마다 새로운 row를 삽입하는 구조로 바꾸면서 락 경합이 사라졌습니다. 랭킹 조회는 Redis Sorted Set 기반으로 전면 전환했습니다. 점수 원본은 항상 RDS에 적재하고, Redis에는 조회용 Sorted Set만 동기화하여 쓰기와 읽기의 저장소를 분리했습니다. RDS에서 Redis로의 동기화 과정에서 실패가 발생할 수 있기 때문에, 동기화 실패 시 자동 재시도 로직을 구성하고 실패 로그를 별도로 적재해서 정합성이 깨진 구간을 추적할 수 있도록 했습니다. Redis 조회 오류 시에는 RDS 기반의 랭킹 계산으로 자동 전환되도록 Fallback을 구성했습니다. Redis가 복구되면 배치가 RDS 데이터를 기반으로 Redis를 다시 채워넣고, 이후 Redis에 적재된 점수를 RDS와 비교하며 정합성을 검증하는 배치 로직도 미리 작성해두어 정상 상태 복구 경로까지 확보했습니다. 또한, 랭킹 시즌 마감에 맞춰 분기별로 RDS 데이터를 덤프해서 활성 테이블 크기를 제어했습니다. 현재 누적 데이터 4억건을 넘어섰지만, 랭킹 조회 응답 레이턴시는 평균 32ms로 운영 중입니다. 같은 랭킹이라는 기능이지만, 구현 방식에 따라 성능이 100배 이상 차이난다는 것을 직접 체감했고, 문제를 해결하는 방법은 여러 가지가 있지만 서비스의 특성과 규모에 맞는 최적의 구조를 찾아내는 것이 개발자의 역할이라는 것을 배웠습니다.
IAP 인앱 결제 시스템
서비스 내 다이아몬드, 코인 재화에 대한 결제 체계를 설계하고, App Store와 Google Play 인앱 결제 서버를 직접 연동했습니다. 상품, 주문, 결제 도메인을 분리하고, 결제 요청 시 주문서를 먼저 생성했습니다. 주문서 고유번호를 트랜잭션의 requestId로 사용하고, 각 결제 트랜잭션에는 자체 고유번호를 부여했습니다. 내부 결제수단인 코인과 외부 인앱결제 서비스(Apple, Google)가 발급하는 트랜잭션 ID는 externalTransactionId로 별도 관리해서 내부/외부 식별자를 명확히 분리했습니다. 이러한 멱등성 키들에 Unique Constraint를 걸어 중복 요청이 DB 레벨에서 차단되도록 하여, 네트워크 재시도나 클라이언트 중복 호출에 의한 이중 지급을 원천적으로 방지했습니다. 결제 흐름은 단계별로 트랜잭션을 분리했습니다. 외부 스토어 API 호출이 포함되는 영수증 검증은 트랜잭션 밖에서 처리해 DB 커넥션 점유를 최소화했습니다. 주문, 결제, 상품 지급의 과정에 대해 적절한 트랜잭션으로 분리하고, 각 단계의 상태를 트랜잭션 종료 전에 기록하여 주문/결제/상품 지급 중 어느 시점에서 실패했는지 즉시 파악할 수 있고, 미처리 건에 대한 재시도가 가능한 구조를 만들었습니다. 또한, Google/Apple의 S2S(Server-to-Server) Notification을 수신해서 구독 갱신, 취소, 환불 등의 이벤트도 서버에서 실시간으로 처리할 수 있게끔 했습니다. 외부 API와 통신하며 검증하는 흐름을 직접 구현하면서, 결제처럼 실패가 허용되지 않는 영역에서는 "어디서 실패했는지 모르는 상태"가 가장 위험하다는 것을 체감했고, 트랜잭션 설계가 곧 장애 대응 전략이라는 것을 이 경험에서 배웠습니다.
소켓 서버 성능 최적화
Node.js 이벤트 루프 기반의 소켓 서버에서 스트레스 테스트를 진행한 결과, 접속자가 늘어날수록 이벤트 처리 지연이 급격히 증가하고 일부 이벤트가 유실되는 현상이 발생했습니다. Node.js는 이벤트 루프가 단일 스레드이기 때문에, CPU 바운드 작업이 하나라도 끼면 전체 이벤트 처리가 멈추는 구조적 특성이 있다는 것을 파악했습니다. 게임 종료 시 팀별 점수 계산, 응답 데이터 조합, 정합성 판단 등의 결과 정산 로직이 이벤트 루프에서 동기로 실행되면서 다른 소켓 이벤트를 전부 밀어내고 있었습니다. 결과 정산을 Worker Thread로 분리하고, 워커 풀을 미리 생성해 재사용하는 구조로 만들어 스레드 생성 비용도 제거했습니다. 메인 스레드는 결과만 수신하는 구조로 전환하여 이벤트 루프 지연이 200ms+ → 15ms 이내로 줄었습니다. 통신 데이터의 크기도 병목이었습니다. 게임 상태를 JSON으로 직렬화해서 전송하고 있었는데, 접속자가 늘어날수록 패킷 크기가 비례해서 커지면서 네트워크 대역폭을 압박했습니다. 직렬화 포맷을 JSON에서 Protocol Buffers로 전환하여 패킷 크기를 약 70% 절감했습니다. .proto 스키마를 정의하고 서버와 클라이언트가 같은 스키마를 공유하는 구조로 바꾸면서, 스키마가 곧 서버-클라이언트 간 통신 계약서 역할을 하게 됐고 필드 누락이나 타입 불일치 같은 런타임 오류도 사라졌습니다. 이 과정에서 논블로킹 런타임의 성능 문제는 단순히 코드 최적화만으로 해결되지 않으며, 이벤트 루프 구조의 특성을 이해하고 작업의 성격(CPU vs I/O)에 따라 처리 방식을 나눠야 한다는 것을 배웠습니다. 또한, 수많은 사용자가 동시에 몰리는 상황은 예고 없이 찾아오기 때문에, 사전에 병목을 찾아 최적화해두는 것이 장애를 막는 가장 확실한 방법이라는 것을 체감했습니다.