당신의 프로그램은 싱글 스레드가 아닙니다

다른 언어: English Español Français Deutsch 日本語 Português 中文

코드를 작성할 때, 우리는 동기적으로 생각하는 경향이 있습니다. 하나의 문장을 차례로 작성하고, 런타임은 그것들을 순서대로 실행합니다. 이 멘탈 모델은 스크립트와 커맨드라인 도구에는 훌륭하게 작동하지만, 프레임워크와 서버 애플리케이션을 다룰 때는 위험할 정도로 오해를 불러일으킬 수 있습니다.

스레드를 명시적으로 생성하거나 동시성 프리미티브를 사용하지 않더라도, 웹 애플리케이션은 거의 확실히 코드를 병렬로 실행하고 있습니다. 프레임워크가 이를 처리해 주므로 편리하지만, 이를 염두에 두고 프레임워크가 제공하는 스레딩 모델과 구성 요소를 올바르게 사용해야 합니다.

이 글에서는 겉보기에 동기적인 코드에서 싱글 스레드 멘탈 모델을 잘못 따랐을 때 발생할 수 있는 버그의 종류를 살펴보겠습니다. 원인을 찾고, 디버거를 사용하여 재현하고, 여러 가지 가능한 해결책을 살펴보겠습니다.

문제

개발 환경과 프로덕션 환경에서 잘 작동하는 Spring Boot 애플리케이션이 있다고 가정해 봅시다. 오랫동안 모든 것이 원활하게 작동했지만, 회계팀에서 심각한 문제를 보고했습니다: 여러 송장에 같은 번호가 있습니다. 이것은 문제입니다. 송장 번호는 법적, 회계적 이유로 고유해야 합니다. 중복은 한 번만 나타났고, 로컬에서 문제를 재현할 수 없습니다.

Tip icon

이 연습을 더 흥미롭게 만들기 위해, 계속 읽기 전에 직접 문제를 찾아보세요. 프로젝트는 GitHub에서 사용할 수 있습니다.

데이터베이스를 확인하면, 동일한 송장 번호를 가진 별개의 레코드가 표시됩니다. 구성은 문제없어 보이지만, 뭔가 분명히 잘못되었습니다. 송장 생성 흐름을 추적하면, 송장 번호를 생성하는 서비스를 찾을 수 있습니다.

코드는 다음과 같습니다:

@Service
public class InvoiceService {
    private InvoiceNumberGenerator generator;

    public Invoice createInvoice(Order order) {
        if (generator == null) {
            generator = createGenerator();
        }
        String invoiceNumber = generator.nextNumber();
        return new Invoice(invoiceNumber, order);
    }

    private InvoiceNumberGenerator createGenerator() {
        // Reads last used number from database, initializes counter
        int lastNumber = invoiceRepository.findMaxInvoiceNumber();
        return new InvoiceNumberGenerator(lastNumber);
    }
}

이것은 당신이 작성할 코드가 아닙니다. 문제를 발견할 수 있나요?

무엇이 잘못되었나요?

작성자의 멘탈 모델은 아마도 “Spring이 내 빈을 관리하니까 안전해”였을 것입니다. 그리고 그것은 맞습니다: Spring은 실제로 빈 라이프사이클을 처리하고 InvoiceService 가 한 번만 생성되고 적절하게 공유되도록 보장합니다. 하지만 Spring은 당신이 직접 초기화하는 필드에 대해서는 알지 못합니다. generator 필드는 당신의 책임이며, 여기에 문제가 숨어 있습니다.

이벤트 순서

Spring 빈은 기본적으로 싱글톤입니다. 이는 모든 요청에서 공유되는 InvoiceService 의 인스턴스가 하나만 있다는 것을 의미합니다. 여러 HTTP 요청이 동시에 들어오면, 서로 다른 스레드에서 처리되며, 모두 동일한 서비스 인스턴스에 액세스합니다.

