트랜잭션·데이터 정합성 · Spring / Concurrency

Spring Singleton Service에 요청 데이터를 멤버변수로 두면 생기는 일

Spring Service의 공유 가변 상태가 동시 요청 사이의 데이터 혼입 위험을 만드는 이유와, 요청 상태 지역변수화·공통 인터페이스·회귀 테스트로 이를 제거한 과정을 정리합니다.

JavaSpringConcurrencyRefactoringLegacy Modernization

문제의 출발점

모든 사례와 코드 예시는 공개용으로 일반화했으며, 실제 시스템의 구조와 식별자를 재현하지 않습니다.

Spring의 @Service는 기본적으로 Singleton Bean이다. 의존 객체만 보관하고 요청 데이터는 파라미터와 지역변수로 처리한다면 여러 요청이 같은 인스턴스를 사용해도 문제가 없다.

위험은 주문 식별값, 고객 국가, 처리 건수와 저장 대상 목록처럼 요청마다 달라지는 값을 Service 멤버변수에 저장할 때 생긴다.

코드에서 발견한 위험 신호

주문등록 로직에서 요청별 상태가 Singleton Service의 멤버변수에 저장되는 구조를 확인했다. 아래 이름과 구조는 실제 소스를 복원할 수 없도록 단순화했다.

아래 코드는 문제 해결 구조를 설명하기 위한 pseudocode이며 실제 클래스·테이블·설정 이름과는 무관합니다.

@Service
public class OrderRegistrationService {

    private String tenantKey;
    private List<OrderItem> orderItems;
    private List<DeliveryItem> deliveryItems;

    public void register(RegisterRequest request) {
        tenantKey = request.getTenantKey();
        orderItems = new ArrayList<>();
        deliveryItems = new ArrayList<>();
        // 주문 저장과 후속 처리
    }
}

메서드 시작 시 목록을 새로 만들어도 객체 자체는 요청마다 새로 만들어지지 않는다. 두 요청이 같은 인스턴스에서 교차 실행되면 한 요청의 처리 중에 다른 요청이 멤버변수를 바꿀 수 있다.

이 작업은 실제 데이터 혼입 장애가 발생한 뒤 수습한 사례가 아니다. 소스 구조상 혼입될 위험을 확인하고 운영 장애가 되기 전에 제거한 개선이다.

해결 1: 요청 상태를 메서드 안으로 격리

요청마다 달라지는 데이터는 메서드의 파라미터와 지역변수로 옮겼다.

아래 코드는 문제 해결 구조를 설명하기 위한 pseudocode이며 실제 클래스·테이블·설정 이름과는 무관합니다.

public void register(RegisterRequest request) {
    String tenantKey = request.getTenantKey();
    List<OrderItem> orderItems = new ArrayList<>();
    List<DeliveryItem> deliveryItems = new ArrayList<>();

    registerItems(tenantKey, orderItems, deliveryItems);
}

지역변수는 각 호출의 Stack Frame에 속하므로 같은 Singleton Service에서 동시에 실행돼도 호출별 상태가 분리된다. 공유할 이유가 없는 상태를 공유하지 않게 만든 것이 핵심이다.

해결 2: 여러 주문 유형의 공통 계약 정의

동시성 위험을 제거하는 과정에서 주문 유형별로 반복되던 변환·집계·저장 준비 로직도 확인했다. 모든 분기를 한 메서드에 합치지 않고, 공통 동작만 인터페이스 계약으로 정의했다.

아래 코드는 문제 해결 구조를 설명하기 위한 pseudocode이며 실제 클래스·테이블·설정 이름과는 무관합니다.

public interface OrderTypeAdapter {
    OrderItem toOrderItem();
    boolean isEmpty();
    int calculateQuantity();
}

아래 코드는 문제 해결 구조를 설명하기 위한 pseudocode이며 실제 클래스·테이블·설정 이름과는 무관합니다.

private <T extends OrderTypeAdapter> List<OrderItem> convert(
        List<T> sourceItems) {
    return sourceItems.stream()
        .filter(item -> !item.isEmpty())
        .map(OrderTypeAdapter::toOrderItem)
        .collect(Collectors.toList());
}

Service는 공통 계약만 사용하고, 유형별 모델은 자신의 변환 규칙을 구현한다. 시퀀스 정규화, 주문 속성 검증, 가격·품목 처리도 역할별 컴포넌트로 분리하되 실제 클래스·메서드 이름은 공개하지 않는다.

같은 개선 방향과 같은 코드는 다르다

이 개선은 본사와 해외법인 시스템에 모두 필요했지만 한쪽 코드를 다른 쪽에 그대로 복사한 것은 아니다.

  • 요청 상태 격리와 공통 계약이라는 설계 원칙은 공유했다.
  • 각 시스템의 고객·견적·배송·할인·메일·외부 연계 규칙은 별도로 분석해 보존했다.
  • 공통화의 목표는 차이를 지우는 것이 아니라 안정적인 공통 부분과 변하는 업무 규칙의 경계를 분리하는 것이었다.

검증 범위를 정확히 표현

본사 적용에서는 동일한 11개 회귀 시나리오를 외부 검증과 사용자 검증의 두 단계에서 반복해 운영 반영 기준을 확인했다.

이를 22개의 서로 다른 시나리오로 환산하지 않는다. 또한 해외법인까지 동일한 11개 시나리오를 수행했다는 의미도 아니다.

검증한 범위는 여러 주문 유형의 신규 등록, 일부 유형의 등록 후 수정, 재고성 주문 조건과 다른 주문 영역과의 연계였다. 실제 데이터 혼입 장애가 발생했다고 표현하지 않고, Singleton 공유 상태로 인한 혼입 위험을 제거한 것으로 한정한다.

정리

Singleton Service 자체가 위험한 것은 아니다. 위험한 것은 그 안에 요청별 가변 상태를 보관하는 설계다.

  • 요청마다 바뀌는 멤버변수가 있는가
  • 메서드 시작 시 멤버 목록을 다시 초기화하는가
  • 요청 파라미터를 멤버변수로 옮겨 private 메서드가 참조하는가
  • 메일·파일·DB 저장용 임시 데이터가 Bean 생명주기와 함께 남는가

공유할 이유가 없는 상태를 지역변수로 내리고, 공통 동작은 인터페이스로 분리하며, 시스템별 업무 차이는 명시적으로 보존한 것이 핵심이었다.