본문 바로가기

동시 요청과 공유 상태

호출 깊이와 추적 ID를 기록하며 싱글톤의 변경 가능한 필드가 병렬 요청에서 섞이는 과정을 재현하고 상태 전달 경계를 정합니다.

웹 서버는 여러 요청을 동시에 처리합니다. 스레드(thread)는 그 가운데 하나의 실행 흐름이며, 두 스레드가 같은 변경 가능한 값을 함께 쓰면 실행 순서에 따라 결과가 달라질 수 있습니다. 이것을 공유 상태(shared state) 문제라고 하고, 두 실행 흐름의 명령이 번갈아 끼어드는 모습을 인터리빙(interleaving)이라고 합니다.

로그에 메서드의 시작과 끝만 더하면 어느 줄이 같은 요청에 속하는지 알기 어렵습니다. 그래서 요청마다 트레이스 ID를 붙이고, 호출 깊이와 소요 시간, 실패를 같은 문맥으로 묶습니다. 하지만 Spring 싱글톤 빈의 필드에 현재 트레이스를 저장하면, 여러 스레드가 그 한 칸을 덮어씁니다. 그래서 보기 좋은 출력보다 먼저 이 상태를 누가 가져야 하는지를 찾습니다.

동시 요청과 공유 상태

한 공유 current를 A 저장·B 덮어쓰기·A 재읽기로 바꾸는 래치 고정 interleaving이다.

CONCURRENCY · PER-EXECUTION OWNERSHIP

공유 필드 한 칸에는 요청 소유권이 없다

불변 trace라도 현재 값을 싱글톤 필드 하나에 저장하면 다른 요청이 덮어씁니다. 호출별 프레임과 실행별 저장 공간, 종료 정리를 함께 설계합니다.

같은 current 한 칸의 세 상태세 노드는 한 공유 current의 A 저장, B 덮어쓰기, A 재읽기라는 순서 있는 세 상태다. aStored와bStored 래치가 원 두워커의 순서와 가시성을 연결한다. 세 독립 저장 칸이나 ThreadLocal 격리를 뜻하지 않는다. 원 출력은 A와B 둘다trace-B이다.
1 · A 저장같은 currenttrace-A
연결 관계1 · A 저장 · 같은 current · trace-A → 2 · B 덮어씀 · 같은 current · trace-B
2 · B 덮어씀같은 currenttrace-B
연결 관계2 · B 덮어씀 · 같은 current · trace-B → 3 · A 다시 읽음 · 반환: trace-B
3 · A 다시 읽음반환: trace-B

래치로 A 저장→B 저장→A 읽기 순서를 고정

같은 current 한 칸의 세 상태세 노드는 한 공유 current의 A 저장, B 덮어쓰기, A 재읽기라는 순서 있는 세 상태다. aStored와bStored 래치가 원 두워커의 순서와 가시성을 연결한다. 세 독립 저장 칸이나 ThreadLocal 격리를 뜻하지 않는다. 원 출력은 A와B 둘다trace-B이다.
1 · A 저장같은 currenttrace-A
aStoredaStored1 · A 저장 · 같은 current · trace-A → 2 · B 덮어씀 · 같은 current · trace-B
2 · B 덮어씀같은 currenttrace-B
bStoredbStored2 · B 덮어씀 · 같은 current · trace-B → 3 · A 다시 읽음 · 반환: trace-B
3 · A 다시 읽음반환: trace-B

세 상태는 같은 필드워커별 칸이 아님

요청 A, 요청 B, 요청 A 재개가 같은 current 필드를 덮는 순서A가 trace-A를 공유 current에 저장한다. B가 trace-B로 같은 칸을 덮는다. A 재개에서 B를 읽는 것은 요청 상태 소유권 실패다. 실행별 프레임 해법은 아래 별도 패널로 분리한다.1 · A가 저장current = trace-A2 · B가 덮어씀current = trace-B3 · A가 재개trace-B를 잘못 읽음latch로 이 교차 순서를 고정해 소유권 실패를 재현
  1. A 저장 → B 덮어쓰기 → A 재개

    두 워커가 같은 current 필드를 공유합니다. latch로 순서를 고정하면 A가 B의 trace를 읽는 실패를 재현할 수 있습니다.

해법 · 실행별 프레임

A: trace-A · depth 0 → 1 → 0

