accept 중단과 단계적 종료
정상 프로세스 종료에서 `shutdown` 훅이 새 접속을 차단하고 활성 세션을 닫은 뒤 작업자를 기다리게 하며, 강제 종료 한계와 훅의 시간·교착 위험을 다룹니다.
서버 main 스레드는 accept()에서 블로킹하므로 단순 boolean 플래그만 바꿔서는 루프가 종료되지 않습니다.
다른 스레드가 ServerSocket.close()를 호출하면 accept가 IOException으로 풀립니다.
종료 상태에서 예상된 예외인지 실제 리스너 장애인지 구분한 뒤 루프를 끝냅니다.
JVM shutdown 훅은 Ctrl+C, 일반적인 종료 신호, 정상 JVM 종료 과정에서 마지막 정리 기회를 제공합니다.
그러나 운영체제 강제 종료, 전원 손실, JVM 자체 장애에서는 실행되지 않을 수 있습니다.
훅은 최선의 배출 수단이지 데이터 내구성을 보장하는 유일한 장치가 아닙니다.
종료 훅의 무조건 sleep
고정 sleep은 세션이 이미 끝났어도 기다리고, 아직 정리되지 않았어도 시간이 지나면 끝납니다.
훅이 외부 네트워크 호출이나 무제한 락 대기를 수행하면 플랫폼의 종료 제한을 넘겨 강제 kill될 수 있습니다.
종료 조건을 실제 작업자 상태로 기다리고 전체 기한을 둬야 합니다.
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");
}
}학습 실행에서는 5초를 실제로 기다리므로 검증 실행기는 이 반례를 실행하지 않고 컴파일만 확인하는 편이 낫습니다.
올바른 훅은 종료 조정자의 close(deadline)을 호출하고 각 단계가 남은 시간만 사용합니다.
오래 걸리는 데이터 업로드는 중단 후 재개 가능한 프로토콜을 별도로 설계합니다.
리스너 close와 정상 종료
accept 루프는 서버가 닫히지 않은 동안만 반복하고 IOException에서 serverSocket.isClosed()를 확인합니다.
종료가 아닌데 accept가 실패했다면 서버 장애로 보고 상위 수명 관리자에 전달해야 합니다.
모든 IOException을 정상 종료로 삼키면 포트와 파일 디스크립터 문제를 놓칩니다.
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();
}
}
}close와 connect가 동시에 일어나면 accept가 Socket을 반환한 직후 종료 상태가 될 수도 있습니다.
반환된 Socket을 레지스트리에 등록하려다 거부되면 즉시 닫는 이전 문서의 규칙이 필요합니다.
리스너 close 하나만으로 이미 수락한 연결을 정리할 수는 없습니다.
새 유입 차단을 우선한 종료 순서
정상 종료의 일반 순서는 readiness를 내려 로드밸런서가 새 요청을 보내지 않게 하고, 리스너를 닫아 accept를 풀며, 레지스트리를 closing으로 전환하고 활성 세션을 닫는 것입니다.
그 뒤 실행기에 shutdown을 호출하고 제한 시간 동안 완료를 기다립니다.
마지막으로 로그와 메트릭을 flush합니다.
순서를 세션 close부터 시작하면 그 사이 새 연결이 계속 등록되어 종료가 끝나지 않을 수 있습니다.
리스너를 닫기 전에 readiness만 내리는 짧은 비우기 구간을 둘 수도 있지만 배포 플랫폼이 트래픽 중단을 실제로 반영한 시점을 확인해야 합니다.
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));
}
}이 축약 예제는 세션 목록의 동시 변경을 다루지 않으므로 실제 서버에서는 앞 문서의 레지스트리 스냅샷을 연결합니다.
workers에 실제 세션이 제출되도록 조립하고 shutdownNow 뒤 남은 작업을 계측합니다.
close가 멱등적이라 훅과 명시적 관리 API가 동시에 호출해도 한 번만 전환합니다.
종료 훅과 서비스 객체
훅 안에서 새 서버 객체나 별도 의존성을 만들지 않습니다.
main에서 완전히 조립된 coordinator 참조를 캡처하고 가능한 한 짧게 close를 호출합니다.
훅 스레드는 등록된 다른 훅과 병렬로 실행될 수 있어 로깅 시스템이 먼저 닫히는 순서에 의존하면 안 됩니다.
import java.time.Duration;
import java.util.List;
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());
}
}예제는 테스트에서 훅이 프로세스 종료 때 다시 실행되지 않도록 제거합니다.
실제 서버는 훅을 유지하고 명시적 close와 중복되어도 안전하게 만듭니다.
JVM 종료가 이미 시작된 뒤에는 훅 추가·제거가 IllegalStateException을 던질 수 있으므로 서비스 시작과 종료 상태에 맞춰 호출합니다.
정상 종료와 강제 종료의 경계
SIGTERM 같은 정상 종료 신호는 JVM이 훅을 실행할 기회를 주지만 SIGKILL, Windows 강제 종료, 컨테이너 제한 초과에서는 기회가 없습니다. 따라서 접수된 업무가 메모리 세션 하나에만 존재하면 강제 종료에서 잃을 수 있습니다. 유실할 수 없는 명령은 처리 전 내구성 큐나 트랜잭션 저장소에 기록합니다.
외부 orchestrator의 termination grace 주기보다 애플리케이션 기한을 짧게 설정합니다. 예를 들어 30초 제한이라면 readiness 전파와 훅 실행, 최종 로그에 필요한 여유를 빼고 세션 비우기에 20초를 줄 수 있습니다. 시간 초과 뒤 무엇을 버리고 무엇을 재처리할지 제품 정책으로 정합니다.
종료 관측 항목
종료 시작 시각, 리스너 close 결과, 닫기 요청한 세션 수, 제한 시간 안 완료 수, 강제 중단 수, 총 소요 시간을 한 종료 ID로 기록합니다.
평소에는 보기 어려운 종료 경로가 배포마다 실행되므로 이 지표를 이용해 세션 누수와 느린 작업을 찾을 수 있습니다.
훅 내부에서만 로그를 남기면 로깅 백엔드가 먼저 종료될 수 있습니다. 핵심 카운터는 동기 표준 오류 또는 플랫폼 이벤트로도 남길 대체 경로를 고려합니다. 비밀 데이터와 전체 요청 본문은 종료 진단에도 기록하지 않습니다.
연습 문제
전체 종료 기한을 받아 리스너, 세션, 작업자 단계가 작업한 뒤 남은 Duration만 다음 단계에 전달하는 예제를 작성하세요. 시간이 소진되면 뒤 단계는 대기하지 않고 강제 정리로 이동합니다.
정답과 해설
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()));
}
boolean expired() { return remaining().isZero(); }
}
static void stage(String name, Deadline deadline) {
if (deadline.expired()) {
System.out.println(name + " skipped");
return;
}
System.out.println(name + " budgetMillis=" + deadline.remaining().toMillis());
}
public static void main(String[] args) {
Deadline deadline = new Deadline(Duration.ofMillis(250));
stage("listener", deadline);
stage("sessions", deadline);
stage("workers", deadline);
}
}각 단계가 처음 예산을 새로 받으면 전체 시간이 단계 수만큼 늘어납니다. 하나의 절대 기한에서 남은 시간을 계산해야 플랫폼 종료 제한을 지킬 수 있습니다. 실제 대기 API에는 0보다 큰 남은 시간만 전달합니다.
배포 종료 절차를 승인하는 조건
안전한 서버 종료는 훅 등록 자체가 아니라 새 유입 차단, accept 해제, 세션 close, 작업자 대기의 순서와 하나의 기한으로 완성됩니다.
훅이 실행되지 않는 강제 종료까지 가정해 내구성 구분을 두고 종료 지표를 남기면 배포와 장애가 반복되어도 예측 가능한 수명주기를 만들 수 있습니다.