본문으로 건너뛰기
안동민 개발노트 아이콘

안동민 개발노트

본문 시작
25장 : TCP 네트워크

활성 세션과 서버 종료

서버가 `accept`한 세션을 추적하고 정상 완료와 전체 `shutdown`의 `close` 경쟁을 한 번만 처리하며 새 등록 거부·세션 제거·작업자 대기를 상태 머신으로 구성합니다.

try-with-resources는 세션 하나의 지역 자원을 정리하지만 서버 전체를 종료하려면 현재 살아 있는 세션을 찾아야 합니다. accept한 Socket을 어느 목록에도 기록하지 않으면 shutdown 스레드는 리스너만 닫을 수 있고 이미 연결된 세션은 read에서 계속 기다립니다. 활성 세션 레지스트리는 전체 수명 관리의 핵심입니다.

레지스트리는 단순 컬렉션보다 강한 규칙이 필요합니다. 종료 시작 뒤 새 세션을 받지 않고, 등록된 모든 세션에 close를 호출하며, 정상 종료한 세션은 목록에서 제거해야 합니다. 등록과 shutdown이 동시에 실행되는 경쟁에서도 Socket 하나가 추적 없이 빠져나가면 안 됩니다.

확인 결과는 모든 수락 Socket이 등록 성공 뒤 정확히 한 번 닫혔거나 등록 거부 경로에서 즉시 닫혔다는 두 경우로만 나타나야 합니다. 어느 쪽에도 속하지 않는 연결은 수명주기 누수입니다.

작업자만 만들고 세션 참조를 잃는 실패

accept 루프가 연결마다 새 Thread를 시작한 뒤 참조를 버리면 서버 종료 시 어떤 Socket이 열린지 알 수 없습니다. 인터럽트만 보내도 소켓 read가 항상 즉시 풀린다고 보장할 수 없습니다. 연결 자원을 직접 close해서 블로킹 I/O를 깨울 수 있어야 합니다.

bad/UntrackedSessions.java
import java.net.ServerSocket;
import java.net.Socket;

public final class UntrackedSessions {
    static void acceptForever(ServerSocket server) throws Exception {
        while (true) {
            Socket socket = server.accept();
            Thread.ofVirtual().start(() -> {
                try {
                    socket.getInputStream().read();
                } catch (Exception ignored) {
                    // 작업은 끝나지만 서버에는 활성 Socket 목록이 없다.
                }
            });
        }
    }

    public static void main(String[] args) throws Exception {
        try (var server = new ServerSocket(0)) {
            System.out.println("listener=" + server.getLocalPort());
            System.out.println("shutdown이 이미 수락된 연결을 찾을 수 없는 구조입니다");
        }
    }
}

Thread 목록만 유지하는 것도 충분하지 않을 수 있습니다. 작업자 종료 요청과 네트워크 자원 종료를 함께 수행할 세션 객체가 필요합니다. 세션이 Runnable과 AutoCloseable을 구현하면 실행 로직과 자원 수명은 한 객체에 모이고 레지스트리는 구체 스트림을 몰라도 close를 호출할 수 있습니다.

종료 상태와 세션 집합의 잠금

addshutdown 상태를 확인한 뒤 컬렉션에 넣기 전에 종료 스레드가 closeAll을 끝내면 새 세션이 영원히 남습니다. 상태 확인과 등록을 하나의 임계 영역으로 묶어야 합니다. 종료 중 등록 요청은 false를 반환하고 호출자가 Socket을 닫습니다.

src/SessionRegistry.java
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);
        notifyAll();
    }

    public List<Session> beginClose() {
        synchronized (this) {
            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(() -> { }));
    }
}

close는 외부 코드이므로 synchronized 블록 안에서 실행하지 않습니다. 느리거나 재진입하는 close가 레지스트리 잠금을 오래 잡으면 정상 세션 remove와 다른 등록이 멈춥니다. 잠금 안에서는 상태 전환과 스냅샷만 만들고 실제 종료는 바깥에서 수행합니다.