B: trace-B · depth 0 → 1 → 0

같은 ID의 자식 프레임을 만들고 내부 종료에 부모를 복원합니다. 루트의 finally에서 저장소를 제거합니다.

최신성·상호 배제와 구분

volatile은 최신 값을 보게 할 뿐 A/B 저장 공간을 분리하지 않습니다. 요청 전체를 lock하면 직렬화됩니다. 불변 값 자체와 현재 프레임의 저장 위치는 다른 문제입니다.

업무 예외는 그대로 전달하고 추적기 자체 실패와 관찰 비용은 별도 정책으로 제한합니다. ID를 메트릭 태그에 넣지 않습니다.

이 예의 조건

  • 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
  • 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
  • 세 노드는 한 공유 current의 A 저장, B 덮어쓰기, A 재읽기라는 순서 있는 세 상태다. aStored와 bStored 래치가 원래 두 워커의 순서와 가시성을 연결한다. 세 독립 저장 칸이나 ThreadLocal 격리를 뜻하지 않는다. 원래 출력은 A와 B 둘 다 trace-B다.
  • A가 trace-A를 공유 current에 저장한다. B가 trace-B로 같은 칸을 덮는다. A 재개에서 B를 읽는 것은 요청 상태 소유권 실패다. 실행별 프레임 해법은 아래 별도 패널로 분리한다.
단계별 상태·값과 전환 이유
장면관찰 값전환 이유
A가 저장하고 대기current: trace-A · A: 대기 · B: 저장 전공유 필드가 trace-A로 바뀝니다.
B가 같은 칸을 덮어씀current: trace-B · A: 재읽기 가능 · B: 저장 완료A가 저장했던 값이 같은 공유 칸에서 교체됩니다. 래치가 A 재읽기의 선후·가시성을 연결합니다.
둘 다 B의 값을 읽음A 출력: trace-B · B 출력: trace-B · 저장소: 공유 필드 한 개A의 관찰값이 자신의 요청 값과 달라집니다.
관계도의 보충 설명
원래 전체 그림의 표시 문구·조건·주석
순번원문 표시
1동시 요청과 공유 상태
6세 노드는 한 공유 current의 A 저장, B 덮어쓰기, A 재읽기라는 순서 있는 세 상태다. aStored와 bStored 래치가 원래 두 워커의 순서와 가시성을 연결한다. 세 독립 저장 칸이나 ThreadLocal 격리를 뜻하지 않는다. 원래 출력은 A와 B 둘 다 trace-B다.
19요청 A와 요청 B가 같은 current 필드를 덮어쓰고, 요청 A가 재개해 읽는 순서
예의 값과 변화
관찰·상황값·보충 정보변화·이유
A가 저장하고 대기

A 워커가 같은 객체의 current에 trace-A를 저장한 뒤 B 저장을 기다립니다.

  • current: trace-A
  • A: 대기
  • B: 저장 전
  • 공유 필드가 trace-A로 바뀝니다.
B가 같은 칸을 덮어씀

B 워커가 current를 trace-B로 바꾸고 저장 완료 래치를 내립니다.

  • current: trace-B
  • A: 재읽기 가능
  • B: 저장 완료
  • A가 저장했던 값이 같은 공유 칸에서 교체됩니다.
  • 래치가 A 재읽기의 선후·가시성을 연결합니다.
둘 다 B의 값을 읽음

A와 B가 같은 current를 읽어 모두 trace-B를 관찰합니다.

  • A 출력: trace-B
  • B 출력: trace-B
  • 저장소: 공유 필드 한 개
  • A의 관찰값이 자신의 요청 값과 달라집니다.

동시 요청과 공유 상태

한 공유 current를 A 저장·B 덮어쓰기·A 재읽기로 바꾸는 래치 고정 interleaving이다.

CONCURRENCY · PER-EXECUTION OWNERSHIP

공유 필드 한 칸에는 요청 소유권이 없다

불변 trace라도 현재 값을 싱글톤 필드 하나에 저장하면 다른 요청이 덮어씁니다. 호출별 프레임과 실행별 저장 공간, 종료 정리를 함께 설계합니다.

