Weekly Paper

Weekly Paper #15

승주우에요 2026. 6. 9. 10:01

1. Race Condition (경쟁 상태)

Race Condition이란?

두 개 이상의 스레드가 공유 자원에 동시에 접근할 때, 실행 순서에 따라 결과가 달라지는 문제입니다.

재고: 1개

스레드 A: 재고 조회 → 1개 확인 → (아직 차감 전)
스레드 B: 재고 조회 → 1개 확인 → (아직 차감 전)
스레드 A: 재고 차감 → 0개
스레드 B: 재고 차감 → -1개  ← 문제 발생!

해결 전략 1 - synchronized (Java 내장)

JVM 레벨에서 하나의 스레드만 해당 블록에 진입하도록 잠금을 겁니다.

public class StockService {

    private int stock = 1;

    public synchronized void decreaseStock() {
        if (stock <= 0) throw new RuntimeException("재고 없음");
        stock--;
    }
}

장점: 별도 의존성 없이 간단하게 적용

단점: 서버가 여러 대이면 효과 없음 (JVM 단위 잠금이라 다른 서버는 영향 없음)


해결 전략 2 - DB 비관적 락 (Pessimistic Lock)

조회 시점에 DB 레벨에서 락을 걸어 다른 트랜잭션이 접근하지 못하게 합니다.

public interface StockRepository extends JpaRepository<Stock, Long> {

    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("SELECT s FROM Stock s WHERE s.id = :id")
    Optional<Stock> findByIdWithLock(@Param("id") Long id);
}

@Service
@Transactional
public class StockService {

    public void decreaseStock(Long id) {
        Stock stock = stockRepository.findByIdWithLock(id)
            .orElseThrow();   // SELECT ... FOR UPDATE → 다른 트랜잭션 대기

        stock.decrease();     // 안전하게 차감
    }
}

장점: 충돌이 잦은 환경에서 데이터 정합성 확실히 보장

단점: 락 대기로 인한 성능 저하, 데드락 위험


해결 전략 3 - DB 낙관적 락 (Optimistic Lock)

락을 걸지 않고, 버전 번호로 충돌을 감지합니다. 충돌 시 재시도 로직이 필요합니다.

@Entity
public class Stock {

    @Id
    private Long id;

    private int quantity;

    @Version          // 버전 필드 추가
    private Long version;
}

// 충돌 시 OptimisticLockException 발생 → 재시도
@Service
public class StockFacade {

    public void decreaseStock(Long id) throws InterruptedException {
        while (true) {
            try {
                stockService.decrease(id);
                break;
            } catch (OptimisticLockingFailureException e) {
                Thread.sleep(50);   // 잠시 대기 후 재시도
            }
        }
    }
}

장점: 락을 걸지 않아 충돌이 적은 환경에서 성능 우수

단점: 충돌이 잦으면 재시도 오버헤드 발생


해결 전략 4 - Redis 분산 락 (Distributed Lock)

서버가 여러 대인 환경에서 Redis를 통해 전체 서버에 잠금을 겁니다.

// Redisson 라이브러리 사용
@Service
public class StockService {

    private final RedissonClient redissonClient;

    public void decreaseStock(Long id) {
        RLock lock = redissonClient.getLock("stock:lock:" + id);

        try {
            // 락 획득 시도 (최대 5초 대기, 3초 후 자동 해제)
            if (!lock.tryLock(5, 3, TimeUnit.SECONDS)) {
                throw new RuntimeException("락 획득 실패");
            }

            Stock stock = stockRepository.findById(id).orElseThrow();
            stock.decrease();
            stockRepository.save(stock);

        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        } finally {
            lock.unlock();  // 반드시 해제
        }
    }
}

장점: 멀티 서버 환경에서도 정합성 보장, 유연한 만료 시간 설정

단점: Redis 서버 의존성 추가, 네트워크 비용 발생


해결 전략 5 - DB Unique 제약 + 멱등성 설계

동일한 요청이 중복 처리되지 않도록 멱등 키를 사용합니다.

@Entity
@Table(uniqueConstraints = @UniqueConstraint(columnNames = "idempotencyKey"))
public class Order {

    @Column(unique = true)
    private String idempotencyKey;  // 클라이언트가 생성한 고유 키
}

// 동일한 idempotencyKey로 중복 요청 시 DB Unique 제약 위반 → 무시

전략 비교 정리

전략 적합한 환경 성능 복잡도

synchronized 단일 서버, 단순 케이스 중간 낮음
비관적 락 충돌 빈번, 정합성 최우선 낮음 낮음
낙관적 락 충돌 드문 환경 높음 중간
Redis 분산 락 멀티 서버 환경 중간 높음
멱등성 설계 중복 요청 방지 높음 중간

2. 비동기 환경에서 Context 전달

