동시 요청과 공유 상태
호출 깊이와 추적 ID를 기록하며 싱글톤의 변경 가능한 필드가 병렬 요청에서 섞이는 과정을 재현하고 상태 전달 경계를 정합니다.
웹 서버는 여러 요청을 동시에 처리합니다.
스레드는 그중 한 실행 흐름이고, 두 스레드가 같은 변경 가능한 값을 함께 쓰면 실행 순서에 따라 결과가 달라질 수 있습니다.
이를 공유 상태 문제라고 합니다.
인터리빙(interleaving)은 두 실행 흐름의 명령이 번갈아 끼어드는 모습입니다.
로그에 메서드 시작과 종료만 추가하면 어느 줄이 같은 요청에 속하는지 알기 어렵습니다.
그래서 요청마다 트레이스 ID를 붙이고 호출 깊이·소요 시간·실패를 같은 문맥으로 묶습니다.
하지만 Spring 싱글톤 빈의 필드에 현재 트레이스를 저장하면 여러 스레드가 그 한 칸을 덮어씁니다.
먼저 좋은 출력 모양보다 상태를 누가 소유해야 하는지 찾습니다.
TraceStatus
컨텍스트는 한 요청을 추적하는 데 필요한 값을 함께 묶은 상태입니다.
begin은 이전 컨텍스트에서 다음 깊이를 만들고 시작 시각을 저장합니다.
end와 exception은 같은 상태를 받아 지속 시간을 계산합니다.
실제 시각은 로그 타임스탬프에, 경과 시간은 앞으로만 증가하는 단조 시계 System.nanoTime()에 사용하면 시스템 시각 보정에도 음수 지속 시간이 생기지 않습니다.
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을 붙이면 최신 값은 보이지만 요청별 격리는 생기지 않습니다.
synchronized 블록으로 전체 요청을 잠그면 정확성 대신 처리량을 한 스레드로 제한합니다.
필요한 성질은 상호 배제가 아니라 실행 컨텍스트별 저장 공간입니다.
호출 깊이 관리
첫 진입은 새 트레이스 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일 때만 저장소를 제거합니다.
호출 수가 깊어질 수 있으면 최대 깊이와 메시지 길이 제한도 둡니다.