같은 current 한 칸의 세 상태세 노드는 한 공유 current의 A 저장, B 덮어쓰기, A 재읽기라는 순서 있는 세 상태다. aStored와bStored 래치가 원 두워커의 순서와 가시성을 연결한다. 세 독립 저장 칸이나 ThreadLocal 격리를 뜻하지 않는다. 원 출력은 A와B 둘다trace-B이다.
1 · A 저장같은 currenttrace-A
연결 관계1 · A 저장 · 같은 current · trace-A → 2 · B 덮어씀 · 같은 current · trace-B
2 · B 덮어씀같은 currenttrace-B
연결 관계2 · B 덮어씀 · 같은 current · trace-B → 3 · A 다시 읽음 · 반환: trace-B
3 · A 다시 읽음반환: trace-B

래치로 A 저장→B 저장→A 읽기 순서를 고정

같은 current 한 칸의 세 상태세 노드는 한 공유 current의 A 저장, B 덮어쓰기, A 재읽기라는 순서 있는 세 상태다. aStored와bStored 래치가 원 두워커의 순서와 가시성을 연결한다. 세 독립 저장 칸이나 ThreadLocal 격리를 뜻하지 않는다. 원 출력은 A와B 둘다trace-B이다.
1 · A 저장같은 currenttrace-A
aStoredaStored1 · A 저장 · 같은 current · trace-A → 2 · B 덮어씀 · 같은 current · trace-B
2 · B 덮어씀같은 currenttrace-B
bStoredbStored2 · B 덮어씀 · 같은 current · trace-B → 3 · A 다시 읽음 · 반환: trace-B
3 · A 다시 읽음반환: trace-B

세 상태는 같은 필드워커별 칸이 아님

요청 A, 요청 B, 요청 A 재개가 같은 current 필드를 덮는 순서A가 trace-A를 공유 current에 저장한다. B가 trace-B로 같은 칸을 덮는다. A 재개에서 B를 읽는 것은 요청 상태 소유권 실패다. 실행별 프레임 해법은 아래 별도 패널로 분리한다.1 · A가 저장current = trace-A2 · B가 덮어씀current = trace-B3 · A가 재개trace-B를 잘못 읽음latch로 이 교차 순서를 고정해 소유권 실패를 재현
  1. A 저장 → B 덮어쓰기 → A 재개

    두 워커가 같은 current 필드를 공유합니다. latch로 순서를 고정하면 A가 B의 trace를 읽는 실패를 재현할 수 있습니다.

해법 · 실행별 프레임

A: trace-A · depth 0 → 1 → 0

B: trace-B · depth 0 → 1 → 0

같은 ID의 자식 프레임을 만들고 내부 종료에 부모를 복원합니다. 루트의 finally에서 저장소를 제거합니다.

최신성·상호 배제와 구분

volatile은 최신 값을 보게 할 뿐 A/B 저장 공간을 분리하지 않습니다. 요청 전체를 lock하면 직렬화됩니다. 불변 값 자체와 현재 프레임의 저장 위치는 다른 문제입니다.

업무 예외는 그대로 전달하고 추적기 자체 실패와 관찰 비용은 별도 정책으로 제한합니다. ID를 메트릭 태그에 넣지 않습니다.

이 예의 조건

  • 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
  • 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
  • 세 노드는 한 공유 current의 A 저장, B 덮어쓰기, A 재읽기라는 순서 있는 세 상태다. aStored와 bStored 래치가 원래 두 워커의 순서와 가시성을 연결한다. 세 독립 저장 칸이나 ThreadLocal 격리를 뜻하지 않는다. 원래 출력은 A와 B 둘 다 trace-B다.
  • A가 trace-A를 공유 current에 저장한다. B가 trace-B로 같은 칸을 덮는다. A 재개에서 B를 읽는 것은 요청 상태 소유권 실패다. 실행별 프레임 해법은 아래 별도 패널로 분리한다.
단계별 상태·값과 전환 이유
장면관찰 값전환 이유
A가 저장하고 대기current: trace-A · A: 대기 · B: 저장 전공유 필드가 trace-A로 바뀝니다.
B가 같은 칸을 덮어씀current: trace-B · A: 재읽기 가능 · B: 저장 완료A가 저장했던 값이 같은 공유 칸에서 교체됩니다. 래치가 A 재읽기의 선후·가시성을 연결합니다.
둘 다 B의 값을 읽음A 출력: trace-B · B 출력: trace-B · 저장소: 공유 필드 한 개A의 관찰값이 자신의 요청 값과 달라집니다.

