동시 요청과 공유 상태
호출 깊이와 추적 ID를 기록하며 싱글톤의 변경 가능한 필드가 병렬 요청에서 섞이는 과정을 재현하고 상태 전달 경계를 정합니다.
웹 서버는 여러 요청을 동시에 처리합니다.
스레드는 그중 한 실행 흐름이고, 두 스레드가 같은 변경 가능한 값을 함께 쓰면 실행 순서에 따라 결과가 달라질 수 있습니다.
이를 공유 상태 문제라고 합니다.
인터리빙(interleaving)은 두 실행 흐름의 명령이 번갈아 끼어드는 모습입니다.
로그에 메서드 시작과 종료만 추가하면 어느 줄이 같은 요청에 속하는지 알기 어렵습니다.
그래서 요청마다 트레이스 ID를 붙이고 호출 깊이·소요 시간·실패를 같은 문맥으로 묶습니다.
하지만 Spring 싱글톤 빈의 필드에 현재 트레이스를 저장하면 여러 스레드가 그 한 칸을 덮어씁니다.
먼저 좋은 출력 모양보다 상태를 누가 소유해야 하는지 찾습니다.
동시 요청과 공유 상태
한 공유 current를 A 저장·B 덮어쓰기·A 재읽기로 바꾸는 래치 고정 interleaving이다.
CONCURRENCY · PER-EXECUTION OWNERSHIP
공유 필드 한 칸에는 요청 소유권이 없다
불변 trace라도 현재 값을 싱글톤 필드 하나에 저장하면 다른 요청이 덮어씁니다. 호출별 프레임과 실행별 저장 공간, 종료 정리를 함께 설계합니다.
래치로 A 저장→B 저장→A 읽기 순서를 고정
세 상태는 같은 필드워커별 칸이 아님
- 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 저장을 기다립니다. |
|
|
| B가 같은 칸을 덮어씀 B 워커가 current를 trace-B로 바꾸고 저장 완료 래치를 내립니다. |
|
|
| 둘 다 B의 값을 읽음 A와 B가 같은 current를 읽어 모두 trace-B를 관찰합니다. |
|
|
동시 요청과 공유 상태
한 공유 current를 A 저장·B 덮어쓰기·A 재읽기로 바꾸는 래치 고정 interleaving이다.
CONCURRENCY · PER-EXECUTION OWNERSHIP
공유 필드 한 칸에는 요청 소유권이 없다
불변 trace라도 현재 값을 싱글톤 필드 하나에 저장하면 다른 요청이 덮어씁니다. 호출별 프레임과 실행별 저장 공간, 종료 정리를 함께 설계합니다.
래치로 A 저장→B 저장→A 읽기 순서를 고정
세 상태는 같은 필드워커별 칸이 아님
- 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 저장을 기다립니다. |
|
|
| B가 같은 칸을 덮어씀 B 워커가 current를 trace-B로 바꾸고 저장 완료 래치를 내립니다. |
|
|
| 둘 다 B의 값을 읽음 A와 B가 같은 current를 읽어 모두 trace-B를 관찰합니다. |
|
|
TraceStatus
컨텍스트는 한 요청을 추적하는 데 필요한 값을 함께 묶은 상태입니다.
begin은 이전 컨텍스트에서 다음 깊이를 만들고 시작 시각을 저장합니다.
end와 exception은 같은 상태를 받아 지속 시간을 계산합니다.
로그 타임스탬프에는 실제 시각을 쓰고, 경과 시간에는 같은 JVM에서 얻은 두 System.nanoTime() 값의 차이를 사용합니다. 이 값의 기준점은 임의적이므로 벽시계 시각이나 다른 JVM의 값과 비교하지 않습니다. 나노초 단위라는 것도 매 나노초마다 값이 달라진다는 보장은 아닙니다. nanoTime API의 경과 시간 규칙을 원 코드의 뺄셈과 함께 읽어 보세요.
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가 다시 읽습니다.
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());
}
}
}A-read=trace-B
B-read=trace-Bvolatile을 붙이면 최신 값은 보이지만 요청별 격리는 생기지 않습니다.
예시 원 프로그램의 저장·대기·읽기만 순서대로 따라가면 A 저장 → aStored 해제 → B 대기 통과 → B 저장 → bStored 해제 → A 대기 통과 → A 읽기입니다. CountDownLatch의 메모리 가시성 계약은 countDown 전의 작업이 대응하는 await 정상 복귀 뒤 작업보다 먼저 일어남을 보장합니다. 그래서 이 프로그램은 필드가 volatile이 아니어도 B가 쓴 trace-B를 A가 읽도록 재현합니다. 마지막 저장 뒤 다른 쓰기는 없으므로 B의 읽기도 trace-B입니다.
두 println 줄의 순서는 main이 requestA와 requestB의 Future를 조회하는 순서입니다. 그 순서가 두 워커의 모든 실행을 직렬화했다는 뜻은 아닙니다. 래치를 제거한 변형의 결과를 이 결정적 출력으로 단정하지 않습니다.
같은 락으로 요청 전체를 감싸고 모든 접근이 그 락을 따른다면 공유 필드의 요청 간 교차를 막을 수 있지만, 그 구간은 한 번에 한 스레드만 처리합니다.
필요한 성질은 상호 배제가 아니라 실행 컨텍스트별 저장 공간입니다.
락의 범위와 대기 조건. set과 get을 각각 같은 락으로 보호해도 두 메서드 사이에는 다른 요청이 들어올 수 있습니다. A 저장 → B 저장 → A 읽기라는 요청 간 교차가 그대로 가능하므로 메서드 단위의 상호 배제와 요청별 소유권은 다릅니다. 반대로 원 UnsafeTraceDemo에서 A가 요청 전체의 락을 잡은 채 bStored.await()하면, B는 그 락을 얻지 못해 bStored.countDown()에 도달할 수 없습니다. 대기 중 락을 계속 보유하는 구조까지 함께 점검해야 합니다.
호출 깊이 관리
첫 진입은 새 트레이스 ID와 깊이 0을 만들고 내부 호출은 같은 ID의 깊이만 증가시킵니다.
내부 종료에서는 부모 깊이로 돌아가며 루트 종료에서 컨텍스트를 제거합니다.
예외 경로에서도 동일한 정리가 필요합니다.
깊이가 음수가 되거나 루트가 남아 있으면 시작·종료 짝이 맞지 않는 결함입니다.
상태 스택을 명시적으로 두면 재귀 호출과 중첩 콜백도 처리할 수 있습니다.
단일 현재 값만 저장하고 종료 시 무조건 null로 만들면 내부 종료가 외부 컨텍스트까지 지웁니다.
비동기 실행자로 작업을 넘기면 스레드가 바뀝니다.
원래 컨텍스트가 자동으로 이동한다고 가정하지 말고 불변 스냅샷을 작업에 붙이거나 프레임워크의 컨텍스트 전파 기능을 사용합니다.
요청 본문이나 구성원 이름을 트레이스 ID에 넣지 않습니다.
로깅 실패 격리
트레이서의 포매팅 예외 때문에 게시글 등록이 실패하면 횡단 관심사가 핵심 가용성을 해칩니다.
메시지 공급자와 로깅 백엔드 호출을 작은 경계로 감싸고, 트레이싱 자체 실패는 별도 카운터로 관찰합니다.
그렇다고 업무 예외를 트레이서가 삼키면 안 됩니다.
try에서 대상을 실행하고 정상 결과를 기록하며, catch에서 실패를 기록한 뒤 원래 예외를 그대로 다시 던지고, 컨텍스트 정리는 finally에서 수행합니다.
스택 트레이스를 여러 계층에서 반복 출력하지 않고 요청 경계에서 한 번 연결합니다.
표본 추출은 트레이스를 생성할지 여부를 루트에서 결정합니다.
내부 메서드마다 임의 표본 추출하면 한 요청의 중간 조각만 남습니다.
오류는 표본 여부와 무관하게 최소 이벤트를 남길지 정책을 정합니다.
결정적 동시성 검증
대기에 기대는 테스트는 환경에 따라 통과합니다.
래치, 장벽, 가상 스레드 스케줄러 제어, 가짜 시계로 실행 순서 교차를 재현합니다.
최소 검증은 A와 B의 ID가 섞이지 않고 루트 종료 뒤 컨텍스트가 비어 있다는 것입니다.
운영 환경에서는 트레이스 ID별 로그 조각, 잘못된 깊이, 활성 컨텍스트 수, 컨텍스트 누수를 관찰합니다.
고카디널리티 ID를 메트릭 태그로 쓰지 않고 로그·트레이스 필드에 둡니다.
성능 테스트에서는 트레이서를 끈 기준선과 켠 상태를 같은 작업 부하로 비교합니다.
문자열 조립과 스택 트레이스 캡처가 빈번한 경로의 객체 할당을 늘리는지, 비동기 어펜더 대기열이 가득 찼을 때 요청 스레드를 막는지 확인합니다.
관찰 도구가 장애 순간에 본 시스템보다 더 큰 부하를 만들지 않도록 메시지 길이와 표본 추출, 대기열 폐기 정책을 함께 제한합니다.
연습 문제
재귀적으로 세 개 리포지토리를 호출하는 추적기를 설계하세요.
깊이별 접두사, 예외 표시, 루트 종료 정리를 구현하고 두 병렬 요청이 서로 다른 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의 루트에서 자식, 그 자식을 한 번 더 만든 뒤 가장 안쪽부터 finish를 호출합니다. 현재 깊이의 전체 순서와 저장소를 제거하는 시점을 구하세요. 루트의 두 자식을 차례로 시작·종료하는 경우도 최고 깊이가 2인지 비교하세요. 비동기 전파는 없습니다.
풀이 확인
중첩은 0→1→2→1→0→null이고 마지막 루트 종료 때만 제거합니다. 루트의 첫 자식을 종료해 깊이 0으로 돌아온 뒤 둘째 자식을 만들면 0→1→0→1→0→null로 최고 깊이는 1입니다. 호출한 메서드의 총 개수와 동시에 열린 프레임 깊이는 다릅니다. 내부 finish가 반환한 parent를 현재 상태로 복원하지 않고 언제나 null로 바꾸면 남아 있는 바깥 프레임을 잃습니다.
호출이 깊게 중첩될 수 있으면 최대 깊이와 메시지 길이 제한도 둡니다.
직접 연습: 잠금 단위가 달라지면 무엇이 보장되는가?
공유 필드의 교차와 대기 순환을 구별하기
원 UnsafeTraceDemo의 래치 순서는 그대로 두고 set과 get 각각을 같은 저장소 객체의 synchronized 메서드로 바꾸었다고 가정합니다. 각 메서드가 반환하면 락을 놓고, 마지막 B 저장 뒤 다른 쓰기는 없습니다. A와 B는 각각 무엇을 읽나요? 별도 변형에서는 A가 먼저 저장소의 락을 잡고 요청 전체를 감싸며, A가 락을 놓기 전에 bStored.await()를 합니다. B도 저장부터 countDown까지 같은 락 안에서만 실행합니다. 다른 해제 주체·시간제한·인터럽트가 없다면 어느 둘이 서로 기다리나요?
풀이: 메서드 단위 잠금에서는 A의 set이 끝나 락이 풀린 뒤 B가 set할 수 있고, bStored 뒤 A가 get합니다. 따라서 A와 B 모두 trace-B를 읽습니다. 필드 접근의 상호 배제가 요청별 저장 공간을 만들지는 않습니다. 전체 요청 잠금 변형에서는 A가 B의 countDown을 기다리고 B는 A가 보유한 락을 기다립니다. 이 조건에서는 스스로 진전할 수 없는 대기 순환이 생깁니다. 메서드에 잠금을 붙였는지보다 어떤 작업을 한 묶음으로 보호하고, 대기할 때 어떤 락을 보유하는지 확인합니다. 이 추적기의 목표는 요청 ID를 실행별로 소유하도록 분리하면서 루트 종료까지 수명을 닫는 것입니다.