본문으로 건너뛰기

멀티스레드 환경에서 살아남기

HTTP 서버와 WebSocket을 구현하고, 이제 본격적으로 멀티플레이 게임을 만들 차례였습니다.

실제로 여러 명이 동시에 접속할 수 있는지 테스트하기 위해 브라우저 창 4개를 열었는데, 예상치 못한 문제가 발생했습니다. 첫 번째 창은 잘 열리는데, 두 번째 창부터는 계속 로딩 중이었습니다.

한 명씩만 처리되는 서버

코드를 다시 보니 원인을 찾았습니다.

java
while (true) {
Socket clientSocket = serverSocket.accept();

// 요청 처리
handleRequest(clientSocket);

clientSocket.close();
}

accept()로 연결을 받고 요청을 처리하고 응답을 보내고 소켓을 닫는 과정이 순차적으로 진행되고 있었습니다.

첫 번째 클라이언트의 요청을 처리하는 동안 두 번째 클라이언트는 대기열에서 기다려야 했습니다. 한 명씩만 서빙할 수 있는 식당과 같았습니다.

Thread 사용

"각 연결을 별도 스레드에서 처리하면 되지 않을까?" 생각했습니다.

java
while (true) {
Socket clientSocket = serverSocket.accept();

new Thread(() -> {
try {
handleRequest(clientSocket);
} finally {
clientSocket.close();
}
}).start();
}

브라우저 창 4개를 열어보니 모두 동시에 로드되었습니다.

동시성 제어

멀티스레드로 동시 접속 문제를 해결했지만, 이제 새로운 고민이 생겼습니다.

게임을 만들려면 플레이어 정보를 어딘가에 저장해야 하는데, 여러 스레드가 동시에 같은 자료구조에 접근하면 문제가 생길 것 같았습니다.

Java에는 ConcurrentHashMap이라는 thread-safe한 자료구조가 있어서, 여러 스레드가 동시에 접근해도 안전하게 동작합니다.

java
private final Map<String, WebSocketSession> sessions = new ConcurrentHashMap<>();

SessionManager에 ConcurrentHashMap을 적용하니 게임이 정상적으로 작동하기 시작했습니다.

메모리 누수 발견

동시성 문제를 해결하고 며칠간 테스트를 하다가 이상한 점을 발견했습니다.

서버를 계속 켜두고 여러 번 게임을 하다 보니 메모리 사용량이 계속 증가했습니다.

디버깅을 해보니 GameRoomRepository에 게임룸이 계속 쌓이고 있었습니다.

java
private final Map<RoomId, GameRoom> multiRooms = new ConcurrentHashMap<>();
private final Map<RoomId, SingleGameRoom> singleRooms = new ConcurrentHashMap<>();

게임이 끝나도 GameRoom이 Map에 남아있어서, 10번 게임하면 GameRoom이 10개 쌓이고 100번 하면 100개가 쌓였습니다.

가비지 컬렉션의 한계

이상하게 느껴졌습니다. "Java는 가비지 컬렉션이 있는데 왜 자동으로 안 없어지지?"라는 의문이 들어 찾아보니, 중요한 사실을 알게 되었습니다.

GameRoom이 Map에 참조되어 있는 한, 가비지 컬렉션의 대상이 아니라는 것이었습니다.

GC는 도달 가능성으로 판단한다

Java의 가비지 컬렉터는 "도달 가능성(Reachability)"을 기준으로 객체를 판단합니다. 살아있는 스레드, 스택의 지역 변수, 정적 필드 같은 "GC Root"로부터 참조 체인을 따라 도달할 수 있다면, 그 객체는 살아있는 것으로 간주됩니다.

우리 코드를 보면:

java
public class GameRoomRepository {
// static 필드 → GC Root
private final Map<RoomId, GameRoom> multiRooms = new ConcurrentHashMap<>();

public void finishGame(RoomId roomId) {
GameRoom room = multiRooms.get(roomId);
room.announceWinner();
// multiRooms.remove(roomId)를 호출하지 않음
// → room은 여전히 Map에서 참조되고 있음
// → GC Root로부터 도달 가능
// → 가비지 컬렉션 대상이 아님!
}
}

Map에 저장된 참조는 "Strong Reference"입니다. 명시적으로 remove()를 호출해서 참조를 끊지 않는 한, GC는 절대 그 객체를 수집하지 않습니다.

이전에 Spring Boot를 사용할 때는 이런 문제를 직접 마주할 일이 적었습니다. 프레임워크가 세션 관리와 리소스 정리를 자동으로 처리해주었기 때문입니다.

