accept 루프 멈추기와 단계적 서버 종료
다른 스레드에서 ServerSocket을 닫아 accept 대기를 풀고, 새 연결 차단·세션 닫기·작업 대기를 정해진 순서와 시간 안에 진행하는 종료 절차를 만들며, shutdown 훅이 실행되는 경우와 실행되지 않는 경우를 배웁니다.
앞 글에서는 살아 있는 세션을 등록부로 관리하고, 종료할 때 등록부가 돌려준 스냅샷으로 세션을 닫았습니다. 이제 서버 종료 절차 전체를 조립할 차례인데, 첫 단계부터 질문이 생깁니다. 서버의 accept 루프는 어떻게 멈출까요? 이 루프를 도는 스레드는 대부분의 시간을 accept() 안에서 다음 연결을 기다리며 보냅니다.
이 글에서는 먼저 accept 루프를 멈추는 방법을 봅니다. 이어서 새 연결을 막고, 세션을 닫고, 작업자 스레드를 기다리는 일을 정해진 순서와 시간 안에 끝내는 종료 절차를 만듭니다. 마지막으로 이 절차를 시작하는 JVM의 shutdown 훅이 언제 실행되고 언제 실행되지 않는지 살펴봅니다.
플래그만 바꿔서는 accept 루프가 멈추지 않는다
반복문을 멈출 때 가장 먼저 떠오르는 방법은 boolean 플래그입니다. 다른 스레드가 running을 false로 바꾸면 루프가 끝나도록 쓰는 것입니다.
while (running) { // 다른 스레드가 running을 false로 바꿔도
Socket socket = listener.accept(); // 여기서 기다리는 동안에는 조건을 다시 볼 기회가 없다
handOff(socket);
}그런데 이 루프는 running이 바뀌어도 끝나지 않습니다. 조건은 한 바퀴를 돌고 돌아왔을 때만 검사하는데, 스레드는 accept() 안에서 다음 연결이 올 때까지 기다리고 있기 때문입니다. 서버를 종료하는 시점에 새 연결이 온다는 보장은 없으니, 루프는 영영 조건을 다시 보지 못할 수도 있습니다. 앞 글에서 본 소켓 읽기처럼, 플랫폼 스레드가 accept()에서 기다리는 중이라면 인터럽트로도 깨어나지 않습니다.
해결책은 다른 스레드가 ServerSocket을 닫는 것입니다. 리스너가 닫히면 accept()에서 기다리던 스레드는 곧바로 SocketException을 받고 빠져나옵니다. 이제 루프가 할 일은 이 예외가 종료 때문에 생긴 것인지, 리스너에 진짜 문제가 생긴 것인지 구분하는 것입니다.
리스너를 닫아 accept 깨우기
아래 StoppableAcceptLoop은 루프 한 바퀴에 해당하는 runOnce()와 리스너를 닫는 close()를 가집니다. main은 가상 스레드를 하나 만들어 runOnce()를 실행하게 하고, 곧바로 자기 스레드에서 리스너를 닫습니다. 클라이언트는 하나도 접속하지 않습니다.
import java.io.IOException;
import java.net.InetAddress;
import java.net.ServerSocket;
import java.net.Socket;
public final class StoppableAcceptLoop implements AutoCloseable {
private final ServerSocket listener;
StoppableAcceptLoop() throws IOException {
listener = new ServerSocket(0, 10, InetAddress.getLoopbackAddress());
}
int port() { return listener.getLocalPort(); }
void runOnce() throws IOException {
try (Socket socket = listener.accept()) {
System.out.println("accepted=" + socket.getRemoteSocketAddress());
} catch (IOException error) {
if (!listener.isClosed()) throw error;
System.out.println("accept stopped by close");
}
}
@Override public void close() throws IOException { listener.close(); }
public static void main(String[] args) throws Exception {
try (var loop = new StoppableAcceptLoop()) {
Thread acceptor = Thread.startVirtualThread(() -> {
try { loop.runOnce(); }
catch (IOException error) { throw new RuntimeException(error); }
});
loop.close();
acceptor.join();
}
}
}클라이언트가 하나도 접속하지 않으므로 accepted= 줄이 찍힐 일은 없습니다. 볼 것은 리스너를 닫았을 때 accept()에서 난 예외가 다시 던져지는지, 아니면 catch에서 정상 종료로 처리되는지입니다.
accept stopped by closemain이 리스너를 닫는 시점은 실행할 때마다 조금씩 다릅니다. 가상 스레드가 accept()를 부르기도 전에 닫힐 수도 있고, accept()에서 기다리는 도중에 닫힐 수도 있습니다. 앞의 경우에는 Socket is closed, 뒤의 경우에는 Socket closed라는 메시지의 SocketException이 납니다. 어느 쪽이든 catch에서 listener.isClosed()가 true이므로 출력은 같습니다. 코드가 예외 메시지가 아니라 리스너의 상태를 보고 판단하는 이유가 여기에 있습니다. main의 try-with-resources가 마지막에 리스너를 한 번 더 닫지만, 이미 닫힌 소켓이므로 아무 일도 일어나지 않습니다.
리스너가 닫히지 않았는데 accept()가 실패했다면 예외를 다시 던집니다. 이것은 종료가 아니라 서버의 문제이기 때문입니다. 예를 들어 소켓 하나는 파일 디스크립터 하나를 쓰는데, 프로세스가 열 수 있는 파일 디스크립터가 바닥나면 accept()는 IOException으로 실패합니다(Linux에서는 Too many open files). 모든 IOException을 정상 종료로 삼켜 버리면, 프로세스는 살아 있는데 새 연결은 받지 않는 서버가 됩니다. 이런 예외는 서버의 시작과 종료를 관리하는 상위 코드로 올려 보내 기록하고 대응하게 해야 합니다.
isClosed()는 지금 리스너가 닫혀 있는지만 알려 줄 뿐, 누가 닫았는지는 알려 주지 않습니다. 버그 때문에 엉뚱한 곳에서 리스너를 닫았더라도 정상 종료처럼 보인다는 뜻입니다. 이를 구별하고 싶다면 종료 코드가 리스너를 닫기 전에 '종료 중'이라는 플래그를 먼저 세우고, catch에서는 그 플래그를 확인하면 됩니다.
리스너를 닫는 순간과 새 연결이 들어오는 순간이 겹치면, accept()가 소켓을 돌려준 직후에 종료가 시작될 수도 있습니다. 이 소켓은 앞 글의 규칙대로 처리합니다. 등록부에 등록을 시도하고, 이미 종료 중이라 거절되면 받은 쪽이 그 자리에서 닫습니다. 또 리스너를 닫는다고 이미 받은 연결이 닫히지는 않으므로, 세션 정리는 다음 단계에서 따로 해야 합니다.
종료 순서: 새 연결을 막는 일부터
서버를 정상적으로 종료하는 일반적인 순서는 다음과 같습니다.
- 준비 상태(readiness)를 '요청을 받을 수 없음'으로 바꿔, 로드 밸런서가 새 요청을 보내지 않게 합니다.
- 리스너를 닫아
accept루프를 멈춥니다. - 등록부를 종료 상태로 바꾸고, 돌려받은 스냅샷의 세션을 닫습니다.
- 실행기를
shutdown()하고, 정해진 시간 동안 작업자 스레드가 끝나기를 기다립니다. - 로그와 지표를 마지막으로 내보냅니다.
준비 상태는 배포 환경에서 서버 앞에 놓인 로드 밸런서가 주기적으로 '지금 요청을 받을 수 있나'를 묻는 검사의 응답입니다. 이것을 먼저 내리면 리스너를 닫기 전에 새 요청이 들어오는 것부터 멈출 수 있습니다. 순서가 중요한 이유는 반대로 해 보면 알 수 있습니다. 리스너가 열린 채로 세션부터 닫으면, 그동안에도 새 연결이 계속 들어와 닫아야 할 세션이 끝없이 생깁니다. 준비 상태를 내린 뒤 리스너를 닫기 전까지 잠시 기다려 남은 요청이 빠져나가게 하는 방법도 있습니다. 다만 배포 플랫폼이 실제로 트래픽을 끊은 시점을 확인하고 기다리는 시간을 정해야 합니다.
아래 ShutdownCoordinator는 2단계부터 4단계까지를 하나의 메서드로 묶은 축약 예제입니다. main에서는 리스너와 세션 대신 이름을 출력하는 람다를 넘깁니다.
import java.time.Duration;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
public final class ShutdownCoordinator implements AutoCloseable {
private final AtomicBoolean closing = new AtomicBoolean();
private final ExecutorService workers = Executors.newVirtualThreadPerTaskExecutor();
private final List<AutoCloseable> sessions;
private final AutoCloseable listener;
ShutdownCoordinator(AutoCloseable listener, List<AutoCloseable> sessions) {
this.listener = listener;
this.sessions = sessions;
}
void close(Duration timeout) {
if (!closing.compareAndSet(false, true)) return;
closeQuietly(listener);
sessions.forEach(ShutdownCoordinator::closeQuietly);
workers.shutdown();
try {
if (!workers.awaitTermination(timeout.toMillis(), TimeUnit.MILLISECONDS)) {
workers.shutdownNow();
}
} catch (InterruptedException error) {
workers.shutdownNow();
Thread.currentThread().interrupt();
}
}
private static void closeQuietly(AutoCloseable value) {
try { value.close(); }
catch (Exception error) { System.err.println("close=" + error); }
}
@Override public void close() { close(Duration.ofSeconds(1)); }
public static void main(String[] args) {
var coordinator = new ShutdownCoordinator(
() -> System.out.println("listener closed"),
List.of(() -> System.out.println("session closed")));
coordinator.close(Duration.ofMillis(100));
coordinator.close(Duration.ofMillis(100));
}
}main이 넘긴 두 람다는 닫힐 때 자기 이름을 찍으므로, 출력 순서가 곧 close(Duration)이 자원을 닫는 순서입니다. workers에는 제출된 작업이 없으므로 awaitTermination은 시간 제한까지 기다리지 않고 바로 돌아옵니다.
listener closed
session closedclose(Duration)의 첫 줄은 앞 글의 세션과 같은 방법으로, 여러 번 불려도 종료 절차가 한 번만 실행되게 합니다. main이 close()를 두 번 불렀지만 두 번째 호출은 compareAndSet에서 실패해 바로 돌아가므로, 출력은 첫 호출이 남긴 두 줄뿐입니다. closeQuietly()는 닫다가 난 예외를 기록하고 다음 자원으로 넘어갑니다. 리스너를 닫다 실패했다고 세션을 닫지 않으면 안 되기 때문입니다.
실행기를 다루는 부분은 ExecutorService를 안전하게 종료하기에서 본 방식 그대로입니다. shutdown()은 새 작업을 더 받지 않게 할 뿐 실행 중인 작업은 계속 돌게 둡니다. awaitTermination()은 주어진 시간 안에 모든 작업이 끝나면 true를, 시간이 다 되면 false를 돌려줍니다. false라면 shutdownNow()로 남은 작업을 인터럽트해 중단을 요청합니다. 기다리는 도중 이 스레드가 인터럽트되면 InterruptedException이 나는데, 이때도 남은 작업의 중단을 요청하고, 인터럽트 상태를 다시 설정해 이 메서드를 부른 쪽도 인터럽트 사실을 알 수 있게 합니다.
3단계에서 세션을 닫는 일이 4단계보다 앞서야 하는 이유도 여기서 보입니다. 세션 작업이 플랫폼 스레드의 소켓 읽기에서 기다리고 있다면 shutdownNow()의 인터럽트로는 깨어나지 않습니다. 소켓을 먼저 닫아야 그 작업들이 예외로 빠져나와 끝나고, 그래야 awaitTermination()이 제시간에 true를 돌려줄 수 있습니다.
종료 예제가 수행하는 일과 기한이 적용되는 범위
listener와 세션 정리 뒤 executor.shutdown과 제한된 awaitTermination이 이어집니다. 대기는 앞선 정리 시간을 포함하지 않으며 false 뒤 shutdownNow도 완료 확인이 아니라는 경계를 단계로 설명할 수 있습니다.
종료 예제가 수행하는 일과 기한이 적용되는 범위
종료 조정 · timeout의 범위
종료 예제가 수행하는 일과 기한이 적용되는 범위
coordinator의 timeout은 작업자 대기에만 쓰이며, 앞선 close 시간은 차감하지 않습니다.
선행 close에는 작업자 대기 예산이 적용되지 않는다
CAS 성공만 listener와 session close를 순서대로 수행한다. 각 close에는 기한이 없고 예외는 기록 후 계속한다. timeout은 뒤의 worker await에만 적용하며 false 뒤 두 번째 대기는 없다. 원 main은 출력용 콜백과 빈 실행기를 사용한다. 별도 ShutdownDeadlineSolution stage는 예산·skipped를 출력하는 독립 데모다.
작업자 대기 중 호출자가 중단된 출구
await의 InterruptedException 경로는 shutdownNow를 요청하고 호출자 interrupt를 복원한다. worker 종료를 다시 기다리지는 않는다.
상태별 상세 해설
coordinator의 listener·sessions
- 실제 동작
- 순서대로 close, Exception은 기록 후 계속
- 기한·완료의 한계
- 각 close 호출에는 시간 제한이 없음
coordinator의 workers
- 실제 동작
- shutdown 후 awaitTermination(timeout)
- 기한·완료의 한계
- false면 shutdownNow 요청, 두 번째 대기는 없음
coordinator의 중복 close
- 실제 동작
- 첫 CAS 성공 호출만 정리 시작
- 기한·완료의 한계
- 다른 호출의 반환은 첫 정리 완료를 뜻하지 않음
ShutdownDeadlineSolution의 stage
- 실제 동작
- 남은 예산 또는 skipped 출력
- 기한·완료의 한계
- 실제 close·대기·강제 정리 코드는 없음
timeout 인자는 앞선 close 시간을 차감한 전체 기한이 아닙니다. 운영 조립에서는 같은 절대 기한을 각 작업에 전달하고 완료 확인을 별도로 연결해야 합니다.
이 예의 조건
- ShutdownCoordinator 한 프로그램의 close 경로만 추적합니다. 원 main은 출력용 listener·session과 작업 없는 실행기를 사용하고 100ms 인수로 순차 두 번 호출합니다.
- ShutdownDeadlineSolution의 stage는 남은 예산·skipped를 출력하는 독립 데모이며 실제 close·대기·강제 정리를 수행하지 않습니다. 원문 전체 표로 보존합니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 설명 | |
| 전체 읽기 정보 | 종료 예제가 수행하는 일과 기한이 적용되는 범위 종료 예제가 수행하는 일과 기한이 적용되는 범위 종료 예제가 수행하는 일과 기한이 적용되는 범위 종료 조정 · timeout의 범위 종료 예제가 수행하는 일과 기한이 적용되는 범위 coordinator의 timeout은 작업자 대기에만 쓰이며, 앞선 close 시간은 차감하지 않습니다. coordinator의 listener·sessions 실제 동작 순서대로 close, Exception은 기록 후 계속 기한·완료의 한계 각 close 호출에는 시간 제한이 없음 coordinator의 workers 실제 동작 shutdown 후 awaitTermination(timeout) 기한·완료의 한계 false면 shutdownNow 요청, 두 번째 대기는 없음 coordinator의 중복 close 실제 동작 첫 CAS 성공 호출만 정리 시작 기한·완료의 한계 다른 호출의 반환은 첫 정리 완료를 뜻하지 않음 ShutdownDeadlineSolution의 stage 실제 동작 남은 예산 또는 skipped 출력 기한·완료의 한계 실제 close·대기·강제 정리 코드는 없음 timeout 인자는 앞선 close 시간을 차감한 전체 기한이 아닙니다. 운영 조립에서는 같은 절대 기한을 각 작업에 전달하고 완료 확인을 별도로 연결해야 합니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 실행권 뒤 선행 자원 정리 첫 CAS 성공 호출이 listener와 session을 차례로 close합니다. |
|
|
| 작업자 대기의 각 출구 shutdown 뒤 awaitTermination(timeout)의 결과와 호출자 중단을 구분합니다. |
|
|
| 순차 둘째 호출과 기한 범위 첫 호출 뒤 원 main의 둘째 close는 CAS 실패로 생략됩니다. |
|
|
종료 예제가 수행하는 일과 기한이 적용되는 범위
listener와 세션 정리 뒤 executor.shutdown과 제한된 awaitTermination이 이어집니다. 대기는 앞선 정리 시간을 포함하지 않으며 false 뒤 shutdownNow도 완료 확인이 아니라는 경계를 단계로 설명할 수 있습니다.
종료 예제가 수행하는 일과 기한이 적용되는 범위
종료 조정 · timeout의 범위
종료 예제가 수행하는 일과 기한이 적용되는 범위
coordinator의 timeout은 작업자 대기에만 쓰이며, 앞선 close 시간은 차감하지 않습니다.
선행 close에는 작업자 대기 예산이 적용되지 않는다
CAS 성공만 listener와 session close를 순서대로 수행한다. 각 close에는 기한이 없고 예외는 기록 후 계속한다. timeout은 뒤의 worker await에만 적용하며 false 뒤 두 번째 대기는 없다. 원 main은 출력용 콜백과 빈 실행기를 사용한다. 별도 ShutdownDeadlineSolution stage는 예산·skipped를 출력하는 독립 데모다.
작업자 대기 중 호출자가 중단된 출구
await의 InterruptedException 경로는 shutdownNow를 요청하고 호출자 interrupt를 복원한다. worker 종료를 다시 기다리지는 않는다.
상태별 상세 해설
coordinator의 listener·sessions
- 실제 동작
- 순서대로 close, Exception은 기록 후 계속
- 기한·완료의 한계
- 각 close 호출에는 시간 제한이 없음
coordinator의 workers
- 실제 동작
- shutdown 후 awaitTermination(timeout)
- 기한·완료의 한계
- false면 shutdownNow 요청, 두 번째 대기는 없음
coordinator의 중복 close
- 실제 동작
- 첫 CAS 성공 호출만 정리 시작
- 기한·완료의 한계
- 다른 호출의 반환은 첫 정리 완료를 뜻하지 않음
ShutdownDeadlineSolution의 stage
- 실제 동작
- 남은 예산 또는 skipped 출력
- 기한·완료의 한계
- 실제 close·대기·강제 정리 코드는 없음
timeout 인자는 앞선 close 시간을 차감한 전체 기한이 아닙니다. 운영 조립에서는 같은 절대 기한을 각 작업에 전달하고 완료 확인을 별도로 연결해야 합니다.
이 예의 조건
- ShutdownCoordinator 한 프로그램의 close 경로만 추적합니다. 원 main은 출력용 listener·session과 작업 없는 실행기를 사용하고 100ms 인수로 순차 두 번 호출합니다.
- ShutdownDeadlineSolution의 stage는 남은 예산·skipped를 출력하는 독립 데모이며 실제 close·대기·강제 정리를 수행하지 않습니다. 원문 전체 표로 보존합니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 설명 | |
| 전체 읽기 정보 | 종료 예제가 수행하는 일과 기한이 적용되는 범위 종료 예제가 수행하는 일과 기한이 적용되는 범위 종료 예제가 수행하는 일과 기한이 적용되는 범위 종료 조정 · timeout의 범위 종료 예제가 수행하는 일과 기한이 적용되는 범위 coordinator의 timeout은 작업자 대기에만 쓰이며, 앞선 close 시간은 차감하지 않습니다. coordinator의 listener·sessions 실제 동작 순서대로 close, Exception은 기록 후 계속 기한·완료의 한계 각 close 호출에는 시간 제한이 없음 coordinator의 workers 실제 동작 shutdown 후 awaitTermination(timeout) 기한·완료의 한계 false면 shutdownNow 요청, 두 번째 대기는 없음 coordinator의 중복 close 실제 동작 첫 CAS 성공 호출만 정리 시작 기한·완료의 한계 다른 호출의 반환은 첫 정리 완료를 뜻하지 않음 ShutdownDeadlineSolution의 stage 실제 동작 남은 예산 또는 skipped 출력 기한·완료의 한계 실제 close·대기·강제 정리 코드는 없음 timeout 인자는 앞선 close 시간을 차감한 전체 기한이 아닙니다. 운영 조립에서는 같은 절대 기한을 각 작업에 전달하고 완료 확인을 별도로 연결해야 합니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 실행권 뒤 선행 자원 정리 첫 CAS 성공 호출이 listener와 session을 차례로 close합니다. |
|
|
| 작업자 대기의 각 출구 shutdown 뒤 awaitTermination(timeout)의 결과와 호출자 중단을 구분합니다. |
|
|
| 순차 둘째 호출과 기한 범위 첫 호출 뒤 원 main의 둘째 close는 CAS 실패로 생략됩니다. |
|
|
이 예제를 실제 서버에 그대로 쓰기에는 한계가 몇 가지 있습니다. 먼저 리스너와 세션의 close()에는 시간 제한이 없습니다. 어느 close()가 오래 멈추면 종료 절차 전체가 그 자리에서 멈춥니다. 또 timeout은 작업자 스레드를 기다리는 데에만 쓰이고, 앞 단계에서 쓴 시간을 빼지 않습니다. 예를 들어 timeout을 200ms로 주었는데 리스너를 닫는 데 50ms, 세션을 닫는 데 80ms가 걸렸다고 해 봅시다. 이 코드는 awaitTermination()에 다시 200ms를 주므로 전체 종료에는 최대 330ms가 걸립니다. 전체 종료를 200ms 안에 끝내야 한다면 작업 대기에 쓸 수 있는 시간은 200−50−80=70ms뿐입니다. 이 문제는 글 끝의 연습 문제에서 하나의 기한을 단계별로 나눠 쓰는 방식으로 고쳐 봅니다.
shutdownNow()를 부른 뒤에는 다시 기다리지 않는다는 점도 알아 두어야 합니다. 중단을 요청했을 뿐 작업이 실제로 끝났는지는 확인하지 않은 상태입니다. 그리고 compareAndSet에서 진 두 번째 호출자는 첫 호출의 종료 절차가 끝나기를 기다리지 않고 곧바로 돌아갑니다. 마지막으로 이 축약 예제의 sessions는 처음에 받은 고정된 목록이고, workers에는 제출된 작업이 하나도 없습니다. 실제 서버에서는 앞 글의 SessionRegistry에서 beginClose()로 받은 스냅샷을 닫고, 세션을 workers에 제출하도록 조립합니다. shutdownNow()를 부른 뒤에는 끝나지 않은 작업이 몇 개였는지도 기록합니다.
shutdown 훅은 언제 실행되는가
종료 절차를 만들었으니 이제 누가 그것을 시작할지 정해야 합니다. 운영 중인 서버는 보통 운영체제가 보내는 종료 신호를 받고 끝납니다. 자바에서 이런 신호에 반응하는 대표적인 방법이 shutdown 훅입니다. Runtime.getRuntime().addShutdownHook(thread)로 아직 시작하지 않은 스레드를 등록해 두면, JVM이 종료 절차를 시작할 때 그 스레드를 실행합니다.
JDK 25의 공식 문서에 따르면 JVM이 종료 절차를 시작하는 경우는 세 가지입니다. 데몬이 아닌 스레드가 처음으로 하나도 남지 않았을 때(예를 들어 main이 끝나고 다른 일반 스레드가 없을 때), System.exit()이나 Runtime.exit()이 처음 호출됐을 때, 그리고 사용자의 인터럽트나 운영체제의 신호 같은 외부 사건이 일어났을 때입니다. Linux에서 확인해 보면 터미널의 Ctrl+C(SIGINT)와 일반적인 종료 요청인 SIGTERM을 받았을 때는 훅이 실행됐고, SIGKILL로 강제 종료했을 때는 실행되지 않았습니다. SIGKILL은 프로세스가 가로챌 수 없는 신호라서 JVM은 아무 코드도 실행하지 못하고 즉시 사라집니다. 전원이 나가거나 JVM 자체가 비정상적으로 죽는 경우, Runtime.halt()를 호출한 경우에도 훅은 실행되지 않습니다. 그러니 훅은 남은 일을 정리할 마지막 기회일 뿐이고, 데이터를 잃지 않게 지켜 주는 장치는 아닙니다.
훅이 실행되는 방식에도 주의할 점이 있습니다. 등록된 훅들은 정해지지 않은 순서로 동시에 시작되고, 종료 절차가 시작될 때 살아 있던 다른 스레드들과도 함께 실행됩니다. 공식 문서는 훅이 일을 빨리 끝내야 한다고 권합니다. 사용자 로그오프나 시스템 종료처럼 운영체제가 정한 짧은 시간 안에 프로세스가 끝나야 하는 경우가 있기 때문입니다. 모든 훅이 끝나면 JVM은 남은 스레드를 즉시 멈춥니다. 이때는 그 스레드들의 finally 블록도 실행되지 않습니다.
정해진 시간만큼 sleep하는 훅
다음은 종료 훅을 처음 만들 때 흔히 보이는 모양입니다. 훅이 시작되면 5초를 기다린 뒤 정리가 끝났다고 가정합니다.
public final class SleepingShutdownHook {
public static void main(String[] args) {
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
System.out.println("shutdown start");
try {
Thread.sleep(5_000);
} catch (InterruptedException error) {
Thread.currentThread().interrupt();
}
System.out.println("shutdown assumed complete");
}, "bad-shutdown-hook"));
System.out.println("main complete");
}
}addShutdownHook()은 아직 시작하지 않은 스레드를 등록만 해 둡니다. 그래서 훅의 두 줄은 main의 출력보다 먼저 나올 수 없습니다.
main complete
shutdown start
shutdown assumed completemain이 main complete를 출력하고 끝나면 데몬이 아닌 스레드가 하나도 남지 않으므로 JVM이 종료 절차를 시작하고, 훅이 shutdown start를 출력합니다. 마지막 줄은 그로부터 5초 뒤에 나옵니다. 문제는 이 5초가 실제 상태와 아무 관계가 없다는 점입니다. 세션이 이미 모두 끝났어도 5초를 꼬박 기다리고, 아직 정리가 덜 됐어도 5초가 지나면 끝났다고 선언합니다. 훅 안에서 외부 서비스를 호출하거나 끝없이 잠금을 기다리는 것도 같은 이유로 위험합니다. 배포 플랫폼이 허용한 종료 시간을 넘기면 결국 강제 종료되고, 그 뒤의 정리는 실행되지 못합니다.
기다림은 실제 상태를 기준으로 해야 합니다. 앞의 ShutdownCoordinator처럼 awaitTermination()으로 작업자 스레드가 실제로 끝나기를 기다리고, 전체 종료에는 하나의 기한을 둡니다. 큰 파일을 올리는 작업처럼 종료 시간 안에 끝날 수 없는 일은 중단했다가 나중에 이어서 할 수 있도록 프로토콜을 따로 설계합니다.
훅에서는 조립이 끝난 서비스의 close만 부른다
훅 안에서 새 서버 객체를 만들거나 새 의존성을 준비해서는 안 됩니다. 종료 중에는 그 객체가 기대는 다른 서비스가 이미 정리되고 있을 수 있습니다. 대신 main에서 완전히 조립한 서비스 객체의 참조를 훅이 들고 있다가, 그 객체의 close() 하나만 짧게 호출하게 합니다. 아래 예제는 이 구조와 함께, 명시적인 종료와 훅이 같은 close()를 나눠 쓰는 방법을 보여 줍니다.
import java.util.concurrent.atomic.AtomicInteger;
public final class RegisteredShutdownHook {
static final class Service implements AutoCloseable {
private final AtomicInteger calls = new AtomicInteger();
@Override public void close() {
if (calls.incrementAndGet() == 1) System.out.println("service closed");
}
int calls() { return calls.get(); }
}
public static void main(String[] args) {
Service service = new Service();
Thread hook = new Thread(service::close, "network-shutdown");
Runtime.getRuntime().addShutdownHook(hook);
service.close();
boolean removed = Runtime.getRuntime().removeShutdownHook(hook);
System.out.println("hookRemoved=" + removed + " calls=" + service.calls());
}
}이 프로그램은 JVM이 종료를 시작하기 전에 훅을 다시 빼므로, 훅 스레드는 한 번도 실행되지 않습니다. 아래 두 줄은 모두 main 스레드가 찍은 것입니다.
service closed
hookRemoved=true calls=1main은 서비스를 만들고, 그 서비스의 close()를 실행하는 스레드를 훅으로 등록합니다. 그다음 정상 종료 경로를 흉내 내어 close()를 직접 부르므로 service closed가 출력됩니다. close()는 호출 횟수를 세어 첫 호출에서만 정리하므로, 훅이 나중에 다시 불러도 정리가 두 번 일어나지 않습니다. 이 예제는 프로그램이 끝날 때 훅이 한 번 더 실행되지 않도록 removeShutdownHook()으로 훅을 뺍니다. 등록돼 있던 훅을 뺐으므로 true가 나오고, 그때까지 close()는 한 번 호출됐습니다.
실제 서버에서는 훅을 빼지 않고 남겨 둡니다. SIGTERM을 받았을 때 종료 절차를 시작할 수 있는 곳은 훅뿐인 경우가 많기 때문입니다. 대신 명시적인 종료 경로와 훅이 같은 close()를 불러도 안전하도록 멱등하게 만듭니다. 훅을 추가하거나 빼는 일은 서버가 시작될 때와 정상적으로 종료될 때 합니다. 종료 절차가 이미 시작된 뒤에 addShutdownHook()이나 removeShutdownHook()을 호출하면 IllegalStateException이 납니다.
훅이 실행되지 않는 종료에 대비하기
앞에서 본 것처럼 SIGKILL을 받으면 훅은 실행되지 않습니다. Windows에서 프로세스를 강제로 끝내는 경우도 마찬가지이고, 컨테이너가 메모리 한도를 넘겨 운영체제에 의해 강제 종료되는 경우도 그렇습니다. 많은 배포 플랫폼은 먼저 SIGTERM을 보내고, 정해진 유예 시간이 지나도 프로세스가 끝나지 않으면 SIGKILL을 보냅니다. 그러니 접수한 요청이 메모리 속 세션 하나에만 있다면 강제 종료와 함께 사라질 수 있습니다. 잃어서는 안 되는 명령은 처리하기 전에 메시지 큐나 트랜잭션을 지원하는 저장소처럼 프로세스가 죽어도 남는 곳에 먼저 기록해 둡니다.
애플리케이션의 종료 기한은 플랫폼의 유예 시간보다 짧게 잡습니다. 예를 들어 플랫폼이 SIGTERM을 보낸 뒤 30초를 기다려 준다면, 준비 상태 변경이 로드 밸런서에 전파되는 시간과 훅이 실행되는 시간, 마지막 로그를 남길 시간을 빼고 세션 정리에는 20초 정도를 줄 수 있습니다. 그 시간 안에 끝나지 않은 작업을 버릴지, 나중에 다시 처리할지는 코드가 아니라 서비스의 정책으로 정해야 할 문제입니다.
종료 과정에서 남길 기록
종료 절차는 배포할 때마다 실행되지만, 평소에는 잘 보이지 않는 코드 경로입니다. 그래서 종료마다 ID를 하나 붙이고 종료를 시작한 시각, 리스너를 닫은 결과, 닫으라고 요청한 세션 수, 제한 시간 안에 끝난 작업 수, 강제로 중단한 작업 수, 전체 소요 시간을 함께 기록해 두면 세션 누수나 느린 작업을 찾는 데 도움이 됩니다.
기록할 곳도 신경 써야 합니다. 로깅 라이브러리가 자기 종료 훅을 따로 등록해 두었다면, 훅들은 정해진 순서 없이 동시에 실행되므로 우리 훅이 로그를 쓰려는 순간 로깅 시스템이 이미 닫혀 있을 수 있습니다. 꼭 남겨야 하는 숫자는 표준 오류 출력처럼 단순한 경로로도 함께 내보내는 것을 고려합니다. 그리고 종료 진단이라도 비밀 정보나 요청 본문 전체는 기록하지 않습니다.
정리
accept()에서 기다리는 루프는 플래그로 멈출 수 없습니다. 다른 스레드가ServerSocket을 닫으면accept()가SocketException으로 깨어납니다.catch에서는 리스너가 닫혔는지 확인해 종료와 진짜 장애를 구분합니다. 모든IOException을 정상 종료로 삼키지 않습니다.- 종료는 준비 상태 내리기, 리스너 닫기, 세션 닫기, 작업 대기, 로그 내보내기 순서로 진행합니다. 세션을 닫아야 소켓 읽기에서 기다리던 작업이 끝납니다.
- 단계마다 새 제한 시간을 주면 전체 종료 시간이 늘어납니다. 하나의 기한에서 남은 시간을 나눠 써야 합니다.
- shutdown 훅은 Ctrl+C, SIGTERM, 정상 종료에서 실행되지만 SIGKILL, 전원 차단, JVM 비정상 종료에서는 실행되지 않습니다. 잃으면 안 되는 데이터는 훅에 기대지 말고 먼저 오래 남는 저장소에 기록합니다.
- 훅에서는 조립이 끝난 서비스의 멱등한
close()만 짧게 호출하고, 정해진 시간만큼sleep하지 않습니다.
연습 문제
- 전체 종료 기한 하나를 받아 리스너, 세션, 작업자 스레드, 지표 단계를 차례로 실행하는 예제를 작성하세요. 각 단계는 일을 마친 뒤 남은 시간만 다음 단계에 넘기고, 시간이 다 떨어지면 뒤 단계는 기다리지 않고 건너뜁니다.
StoppableAcceptLoop.runOnce()의catch에서isClosed()확인을 빼고 모든IOException을 정상 종료로 처리하도록 바꿨다고 합시다. 운영 중에 파일 디스크립터가 바닥나accept()가 실패하면 어떤 일이 생길까요?- SIGTERM 훅과
main의 종료 경로가 같은ShutdownCoordinator의close(Duration)을 거의 동시에 불렀습니다. 종료 절차는 몇 번 실행되나요? 먼저 돌아온 쪽이 '종료가 끝났다'고 판단해도 될까요?
1번 해설
import java.time.Duration;
public final class ShutdownDeadlineSolution {
static final class Deadline {
private final long endNanos;
Deadline(Duration budget) { endNanos = System.nanoTime() + budget.toNanos(); }
Duration remaining() {
return Duration.ofNanos(Math.max(0, endNanos - System.nanoTime()));
}
}
static void stage(String name, Deadline deadline, long workMillis) throws InterruptedException {
long budgetMillis = deadline.remaining().toMillis();
if (budgetMillis == 0) {
System.out.println(name + " skipped");
return;
}
System.out.println(name + " budgetMillis=" + budgetMillis);
Thread.sleep(Math.min(workMillis, budgetMillis)); // 일을 흉내 내되 남은 예산을 넘기지 않는다
}
public static void main(String[] args) throws InterruptedException {
Deadline deadline = new Deadline(Duration.ofMillis(300));
stage("listener", deadline, 50);
stage("sessions", deadline, 80);
stage("workers", deadline, 500);
stage("metrics", deadline, 10);
}
}실행 결과의 한 예입니다. 숫자는 실행할 때마다 조금씩 달라집니다.
listener budgetMillis=299
sessions budgetMillis=226
workers budgetMillis=146
metrics skippedDeadline은 시작할 때 '언제까지'라는 절대 시각을 한 번만 계산해 두고, 각 단계는 그 시각까지 남은 시간을 그때그때 다시 계산합니다. System.nanoTime()은 시스템 시계를 바꿔도 영향을 받지 않는 경과 시간용 시계라서 이런 계산에 알맞습니다. 리스너 단계는 300ms 가운데 거의 전부를 받고 50ms 동안 일합니다. 세션 단계의 예산이 250이 아니라 그보다 조금 적은 것은, 첫 출력처럼 일 외의 작업에도 시간이 들기 때문입니다. 작업 단계는 500ms가 필요하지만 남은 예산만큼만 일하고 멈추므로, 마지막 지표 단계에는 1ms도 남지 않아 건너뜁니다.
단계마다 처음 받은 300ms를 새로 준다면 전체 시간은 단계 수만큼 늘어납니다. 하나의 절대 기한에서 남은 시간을 나눠 써야 전체 종료가 약속한 시간 안에 끝납니다. 다만 남은 시간을 계산한다고 해서 블로킹 중인 close()가 저절로 중단되지는 않습니다. 실제 서버에서는 기다리는 연산마다 남은 시간을 제한 시간으로 넘겨야 합니다. 이 코드가 1ms 미만이 남았을 때 아예 건너뛰는 데에도 이유가 있습니다. Thread.join(0)이나 Socket.setSoTimeout(0)처럼 0을 '기다리지 않음'이 아니라 '무한히 기다림'으로 해석하는 API가 있으므로, 다 쓴 예산을 0으로 넘기면 오히려 끝없이 기다리게 될 수 있습니다.
2번 해설
accept()가 IOException을 던질 때마다 '종료됐다'고 판단하고 루프를 빠져나가므로, 서버는 그 순간부터 새 연결을 받지 않습니다. 그런데 프로세스는 살아 있고 이미 연결된 세션도 계속 동작하므로, 겉으로는 정상처럼 보이는 상태로 남습니다. 클라이언트는 연결이 거절되거나 응답이 없는데 서버 로그에는 정상 종료 메시지만 있으니 원인을 찾기도 어렵습니다. 리스너가 닫히지 않았는데 accept()가 실패했다면 예외를 다시 던져, 서버를 관리하는 상위 코드가 경보를 울리거나 재시작하게 해야 합니다.
3번 해설
종료 절차는 한 번만 실행됩니다. closing.compareAndSet(false, true)에서 이긴 호출 하나만 리스너와 세션을 닫고 작업자 스레드를 기다리며, 진 호출은 첫 줄에서 바로 돌아갑니다. 그래서 먼저 돌아온 쪽이 진 호출이라면, 그 시점에 종료 절차는 아직 진행 중일 수 있습니다. 반환됐다는 사실만으로 종료가 끝났다고 판단하면 안 됩니다. 완료를 알아야 하는 쪽이 있다면, 종료 절차가 끝날 때 완료되는 CountDownLatch나 CompletableFuture를 두고 진 호출도 그것을 기다리게 만들어야 합니다.
다음 글
연결 실패 원인 구분과 재시도에서는 연결을 맺는 과정에서 생기는 이름 해석 실패, 연결 거부, 도달 불가 같은 오류를 서로 다른 원인으로 구분하는 방법을 다룹니다.