프레임워크 없이 개발하며 배운 것들
프레임워크 없이 멀티플레이 레이싱 게임을 만들었습니다.
3주 동안 ServerSocket으로 학습용 HTTP 서버를 구현하고, RFC 6455 문서를 읽으며 WebSocket의 제한된 경로를 직접 다루고, Race Condition과 객체 생명주기를 분석하고, 여러 책임이 섞인 서비스를 분리하는 과정을 거쳤습니다.
돌이켜보니 단순히 기능을 구현한 것 이상으로 많은 것을 배웠습니다. 이번 미션에서 무엇을 배웠는지 정리해보고자 합니다.
프레임워크의 가치
프레임워크를 사용할 때는 이런 것들이 얼마나 복잡한지 전혀 몰랐습니다.
@RestController만 쓰면 HTTP 요청이 들어오고, 간단한 설정만으로 WebSocket이 작동하고, @Transactional만 쓰면 트랜잭션이 관리되는 것이 굉장히 간단하게 느껴졌습니다.
하지만 직접 구현하고 나니 프레임워크가 얼마나 많은 것을 해주고 있는지 체감했습니다.
- HTTP 요청 파싱 (프로젝트에 필요한 GET 요청 라인과 헤더 범위)
- MIME Type 자동 설정 (.html, .css, .js 확장자별 처리)
- WebSocket Handshake (Sec-WebSocket-Key 해싱)
- WebSocket Frame 처리 (짧은 단일 텍스트 frame과 masking)
- 세션 관리에 필요한 생성·저장·정리 흐름
- 동시성 제어에 필요한 executor와 원자 연산
- 애플리케이션 객체의 생명주기 관리
이 모든 것을 직접 구현하는 것이 얼마나 복잡한지 알게 되었습니다. 특히 WebSocket frame을 읽는 과정에서 payload length와 masking key를 해석하는 데 오랜 시간 공부가 필요했습니다. 다시 소스를 검토해보니 당시 코드는 opcode를 실제로 추출하거나 검증하지 않았고, 확장 길이와 제어 frame도 지원하지 않았습니다.
이런 과정을 거치고 나니 프레임워크는 편의를 위한 것이 아니라 검증된 방식으로 복잡한 문제를 해결해주는 도구라는 것을 깨달았습니다.
RFC 6455와 WebSocket 프로토콜
RFC 6455 문서를 처음 열었을 때는 막막했습니다. 영어로 빽빽하게 적혀있고 비트 단위 다이어그램이 가득했습니다.
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|F|R|R|R| opcode|M| Payload len | Extended payload length |
|I|S|S|S| (4) |A| (7) | (16/64) |
처음 봤을 때는 "이걸 어떻게 읽지?"라는 생각에 막막했습니다.
그러나 포기하지 않고 한국어 블로그 글들을 참고하면서 한 섹션씩 천천히 읽어나가자, 점차 이해가 되기 시작했습니다.
opening handshake와 짧은 단일 텍스트 frame의 송수신 경로를 구현하면서 WebSocket의 기본 구조를 알게 되었습니다. closing handshake와 ping/pong, fragmentation, 확장 payload 길이는 구현 범위에 포함되지 않았습니다.
Race Condition과 ConcurrentHashMap
닉네임 중복 버그를 추적하면서 Race Condition을 처음 경험했습니다.
if (nicknames.contains(nickname)) { // 검증
throw new IllegalArgumentException();
}
nicknames.add(nickname); // 실행
싱글스레드 환경에서는 문제없던 코드가 멀티스레드 환경에서는 버그가 되었습니다.
두 스레드가 동시에 contains()를 호출하면 둘 다 false를 받고 둘 다 add()를 호출해서 중복이 발생했는데, "검증과 실행 사이의 갭"이 문제였습니다.
이 경험을 통해 ConcurrentHashMap 같은 thread-safe한 자료구조가 왜 필요한지 체감했습니다. 동시에 자료구조를 바꾸는 것만으로 여러 단계의 로직까지 원자적이 되는 것은 아니라는 점도 배웠습니다. 현재 구현의 containsKey() 이후 put() 구조에는 여전히 경쟁 조건이 남아 있어, putIfAbsent() 같은 단일 원자 연산으로 보완해야 합니다.
Repository 참조와 가비지 컬렉션
정리 로직이 없던 코드에서는 게임이 끝난 뒤에도 GameRoom이 Repository의 Map에 남아 참조가 누적될 수 있었습니다. 가비지 컬렉터는 살아있는 Map에서 도달 가능한 객체를 자동으로 제거하지 않습니다.
찾아보니 Map에 참조되어 있는 한 가비지 컬렉션이 수거하지 않는다는 것을 알게 되었습니다.
결국 서버 수명 동안 유지되는 Repository에서 명시적으로 제거해야 했습니다.
room.start();
startMultiGameLoop(room);
scheduleRoomCleanup(roomId, false); // 현재는 방 생성 시점부터 10초
현재 구현은 방이 만들어진 시점부터 10초 뒤 Repository에서 제거합니다. 영구히 남기는 문제는 줄였지만 "게임 종료 10초 후"를 보장하지는 않습니다. 종료 이벤트를 기준으로 ScheduledExecutorService.schedule()을 호출하도록 개선해야 생명주기 의도가 정확해집니다.
프레임워크와 서버 라이브러리는 연결 수명주기와 executor 같은 검증된 도구를 제공하지만, 애플리케이션이 만든 게임룸 Map의 정리 시점까지 자동으로 결정해주지는 않습니다.
하지만 직접 구현하니 "언제 객체를 제거해야 하는가", "얼마나 기다린 후 제거할 것인가" 같은 결정을 직접 내려야 했습니다.
SRP, DIP, 그리고 책임 분리
프리코스에서 배운 원칙들을 실제 프로젝트에 적용했습니다.
단일 책임 원칙 (SRP): God Object였던 GameService를 3개로 분리
- PlayerSessionService: 세션 관리
- MatchingService: 대기열 및 매칭
- GameRoomService: 게임룸 생성 및 스케줄링
의존성 역전 원칙 (DIP): GameEventPublisher 인터페이스로 추상화
- 도메인(GameRoom)이 인프라(SessionManager)를 직접 의존하지 않음
- 구체적인 WebSocket·SessionManager 의존을 포트 뒤로 역전
- 연결 상태 조회와 출력 책임은 남아 있어 완전히 순수한 도메인은 아님
Value Object: Nickname, Position, Round, RoomId
- 원시값을 포장해서 의미를 명확히 하고 검증 로직을 한 곳에 모음
일급 컬렉션: Players
- 플레이어 목록을 관리하는 로직을 한 곳에 모음
이론으로만 알던 원칙들을 실제로 적용하면서, 왜 이런 원칙들이 중요한지 비로소 이해하게 되었습니다.
초기 개발 단계에서는 빠른 구현을 우선했습니다. 일단 동작하는 게임을 만드는 것이 목표였고, 하나의 Service에 모든 로직을 넣는 방식으로 빠르게 기능을 완성했습니다.
3주차 리팩토링 단계에서 설계를 개선했습니다. SRP에 따라 3개 서비스로 분리하고, DIP로 구체적인 WebSocket 의존을 포트 뒤로 옮겼습니다. 빠른 구현과 설계 개선, 두 단계를 모두 경험하면서 각각의 가치를 이해할 수 있었습니다.
마치며
RFC 6455 문서를 읽으며 opening handshake와 frame 구조를 이해했고, 제한된 텍스트 frame 경로를 구현했습니다. 닉네임 중복 버그를 검토하며 Race Condition을 알게 되었고, ConcurrentHashMap의 개별 연산 보장과 복합 연산의 원자성이 서로 다른 문제라는 점을 배웠습니다. Repository에 남은 참조와 가비지 컬렉션의 도달 가능성도 구분하게 됐습니다.
여러 책임이 섞인 코드를 SRP에 따라 3개 서비스로 나누고, DIP를 적용해 구체적인 인프라 의존을 역전했습니다. Singleton의 전역 상태 문제를 경험하고 생성자 주입으로 바꿨습니다.
막힐 때마다 검색하고, 블로그 글을 읽고, 공식 문서를 찾아보고, 코드를 고쳤습니다.
프레임워크를 썼다면 훨씬 빠르게 끝났을 것입니다. 하지만 직접 구현하면서 프레임워크가 해주는 것들을 하나하나 이해하게 되었습니다.
오픈미션에서 결국 제가 배운 것은 단순히 "멀티플레이 게임 만드는 법"이 아니라, "모르는 것을 스스로 찾아서 배우는 법"이었습니다.