하지만 직접 구현하는 환경에서는 달랐습니다. "언제 객체를 제거해야 하는가"라는 질문에 스스로 답을 내리고, 직접 정리 로직을 구현해야 했습니다.

GC의 동작 원리를 이해하고 나니, 왜 명시적으로 remove()를 호출해야 하는지 명확해졌습니다. Map에 남아있는 참조는 명백히 도달 가능하고, GC는 도달 가능한 객체는 절대 수집하지 않기 때문입니다.

게임룸 정리 시점

가장 간단한 방법은 게임이 끝나는 순간 바로 제거하는 것이었습니다.

java
public void finishGame(String roomId) {
GameRoom room = gameRooms.get(roomId);
room.announceWinner();

gameRooms.remove(roomId); // 바로 제거
}

하지만 곧 문제를 발견했습니다. 우승자 발표 메시지를 보내는 시점과 클라이언트가 받는 시점 사이에 GameRoom이 사라질 수 있었고, 더 심각한 문제는 사용자가 "Restart" 버튼을 누르기 전에 GameRoom이 사라지면 재시작이 불가능하다는 점이었습니다.

이 문제를 해결하기 위해, 게임이 끝난 후 일정 시간 기다렸다가 정리하는 방식으로 바꾸기로 했습니다.

세션 정리 전략

게임룸 정리 방식을 정하고 나니, 자연스럽게 세션 관리에 대해서도 고민하게 되었습니다.

처음에는 게임룸 정리 시 세션도 함께 제거하는 단순한 방식을 생각했습니다. 하지만 이 방식은 심각한 문제가 있었습니다. 게임이 끝나고 10초 후 세션까지 제거하면, 사용자가 Restart 버튼을 누르기 전에 세션이 사라져버립니다. 그러면 다른 사람이 같은 닉네임으로 입장할 수 있게 되고, 원래 사용자가 Restart를 눌렀을 때 "이미 사용 중인 닉네임" 오류가 발생하게 됩니다.

이 문제를 분석하면서 게임룸과 세션의 생명주기가 다르다는 것을 깨달았습니다. 최종적으로는 다음과 같이 분리했습니다:

  • 게임룸 정리: 게임 종료 10초 후
  • 세션 정리: WebSocket 연결 종료 시

이렇게 하면 세션은 연결이 살아있는 한 계속 유지되므로, 사용자가 Restart를 눌렀을 때 동일한 닉네임으로 바로 재입장할 수 있습니다.

두 가지 스케줄링 방식

정리 로직을 구현하면서 서로 다른 두 가지 스케줄링이 필요하다는 것을 깨달았습니다:

  1. 게임 라운드 진행: 1초마다 반복 실행 → ScheduledExecutorService 사용
  2. 룸 정리: 10초 후 1번만 실행 → Thread.sleep() 사용
java
// 게임 라운드: 반복 실행
ScheduledExecutorService executor = Executors.newScheduledThreadPool(1);
executor.scheduleAtFixedRate(() -> {
boolean continueGame = room.playNextRound();
if (!continueGame) {
executor.shutdown();
}
}, 1, 1, TimeUnit.SECONDS);
java
// 룸 정리: 1회 실행
public class RoomCleanupScheduler {
private static final int CLEANUP_DELAY_SECONDS = 10;

public void scheduleCleanup(Runnable cleanupTask) {
Thread thread = new Thread(() -> runAfterDelay(cleanupTask));
thread.start();
}

private void runAfterDelay(Runnable cleanupTask) {
try {
Thread.sleep(CLEANUP_DELAY_SECONDS * 1000);
cleanupTask.run();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}

반복 작업과 단발성 작업, 용도에 맞는 방식을 선택하니 코드가 훨씬 명확해졌습니다.

멀티스레드 환경에서 개발하면서 정말 많은 것을 배웠습니다.

Thread로 동시 접속을 처리하는 법, ConcurrentHashMap으로 동시성 문제를 개선하는 법, Java의 가비지 컬렉션이 모든 것을 해결해주지 않는다는 것, 명시적으로 리소스를 정리해야 한다는 것, 그리고 세션과 게임룸의 생명주기를 분리하는 법까지 배웠습니다.

Spring Boot에서는 프레임워크가 자동으로 해주던 것들을 직접 구현하면서, 멀티스레드 프로그래밍이 굉장히 어렵다는 것을 느낄 수 있었습니다.