본문 바로가기

활성 세션과 서버 종료

서버 세션의 등록·제거·종료 상태를 추적하고 동시 close와 작업자 대기를 안전하게 처리합니다.

try-with-resources는 세션 하나의 지역 자원을 정리하지만 서버 전체를 종료하려면 현재 살아 있는 세션을 찾아야 합니다.

accept한 Socket을 어느 목록에도 기록하지 않으면 shutdown 스레드는 리스너만 닫을 수 있고 이미 연결된 세션은 read에서 계속 기다립니다.

활성 세션 레지스트리는 전체 수명 관리의 핵심입니다.

레지스트리는 단순 컬렉션보다 강한 규칙이 필요합니다.

종료 시작 뒤 새 세션을 받지 않고, 등록된 모든 세션에 close를 호출하며, 정상 종료한 세션은 목록에서 제거해야 합니다.

등록과 shutdown이 동시에 실행되는 경쟁에서도 Socket 하나가 추적 없이 빠져나가면 안 됩니다.

수락한 모든 Socket은 등록 성공 뒤 세션이 정리를 맡거나, 등록 거부 경로에서 호출자가 닫도록 책임을 정해야 합니다. 검증에서는 정리 누락이 없는지와 중복 close가 안전한지를 확인합니다.

어느 쪽에도 속하지 않는 연결은 수명주기 누수입니다.


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

accept 루프가 연결마다 새 Thread를 시작한 뒤 참조를 버리면 서버 종료 시 어떤 Socket이 열린지 알 수 없습니다.

채널에 연결되지 않은 플랫폼 스레드의 일반 소켓 read는 인터럽트만으로 풀리지 않을 수 있습니다. 반면 JDK 25의 시스템 기본 소켓 구현에서 가상 스레드가 블로킹 read 중이면, 인터럽트가 소켓을 닫고 SocketException을 발생시킵니다.

연결 자원을 직접 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이 이미 수락된 연결을 찾을 수 없는 구조입니다");
        }
    }
}

이 반례의 main은 리스너를 생성·정리할 뿐 acceptForever를 호출하지 않습니다.

Thread 목록만 유지하는 것도 충분하지 않을 수 있습니다.

작업자 종료 요청과 네트워크 자원 종료를 함께 수행할 세션 객체가 필요합니다.

세션이 Runnable과 AutoCloseable을 구현하면 실행 로직과 자원 수명은 한 객체에 모이고 레지스트리는 구체 스트림을 몰라도 close를 호출할 수 있습니다.


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

add가 shutdown 상태를 확인한 뒤 컬렉션에 넣기 전에 종료 스레드가 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와 다른 등록이 멈춥니다.

잠금 안에서는 상태 전환과 스냅샷만 만들고 실제 종료는 바깥에서 수행합니다.

Session.close()가 검사 예외를 선언하지 않아도 런타임 예외는 날 수 있습니다. 원문의 forEach는 그런 실패 뒤 나머지 세션을 계속 닫지 않으므로, 운영에서는 세션별 실패를 격리해야 합니다.


세션 close의 멱등성

세션 run의 finally와 전체 종료 스레드가 동시에 close를 호출할 수 있습니다.

AtomicBoolean.compareAndSet 또는 synchronized 플래그로 close 메서드의 정리 경로에 한 호출만 진입하게 합니다.

먼저 닫는 쪽이 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();
        }
    }
}

세션 종료의 표시와 실제 완료는 다른 경계다

closed CAS, 소켓 close 시도, finally의 onClosed 콜백은 서로 다른 완료 경계입니다. 집합 제거·close 반환·작업자 종료를 같은 사건으로 오해하지 않도록 전환을 분리할 가치가 있습니다.

세션 종료의 표시와 실제 완료는 다른 경계다

세션 종료의 표시와 실제 완료는 다른 경계다

세션 수명 · 상태와 완료 경계

세션 종료의 표시와 실제 완료는 다른 경계다

집합이 비었거나 CAS에 성공했다는 사실만으로 모든 소켓과 작업자의 완료를 알 수 없습니다.

세션 close의 실행권과 실제 호출 순서