세션 close의 멱등성

세션 runfinally와 전체 종료 스레드가 동시에 close를 호출할 수 있습니다. AtomicBoolean.compareAndSet 또는 synchronized 플래그로 실제 자원 정리를 한 번만 수행합니다. 먼저 닫는 쪽이 Socket을 닫으면 다른 쪽의 read가 IOException으로 풀리고 finally의 두 번째 close는 아무 일도 하지 않습니다.

src/ManagedSocketSession.java
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();
        }
    }
}

실제 onClosed는 레지스트리에서 이 세션을 제거해야 하므로 생성 뒤 자기 참조 연결 방법을 신중히 설계합니다. 팩터리가 세션을 만든 뒤 등록하고 콜백은 ID로 제거하게 만들 수 있습니다. 콜백 실패도 Socket close를 되돌리지 않으므로 별도 계측과 복구가 필요합니다.

accept 이후 소유권 이전 순서

새 Socket을 받으면 세션 객체를 만들고 레지스트리에 등록한 뒤 실행기에 제출합니다. 등록이 거부되면 세션을 바로 닫습니다. 제출이 거부되면 레지스트리에서 제거하고 닫습니다. 제출 성공 뒤에는 세션 run/finally가 책임을 갖습니다.

순서를 제출 후 등록으로 바꾸면 매우 짧은 세션이 먼저 끝나 remove를 호출한 뒤 나중에 등록되어 유령 항목으로 남을 수 있습니다. 등록 후 제출은 제출 실패 보상만 명시하면 이 경쟁을 피할 수 있습니다. 소유권 전이는 코드 주석이 아니라 각 실패 분기의 close로 증명됩니다.

세션 제거와 종료 대기 분리

레지스트리 size가 0이라는 사실은 작업자 Future가 완전히 끝났다는 뜻과 약간 다를 수 있습니다. close 콜백 뒤 finally의 남은 로그가 실행될 수 있습니다. 서버 종료가 작업자 완료까지 요구한다면 실행기 shutdownawaitTermination, 또는 세션 Future 목록을 함께 관리합니다.

반대로 종료가 너무 오래 기다리면 배포가 멈춥니다. 정상 close 뒤 유예 시간을 두고 남은 작업을 취소하며 최종적으로 프로세스 종료 정책을 적용합니다. 각 단계에서 활성 세션 ID, 원격 주소, 마지막 활동 시각을 기록하면 어떤 연결이 배출을 막았는지 알 수 있습니다.

종료 상태 판단표

사건레지스트리Socket 책임
정상 accept등록세션으로 이전
종료 중 accept등록 거부acceptor가 닫음
제출 실패제거acceptor가 닫음
세션 정상 완료제거세션 finally
전체 shutdown스냅샷 비움coordinator close

연습 문제

한 스레드는 세션을 등록하고 다른 스레드는 종료를 시작하게 반복 실행하세요. 모든 세션이 등록 성공 후 close되거나 등록 거부 후 호출자가 close하도록 만들어 close 횟수가 정확히 한 번인지 확인합니다.

정답과 해설
exercise/RegistrationRaceSolution.java
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());
    }
}

두 상태 변경이 같은 synchronized 구분을 사용하므로 세션은 종료 스냅샷에 포함되거나 등록을 거부당합니다. close 자체를 멱등적으로 만들면 더 복잡한 실제 경쟁에서도 중복 호출 부작용을 줄일 수 있습니다.

세션 수명 상태 머신의 종료 기준

활성 세션 레지스트리는 목록이 아니라 서버 종료 상태 머신입니다. 등록·제출·정상 제거·전체 close의 순서를 정하고 외부 close를 잠금 밖에서 실행하며 세션 정리를 멱등적으로 만들면 acceptshutdown이 겹쳐도 추적되지 않은 연결이 남지 않습니다.