문제 상황

Spring의 MDC와 SecurityContext는 ThreadLocal 기반입니다.

메인 스레드: MDC에 requestId="abc" 저장, SecurityContext에 사용자 정보 저장

@Async 실행 → 새로운 스레드 생성
새 스레드: MDC 비어있음, SecurityContext 비어있음  ← 정보 전달이 안 됨
@Service
public class AsyncService {

    @Async
    public void doAsync() {
        String requestId = MDC.get("requestId");  // null ← 문제
        Authentication auth = SecurityContextHolder.getContext().getAuthentication(); // null
    }
}

MDC 전달 방법

방법 1 - TaskDecorator 구현

@Component
public class MdcTaskDecorator implements TaskDecorator {

    @Override
    public Runnable decorate(Runnable runnable) {
        // 호출 스레드(메인)의 MDC 복사
        Map<String, String> mdcContext = MDC.getCopyOfContextMap();

        return () -> {
            try {
                // 새 스레드에 MDC 설정
                if (mdcContext != null) {
                    MDC.setContextMap(mdcContext);
                }
                runnable.run();
            } finally {
                MDC.clear();  // 스레드 풀 재사용 시 오염 방지
            }
        };
    }
}
@Configuration
@EnableAsync
public class AsyncConfig {

    @Bean
    public Executor asyncExecutor(MdcTaskDecorator decorator) {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(5);
        executor.setMaxPoolSize(10);
        executor.setTaskDecorator(decorator);  // 여기에 적용
        executor.initialize();
        return executor;
    }
}

SecurityContext 전달 방법

방법 1 - SecurityContextHolder 전략 변경

// 애플리케이션 시작 시 전략 변경
@PostConstruct
public void init() {
    // MODE_INHERITABLETHREADLOCAL: 자식 스레드에 자동 상속
    SecurityContextHolder.setStrategyName(
        SecurityContextHolder.MODE_INHERITABLETHREADLOCAL
    );
}

⚠️ 스레드 풀 환경에서는 스레드가 재사용되므로 이전 컨텍스트가 남아있을 수 있습니다.


방법 2 - TaskDecorator에서 SecurityContext도 함께 전달 (권장)

@Component
public class ContextCopyTaskDecorator implements TaskDecorator {

    @Override
    public Runnable decorate(Runnable runnable) {
        // 호출 스레드의 컨텍스트 캡처
        Map<String, String> mdcContext = MDC.getCopyOfContextMap();
        SecurityContext securityContext = SecurityContextHolder.getContext();

        return () -> {
            try {
                // MDC 복원
                if (mdcContext != null) {
                    MDC.setContextMap(mdcContext);
                }
                // SecurityContext 복원
                SecurityContextHolder.setContext(securityContext);

                runnable.run();
            } finally {
                MDC.clear();
                SecurityContextHolder.clearContext();  // 반드시 클리어
            }
        };
    }
}

CompletableFuture 환경에서 MDC 전달

@Async 없이 직접 CompletableFuture를 사용하는 경우입니다.

@Service
public class AsyncService {

    public CompletableFuture<String> doAsync() {
        Map<String, String> mdcContext = MDC.getCopyOfContextMap();

        return CompletableFuture.supplyAsync(() -> {
            // MDC 수동 복원
            if (mdcContext != null) MDC.setContextMap(mdcContext);

            try {
                return someLogic();
            } finally {
                MDC.clear();
            }
        }, asyncExecutor);
    }
}

WebFlux (Reactor) 환경에서 Context 전달

Reactor는 ThreadLocal을 사용할 수 없습니다. Reactor Context를 사용합니다.

// Reactor Context에 저장
Mono.deferContextual(ctx -> {
    String requestId = ctx.get("requestId");
    MDC.put("requestId", requestId);
    return Mono.just(processRequest());
})
.contextWrite(Context.of("requestId", "abc123"));

정리

환경 MDC 전달 방법 SecurityContext 전달 방법

@Async TaskDecorator TaskDecorator 또는 INHERITABLETHREADLOCAL
CompletableFuture 수동 캡처 후 복원 수동 캡처 후 복원
WebFlux Reactor Context ReactiveSecurityContextHolder

핵심 원칙

  • ThreadLocal은 스레드 경계를 넘지 못함
  • 새 스레드 시작 전에 컨텍스트를 캡처하고, 새 스레드에서 복원 후 작업
  • 스레드 풀 재사용 시 반드시 finally 블록에서 클리어

'Weekly Paper' 카테고리의 다른 글

Weekly Paper #14  (0) 2026.06.09
Weekly Paper #13  (0) 2026.06.09
Weekly Paper #12  (0) 2026.05.11
Weekly Paper #10  (0) 2026.04.01
Weekly Paper #9  (0) 2026.03.23