본문으로 건너뛰기

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

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

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

한 명씩만 처리되는 서버

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

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

// 요청 처리
handleRequest(clientSocket);

clientSocket.close();
}

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

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

Thread 사용

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

아래는 예외 처리를 생략하고 연결당 Thread라는 핵심 흐름만 나타낸 의사 코드입니다. 실제 구현에서는 IOException과 소켓 종료를 명시적으로 처리해야 합니다.

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

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

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

동시성 제어

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

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

Java의 ConcurrentHashMap은 여러 스레드가 동시에 접근할 때 개별 연산의 안전성을 보장합니다. 다만 containsKey()로 확인한 뒤 put()하는 것처럼 여러 연산을 조합하면 그 전체가 원자적이 되는 것은 아닙니다. 이런 check-then-act 로직에는 putIfAbsent()compute() 같은 원자적 API가 필요합니다.

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

당시에는 SessionManager의 저장소를 ConcurrentHashMap으로 바꾼 뒤 여러 창에서 정상 동작하는 것을 보고 문제가 해결됐다고 판단했습니다. 하지만 글을 다시 검토하면서 남은 경쟁 조건을 발견했습니다. 현재 소스의 PlayerSessionServiceexists()로 검사한 뒤 별도의 add()를 호출합니다. 두 스레드가 검사와 등록 사이에 끼어들 수 있으므로 닉네임 중복을 완전히 막지는 못합니다.

등록 자체를 하나의 원자적 연산으로 만들어야 합니다. 예를 들면 SessionManager가 putIfAbsent()의 결과로 등록 성공 여부를 반환하고, 실패한 세션을 닫는 식입니다.

java
public boolean addIfAbsent(String nickname, WebSocketSession session) {
return sessions.putIfAbsent(nickname, session) == null;
}

ConcurrentHashMap을 사용했다는 사실과 비즈니스 불변식까지 원자적으로 지켰다는 사실은 다릅니다. 이 경계 조건은 프로젝트 소스에서 후속 수정해야 할 항목으로 남겨두었습니다.

세션 제거도 같은 원칙이 필요합니다. 오래된 연결이 늦게 종료되면서 같은 닉네임의 새 연결을 지우지 않게 하려면 remove(nickname, expectedSession)처럼 키와 기존 값을 함께 비교해야 합니다. WaitingQueueArrayList 접근과 같은 세션의 OutputStream에 대한 동시 전송도 별도의 동기화 검토가 필요한 상태입니다.

Repository의 참조 누적

정리 로직이 없던 코드를 검토하니 GameRoomRepository에서 종료된 게임룸을 제거하지 않고 있었습니다.

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

이 구조에서는 게임이 끝나도 GameRoom이 Map에 남기 때문에, 정리하지 않으면 게임 횟수만큼 참조가 누적될 수 있습니다. 별도의 heap 측정 수치를 보존한 것은 아니므로 여기서는 코드의 참조 관계만 다룹니다.

가비지 컬렉션의 한계

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

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

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

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

도달 가능성을 설명하기 위해 실제 구조를 단순화한 예시를 보면:

java
public class GameRoomRepository {
// 애플리케이션이 서버 수명 동안 보유하는 Repository의 인스턴스 필드
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에서 참조되고 있음
// → 살아있는 애플리케이션 객체에서 Repository와 Map을 거쳐 도달 가능
// → 가비지 컬렉션 대상이 아님!
}
}

여기서 multiRoomsstatic 필드도, 그 자체가 GC Root인 것도 아닙니다. 실제 참조 체인은 대략 GC Root → 서버 수명 객체 → GameRoomRepository → multiRooms → GameRoom입니다. 서버가 Repository를 계속 보유하는 동안 Map의 값도 강한 참조로 도달 가능하므로 수집되지 않습니다. 반대로 Repository 자체가 도달 불가능해지면 Map에 항목이 남아 있어도 Map과 GameRoom을 함께 수집할 수 있습니다.

이전에 Spring 기반 라이브러리와 컨테이너가 관리하는 연결 생명주기를 사용할 때는 이런 문제를 직접 마주할 일이 적었습니다. 그렇다고 프레임워크가 애플리케이션이 만든 Map과 게임룸을 자동으로 정리해주는 것은 아닙니다. 직접 만든 저장소에서는 "언제 객체를 제거해야 하는가"라는 정책과 코드를 개발자가 명시해야 합니다.

GC의 동작 원리를 이해하고 나니, 서버 수명 동안 유지되는 Repository에서는 왜 명시적으로 remove()를 호출해야 하는지 명확해졌습니다. Repository가 살아있는 한 Map을 거쳐 도달 가능한 GameRoom은 수집 대상이 아니기 때문입니다.

실제 정리 구현과 남은 타이밍 문제

게임룸과 WebSocket 세션은 생명주기가 다릅니다. 게임룸은 게임이 끝나면 저장소에서 제거할 수 있지만, 세션은 실제 연결이 끝날 때 제거해야 합니다. 현재 소스도 룸 정리에서는 Repository의 방만 제거하고, 세션은 연결 종료 흐름에서 별도로 닫습니다.

다만 현재 구현을 다시 확인하면서 정리 시점이 글의 원래 설명과 다르다는 것을 발견했습니다. GameRoomService는 게임 종료 이벤트가 아니라 방을 만들고 게임 loop를 시작한 직후 10초 타이머를 예약합니다.

java
room.start();
startMultiGameLoop(room);
scheduleRoomCleanup(roomId, false); // 방 생성 시점부터 10초

따라서 지금 코드는 "게임 종료 10초 후"를 보장하지 않습니다. 보통 5라운드가 먼저 끝나더라도, 라운드 처리 지연이나 규칙 변경이 생기면 실행 중인 방이 Repository에서 먼저 제거될 수 있습니다. 현재 클라이언트에는 Restart 흐름도 없으므로 Restart를 근거로 정리 시간을 설명하는 것은 맞지 않았습니다.

의도대로 만들려면 GameRoom의 종료를 서비스에 알리는 이벤트나 완료 신호를 두고, 그 시점에 정리를 예약해야 합니다. 라운드 반복은 scheduleAtFixedRate()를 사용하고 있으며, 단발 정리도 매번 새 Thread에서 sleep()하기보다 같은 생명주기를 관리하는 ScheduledExecutorService.schedule()로 표현하는 편이 취소·종료·테스트를 관리하기 쉽습니다.

이 검토를 통해 "정리 코드를 넣었다"와 "올바른 생명주기 시점에 정리된다"는 서로 다른 검증 항목이라는 점을 배웠습니다.

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

Thread로 동시 접속을 처리하는 법, ConcurrentHashMap의 개별 연산과 애플리케이션 불변식의 차이, Java의 가비지 컬렉션이 모든 것을 해결해주지 않는다는 것, 그리고 세션과 게임룸의 생명주기를 코드로 명시해야 한다는 점까지 배웠습니다.

프레임워크와 서버 라이브러리가 제공하던 연결 처리 도구를 직접 구현하면서, 멀티스레드 환경에서는 자료구조 선택뿐 아니라 여러 연산의 경계와 애플리케이션 생명주기까지 함께 설계해야 한다는 것을 느꼈습니다.