«   2026/02   »
1 2 3 4 5 6 7
8 9 10 11 12 13 14
15 16 17 18 19 20 21
22 23 24 25 26 27 28
Recent Posts
Recent Comments
관리 메뉴

_z_z_

8월_3주차. Race Condition과 해결 전략, 비동기 환경에서의 MDC 전달 본문

쪼드잇/위클리페이퍼

8월_3주차. Race Condition과 해결 전략, 비동기 환경에서의 MDC 전달

hyohyo_zz 2025. 8. 18. 02:40
더보기

Q1. 멀티스레드 환경에서 발생하는 대표적인 문제 중 하나인 경쟁 상태(Race Condition)에 대해 설명하고, 이를 해결하기 위한 다양한 전략을 설명해보세요.

Q2. 비동기 환경에서 MDC(Logback Mapped Diagnostic Context)나 SecurityContext 같은 컨텍스트 정보를 스레드 간에 전달해야 할 경우, 처리하는 방법에 대해 설명하세요.

 

1_1) 레이스 컨디션(Race Condition)?

여러 스레드가 같은 값을 동시에 건드려서, 실행 순서에 따라 결과가 달라지는 문제

  1. 공유 데이터가 있고
  2. 동시에 접근하며,
  3. 순서에 따라 결과가 달라질 때

예시

import java.util.concurrent.*;

public class RaceDemo {
  static int value = 0; // 공유 변수 (의도적으로 안전하지 않게)

  public static void main(String[] args) throws Exception {
    ExecutorService pool = Executors.newFixedThreadPool(100);

    for (int i = 0; i < 100; i++) {
      pool.submit(() -> {
        for (int j = 0; j < 1000; j++) {
          value++; // (읽기) -> (더하기) -> (쓰기) 로 쪼개져 원자 아님
        }
      });
    }

    pool.shutdown();
    pool.awaitTermination(1, TimeUnit.MINUTES);

    System.out.println("결과: " + value); // 종종 100_000보다 작게 찍힘
  }
}

// value++ 는 내부적으로 이렇게 쪼개짐
	int tmp = value;    // 1. 읽기
	tmp = tmp + 1;       // 2. 더하기
	value = tmp;        // 3. 쓰기
	
// 업데이트 유실 시나리오
1. A가 value를 읽음
2. B도 value를 읽음
3. A가 더함 -> 1
4. B가 더함 -> 1
5. A가 씀 -> value = 1
6. B가 씀 -> value = 1 (A의 증가가 덮여서 사라짐)
>> 기대값 2 대신 1이 되어 업데이트 유실

>1. synchronized로 감싸거나
>2. AtomicInteger.incrementAndGet()이나 LongAdder.increment()와 같은 원자적 증가로 한번에 처리해야함

  • 자주 보이는 패턴
    • lost Update(업데이트 유실): x++가 사라짐
    • Check-then-Act(체크 후 행동): if(!~) {..} 확인과 행동 사이에 다른 스레드가 끼어듦
    • TOCTOU(Time Of Check to Time Of Use): 검사 시점과 사용 시점 사이 상태 변경
    • 가시성 문제: 한 스레드에서의 변경이 다른 스레드에서 바로 보이지 않음

 


1_2) 해결 전략

A. 공유 변경을 없애기(아예 같이 안 건드리기)

  • 불변(Immutable) 객체: 한 번 만들면 못 바꾸게, 바꿀 땐 새 인스턴스를 만들어 통째로 교체
  • 스레드 컨파인먼트: 스레드마다 로컬로 계산하고(따로 계산하고) 마지막에만 합치기 (Map-Reduce 스타일)
  • 메시지 패싱/큐: 데이터를 공유하지 말고 메시지로 전달(프로듀서–컨슈머, 액터 모델)
    • 사용 시기: 읽기 위주, 설정 값, 캐시 스냅샷 등.

B. 락/동기화로 임계구역 보호(한 스레드만 들어오게 하기)

  • synchronized / ReentrantLock
    • 중요부분에서 한 번에 한 스레드만(상호배제)
    • 락 입장/퇴장 시점에 메모리 가시성 보장(안에서 바꾼 값이 다른 스레드에 보이게)
    • 재진입 가능(재귀 호출 가능)
  • 사용 시점
    • 단순 증감이 아닌 여러 연산을 한 덩어리로 묶어야 할 때
    • ex) 증가 → 임계치 넘으면 알림 → 다른 공유 상태도 수정
    • 여러 변수를 함께 다룰 때
  • 간단히 쓰려면 synchronized, 세밀한 제어(타임아웃/공정성/조건대기)는 ReentrantLock
class Counter {
  private final Object lock = new Object(); // 전용 락
  private int value = 0;

  public int incAndGet() {
    synchronized (lock) {
      value++;
      if (value == 1000) notifySomething();
      return value;
    }
  }
}
  • 주의
    • 락 구간은 짧고 작게(병목 방지)
    • this 나, 공개된 객체, String/박싱 숫자 등에 락을 걸면 의도치 않게 다른 코드와 같은 락을 공유해 성능 저하나 데드락이 날 수 있음
    • → private final Object lock = new Object(); 같은 전용 락 권장.
    • 공정성/타임아웃/인터럽트 대응이 필요하면 ReentrantLock 고려.