세션 close의 실행권과 실제 호출 순서CAS 성공만 socket.close 시도를 하고 finally의 onClosed로 이어진다. 패배 호출은 본문과 콜백을 생략한다. 콜백 호출은 성공이나 worker 종료를 증명하지 않는다.실패성공Session CAS실패본문 생략성공Socket closefinallyonClosed 호출원 mainremoved 출력
세션 close의 실행권과 실제 호출 순서CAS 성공만 socket.close 시도를 하고 finally의 onClosed로 이어진다. 패배 호출은 본문과 콜백을 생략한다. 콜백 호출은 성공이나 worker 종료를 증명하지 않는다.실패성공Session CAS실패본문 생략성공SocketclosefinallyonClosed호출원 mainremoved출력

CAS 성공만 socket.close 시도를 하고 finally의 onClosed로 이어진다. 패배 호출은 본문과 콜백을 생략한다. 콜백 호출은 성공이나 worker 종료를 증명하지 않는다.

레지스트리 비움과 소켓 닫기는 별도 경계다

레지스트리 비움과 소켓 닫기는 별도 경계다별도 Registry의 beginClose는 closing을 설정하고 스냅샷을 만들고 집합을 비운다. 빈 집합은 스냅샷에 든 소켓의 닫기 완료가 아니다.별도 Registry.beginClose집합 비움size=0스냅샷Socket 남음실제 close·worker 완료외부에서 별도 확인
레지스트리 비움과 소켓 닫기는 별도 경계다별도 Registry의 beginClose는 closing을 설정하고 스냅샷을 만들고 집합을 비운다. 빈 집합은 스냅샷에 든 소켓의 닫기 완료가 아니다.별도 Registry.beginClose집합 비움size=0스냅샷Socket남음실제 close·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가 본문 진입권을 얻습니다.

  • closed: false→true
  • 실행권: 한 호출의 close 본문
  • 아직 미확인: Socket close·onClosed 완료
  • CAS 성공은 자원 정리 완료가 아니라 해당 session 본문에 들어갈 권한입니다.
Socket 시도 뒤 콜백

Socket close를 시도한 뒤 finally에서 onClosed를 호출합니다.

  • 선행: socket.close 시도
  • 후속: finally의 onClosed 호출
  • 콜백 호출만의 한계: 콜백 성공·worker 종료 보장 아님
  • close 시도와 콜백 호출 순서를 보존하며 콜백 실패가 이미 수행한 Socket 정리를 되돌리지는 않습니다.
원 main 반환과 둘째 생략

원 출력 콜백은 removed를 출력하고 둘째 session.close는 CAS 실패로 본문을 생략합니다.

  • 원 콜백: removed 출력 · 정상 반환
  • 둘째 session.close: 본문·콜백 추가 진입 없음
  • worker 종료: 이 main의 호출만으로 별도 worker 완료 증명 없음
  • 외부 Socket 수명: main의 try-with-resources 정리는 별도
  • 순차 main의 두 번째 생략과 모든 자원·worker의 완료 확인은 서로 다른 보장입니다.

세션 종료의 표시와 실제 완료는 다른 경계다

closed CAS, 소켓 close 시도, finally의 onClosed 콜백은 서로 다른 완료 경계입니다. 집합 제거·close 반환·작업자 종료를 같은 사건으로 오해하지 않도록 전환을 분리할 가치가 있습니다.

세션 종료의 표시와 실제 완료는 다른 경계다

세션 종료의 표시와 실제 완료는 다른 경계다

세션 수명 · 상태와 완료 경계

세션 종료의 표시와 실제 완료는 다른 경계다

집합이 비었거나 CAS에 성공했다는 사실만으로 모든 소켓과 작업자의 완료를 알 수 없습니다.

세션 close의 실행권과 실제 호출 순서

세션 close의 실행권과 실제 호출 순서CAS 성공만 socket.close 시도를 하고 finally의 onClosed로 이어진다. 패배 호출은 본문과 콜백을 생략한다. 콜백 호출은 성공이나 worker 종료를 증명하지 않는다.실패성공Session CAS실패본문 생략성공Socket closefinallyonClosed 호출원 mainremoved 출력
세션 close의 실행권과 실제 호출 순서CAS 성공만 socket.close 시도를 하고 finally의 onClosed로 이어진다. 패배 호출은 본문과 콜백을 생략한다. 콜백 호출은 성공이나 worker 종료를 증명하지 않는다.실패성공Session CAS실패본문 생략성공SocketclosefinallyonClosed호출원 mainremoved출력

CAS 성공만 socket.close 시도를 하고 finally의 onClosed로 이어진다. 패배 호출은 본문과 콜백을 생략한다. 콜백 호출은 성공이나 worker 종료를 증명하지 않는다.