이제 생성기가 아직 초기화되지 않았고, 두 스레드가 거의 동시에 createInvoice() 를 호출하면 어떤 일이 일어나는지 추적해 봅시다:

  1. 스레드 A가 generator == null 을 확인, true
  2. 스레드 A가 if 블록에 진입하고 생성기 생성 시작
  3. 스레드 B가 generator == null 을 확인, 여전히 true (A가 아직 생성기를 만들지 않음)
  4. 스레드 B도 if 블록에 진입
  5. 두 스레드가 데이터베이스에 findMaxInvoiceNumber() 를 쿼리하고 같은 값(예: 1000 )을 얻음
  6. 두 스레드가 1000 에서 시작하는 자체 InvoiceNumberGenerator 를 생성
  7. 둘 다 nextNumber() 를 호출하고 1001 을 얻음
  8. 같은 번호를 가진 두 개의 송장이 생성됨

지연 초기화는 스레드 세이프하지 않으며, 동시 액세스 하에서 두 스레드가 데이터베이스에서 같은 “마지막 송장 번호”를 읽고 같은 다음 번호를 생성할 수 있습니다.

디버거로 재현하기

방금 찾은 문제 있는 코드는 간단하므로, 보기만 해도 이벤트 순서를 이해할 수 있을 것입니다. 더 복잡한 경우에는 정확히 무슨 일이 일어나고 있는지 이해하기 위해 버그를 단계별로 재현하고 싶을 수 있습니다. 또는 불운한 타이밍을 시뮬레이션하고 프로그램이 부하에서 깨지지 않는지 테스트하고 싶을 수도 있습니다.

이를 위해 IntelliJ IDEA의 디버거를 사용하겠습니다. 개별 스레드를 제어할 수 있습니다 – 정확히 필요한 것입니다.

먼저, generator = createGenerator(); 줄에 브레이크포인트를 설정하고, 마우스 오른쪽 버튼을 클릭하여 일시 중지 (Suspend)를 스레드 (Thread)로 설정합니다. 이렇게 하면 디버거가 모든 스레드가 아닌 브레이크포인트에 도달한 스레드만 일시 중지합니다.

일시 중지 정책이 스레드로 설정된 브레이크포인트 설정 대화 상자 일시 중지 정책이 스레드로 설정된 브레이크포인트 설정 대화 상자

디버그 모드로 애플리케이션을 실행하고 다음 curl 명령을 사용하여 다른 터미널에서 두 개의 요청을 보냅니다:

curl -X POST http://localhost:8080/api/diagnostics/create-order

요청 중 하나가 브레이크포인트에 도달합니다: 이것은 예상된 것입니다. 두 번째 요청도 브레이크포인트에 도달하면, 여러 스레드가 동시에 크리티컬 섹션에 진입할 수 있다는 것을 증명한 것입니다 – 바로 이 버그의 정의입니다.

스레드 (Threads) 탭을 검사하여 두 스레드가 같은 위치에서 대기하고 있는 것을 볼 수 있습니다:

동일한 브레이크포인트 위치에서 중지된 두 스레드를 보여주는 스레드 탭 동일한 브레이크포인트 위치에서 중지된 두 스레드를 보여주는 스레드 탭

두 스레드 모두 null 체크를 통과했으며, 해제하면 각자 자신만의 생성기 인스턴스를 생성합니다.

또는 작은 지연과 함께 비일시중지 로깅 브레이크포인트를 사용하여 더 느린 실행을 시뮬레이션할 수 있습니다:

느린 실행을 시뮬레이션하기 위해 Thread.sleep이 포함된 평가 및 로그 표현식이 있는 브레이크포인트 느린 실행을 시뮬레이션하기 위해 Thread.sleep이 포함된 평가 및 로그 표현식이 있는 브레이크포인트

이 유형의 브레이크포인트는 디버그 로깅을 위해 설계되었지만, 이미 알고 있듯이, 거의 모든 부작용을 도입하는 데 사용할 수 있습니다. 이 인위적인 지연으로 요청이 충돌할 가능성이 높기 때문에 애플리케이션을 일시 중지할 필요가 없습니다.

문제 해결하기

요구 사항에 따라 문제를 해결하는 여러 가지 방법이 있습니다:

옵션 1: 데이터베이스 사용

송장 번호에 대해서는 데이터베이스가 고유성을 처리하도록 하는 것이 올바른 해결책입니다:

@Service
public class InvoiceService {
    private final InvoiceRepository invoiceRepository;

    public InvoiceService(InvoiceRepository invoiceRepository) {
        this.invoiceRepository = invoiceRepository;
    }