관계도의 보충 설명

원래 전체 그림의 표시 문구·조건·주석
순번원문 표시
1동시 요청과 공유 상태
6세 노드는 한 공유 current의 A 저장, B 덮어쓰기, A 재읽기라는 순서 있는 세 상태다. aStored와 bStored 래치가 원래 두 워커의 순서와 가시성을 연결한다. 세 독립 저장 칸이나 ThreadLocal 격리를 뜻하지 않는다. 원래 출력은 A와 B 둘 다 trace-B다.
19요청 A와 요청 B가 같은 current 필드를 덮어쓰고, 요청 A가 재개해 읽는 순서
예의 값과 변화
관찰·상황값·보충 정보변화·이유
A가 저장하고 대기

A 워커가 같은 객체의 current에 trace-A를 저장한 뒤 B 저장을 기다립니다.

  • current: trace-A
  • A: 대기
  • B: 저장 전
  • 공유 필드가 trace-A로 바뀝니다.
B가 같은 칸을 덮어씀

B 워커가 current를 trace-B로 바꾸고 저장 완료 래치를 내립니다.

  • current: trace-B
  • A: 재읽기 가능
  • B: 저장 완료
  • A가 저장했던 값이 같은 공유 칸에서 교체됩니다.
  • 래치가 A 재읽기의 선후·가시성을 연결합니다.
둘 다 B의 값을 읽음

A와 B가 같은 current를 읽어 모두 trace-B를 관찰합니다.

  • A 출력: trace-B
  • B 출력: trace-B
  • 저장소: 공유 필드 한 개
  • A의 관찰값이 자신의 요청 값과 달라집니다.

그림은 하나의 공유 필드 current에서 A가 trace-A를 저장하고, B가 같은 칸을 trace-B로 덮어쓴 뒤, A가 다시 읽어 trace-B를 얻는 세 상태를 보여 줍니다. aStored와 bStored 래치가 두 워커의 순서와 가시성을 이어 주며, 세 칸이 워커마다 따로 있는 것이 아니라 같은 필드입니다. 아래 칸들은 같은 ID의 자식 프레임을 만들고 안쪽이 끝날 때 부모를 되돌리는 해법과, volatile이나 잠금이 요청별 저장 공간을 만들지 않는다는 점을 함께 설명합니다.


TraceStatus

컨텍스트(context)는 한 요청을 추적하는 데 필요한 값을 함께 묶은 상태입니다. begin은 넘겨받은 TraceId와 메시지에 시작 시점의 System.nanoTime() 값을 더해 TraceStatus를 만들고, elapsedMicros는 지금의 nanoTime() 값에서 그 값을 빼 걸린 시간을 마이크로초로 계산합니다. 안쪽 호출에 넘길 다음 깊이는 begin이 아니라 TraceId.next()가 같은 ID로 깊이만 하나 올려 만듭니다.

로그의 타임스탬프에는 실제 시각을 쓰고, 경과 시간에는 같은 JVM에서 얻은 두 System.nanoTime() 값의 차이를 씁니다. 이 값의 기준점은 임의적이므로 벽시계 시각이나 다른 JVM의 값과 비교하지 않으며, 단위가 나노초라고 해서 매 나노초마다 값이 바뀐다는 보장도 없습니다. nanoTime API의 경과 시간 규칙을 코드의 뺄셈과 함께 읽어 봅니다.

src/main/java/board/trace/TraceModel.java
package board.trace;

import java.util.UUID;

public final class TraceModel {
    public record TraceId(String value, int depth) {
        public static TraceId root() {
            return new TraceId(UUID.randomUUID().toString(), 0);
        }

        public TraceId next() {
            return new TraceId(value, depth + 1);
        }
    }

    public record TraceStatus(
            TraceId trace,
            String message,
            long startedNanos
    ) { }

    public static TraceStatus begin(TraceId trace, String message) {
        return new TraceStatus(trace, message, System.nanoTime());
    }

    public static long elapsedMicros(TraceStatus status) {
        return (System.nanoTime() - status.startedNanos()) / 1_000;
    }
}

레코드를 불변으로 만들면 상태 하나 자체는 안전합니다. 문제는 현재의 루트를 어디에 저장하고 누가 다음 호출에 넘기느냐입니다. 파라미터로 명시적으로 넘기면 가장 분명하지만, 컨트롤러부터 저장소까지 모든 시그니처가 추적이라는 관심사를 알게 됩니다.