레지스트리 비움과 소켓 닫기는 별도 경계다

레지스트리 비움과 소켓 닫기는 별도 경계다별도 Registry의 beginClose는 closing을 설정하고 스냅샷을 만들고 집합을 비운다. 빈 집합은 스냅샷에 든 소켓의 닫기 완료가 아니다.별도 Registry.beginClose집합 비움size=0스냅샷Socket 남음실제 close·worker 완료외부에서 별도 확인
레지스트리 비움과 소켓 닫기는 별도 경계다별도 Registry의 beginClose는 closing을 설정하고 스냅샷을 만들고 집합을 비운다. 빈 집합은 스냅샷에 든 소켓의 닫기 완료가 아니다.별도 Registry.beginClose집합 비움size=0스냅샷Socket남음실제 close·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가 본문 진입권을 얻습니다.

  • closed: false→true
  • 실행권: 한 호출의 close 본문
  • 아직 미확인: Socket close·onClosed 완료
  • CAS 성공은 자원 정리 완료가 아니라 해당 session 본문에 들어갈 권한입니다.
Socket 시도 뒤 콜백

Socket close를 시도한 뒤 finally에서 onClosed를 호출합니다.

  • 선행: socket.close 시도
  • 후속: finally의 onClosed 호출
  • 콜백 호출만의 한계: 콜백 성공·worker 종료 보장 아님
  • close 시도와 콜백 호출 순서를 보존하며 콜백 실패가 이미 수행한 Socket 정리를 되돌리지는 않습니다.
원 main 반환과 둘째 생략

원 출력 콜백은 removed를 출력하고 둘째 session.close는 CAS 실패로 본문을 생략합니다.

  • 원 콜백: removed 출력 · 정상 반환
  • 둘째 session.close: 본문·콜백 추가 진입 없음
  • worker 종료: 이 main의 호출만으로 별도 worker 완료 증명 없음
  • 외부 Socket 수명: main의 try-with-resources 정리는 별도
  • 순차 main의 두 번째 생략과 모든 자원·worker의 완료 확인은 서로 다른 보장입니다.

실제 onClosed는 레지스트리에서 이 세션을 제거해야 하므로 생성 뒤 자기 참조 연결 방법을 신중히 설계합니다.

팩터리가 세션을 만든 뒤 등록하고 콜백은 ID로 제거하게 만들 수 있습니다.

콜백 실패도 Socket close를 되돌리지 않으므로 별도 계측과 복구가 필요합니다.


accept 이후 소유권 이전 순서

새 Socket을 받으면 세션 객체를 만들고 레지스트리에 등록한 뒤 실행기에 제출합니다.

등록이 거부되면 세션을 바로 닫습니다.

제출이 거부되면 레지스트리에서 제거하고 닫습니다.

제출 성공 뒤에는 세션 run/finally가 책임을 갖습니다.

순서를 제출 후 등록으로 바꾸면 매우 짧은 세션이 먼저 끝나 remove를 호출한 뒤 나중에 등록되어 유령 항목으로 남을 수 있습니다.

등록 후 제출은 제출 실패 보상만 명시하면 이 경쟁을 피할 수 있습니다.

소유권 전이는 코드 주석이 아니라 각 실패 분기의 close로 증명됩니다.


세션 제거와 종료 대기 분리

원문의 beginClose()는 스냅샷을 만든 뒤 집합부터 비웁니다. 따라서 size=0을 모든 Socket이나 작업자가 종료되었다는 판정으로 사용해서는 안 됩니다.

서버 종료가 작업자 완료까지 요구한다면 실행기 shutdown과 awaitTermination, 또는 세션 Future 목록을 함께 관리합니다.

반대로 종료가 너무 오래 기다리면 배포가 멈춥니다.

정상 close 뒤 유예 시간을 두고 남은 작업을 취소하며 최종적으로 프로세스 종료 정책을 적용합니다.

각 단계에서 활성 세션 ID, 원격 주소, 마지막 활동 시각을 기록하면 어떤 연결이 배출을 막았는지 알 수 있습니다.


종료 상태 판단표

사건레지스트리Socket 책임
정상 accept등록세션으로 이전
종료 중 accept등록 거부acceptor가 닫음
제출 실패제거acceptor가 닫음
세션 정상 완료제거세션 finally
전체 shutdown스냅샷 비움종료 조정자의 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());
    }
}