C. 원자 변수(CAS)와 동시성 컬렉션(안전한 자료구조로 교체)

  • LongAdder.increment()
    • 내부적으로 셀을 여러 개로 분산(스트라이핑) 해서 서로 다른 스레드가 각자 셀을 갱신하여 경합을 완화
    • sum() 할때 각 셀을 모아 합산
  • 사용 시점
    • 아주 많은 스레드가 카운트를 올리는 통계/모니터링 용도
    import java.util.concurrent.atomic.LongAdder;
    
    class ThroughputCounter {
      private final LongAdder adder = new LongAdder();
    
      void inc() {              // 경합 분산, 매우 빠름(고경합)
        adder.increment();
      }
    
      long value() {            // 여러 셀 합산
        return adder.sum();
      }
    }
    
  • 주의
    • sum()은 그 순간의 스냅샷을 합친 것 → 동시에 증가 중인 값이 바로 반영되지 않을 수 있음
    • 결정로직(값이 1000넘는 순간 알람) 에는 부적합
    • → AtomicLong + updateAndGet/accumulateAndGet 또는 synchronized로 사용해야 함
    • 고유 ID 발급처럼 값의 연속성/순서가 필요한 경우에도 부적합
    • → AtomicLong.getAndIncrement() 사용

D. 데이터베이스 레벨 잠금/트랜잭션(db에서 잠그기/검사하기)

  • 비관적 락: SELECT ... FOR UPDATE 
    • 경합 심할 때 안전하지만 대기/교착 위험, 확장성 떨어짐(대기 길어질 수도)
  • 낙관적 락: @Version 칼럼으로 충돌 감지 후 재시도

E. 설계 레벨에서의 안전장치

  • 아이템포턴시: 같은 요청이 여러 번 와도 결과가 같도록
  • 락 순서 규약: 여러 락을 잡을 땐 항상 같은 순서로(데드락 회피)
  • 타임아웃/백오프 재시도: 무한 대기 금지

1_3) 자주 만나는 상황

상황 틀린 접근 안전한 접근
증가/감소 카운터 x++ LongAdder.increment()
“없으면 넣기” 캐시 if (!map.containsKey) put map.computeIfAbsent(key, k -> load())
설정/목록 갱신 리스트에 직접 추가 새 리스트를 복사해 수정 → 불변으로 교체
재고 차감 단순 UPDATE DB 락(짧게) 또는 낙관적 락 + 재시도

 

 


2) 비동기(@Async, 스레드풀) 환경에서 MDC / SecurityContext 전파

MDC(로그용 문맥)와 SecurityContextHolder는 기본적으로 ThreadLocal 기반

둘 다 ThreadLocal에 담기고

스레드가 바뀌면 그 값이 따라가지 않음

그래서 비동기 로그에 traceId가 빠지거나, 인증정보가 null이 되며 securityContext가 사라짐

 

방법 A) Spring TaskDecorator

  1. 제출 시점에 현재 스레드의 MDC와 SecurityContext를 복사
  2. 실행 스레드에 심고
  3. 끝나면 깨끗이 비워 스레드풀 오염을 방지함

설정 순서

  1. 데코레이터 클래스를 만들고, (컨텍스트 복사/주입/정리 데코레이터 클래스)
  2. ThreadPoolTaskExecutor.setTaskDecorator(…)로 연결 (스레드풀에 장착)
  3. @Async(”그 실행기”) 또는 CompletableFuture.supplyAsync(…, 그 실행기)로 같은 실행기 사용

 

[ ] 같은 executor를 쓰고 있는지(thenApplyAsync에서 다른 executor 쓰면 끊김)

[ ] 실행 후 MDC.clear()/SecurityContextHolder.clearContext()로 정리했는지

[ ] 로그 패턴에 %X{traceId}와 같은 MDC 키가 잡히는지

// 1) 컨텍스트 복사/주입/정리 데코레이터
@Component
public class ContextTaskDecorator implements TaskDecorator {
  public Runnable decorate(Runnable task) {
    var mdc = MDC.getCopyOfContextMap();
    var sec = SecurityContextHolder.getContext();
    return () -> {
      try {
        if (mdc != null) MDC.setContextMap(mdc); else MDC.clear();
        SecurityContextHolder.setContext(sec);
        task.run();
      } finally {
        MDC.clear();
        SecurityContextHolder.clearContext();
      }
    };
  }
}

// 2) 스레드풀에 장착
@Configuration @EnableAsync
public class AsyncConfig {
  @Bean
  public ThreadPoolTaskExecutor appExecutor(ContextTaskDecorator d) {
    var ex = new ThreadPoolTaskExecutor();
    ex.setCorePoolSize(8);
    ex.setTaskDecorator(d);
    ex.initialize();
    return ex;
  }
}

// 3) 사용
@Async("appExecutor")
public void work() {
  log.info("user={}, trace={}", 
    SecurityContextHolder.getContext().getAuthentication().getName(),
    MDC.get("traceId"));
}

대안(상황별)

  • Spring Security 래퍼: DelegatingSecurityContextRunnable/Callable(보안만 전파) → MDC는 별도 처리 필요
  • MODE_INHERITABLETHREADLOCAL: 자식 스레드에만 상속, 스레드풀 재사용에 부적합(누수 위험) → 권장 X
  • TTL 라이브러리: TransmittableThreadLocal(여러 스레드풀 지원). Spring 표준이면 보통 TaskDecorator로 충분

Reactor(WebFlux) 참고

  • 보안: ReactiveSecurityContextHolder
  • MDC: Reactor Context ↔ MDC 브리지 필요(또는 Micrometer Context Propagation)