공유 필드의 동시성

다음 프로그램은 두 워커가 같은 UnsafeTraceStore.current를 함께 쓰게 하고, 래치로 덮어쓰는 순서를 고정합니다. 요청 A가 넣은 컨텍스트를 B가 바꾼 뒤 A가 다시 읽습니다.

src/main/java/board/trace/UnsafeTraceDemo.java
package board.trace;

import java.util.concurrent.CountDownLatch;
import java.util.concurrent.Executors;

public final class UnsafeTraceDemo {
    static final class UnsafeTraceStore {
        private String current;

        void set(String traceId) {
            current = traceId;
        }

        String get() {
            return current;
        }
    }

    public static void main(String[] args) throws Exception {
        var store = new UnsafeTraceStore();
        var aStored = new CountDownLatch(1);
        var bStored = new CountDownLatch(1);
        try (var workers = Executors.newFixedThreadPool(2)) {
            var requestA = workers.submit(() -> {
                store.set("trace-A");
                aStored.countDown();
                bStored.await();
                return "A-read=" + store.get();
            });
            var requestB = workers.submit(() -> {
                aStored.await();
                store.set("trace-B");
                bStored.countDown();
                return "B-read=" + store.get();
            });
            System.out.println(requestA.get());
            System.out.println(requestB.get());
        }
    }
}
UnsafeTraceDemo 실행
A-read=trace-B
B-read=trace-B

volatile을 붙이면 최신 값은 보이지만 요청별로 떼어 놓지는 못합니다.

이 프로그램의 저장과 대기, 읽기만 차례로 따라가면, A의 저장, aStored 해제, B의 대기 통과, B의 저장, bStored 해제, A의 대기 통과, A의 읽기 순입니다. CountDownLatch의 메모리 가시성 규칙은 countDown 전의 작업이 대응하는 await가 정상적으로 돌아온 뒤의 작업보다 먼저 일어난다고 보장합니다. 그래서 이 프로그램은 필드가 volatile이 아니어도 B가 쓴 trace-B를 A가 읽도록 재현합니다. 마지막 저장 뒤 다른 쓰기는 없으므로 B의 읽기도 trace-B입니다.

두 println 줄의 순서는 main이 requestA와 requestB의 Future를 조회하는 순서일 뿐, 두 워커의 모든 실행이 차례로 일어났다는 뜻은 아닙니다. 래치를 없앤 변형의 결과를 이 결정적인 출력으로 단정하지도 않습니다.

같은 잠금으로 요청 전체를 감싸고 모든 접근이 그 잠금을 따른다면 공유 필드에서 요청이 섞이는 것은 막을 수 있지만, 그 구간은 한 번에 한 스레드만 처리합니다. 필요한 성질은 상호 배제가 아니라, 실행마다 따로 있는 저장 공간입니다.

더 깊이: 한 싱글톤을 동시에 지나는 요청은 몇 개일까

이 예제는 워커 두 개로 덮어쓰기를 재현하지만, 실제 서버에서는 같은 싱글톤을 지나는 실행 흐름이 훨씬 많습니다. Spring Boot 4.1.2의 TomcatServerProperties를 보면 내장 Tomcat의 작업 스레드는 기본 최대 200개(server.tomcat.threads.max)이고, 처리 중인 연결은 8,192개(server.tomcat.max-connections)까지 받습니다. 처리 중인 연결이 이 한도에 이르렀을 때 운영체제에 쌓아 둘 수 있는 연결 요청의 대기열 길이는 100(server.tomcat.accept-count)입니다. 즉 기본 설정에서도 최대 200개의 요청이 같은 추적기 인스턴스의 같은 필드를 동시에 읽고 쓸 수 있습니다.

spring.threads.virtual.enabled=true로 가상 스레드를 켜면 이 숫자의 의미가 달라집니다. 같은 소스의 설명대로 이때 threads.max와 threads.min-spare는 효과가 없고, 요청을 처리하는 스레드 수가 고정된 풀 크기로 제한되지 않습니다. 동시에 실행되는 요청이 늘어나는 만큼 공유 필드에서 순서가 뒤섞일 기회도 늘어나므로, 가상 스레드로 바꾸는 일은 이 글의 문제를 줄이는 것이 아니라 더 자주 드러나게 할 수 있습니다.


