활성 세션 관리와 서버 종료
서버를 종료할 때 이미 연결된 세션까지 닫을 수 있도록 활성 세션을 목록으로 관리하고, 등록과 종료가 겹치는 경쟁, 여러 번 불려도 안전한 close, accept 뒤 소유권을 넘기는 순서를 다룹니다.
앞 글에서는 세션 하나가 쓰는 소켓과 스트림을 try-with-resources로 닫았습니다. 그런데 서버 전체를 종료할 때는 질문이 달라집니다. 지금 이 순간 살아 있는 세션이 무엇인지 알아야 그 세션들을 닫을 수 있기 때문입니다. accept()로 받은 소켓을 어디에도 기록하지 않았다면, 종료를 맡은 스레드가 닫을 수 있는 것은 연결을 받는 리스너(ServerSocket)뿐입니다. 이미 연결된 세션은 read()에서 계속 기다립니다.
이 글에서는 살아 있는 세션을 모아 두는 등록부(registry)를 만듭니다. 단순한 컬렉션으로는 부족하고 몇 가지 규칙이 필요합니다. 종료가 시작된 뒤에는 새 세션을 받지 않아야 하고, 등록된 세션은 모두 닫아야 하며, 스스로 끝난 세션은 목록에서 빠져야 합니다. 무엇보다 등록과 종료가 동시에 일어나도 소켓 하나가 아무도 닫지 않는 채로 빠져나가서는 안 됩니다.
리스너를 닫아도 세션은 멈추지 않는다
다중 클라이언트 서버를 처음 만들면 흔히 다음과 같은 accept 루프를 씁니다. 연결마다 스레드를 시작해 세션을 맡기고, 스레드와 소켓의 참조는 그대로 버립니다.
while (true) {
Socket socket = server.accept();
Thread.ofVirtual().start(() -> handle(socket)); // 스레드도 소켓도 어디에도 기록하지 않는다
}평소에는 잘 동작하지만 서버를 종료할 때 문제가 드러납니다. 아래 프로그램으로 그 상황을 재현해 보겠습니다. 클라이언트 하나를 연결한 뒤, 서버 쪽 세션 스레드가 그 연결에서 1바이트를 읽으려고 기다리게 합니다. 클라이언트는 아무것도 보내지 않으므로 세션은 read()에서 멈춰 있습니다. 이 상태에서 먼저 리스너를 닫고, 그다음 세션의 소켓을 닫으면서 세션 스레드가 살아 있는지 확인합니다.
import java.io.IOException;
import java.net.InetAddress;
import java.net.ServerSocket;
import java.net.Socket;
public final class ListenerCloseDemo {
public static void main(String[] args) throws Exception {
InetAddress loopback = InetAddress.getLoopbackAddress();
var listener = new ServerSocket(0, 1, loopback);
var client = new Socket(loopback, listener.getLocalPort());
Socket accepted = listener.accept();
Thread session = Thread.ofPlatform().start(() -> {
try {
int value = accepted.getInputStream().read(); // 클라이언트가 아무것도 보내지 않는다
System.out.println("read=" + value);
} catch (IOException error) {
System.out.println("session woke up: " + error);
}
});
listener.close();
session.join(500); // 최대 0.5초 기다린다
System.out.println("after listener close: alive=" + session.isAlive());
accepted.close(); // 소켓 참조가 있어야 이렇게 닫을 수 있다
session.join();
System.out.println("after socket close: alive=" + session.isAlive());
client.close();
}
}join(500)은 스레드가 끝나기를 최대 0.5초만 기다리고 돌아오므로, 첫 줄의 alive 값은 리스너를 닫은 것만으로 세션이 깨어났는지를 알려 줍니다. 반면 두 번째 join()은 시간 제한 없이 세션 스레드가 끝날 때까지 기다립니다.
after listener close: alive=true
session woke up: java.net.SocketException: Socket closed
after socket close: alive=false첫 줄을 보면 리스너를 닫고 0.5초를 기다려도 세션 스레드는 살아 있습니다. 리스너는 새 연결을 받는 일만 하고, 이미 만들어진 연결은 별개의 소켓이기 때문입니다. 세션의 소켓을 닫자 비로소 read()가 SocketException: Socket closed로 깨어났고, 세션 스레드가 끝났습니다. Socket.close()의 공식 문서도 그 소켓에서 입출력을 기다리던 스레드는 SocketException을 받는다고 설명합니다. 이 예제가 세션을 깨울 수 있었던 것은 accepted라는 참조를 들고 있었기 때문입니다. 앞의 accept 루프처럼 참조를 버리면 종료하는 쪽은 이 일을 할 방법이 없습니다.
스레드를 인터럽트해서 깨우면 되지 않을까 싶을 수 있지만, 플랫폼 스레드가 일반 소켓의 read()에서 기다리는 중이라면 인터럽트로는 깨어나지 않습니다. 가상 스레드는 다릅니다. Java 21부터 기본 소켓 구현을 쓰는 가상 스레드가 소켓 읽기나 쓰기에서 기다리다 인터럽트되면, 소켓이 닫히고 SocketException이 발생합니다. 하지만 이 경우에도 인터럽트할 스레드의 참조는 있어야 합니다. 어느 쪽이든 살아 있는 세션을 어딘가에 기록해 두는 것이 출발점입니다.
실행과 정리를 함께 맡는 세션 객체
그렇다면 스레드 목록만 들고 있으면 될까요? 스레드 참조만으로는 소켓을 닫을 수 없고, 앞에서 본 것처럼 인터럽트가 통하지 않는 경우도 있습니다. 종료하는 쪽에 필요한 것은 '이 세션을 끝내라'는 요청 하나로 작업 중단과 소켓 정리를 함께 해 주는 대상입니다.
그래서 세션을 객체로 만들고, 이 객체가 Runnable과 AutoCloseable을 함께 구현하게 합니다. 작업자 스레드는 run()을 실행하고, 종료하는 쪽은 close()를 호출합니다. 등록부는 세션이 어떤 스트림을 쓰는지 몰라도 close()만 부르면 됩니다. 세션 안에서 일어나는 일과 세션의 수명이 한 객체에 모이는 것입니다.
등록과 종료 시작을 한 잠금으로 묶기
등록부의 핵심 규칙은 '종료가 시작됐으면 등록을 거절한다'입니다. 이 규칙을 closing 플래그 확인과 집합에 추가하기, 두 단계로 나눠 구현하면 그 사이에 틈이 생깁니다. 두 스레드가 다음 순서로 실행되는 경우를 보겠습니다.
| 순서 | 등록 스레드 | 종료 스레드 |
|---|---|---|
| 1 | closing을 확인한다. 아직 false다 | |
| 2 | closing을 true로 바꾸고 집합을 복사한 뒤 비운다. 복사본에 새 세션은 없다 | |
| 3 | 집합에 새 세션을 추가한다 | |
| 4 | 복사본의 세션만 닫고 끝낸다 |
4단계가 끝나면 새 세션은 집합에 들어 있지만 아무도 닫지 않습니다. 종료 스레드는 이미 지나갔고, 등록 스레드는 등록에 성공했다고 믿고 세션을 실행합니다. 이 틈을 없애려면 확인과 추가를 하나의 임계 영역, 즉 한 번에 한 스레드만 실행할 수 있는 구간으로 묶어야 합니다. 종료를 시작하는 코드도 같은 잠금 안에서 실행해야 1단계와 3단계 사이에 2단계가 끼어들 수 없습니다.
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
public final class SessionRegistry {
interface Session extends AutoCloseable {
@Override void close();
}
private final Set<Session> sessions = new HashSet<>();
private boolean closing;
public synchronized boolean register(Session session) {
if (closing) return false;
return sessions.add(session);
}
public synchronized void remove(Session session) {
sessions.remove(session);
}
public synchronized List<Session> beginClose() {
closing = true;
List<Session> snapshot = new ArrayList<>(sessions);
sessions.clear();
return snapshot;
}
public synchronized int size() { return sessions.size(); }
public static void main(String[] args) {
var registry = new SessionRegistry();
Session session = () -> System.out.println("closed");
System.out.println(registry.register(session));
registry.beginClose().forEach(Session::close);
System.out.println(registry.register(() -> { }));
}
}main은 등록, 종료 시작, 종료 뒤의 등록을 차례로 실행합니다. 첫 줄과 셋째 줄은 register()의 반환값이고, 가운데 줄은 beginClose()가 돌려준 세션을 닫을 때 찍힙니다.
true
closed
false모든 메서드가 synchronized이므로 같은 등록부 객체의 잠금을 공유합니다. register()가 closing을 확인하고 집합에 넣는 동안에는 beginClose()가 끼어들 수 없고, 그 반대도 마찬가지입니다. 중첩 인터페이스 Session은 AutoCloseable의 close()를 다시 선언하면서 throws Exception을 뺐습니다. 그래서 등록부는 close()를 호출할 때 검사 예외를 처리하지 않아도 됩니다.
출력의 첫 줄 true는 첫 세션의 등록이 성공했다는 뜻입니다. 둘째 줄 closed는 beginClose()가 돌려준 복사본, 즉 스냅샷에 들어 있던 그 세션을 main이 닫으면서 출력한 것입니다. 셋째 줄 false는 종료가 시작된 뒤에 시도한 등록이 거절됐다는 뜻입니다. 거절당한 쪽은 그 세션을 직접 닫아야 하며, 이 규칙은 뒤에서 accept 루프를 만들 때 다시 봅니다.
세션을 닫는 일은 잠금 밖에서 한다
beginClose()가 세션을 직접 닫지 않고 스냅샷만 돌려주는 데는 이유가 있습니다. close()는 등록부 입장에서는 어떤 일을 할지 모르는 외부 코드입니다. 이것을 잠금을 쥔 채로 실행하면 세 가지 문제가 생길 수 있습니다. 첫째, close()가 느리면 그동안 다른 세션의 remove()와 register()가 모두 멈춥니다. 둘째, close()가 다른 스레드의 작업이 끝나기를 기다리는데 그 스레드가 remove()를 부르려고 같은 잠금을 기다린다면, 두 스레드가 서로를 기다리는 교착 상태(deadlock)에 빠집니다. 셋째, 집합을 순회하면서 바로 close()를 호출하는데 그 close()가 등록부의 remove()를 다시 부르면, 같은 스레드는 잠금을 다시 얻을 수 있으므로 remove()가 그대로 실행되어 순회 중인 집합이 바뀝니다. 그러면 다음 순회에서 ConcurrentModificationException이 날 수 있습니다. 그래서 잠금 안에서는 상태를 바꾸고 스냅샷을 만드는 일만 하고, 실제로 닫는 일은 잠금 밖에서 합니다.
main의 forEach(Session::close)에도 실제 서버에서는 고쳐야 할 점이 있습니다. Session.close()가 검사 예외를 선언하지 않았을 뿐 런타임 예외는 얼마든지 던질 수 있는데, forEach는 한 세션의 close()가 예외를 던지면 거기서 멈추고 나머지 세션을 닫지 않습니다. 세션마다 실패를 따로 처리해야 합니다.
for (SessionRegistry.Session session : registry.beginClose()) {
try {
session.close();
} catch (RuntimeException error) {
System.err.println("session close failed: " + error); // 기록하고 다음 세션으로 넘어간다
}
}여러 번 불려도 한 번만 정리하는 close
세션의 close()는 두 곳에서 불립니다. 세션이 스스로 일을 마치면 run()의 finally에서 부르고, 서버를 종료할 때는 종료 스레드가 부릅니다. 둘이 거의 동시에 일어날 수도 있습니다. 그래서 close()는 몇 번 불리든 실제 정리는 한 번만 하도록 만들어야 합니다. 아래 ManagedSocketSession은 AtomicBoolean의 compareAndSet(false, true)로 이를 처리합니다. 이 메서드는 값이 false일 때만 true로 바꾸고 성공 여부를 돌려주는 원자적 연산이므로, 여러 스레드가 동시에 호출해도 true를 받는 호출은 하나뿐입니다.
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.net.Socket;
import java.util.concurrent.atomic.AtomicBoolean;
public final class ManagedSocketSession implements Runnable, AutoCloseable {
private final Socket socket;
private final Runnable onClosed;
private final AtomicBoolean closed = new AtomicBoolean();
public ManagedSocketSession(Socket socket, Runnable onClosed) {
this.socket = socket;
this.onClosed = onClosed;
}
@Override public void run() {
try (var input = new DataInputStream(socket.getInputStream());
var output = new DataOutputStream(socket.getOutputStream())) {
String request = input.readUTF();
output.writeUTF("ok:" + request);
} catch (Exception error) {
if (!closed.get()) System.err.println("session failed: " + error);
} finally {
close();
}
}
@Override public void close() {
if (!closed.compareAndSet(false, true)) return;
try { socket.close(); }
catch (Exception error) { System.err.println("close failed: " + error); }
finally { onClosed.run(); }
}
public static void main(String[] args) throws Exception {
try (var socket = new Socket()) {
var session = new ManagedSocketSession(socket, () -> System.out.println("removed"));
session.close();
session.close();
}
}
}removed는 onClosed가 실행될 때마다 한 줄씩 찍힙니다. 그래서 출력의 줄 수가 곧 실제 정리가 일어난 횟수입니다.
removedmain은 아직 연결하지 않은 소켓으로 세션을 만들고 close()를 두 번 호출합니다. 첫 호출에서는 compareAndSet이 성공하므로 소켓을 닫고 finally에서 onClosed를 실행해 removed를 출력합니다. 두 번째 호출에서는 closed가 이미 true라서 compareAndSet이 실패하고, 메서드는 아무것도 하지 않고 돌아갑니다. 그래서 removed는 한 번만 출력됩니다. main의 try-with-resources가 마지막에 소켓을 한 번 더 닫지만, 이미 닫힌 소켓이므로 아무 일도 일어나지 않습니다.
run()의 catch에 붙은 if (!closed.get()) 조건도 눈여겨볼 만합니다. 종료 스레드가 close()로 소켓을 닫으면 readUTF()에서 기다리던 세션은 SocketException을 받습니다. 이것은 종료 과정에서 당연히 생기는 예외이므로, 이미 닫는 중이라면 실패로 기록하지 않습니다. 이 조건이 없으면 서버를 종료할 때마다 열려 있던 세션 수만큼 실패 로그가 쌓입니다.
이 close()가 막아 주는 범위도 정확히 알아 둬야 합니다. compareAndSet이 성공했다는 것은 '정리를 실행할 권한을 얻었다'는 뜻이지 '정리가 끝났다'는 뜻이 아닙니다. 첫 번째 호출이 아직 소켓을 닫는 중일 때 두 번째 호출이 들어오면, 두 번째 호출은 기다리지 않고 곧바로 돌아갑니다. 그러니 close()가 반환됐다는 사실만으로 정리가 끝났다고 판단하면 안 됩니다. 또 이 플래그는 세션의 close() 본문과 onClosed가 한 번만 실행되게 할 뿐, 소켓의 close()가 한 번만 불리게 하지는 않습니다. run()의 try-with-resources가 스트림을 닫으면서 소켓도 닫고, 이어서 finally의 close()가 소켓을 한 번 더 닫습니다. 소켓의 close()는 여러 번 불려도 안전하므로 문제는 없습니다.
세션 종료의 표시와 실제 완료는 다른 경계다
closed CAS, 소켓 close 시도, finally의 onClosed 콜백은 서로 다른 완료 경계입니다. 집합 제거·close 반환·작업자 종료를 같은 사건으로 오해하지 않도록 전환을 분리할 가치가 있습니다.
세션 종료의 표시와 실제 완료는 다른 경계다
세션 수명 · 상태와 완료 경계
세션 종료의 표시와 실제 완료는 다른 경계다
집합이 비었거나 CAS에 성공했다는 사실만으로 모든 소켓과 작업자의 완료를 알 수 없습니다.
세션 close의 실행권과 실제 호출 순서
CAS 성공만 socket.close 시도를 하고 finally의 onClosed로 이어진다. 패배 호출은 본문과 콜백을 생략한다. 콜백 호출은 성공이나 worker 종료를 증명하지 않는다.
레지스트리 비움과 소켓 닫기는 별도 경계다
별도 Registry의 beginClose는 closing을 설정하고 스냅샷을 만들고 집합을 비운다. 빈 집합은 스냅샷에 든 소켓의 닫기 완료가 아니다.
상태별 상세 해설
beginClose 뒤 size=0
- 이미 수행한 일
- closing 설정, 스냅샷 생성, 집합 비움
- 아직 보장하지 않는 일
- 스냅샷의 모든 Socket이 닫혔는지
closed의 CAS 성공
- 이미 수행한 일
- 한 호출이 close 메서드 본문 진입권 획득
- 아직 보장하지 않는 일
- Socket close와 onClosed의 완료
onClosed 호출
- 이미 수행한 일
- socket.close 시도 뒤 finally 진입
- 아직 보장하지 않는 일
- 콜백 성공과 worker 종료
CAS는 close 메서드 본문·콜백의 중복 진입을 막습니다. run의 try-with-resources에서도 스트림을 통해 Socket을 닫을 수 있으므로 모든 close 호출을 한 번으로 제한하지는 않습니다.
이 예의 조건
- ManagedSocketSession 원 main의 첫 session.close와 둘째 생략을 추적합니다. beginClose는 이 main에서 호출하지 않는 별도 Registry 비교입니다.
- CAS는 session close 본문·콜백의 중복 진입만 막습니다. run의 스트림 정리와 외부 Socket 정리까지 모든 close 호출을 한 번으로 제한하지 않습니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 설명 | |
| 전체 읽기 정보 | 세션 종료의 표시와 실제 완료는 다른 경계다 세션 종료의 표시와 실제 완료는 다른 경계다 세션 종료의 표시와 실제 완료는 다른 경계다 세션 수명 · 상태와 완료 경계 세션 종료의 표시와 실제 완료는 다른 경계다 집합이 비었거나 CAS에 성공했다는 사실만으로 모든 소켓과 작업자의 완료를 알 수 없습니다. beginClose 뒤 size=0 이미 수행한 일 closing 설정, 스냅샷 생성, 집합 비움 아직 보장하지 않는 일 스냅샷의 모든 Socket이 닫혔는지 closed의 CAS 성공 이미 수행한 일 한 호출이 close 메서드 본문 진입권 획득 아직 보장하지 않는 일 Socket close와 onClosed의 완료 onClosed 호출 이미 수행한 일 socket.close 시도 뒤 finally 진입 아직 보장하지 않는 일 콜백 성공과 worker 종료 CAS는 close 메서드 본문·콜백의 중복 진입을 막습니다. run의 try-with-resources에서도 스트림을 통해 Socket을 닫을 수 있으므로 모든 close 호출을 한 번으로 제한하지는 않습니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| Session close 실행권 첫 session.close의 closed CAS가 본문 진입권을 얻습니다. |
|
|
| Socket 시도 뒤 콜백 Socket close를 시도한 뒤 finally에서 onClosed를 호출합니다. |
|
|
| 원 main 반환과 둘째 생략 원 출력 콜백은 removed를 출력하고 둘째 session.close는 CAS 실패로 본문을 생략합니다. |
|
|
세션 종료의 표시와 실제 완료는 다른 경계다
closed CAS, 소켓 close 시도, finally의 onClosed 콜백은 서로 다른 완료 경계입니다. 집합 제거·close 반환·작업자 종료를 같은 사건으로 오해하지 않도록 전환을 분리할 가치가 있습니다.
세션 종료의 표시와 실제 완료는 다른 경계다
세션 수명 · 상태와 완료 경계
세션 종료의 표시와 실제 완료는 다른 경계다
집합이 비었거나 CAS에 성공했다는 사실만으로 모든 소켓과 작업자의 완료를 알 수 없습니다.
세션 close의 실행권과 실제 호출 순서
CAS 성공만 socket.close 시도를 하고 finally의 onClosed로 이어진다. 패배 호출은 본문과 콜백을 생략한다. 콜백 호출은 성공이나 worker 종료를 증명하지 않는다.
레지스트리 비움과 소켓 닫기는 별도 경계다
별도 Registry의 beginClose는 closing을 설정하고 스냅샷을 만들고 집합을 비운다. 빈 집합은 스냅샷에 든 소켓의 닫기 완료가 아니다.
상태별 상세 해설
beginClose 뒤 size=0
- 이미 수행한 일
- closing 설정, 스냅샷 생성, 집합 비움
- 아직 보장하지 않는 일
- 스냅샷의 모든 Socket이 닫혔는지
closed의 CAS 성공
- 이미 수행한 일
- 한 호출이 close 메서드 본문 진입권 획득
- 아직 보장하지 않는 일
- Socket close와 onClosed의 완료
onClosed 호출
- 이미 수행한 일
- socket.close 시도 뒤 finally 진입
- 아직 보장하지 않는 일
- 콜백 성공과 worker 종료
CAS는 close 메서드 본문·콜백의 중복 진입을 막습니다. run의 try-with-resources에서도 스트림을 통해 Socket을 닫을 수 있으므로 모든 close 호출을 한 번으로 제한하지는 않습니다.
이 예의 조건
- ManagedSocketSession 원 main의 첫 session.close와 둘째 생략을 추적합니다. beginClose는 이 main에서 호출하지 않는 별도 Registry 비교입니다.
- CAS는 session close 본문·콜백의 중복 진입만 막습니다. run의 스트림 정리와 외부 Socket 정리까지 모든 close 호출을 한 번으로 제한하지 않습니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 설명 | |
| 전체 읽기 정보 | 세션 종료의 표시와 실제 완료는 다른 경계다 세션 종료의 표시와 실제 완료는 다른 경계다 세션 종료의 표시와 실제 완료는 다른 경계다 세션 수명 · 상태와 완료 경계 세션 종료의 표시와 실제 완료는 다른 경계다 집합이 비었거나 CAS에 성공했다는 사실만으로 모든 소켓과 작업자의 완료를 알 수 없습니다. beginClose 뒤 size=0 이미 수행한 일 closing 설정, 스냅샷 생성, 집합 비움 아직 보장하지 않는 일 스냅샷의 모든 Socket이 닫혔는지 closed의 CAS 성공 이미 수행한 일 한 호출이 close 메서드 본문 진입권 획득 아직 보장하지 않는 일 Socket close와 onClosed의 완료 onClosed 호출 이미 수행한 일 socket.close 시도 뒤 finally 진입 아직 보장하지 않는 일 콜백 성공과 worker 종료 CAS는 close 메서드 본문·콜백의 중복 진입을 막습니다. run의 try-with-resources에서도 스트림을 통해 Socket을 닫을 수 있으므로 모든 close 호출을 한 번으로 제한하지는 않습니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| Session close 실행권 첫 session.close의 closed CAS가 본문 진입권을 얻습니다. |
|
|
| Socket 시도 뒤 콜백 Socket close를 시도한 뒤 finally에서 onClosed를 호출합니다. |
|
|
| 원 main 반환과 둘째 생략 원 출력 콜백은 removed를 출력하고 둘째 session.close는 CAS 실패로 본문을 생략합니다. |
|
|
실제 서버에서 onClosed는 등록부에서 이 세션을 빼는 일을 해야 합니다. 그런데 onClosed 람다는 세션 객체보다 먼저 만들어지므로, 람다 안에서 아직 만들어지지 않은 그 세션을 가리키기가 어색합니다. 흔한 해결책은 두 가지입니다. 세션 생성자에 등록부를 넘겨서 세션이 close() 안에서 registry.remove(this)를 직접 부르게 하거나, 세션마다 ID를 붙여 등록부가 ID로 세션을 빼게 하는 것입니다. 어느 쪽이든 onClosed가 실패해도 이미 닫은 소켓이 다시 열리지는 않습니다. 그 실패는 등록부에 유령 항목이 남았다는 뜻일 수 있으므로 따로 기록해 두어야 합니다.
accept 다음에는 등록하고 나서 제출한다
이제 accept 루프가 받은 소켓을 세션에 넘기는 순서를 정하겠습니다. 소켓을 받으면 세션 객체를 만들고, 등록부에 등록한 다음, 실행기에 제출합니다. 각 단계가 실패했을 때 누가 소켓을 닫는지가 이 순서의 핵심입니다. 아래는 연결 하나를 받아 넘기는 과정을 흐름만 보이도록 줄인 메서드 본문입니다. newSession()은 소켓으로 세션 객체를 만드는 메서드이고, 그렇게 만든 session은 ManagedSocketSession처럼 Runnable이면서 close()를 가진 세션입니다. registry는 앞의 SessionRegistry처럼 동작하는 등록부, workers는 세션을 실행할 실행기라고 생각하면 됩니다.
Socket socket = listener.accept();
var session = newSession(socket); // 이 순간부터 소켓은 세션의 것이다
if (!registry.register(session)) { // 종료 중이라 등록이 거절되면
session.close(); // 받은 쪽이 바로 닫는다
return;
}
try {
workers.execute(session); // 제출에 성공하면 이제 세션의 run()이 닫는다
} catch (RejectedExecutionException rejected) {
registry.remove(session); // 제출이 거절되면 등록을 되돌리고
session.close(); // 직접 닫는다
}등록이 거절되는 경우는 종료가 이미 시작된 경우입니다. 이때 세션은 실행된 적이 없으므로 받은 쪽이 바로 닫습니다. 제출이 거절되는 경우는 실행기가 이미 종료됐거나 작업을 더 받을 수 없을 때이고, RejectedExecutionException이 납니다. 이때는 방금 한 등록을 되돌리고 세션을 닫습니다. 제출에 성공한 뒤로는 세션의 run()이 finally에서 스스로 닫으므로 accept 루프는 더 이상 이 소켓을 신경 쓰지 않습니다. 모든 경로에서 소켓을 닫는 쪽이 정확히 정해져 있다는 점이 중요합니다.
순서를 바꿔 제출한 뒤에 등록하면 어떻게 될까요? 아주 짧은 세션이라면 제출되자마자 실행되어 일을 마치고, finally에서 등록부의 remove()까지 부를 수 있습니다. 아직 등록되지 않았으니 이 remove()는 아무것도 지우지 못하고, 그 뒤에 accept 루프가 이미 끝난 세션을 등록합니다. 이렇게 남은 유령 항목은 아무도 지우지 않으므로 등록부의 크기가 0으로 돌아오지 않고, 서버 종료 때는 이미 닫힌 세션을 다시 닫게 됩니다. 등록을 먼저 하면 이런 일이 생기지 않고, 대신 제출이 실패했을 때 등록을 되돌리는 코드만 챙기면 됩니다.
| 상황 | 등록부 | 소켓을 닫는 쪽 |
|---|---|---|
| 정상적으로 받음 | 등록 | 세션의 run() |
| 종료 중에 받음 | 등록 거절 | accept 루프 |
| 실행기 제출 실패 | 등록 되돌림 | accept 루프 |
| 세션이 스스로 끝남 | remove()로 제거 | 세션의 finally |
| 서버 전체 종료 | beginClose()로 비움 | 스냅샷을 받은 종료 스레드 |
목록이 비었다고 세션이 끝난 것은 아니다
beginClose()는 스냅샷을 만든 직후 집합을 비웁니다. 그래서 beginClose()를 부른 바로 다음에 size()를 확인하면 0이 나오지만, 이것은 스냅샷 속 소켓이 모두 닫혔다는 뜻도, 세션을 실행하던 작업자 스레드가 모두 끝났다는 뜻도 아닙니다. 등록부의 크기는 '종료 스레드가 닫아야 할 목록을 가져갔다'는 사실만 알려 줍니다.
서버 종료가 작업자 스레드의 완료까지 기다려야 한다면 다른 수단이 필요합니다. 실행기를 shutdown()한 뒤 awaitTermination()으로 기다리거나, 제출할 때 받은 Future를 모아 두고 확인합니다. 실행기를 종료하는 방법은 ExecutorService를 안전하게 종료하기에서 다뤘습니다. 반대로 끝없이 기다려서도 안 됩니다. 종료가 멈추면 배포도 멈추기 때문입니다. 세션을 닫은 뒤 정해진 시간만큼만 기다리고, 그래도 남은 작업은 취소하는 식으로 끝을 정해 두어야 합니다. 이때 아직 끝나지 않은 세션의 ID와 원격 주소, 마지막으로 활동한 시각을 기록해 두면 어떤 연결이 종료를 붙잡고 있었는지 나중에 찾을 수 있습니다. 다음 글에서 이 순서와 시간 제한을 갖춘 종료 절차를 만듭니다.
정리
- 리스너를 닫아도 이미 연결된 세션은
read()에서 계속 기다립니다. 세션을 깨우려면 그 세션의 소켓을 닫아야 하므로, 살아 있는 세션을 등록부에 기록해 둡니다. - 세션 객체가
Runnable과AutoCloseable을 함께 구현하면 등록부는close()하나로 세션을 끝낼 수 있습니다. closing확인과 집합 추가, 종료 시작을 같은 잠금으로 묶어야 등록과 종료가 겹쳐도 빠지는 세션이 없습니다. 실제로 닫는 일은 스냅샷을 받아 잠금 밖에서 하고, 세션마다 실패를 따로 처리합니다.compareAndSet으로close()의 정리를 한 번만 실행합니다. 다만 그것이 정리 완료를 알려 주지는 않습니다.accept뒤에는 등록하고 나서 제출하고, 등록이나 제출이 거절되면 받은 쪽이 소켓을 닫습니다.- 등록부가 비었다는 것은 작업자 스레드가 끝났다는 뜻이 아닙니다. 완료는 실행기 종료 대기로 따로 확인합니다.
연습 문제
- 한 스레드는 세션 하나를 등록하고, 다른 스레드는 동시에 종료를 시작하게 하세요. 등록에 성공하면 종료 스레드가, 등록이 거절되면 등록 스레드가 세션을 닫도록 만들고, 세션의
close()가 정확히 한 번 호출되는지 확인합니다. 프로그램을 여러 번 실행해도 결과가 같은지 보고, 그 이유를 두 가지 실행 순서로 설명하세요. - 1번의 등록부에 세션 A와 B가 다음 순서로 들어온다고 가정하세요. A의 등록 성공, 종료 시작(스냅샷 생성), B의 등록 시도. 스냅샷에는 몇 개가 들어 있고, A와 B는 각각 누가 닫나요? 종료 시작 직후 집합이 비었다는 사실로 작업자 스레드의 완료까지 판단할 수 있을까요?
ManagedSocketSession.run()의catch에서if (!closed.get())조건을 지우면, 세션 열 개가 요청을 기다리는 중에 서버를 종료할 때 로그에 무엇이 남을까요?
1번 해설
import java.util.HashSet;
import java.util.Set;
import java.util.concurrent.atomic.AtomicInteger;
public final class RegistrationRaceSolution {
static final class Registry {
private final Set<Tracked> values = new HashSet<>();
private boolean closing;
synchronized boolean add(Tracked value) {
if (closing) return false;
return values.add(value);
}
synchronized Set<Tracked> closeSnapshot() {
closing = true;
Set<Tracked> snapshot = Set.copyOf(values);
values.clear();
return snapshot;
}
}
static final class Tracked implements AutoCloseable {
private final AtomicInteger count = new AtomicInteger();
public void close() { count.incrementAndGet(); }
}
public static void main(String[] args) throws Exception {
Registry registry = new Registry();
Tracked session = new Tracked();
Thread register = Thread.startVirtualThread(() -> {
if (!registry.add(session)) session.close();
});
Thread shutdown = Thread.startVirtualThread(() ->
registry.closeSnapshot().forEach(Tracked::close));
register.join();
shutdown.join();
System.out.println("closeCount=" + session.count.get());
}
}실행 결과:
closeCount=1프로그램을 여러 번 실행해도 결과는 항상 closeCount=1입니다. add()와 closeSnapshot()이 같은 잠금을 쓰므로 둘 중 하나가 먼저 실행되고, 가능한 순서는 두 가지뿐이기 때문입니다. add()가 먼저 실행되면 세션은 등록에 성공하고, 뒤이어 실행된 closeSnapshot()의 스냅샷에 들어가 종료 스레드가 닫습니다. closeSnapshot()이 먼저 실행되면 closing이 이미 true이므로 add()가 false를 돌려주고, 등록 스레드가 직접 닫습니다. 어느 순서든 닫는 쪽은 정확히 한 곳입니다. 두 경로가 함께 실행되는 일은 없으므로 close()가 두 번 불리거나 아예 불리지 않는 경우는 생기지 않습니다. 이 프로그램은 한 번 실행할 때 한 번만 경합하므로, 실제로 두 순서가 모두 나타나는지 보려면 반복문으로 여러 번 경합시키며 어느 쪽이 닫았는지 세어 볼 수 있습니다.
등록과 종료가 같은 잠금을 사용할 때의 두 경로
등록과 종료 중 먼저 잠금을 획득한 쪽에 따라 snapshot에 포함되어 종료 스레드가 닫거나 등록 거부 후 등록 스레드가 닫습니다. 가능한 두 경로에서 closeCount=1을 유지하는 이유를 보여 줄 수 있습니다.
등록과 종료가 같은 잠금을 사용할 때의 두 경로
등록 경합 · 같은 잠금의 두 경로
등록과 종료가 같은 잠금을 사용할 때의 두 경로
먼저 잠금을 얻은 쪽에 따라 종료 스레드 또는 등록 스레드가 같은 세션을 닫습니다.
등록 스레드: add 성공
- 뒤에서 실행되는 연산
- 종료 스레드가 해당 세션을 스냅샷에 포함
- 이 연습 문제에서 close하는 쪽
- 종료 스레드
종료 스레드: closing=true
- 뒤에서 실행되는 연산
- 등록 스레드의 add가 false 반환
- 이 연습 문제에서 close하는 쪽
- 등록 스레드
RegistrationRaceSolution은 한 세션으로 한 번 경합합니다. closeCount=1은 이 코드의 두 분기에서 도출되며, 반복 스트레스 검사를 수행한 결과는 아닙니다.
이 예의 조건
- 같은 세션 A의 add·closeSnapshot 잠금 진입 순서 두 조건을 모든 관찰 단계에서 비교합니다.
- 원 main은 한 번 경합한 뒤 두 스레드를 join하며 반복 스트레스 결과를 제공하지 않습니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | 한 세션 A의 등록 add와 closeSnapshot 중 어느 임계 영역이 먼저 실행되는지를 비교한다. add가 먼저면 성공한 A를 종료 스냅샷에 포함해 종료 스레드가 닫고, closeSnapshot이 먼저면 closing 뒤 add가 실패해 등록 스레드가 닫는다. 두 갈래는 같은 세션의 대안이며 항상 두 close가 실행되는 흐름이 아니다. 원래 main은 한 번 경합하며 반복 스트레스 결과는 없다. |
| 구분 | 내용 |
|---|---|
| 설명 | 한 세션 A의 등록 add와 closeSnapshot 중 어느 임계 영역이 먼저 실행되는지를 비교한다. add가 먼저면 성공한 A를 종료 스냅샷에 포함해 종료 스레드가 닫고, closeSnapshot이 먼저면 closing 뒤 add가 실패해 등록 스레드가 닫는다. 두 갈래는 같은 세션의 대안이며 항상 두 close가 실행되는 흐름이 아니다. 원래 main은 한 번 경합하며 반복 스트레스 결과는 없다. |
| 전체 읽기 정보 | 등록과 종료가 같은 잠금을 사용할 때의 두 경로 등록과 종료가 같은 잠금을 사용할 때의 두 경로 등록과 종료가 같은 잠금을 사용할 때의 두 경로 등록 경합 · 같은 잠금의 두 경로 등록과 종료가 같은 잠금을 사용할 때의 두 경로 먼저 잠금을 얻은 쪽에 따라 종료 스레드 또는 등록 스레드가 같은 세션을 닫습니다. 등록 스레드: add 성공 뒤에서 실행되는 연산 종료 스레드가 해당 세션을 스냅샷에 포함 이 연습 문제에서 close하는 쪽 종료 스레드 종료 스레드: closing=true 뒤에서 실행되는 연산 등록 스레드의 add가 false 반환 이 연습 문제에서 close하는 쪽 등록 스레드 RegistrationRaceSolution은 한 세션으로 한 번 경합합니다. closeCount=1은 이 코드의 두 분기에서 도출되며, 반복 스트레스 검사를 수행한 결과는 아닙니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 같은 잠금의 두 선행 조건 같은 잠금에서 add 또는 closeSnapshot 중 먼저 진입한 쪽이 이후 등록 판정을 정합니다. |
|
|
| 등록과 스냅샷의 각 상태 등록이 앞서면 A가 스냅샷에 있고 종료가 앞서면 add는 거절됩니다. |
|
|
| 각 닫기 책임과 회수 편입된 A는 종료 스레드가, 거절된 A는 등록 스레드가 닫습니다. |
|
|
등록과 종료가 같은 잠금을 사용할 때의 두 경로
등록과 종료 중 먼저 잠금을 획득한 쪽에 따라 snapshot에 포함되어 종료 스레드가 닫거나 등록 거부 후 등록 스레드가 닫습니다. 가능한 두 경로에서 closeCount=1을 유지하는 이유를 보여 줄 수 있습니다.
등록과 종료가 같은 잠금을 사용할 때의 두 경로
등록 경합 · 같은 잠금의 두 경로
등록과 종료가 같은 잠금을 사용할 때의 두 경로
먼저 잠금을 얻은 쪽에 따라 종료 스레드 또는 등록 스레드가 같은 세션을 닫습니다.
등록 스레드: add 성공
- 뒤에서 실행되는 연산
- 종료 스레드가 해당 세션을 스냅샷에 포함
- 이 연습 문제에서 close하는 쪽
- 종료 스레드
종료 스레드: closing=true
- 뒤에서 실행되는 연산
- 등록 스레드의 add가 false 반환
- 이 연습 문제에서 close하는 쪽
- 등록 스레드
RegistrationRaceSolution은 한 세션으로 한 번 경합합니다. closeCount=1은 이 코드의 두 분기에서 도출되며, 반복 스트레스 검사를 수행한 결과는 아닙니다.
이 예의 조건
- 같은 세션 A의 add·closeSnapshot 잠금 진입 순서 두 조건을 모든 관찰 단계에서 비교합니다.
- 원 main은 한 번 경합한 뒤 두 스레드를 join하며 반복 스트레스 결과를 제공하지 않습니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | 한 세션 A의 등록 add와 closeSnapshot 중 어느 임계 영역이 먼저 실행되는지를 비교한다. add가 먼저면 성공한 A를 종료 스냅샷에 포함해 종료 스레드가 닫고, closeSnapshot이 먼저면 closing 뒤 add가 실패해 등록 스레드가 닫는다. 두 갈래는 같은 세션의 대안이며 항상 두 close가 실행되는 흐름이 아니다. 원래 main은 한 번 경합하며 반복 스트레스 결과는 없다. |
| 구분 | 내용 |
|---|---|
| 설명 | 한 세션 A의 등록 add와 closeSnapshot 중 어느 임계 영역이 먼저 실행되는지를 비교한다. add가 먼저면 성공한 A를 종료 스냅샷에 포함해 종료 스레드가 닫고, closeSnapshot이 먼저면 closing 뒤 add가 실패해 등록 스레드가 닫는다. 두 갈래는 같은 세션의 대안이며 항상 두 close가 실행되는 흐름이 아니다. 원래 main은 한 번 경합하며 반복 스트레스 결과는 없다. |
| 전체 읽기 정보 | 등록과 종료가 같은 잠금을 사용할 때의 두 경로 등록과 종료가 같은 잠금을 사용할 때의 두 경로 등록과 종료가 같은 잠금을 사용할 때의 두 경로 등록 경합 · 같은 잠금의 두 경로 등록과 종료가 같은 잠금을 사용할 때의 두 경로 먼저 잠금을 얻은 쪽에 따라 종료 스레드 또는 등록 스레드가 같은 세션을 닫습니다. 등록 스레드: add 성공 뒤에서 실행되는 연산 종료 스레드가 해당 세션을 스냅샷에 포함 이 연습 문제에서 close하는 쪽 종료 스레드 종료 스레드: closing=true 뒤에서 실행되는 연산 등록 스레드의 add가 false 반환 이 연습 문제에서 close하는 쪽 등록 스레드 RegistrationRaceSolution은 한 세션으로 한 번 경합합니다. closeCount=1은 이 코드의 두 분기에서 도출되며, 반복 스트레스 검사를 수행한 결과는 아닙니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 같은 잠금의 두 선행 조건 같은 잠금에서 add 또는 closeSnapshot 중 먼저 진입한 쪽이 이후 등록 판정을 정합니다. |
|
|
| 등록과 스냅샷의 각 상태 등록이 앞서면 A가 스냅샷에 있고 종료가 앞서면 add는 거절됩니다. |
|
|
| 각 닫기 책임과 회수 편입된 A는 종료 스레드가, 거절된 A는 등록 스레드가 닫습니다. |
|
|
여기에 더해 close() 자체를 멱등하게 만들어 두면, 이보다 복잡한 경합에서 실수로 두 곳이 닫더라도 부작용이 생기지 않습니다.
2번 해설
스냅샷에는 A 한 개만 들어 있고, 종료 스레드가 A를 닫습니다. B는 closing이 true가 된 뒤에 등록을 시도했으므로 거절되고, 등록 스레드가 B를 직접 닫습니다. 그래서 A와 B는 각각 한 번씩, 모두 두 번 닫힙니다. 종료 시작 직후 집합이 빈 것은 closeSnapshot()이 스냅샷을 만들고 집합을 바로 비웠기 때문입니다. 그 시점에는 A의 close()가 아직 호출되지 않았을 수도 있고, A를 실행하던 작업자 스레드도 끝나지 않았을 수 있습니다. 따라서 빈 집합으로 작업자 스레드의 완료를 판단할 수 없고, 완료는 실행기 종료 대기 같은 별도 수단으로 확인해야 합니다.
3번 해설
열 개의 세션이 모두 readUTF()에서 기다리고 있으므로, 종료 스레드가 각 세션의 close()로 소켓을 닫는 순간 세션마다 SocketException: Socket closed가 납니다. 조건을 지우면 이 예외가 모두 session failed: java.net.SocketException: Socket closed 같은 실패 로그로 남아 종료할 때마다 실패가 열 건씩 기록됩니다. 실제로는 서버가 의도한 종료이므로 실패가 아닙니다. 조건이 있으면 closed가 이미 true인 세션의 예외는 기록하지 않으므로, 종료 과정에서 생긴 예외와 진짜 장애를 구분할 수 있습니다.
다음 글
accept 루프 멈추기와 단계적 서버 종료에서는 accept()에서 기다리는 루프를 멈추는 방법과, 리스너·세션·작업자 스레드를 정해진 순서와 시간 안에 정리하는 종료 절차를 만듭니다.