등록과 종료가 같은 잠금을 사용할 때의 두 경로

등록과 종료 중 먼저 잠금을 획득한 쪽에 따라 snapshot에 포함되어 종료 스레드가 닫거나 등록 거부 후 등록 스레드가 닫습니다. 가능한 두 경로에서 closeCount=1을 유지하는 이유를 보여 줄 수 있습니다.

등록과 종료가 같은 잠금을 사용할 때의 두 경로

등록과 종료가 같은 잠금을 사용할 때의 두 경로

등록 경합 · 같은 잠금의 두 경로

등록과 종료가 같은 잠금을 사용할 때의 두 경로

먼저 잠금을 얻은 쪽에 따라 종료 스레드 또는 등록 스레드가 같은 세션을 닫습니다.

같은 잠금에서 등록과 종료의 순서가 닫는 소유자를 정한다한세션A의등록add와closeSnapshot중어느임계영역이먼저실행되는지를비교한다.add먼저면성공한A를종료스냅샷에포함해종료스레드가닫고,closeSnapshot먼저면closing뒤add가실패해등록스레드가닫는다.두갈래는같은세션의대안이며항상두close가실행되는흐름이아니다.원main은한번경합하며반복스트레스결과는없다.먼저 잠금 진입?add 먼저A 등록 성공closeSnapshot에 A종료 스레드 closecloseCount=1closeSnapshot 먼저closing=true스냅샷은 비어 있음A 등록 거부등록 스레드 closecloseCount=1같은 세션의 두 대안 · 두 경로를 모두 실행하는 순서가 아님
같은 잠금에서 등록과 종료의 순서가 닫는 소유자를 정한다한세션A의등록add와closeSnapshot중어느임계영역이먼저실행되는지를비교한다.add먼저면성공한A를종료스냅샷에포함해종료스레드가닫고,closeSnapshot먼저면closing뒤add가실패해등록스레드가닫는다.두갈래는같은세션의대안이며항상두close가실행되는흐름이아니다.원main은한번경합하며반복스트레스결과는없다.먼저 잠금 진입?add 먼저A 등록 성공스냅샷에 A종료 스레드 closecloseCount=1closeSnapshot 먼저closing=true스냅샷은 비어 있음A 등록 거부등록 스레드 closecloseCount=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 중 먼저 진입한 쪽이 이후 등록 판정을 정합니다.

  • add 먼저: closing=false에서 A 등록 가능
  • closeSnapshot 먼저: closing=true 설정 · 빈 스냅샷
  • 비교 대상: 같은 세션 A · 두 대안
  • 같은 임계 영역이 등록 성공과 종료 선행의 순서를 직렬화합니다.
등록과 스냅샷의 각 상태

등록이 앞서면 A가 스냅샷에 있고 종료가 앞서면 add는 거절됩니다.

  • add 먼저: 등록 성공 → closeSnapshot에 A 포함
  • closeSnapshot 먼저: 빈 스냅샷 → A 등록 실패
  • 동시 양쪽 성공: 같은 A의 두 경로를 함께 실행하지 않음
  • 종료 스냅샷에 편입됐는지에 따라 닫을 소유자가 한쪽으로 정해집니다.
각 닫기 책임과 회수

편입된 A는 종료 스레드가, 거절된 A는 등록 스레드가 닫습니다.

  • add 먼저: 종료 스레드 close · closeCount=1
  • closeSnapshot 먼저: 등록 스레드 close · closeCount=1
  • 원 main: 두 join 뒤 closeCount=1
  • 각 분기의 한 소유자만 같은 A를 닫고 원 main은 두 작업 종료 뒤 계수를 확인합니다.

등록과 종료가 같은 잠금을 사용할 때의 두 경로

등록과 종료 중 먼저 잠금을 획득한 쪽에 따라 snapshot에 포함되어 종료 스레드가 닫거나 등록 거부 후 등록 스레드가 닫습니다. 가능한 두 경로에서 closeCount=1을 유지하는 이유를 보여 줄 수 있습니다.

등록과 종료가 같은 잠금을 사용할 때의 두 경로

등록과 종료가 같은 잠금을 사용할 때의 두 경로

등록 경합 · 같은 잠금의 두 경로

등록과 종료가 같은 잠금을 사용할 때의 두 경로

먼저 잠금을 얻은 쪽에 따라 종료 스레드 또는 등록 스레드가 같은 세션을 닫습니다.