호출 깊이의 관리

첫 진입은 새 트레이스 ID와 깊이 0을 만들고, 안쪽 호출은 같은 ID로 깊이만 올립니다. 안쪽이 끝나면 부모의 깊이로 돌아가고, 루트가 끝날 때 컨텍스트를 지우며, 예외 경로에서도 같은 정리가 필요합니다. 깊이가 음수가 되거나 루트가 남아 있다면 시작과 끝의 짝이 맞지 않는 결함입니다.

상태를 스택으로 명시하면 재귀 호출과 중첩된 콜백도 다룰 수 있습니다. 지금 값 하나만 저장하고 끝날 때 무조건 null로 만들면, 안쪽이 끝날 때 바깥 컨텍스트까지 지워 버립니다.

비동기 실행기로 작업을 넘기면 스레드가 바뀝니다. 원래의 컨텍스트가 저절로 따라간다고 가정하지 말고, 불변 스냅샷을 작업에 붙이거나 프레임워크의 컨텍스트 전파 기능을 씁니다. 요청 본문이나 회원 이름을 트레이스 ID에 넣지 않습니다.


로깅 실패의 격리

추적기의 포매팅 예외 때문에 게시글 등록이 실패하면, 횡단 관심사가 핵심 기능의 가용성을 해칩니다. 메시지 공급자와 로깅 백엔드 호출을 작은 범위로 감싸고, 추적 자체의 실패는 별도의 카운터로 봅니다.

그렇다고 업무 예외를 추적기가 삼키면 안 됩니다. try에서 대상을 실행해 정상 결과를 기록하고, catch에서 실패를 기록한 뒤 원래 예외를 그대로 다시 던지며, 컨텍스트의 정리는 finally에서 합니다. 스택 트레이스를 여러 계층에서 되풀이해 출력하지 않고, 요청을 처리하는 곳에서 한 번만 잇습니다.

표본 추출은 트레이스를 만들지를 루트에서 정합니다. 안쪽 메서드마다 임의로 표본을 뽑으면 한 요청의 중간 조각만 남습니다. 오류는 표본 여부와 상관없이 최소한의 이벤트를 남길지 정책으로 정합니다.


결정적인 동시성 검증

기다리는 시간에 기대는 테스트는 환경에 따라 통과하기도 합니다. 래치와 장벽, 가상 스레드 스케줄러의 제어, 가짜 시계로 실행 순서의 교차를 재현합니다. 최소한 A와 B의 ID가 섞이지 않고, 루트가 끝난 뒤 컨텍스트가 비어 있는지를 검증합니다.

운영 환경에서는 트레이스 ID별 로그 조각과 잘못된 깊이, 활성 컨텍스트의 수, 컨텍스트의 누수를 봅니다. 고카디널리티 ID를 메트릭 태그로 쓰지 않고 로그와 트레이스의 필드에 둡니다.

성능 테스트에서는 추적기를 끈 기준선과 켠 상태를 같은 작업 부하로 비교합니다. 문자열 조립과 스택 트레이스 캡처가 자주 지나는 경로의 객체 할당을 늘리는지, 비동기 어펜더의 대기열이 가득 찼을 때 요청 스레드를 막는지 확인합니다. 관찰 도구가 장애 순간에 본 시스템보다 더 큰 부하를 만들지 않도록, 메시지 길이와 표본 추출, 대기열의 폐기 정책을 함께 제한합니다.


정리

  • 불변 레코드라도 현재 값을 싱글톤 필드 한 칸에 저장하면 다른 요청이 덮어쓰므로, 상태를 누가 가질지부터 정합니다.
  • volatile은 최신 값을 보게 할 뿐이고, 요청 전체를 잠그면 직렬화될 뿐이며, 둘 다 실행마다 따로 있는 저장 공간을 만들지 않습니다.
  • 호출 깊이는 같은 ID의 자식 프레임으로 나타내고, 안쪽이 끝나면 부모로 돌아가며 루트가 끝날 때만 저장소를 지웁니다.
  • 추적기는 업무 예외를 삼키지 않고 다시 던지며, 자신의 실패는 따로 세고, 정리는 finally에서 합니다.
  • 동시성은 래치와 장벽으로 순서를 고정해 결정적으로 검증하고, 트레이스 ID를 메트릭 태그에 넣지 않습니다.