    public Invoice createInvoice(Order order) {
        // Database generates the number via sequence or auto-increment
        return invoiceRepository.save(new Invoice(order));
    }
}

이렇게 하면 인메모리 카운터가 완전히 제거됩니다. 데이터베이스는 여러 애플리케이션 인스턴스에서도 고유성을 보장합니다.

옵션 2: 즉시 초기화

인메모리 생성기가 필요한 경우, Spring 빈으로 만듭니다:

InvoiceNumberGenerator.java:
@Component
public class InvoiceNumberGenerator {
    private final AtomicInteger counter;

    public InvoiceNumberGenerator(InvoiceRepository invoiceRepository) {
        Integer maxNumber = invoiceRepository.findMaxInvoiceNumber();
        int startingNumber = (maxNumber != null) ? maxNumber : 0;
        this.counter = new AtomicInteger(startingNumber);
    }

    public String nextNumber() {
        int next = counter.incrementAndGet();
        return String.format("INV-%05d", next);
    }
}
InvoiceService.java:
@Service
public class InvoiceService {
    private final InvoiceRepository invoiceRepository;
    private final InvoiceNumberGenerator generator;

    public InvoiceService(InvoiceRepository invoiceRepository,
                          InvoiceNumberGenerator generator) {
        this.invoiceRepository = invoiceRepository;
        this.generator = generator;
    }

    public Invoice createInvoice(Order order) {
        String invoiceNumber = generator.nextNumber();
        Invoice invoice = new Invoice(invoiceNumber, order);
        return invoiceRepository.save(invoice);
    }
}

이것은 Spring의 표준 접근 방식입니다. 싱글톤 빈은 시작 시 생성되고 모든 요청에서 안전하게 공유됩니다.

옵션 3: @Lazy 사용

정말로 지연 초기화가 필요한 경우 (예를 들어, 데이터 로드나 연결 설정으로 인해 빈 생성에 비용이 많이 드는 경우), @Lazy를 사용하여 Spring이 이를 안전하게 처리하도록 할 수 있습니다:

InvoiceNumberGenerator.java:
@Component @Lazy
public class InvoiceNumberGenerator {
    private final AtomicInteger counter;

    public InvoiceNumberGenerator(InvoiceRepository invoiceRepository) {
        Integer maxNumber = invoiceRepository.findMaxInvoiceNumber();
        int startingNumber = (maxNumber != null) ? maxNumber : 0;
        this.counter = new AtomicInteger(startingNumber);
    }

    public String nextNumber() {
        int next = counter.incrementAndGet();
        return String.format("INV-%05d", next);
    }
}
InvoiceService.java:
@Service
public class InvoiceService {
    private final InvoiceRepository invoiceRepository;
    private final InvoiceNumberGenerator generator;

    public InvoiceService(InvoiceRepository invoiceRepository,
                          @Lazy InvoiceNumberGenerator generator) {
        this.invoiceRepository = invoiceRepository;
        this.generator = generator;
    }

    public Invoice createInvoice(Order order) {
        String invoiceNumber = generator.nextNumber();
        Invoice invoice = new Invoice(invoiceNumber, order);
        return invoiceRepository.save(invoice);
    }
}

경험 법칙으로, 지연 초기화, 캐싱, 동기화 또는 유사한 패턴을 수동으로 구현하고 있다면, 프레임워크가 이미 제공하는지 확인하세요. 직접 하는 것은 프레임워크와 싸우는 것을 의미하며, 유지보수 오버헤드와 미묘한 버그를 도입하게 됩니다.

요약

스레드를 명시적으로 생성하지 않아도, 애플리케이션은 종종 동시에 실행됩니다. 이 패턴은 Spring이나 웹 개발에만 국한되지 않습니다. 암시적 스레딩 모델이 있는 곳이면 어디든 위험이 있습니다: 웹 프레임워크, UI 툴킷, 심지어 표준 라이브러리 API까지도 백그라운드에서 스레드를 관리합니다.

다음에 애플리케이션에서 간헐적이고 설명할 수 없는 동작을 보게 되면, 스스로에게 물어보세요: 이것이 스레딩 문제일 수 있을까? 대부분의 경우, 답은 예입니다.

행복한 디버깅 되세요!

all posts ->