프레임워크 없이 개발하며 배운 것들
프레임워크 없이 멀티플레이 레이싱 게임을 만들었습니다.
3주 동안 ServerSocket으로 HTTP 서버를 구현하고, RFC 6455 문서를 읽으며 WebSocket을 구현하고, Race Condition을 해결하고, 메모리 누수를 막고, God Object를 해체하는 과정을 거쳤습니다.
돌이켜보니 단순히 기능을 구현한 것 이상으로 많은 것을 배웠습니다. 이번 미션에서 무엇을 배웠는지 정리해보고자 합니다.
프레임워크의 가치
프레임워크를 사용할 때는 이런 것들이 얼마나 복잡한지 전혀 몰랐습니다.
@RestController만 쓰면 HTTP 요청이 들어오고, 간단한 설정만으로 WebSocket이 작동하고, @Transactional만 쓰면 트랜잭션이 관리되는 것이 굉장히 간단하게 느껴졌습니다.
하지만 직접 구현하고 나니 프레임워크가 얼마나 많은 것을 해주고 있는지 체감했습니다.
- HTTP 요청 파싱 (요청 라인, 헤더, 바디 분리)
- MIME Type 자동 설정 (.html, .css, .js 확장자별 처리)
- WebSocket Handshake (Sec-WebSocket-Key 해싱)
- WebSocket Frame 파싱 (비트 연산, Masking)
- 세션 관리 (생성, 저장, 정리)
- 동시성 제어 (스레드 풀, synchronized)
- 리소스 정리 (메모리 누수 방지)
이 모든 것을 직접 구현하는 것이 얼마나 복잡한지 알게 되었습니다. 특히 WebSocket Frame 파싱이 정말 어려웠는데, 비트 연산으로 Opcode, Payload Length, Masking Key를 추출하는 과정에서 오랜 시간 공부가 필요했습니다.
이런 과정을 거치고 나니 프레임워크는 편의를 위한 것이 아니라 검증된 방식으로 복잡한 문제를 해결해주는 도구라는 것을 깨달았습니다.
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", "Data Framing", "Closing Handshake"를 하나씩 구현하면서 WebSocket이 어떻게 작동하는지 알게 되었습니다.
Race Condition과 ConcurrentHashMap
닉네임 중복 버그를 해결하면서 Race Condition을 처음 경험했습니다.
if (nicknames.contains(nickname)) { // 검증
throw new IllegalArgumentException();
}
nicknames.add(nickname); // 실행
싱글스레드 환경에서는 문제없던 코드가 멀티스레드 환경에서는 버그가 되었습니다.
두 스레드가 동시에 contains()를 호출하면 둘 다 false를 받고 둘 다 add()를 호출해서 중복이 발생했는데, "검증과 실행 사이의 갭"이 문제였습니다.
이 경험을 통해 ConcurrentHashMap 같은 thread-safe한 자료구조가 왜 필요한지 체감했습니다.
가비지 컬렉션의 한계
"Java는 가비지 컬렉션이 있는데 왜 메모리 누수가?" 의아했습니다.
게임이 끝나도 GameRoom이 Map에 남아있었고, SessionManager에 세션들이 계속 쌓였습니다.
찾아보니 Map에 참조되어 있는 한 가비지 컬렉션이 수거하지 않는다는 것을 알게 되었습니다.
결국 명시적으로 제거해야 했습니다.
// 10초 후 정리
Thread.sleep(10000);
gameRooms.remove(roomId);
for (String nickname : nicknames) {
sessionManager.remove(nickname);
}
"생성한 것은 반드시 정리해야 한다"는 원칙을 다시 한번 깨달았습니다.
Spring Boot에서는 프레임워크가 세션 관리, 리소스 정리를 대신 해주기 때문에 직접 마주할 일이 적었습니다.
하지만 직접 구현하니 "언제 객체를 제거해야 하는가", "얼마나 기다린 후 제거할 것인가" 같은 결정을 직접 내려야 했습니다.
SRP, DIP, 그리고 책임 분리
프리코스에서 배운 원칙들을 실제 프로젝트에 적용했습니다.
단일 책임 원칙 (SRP): God Object였던 GameService를 3개로 분리
- PlayerSessionService: 세션 관리
- MatchingService: 대기열 및 매칭
- GameRoomService: 게임룸 생성 및 스케줄링
의존성 역전 원칙 (DIP): GameEventPublisher 인터페이스로 추상화
- 도메인(GameRoom)이 인프라(SessionManager)를 직접 의존하지 않음
- 인터페이스를 의존하게 해서 도메인을 순수하게 유지
Value Object: Nickname, Position, Round, RoomId
- 원시값을 포장해서 의미를 명확히 하고 검증 로직을 한 곳에 모음
일급 컬렉션: Players
- 플레이어 목록을 관리하는 로직을 한 곳에 모음
이론으로만 알던 원칙들을 실제로 적용하면서, 왜 이런 원칙들이 중요한지 비로소 이해하게 되었습니다.
초기 개발 단계에서는 빠른 구현을 우선했습니다. 일단 동작하는 게임을 만드는 것이 목표였고, 하나의 Service에 모든 로직을 넣는 방식으로 빠르게 기능을 완성했습니다.
3주차 리팩토링 단계에서 설계를 개선했습니다. SRP에 따라 3개 서비스로 분리하고, DIP를 적용해 도메인을 순수하게 유지했습니다. 빠른 구현과 좋은 설계, 두 단계를 모두 경험하면서 각각의 가치를 이해할 수 있었습니다.
마치며
RFC 6455 문서를 읽으며 WebSocket 프로토콜을 이해했고, 비트 연산으로 Frame을 파싱하는 법을 배웠습니다. 닉네임 중복 버그를 겪으며 Race Condition을 알게 되었고, ConcurrentHashMap이라는 해결책을 찾았습니다. 메모리 누수 문제를 발견하고 가비지 컬렉션의 한계를 깨달았습니다.
God Object로 시작한 코드를 SRP에 따라 3개 서비스로 나누고, DIP를 적용해 도메인을 순수하게 유지했습니다. Singleton의 문제를 경험하고 생성자 주입으로 바꿨습니다.
막힐 때마다 검색하고, 블로그 글을 읽고, 공식 문서를 찾아보고, 코드를 고쳤습니다.
프레임워크를 썼다면 훨씬 빠르게 끝났을 것입니다. 하지만 직접 구현하면서 프레임워크가 해주는 것들을 하나하나 이해하게 되었습니다.
오픈미션에서 결국 제가 배운 것은 단순히 "멀티플레이 게임 만드는 법"이 아니라, "모르는 것을 스스로 찾아서 배우는 법"이었습니다.