같은 잠금에서 등록과 종료의 순서가 닫는 소유자를 정한다한세션A의등록add와closeSnapshot중어느임계영역이먼저실행되는지를비교한다.add먼저면성공한A를종료스냅샷에포함해종료스레드가닫고,closeSnapshot먼저면closing뒤add가실패해등록스레드가닫는다.두갈래는같은세션의대안이며항상두close가실행되는흐름이아니다.원main은한번경합하며반복스트레스결과는없다.먼저 잠금 진입?add 먼저A 등록 성공closeSnapshot에 A종료 스레드 closecloseCount=1closeSnapshot 먼저closing=true스냅샷은 비어 있음A 등록 거부등록 스레드 closecloseCount=1같은 세션의 두 대안 · 두 경로를 모두 실행하는 순서가 아님
같은 잠금에서 등록과 종료의 순서가 닫는 소유자를 정한다한세션A의등록add와closeSnapshot중어느임계영역이먼저실행되는지를비교한다.add먼저면성공한A를종료스냅샷에포함해종료스레드가닫고,closeSnapshot먼저면closing뒤add가실패해등록스레드가닫는다.두갈래는같은세션의대안이며항상두close가실행되는흐름이아니다.원main은한번경합하며반복스트레스결과는없다.먼저 잠금 진입?add 먼저A 등록 성공스냅샷에 A종료 스레드 closecloseCount=1closeSnapshot 먼저closing=true스냅샷은 비어 있음A 등록 거부등록 스레드 closecloseCount=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 중 먼저 진입한 쪽이 이후 등록 판정을 정합니다.

  • add 먼저: closing=false에서 A 등록 가능
  • closeSnapshot 먼저: closing=true 설정 · 빈 스냅샷
  • 비교 대상: 같은 세션 A · 두 대안
  • 같은 임계 영역이 등록 성공과 종료 선행의 순서를 직렬화합니다.
등록과 스냅샷의 각 상태

등록이 앞서면 A가 스냅샷에 있고 종료가 앞서면 add는 거절됩니다.

  • add 먼저: 등록 성공 → closeSnapshot에 A 포함
  • closeSnapshot 먼저: 빈 스냅샷 → A 등록 실패
  • 동시 양쪽 성공: 같은 A의 두 경로를 함께 실행하지 않음
  • 종료 스냅샷에 편입됐는지에 따라 닫을 소유자가 한쪽으로 정해집니다.
각 닫기 책임과 회수

편입된 A는 종료 스레드가, 거절된 A는 등록 스레드가 닫습니다.

  • add 먼저: 종료 스레드 close · closeCount=1
  • closeSnapshot 먼저: 등록 스레드 close · closeCount=1
  • 원 main: 두 join 뒤 closeCount=1
  • 각 분기의 한 소유자만 같은 A를 닫고 원 main은 두 작업 종료 뒤 계수를 확인합니다.

close 자체를 멱등적으로 만들면 더 복잡한 실제 경쟁에서도 중복 호출 부작용을 줄일 수 있습니다.

확인 문제

세션을 A와 B 두 개로 늘리고, A의 add 성공 → closeSnapshot → B의 add 시도 순서가 정해졌다고 가정하세요. 누가 각 세션을 닫아야 하고, 스냅샷의 개수와 두 close 횟수는 어떻게 되나요? 집합이 비워진 시점에 작업자 완료도 함께 확정할 수 있나요?

확인 문제 해설

스냅샷에는 A 한 개가 있으며 종료 스레드가 A를 닫습니다. closing 뒤에 시도한 B는 등록이 거부돼 등록 스레드가 B를 닫습니다. 이 두 경로를 연결하면 각 세션의 close 횟수는 1, 합은 2입니다. closeSnapshot이 먼저 집합을 비우므로 그때 size=0은 아직 close 실행이나 작업자 종료의 증거가 아닙니다. 이는 새로 정한 두 세션의 순서 추론이며 원 main의 한 번 경합을 반복 스트레스 결과로 바꾸어 읽지 않습니다.


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

활성 세션 레지스트리는 목록이 아니라 서버 종료 상태 머신입니다.

등록·제출·정상 제거·전체 close의 순서를 정하고 외부 close를 잠금 밖에서 실행하며 세션 정리를 멱등적으로 만들면 accept와 shutdown이 겹쳐도 추적되지 않은 연결이 남지 않습니다.