연습 문제

1. 재귀 호출을 추적하는 프레임 스택

저장소 세 개를 재귀적으로 호출하는 추적기를 설계하려고 합니다. 깊이별 접두사와 예외 표시, 루트 종료의 정리를 구현하고, 두 병렬 요청이 서로 다른 ID를 유지하는지 테스트해야 합니다. 상태를 어떻게 나타내나요?

해설 보기

상태를 불변 프레임의 스택으로 나타내면, 안쪽이 끝날 때 바깥 프레임을 되돌릴 수 있습니다. 저장할 위치는 다음 글에서 ThreadLocal로 나눕니다.

package board.trace;

public record TraceFrame(String id, int depth, TraceFrame parent) {
    public TraceFrame child() {
        return new TraceFrame(id, depth + 1, this);
    }

    public static TraceFrame root(String id) {
        if (id.isBlank()) {
            throw new IllegalArgumentException("trace id is blank");
        }
        return new TraceFrame(id, 0, null);
    }

    public TraceFrame finish() {
        return parent;
    }
}

루트의 finish() 결과가 null일 때만 저장소를 지웁니다. 호출이 깊게 중첩될 수 있다면 최대 깊이와 메시지 길이의 제한도 둡니다. 테스트는 두 요청을 래치로 교차시켜, 각 요청의 로그 줄이 자신의 ID만 갖는지와 루트가 끝난 뒤 저장소가 비어 있는지를 확인합니다.

2. 깊이의 순서와 지우는 시점

같은 ID의 루트에서 자식을 만들고, 그 자식에서 한 번 더 자식을 만든 뒤, 가장 안쪽부터 finish를 호출합니다. 현재 깊이의 전체 순서와 저장소를 지우는 시점을 구하세요. 루트의 두 자식을 차례로 시작하고 끝내는 경우에도 최고 깊이가 2인지 비교하세요. 비동기 전파는 없습니다.

해설 보기

중첩된 경우는 0, 1, 2, 1, 0, null 순이고, 마지막 루트가 끝날 때만 지웁니다. 루트의 첫 자식을 끝내 깊이 0으로 돌아온 뒤 둘째 자식을 만들면 0, 1, 0, 1, 0, null 순이므로 최고 깊이는 1입니다. 호출한 메서드의 총 개수와 동시에 열린 프레임의 깊이는 다릅니다. 안쪽 finish가 돌려준 부모를 현재 상태로 되돌리지 않고 언제나 null로 바꾸면, 남아 있는 바깥 프레임을 잃습니다.

3. 잠금의 범위가 달라지면

UnsafeTraceDemo의 래치 순서는 그대로 두고, set과 get을 각각 같은 저장소 객체의 synchronized 메서드로 바꿨다고 가정합니다. 메서드가 돌아오면 잠금을 놓고, 마지막 B의 저장 뒤 다른 쓰기는 없습니다. A와 B는 각각 무엇을 읽나요? 다른 변형에서는 A가 먼저 저장소의 잠금을 잡고 요청 전체를 감싸며, 잠금을 놓기 전에 bStored.await()를 합니다. B도 저장부터 countDown까지 같은 잠금 안에서만 실행합니다. 다른 해제 주체나 시간 제한, 인터럽트가 없다면 누가 누구를 기다리나요?

해설 보기

메서드 단위의 잠금에서는 A의 set이 끝나 잠금이 풀린 뒤 B가 set할 수 있고, bStored 뒤에 A가 get합니다. 따라서 A와 B 모두 trace-B를 읽습니다. 필드 접근의 상호 배제가 요청별 저장 공간을 만들지는 않습니다.

요청 전체를 잠그는 변형에서는 A가 B의 countDown을 기다리고, B는 A가 쥔 잠금을 기다립니다. 이 조건에서는 어느 쪽도 스스로 나아갈 수 없는 대기의 순환이 생깁니다. 메서드에 잠금을 붙였는지보다, 어떤 작업을 한 묶음으로 보호하고 기다리는 동안 어떤 잠금을 쥐고 있는지를 확인해야 합니다.

다음 글 ThreadLocal 수명 주기에서는 이 프레임 스택을 실행하는 스레드마다 따로 저장하는 ThreadLocal과, 스레드 풀에서 반드시 지켜야 할 정리 규칙을 다룹니다.