문제의 출발점
Spring의 @Service는 기본적으로 싱글턴 빈입니다. 의존 객체만 보관하고 요청 데이터는 파라미터와 지역 변수로 처리한다면 여러 요청이 같은 인스턴스를 사용해도 문제가 없습니다.
위험은 주문 식별값, 고객 국가, 처리 건수와 저장 대상 목록처럼 요청마다 달라지는 값을 서비스 멤버 변수에 저장할 때 생깁니다.
코드에서 발견한 위험 신호
주문등록 로직은 요청별 상태를 Singleton Service의 멤버 변수에 저장하고 있었습니다. 아래 코드는 실제 이름을 바꾸고 공유 상태가 생기는 부분만 남긴 형태입니다.
@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: 요청 상태를 메서드 안으로 옮겼습니다
요청마다 달라지는 데이터는 메서드의 파라미터와 지역변수로 옮겼습니다.
아래 코드는 핵심 동작만 보여 주도록 실제 클래스·테이블·설정 이름을 바꾼 예시입니다.
public void register(RegisterRequest request) {
String tenantKey = request.getTenantKey();
List<OrderItem> orderItems = new ArrayList<>();
List<DeliveryItem> deliveryItems = new ArrayList<>();
registerItems(tenantKey, orderItems, deliveryItems);
}지역 변수는 각 호출의 스택 프레임에 속하므로 같은 싱글턴 서비스에서 동시에 실행돼도 호출별 상태가 분리됩니다. 요청마다 달라지는 값을 공유하지 않게 했습니다.
함께 정리한 주문 유형별 중복
동시성 위험을 제거하는 과정에서 주문 유형별로 반복되던 변환·집계·저장 준비 로직도 함께 정리했습니다. 이는 공유 상태 제거의 직접 해결책은 아니지만, 모든 분기를 한 메서드에 합치지 않고 주문 유형의 공통 동작을 인터페이스로 분리하는 계기가 됐습니다.
public interface OrderTypeAdapter {
OrderItem toOrderItem();
boolean isEmpty();
int calculateQuantity();
}private <T extends OrderTypeAdapter> List<OrderItem> convert(
List<T> sourceItems) {
return sourceItems.stream()
.filter(item -> !item.isEmpty())
.map(OrderTypeAdapter::toOrderItem)
.collect(Collectors.toList());
}서비스는 공통 인터페이스만 사용하고, 유형별 모델은 자신의 변환 규칙을 구현합니다. 시퀀스 정규화, 주문 속성 검증, 가격·품목 처리도 역할별 컴포넌트로 분리하되 실제 클래스·메서드 이름은 공개하지 않습니다.
본사와 해외법인의 업무 규칙은 따로 유지했습니다
이 개선은 본사와 해외법인 시스템에 모두 필요했지만 한쪽 코드를 다른 쪽에 그대로 복사한 것은 아닙니다.
- 요청 상태 격리와 공통 인터페이스라는 설계 원칙은 공유했습니다.
- 각 시스템의 고객·견적·배송·할인·메일·외부 연계 규칙은 별도로 분석해 보존했습니다.
- 공통화의 목표는 공통 로직과 시스템별 업무 규칙을 분리하는 것이었습니다.
검증
본사 적용에서는 동일한 11개 회귀 시나리오를 외부 검증과 사용자 검증의 두 단계에서 반복했습니다.
해외법인에는 소스 개선까지 적용했으며, 동일한 11개 시나리오로 운영 반영까지 검증한 범위는 아닙니다.
여러 주문 유형의 신규 등록과 일부 유형의 등록 후 수정, 재고성 주문 조건, 다른 주문 영역과의 연계를 검증했습니다. 이미 발생한 데이터 혼입 장애를 수습한 작업은 아니며, Singleton의 공유 상태가 만들 수 있는 위험을 운영 장애 전에 제거했습니다.
정리
싱글턴 서비스는 여러 요청이 같은 인스턴스를 사용합니다. 그 안에 요청별 가변 상태를 보관하면 서로의 값이 섞일 수 있습니다.
- 요청마다 바뀌는 멤버 변수가 있습니까?
- 메서드 시작 시 멤버 목록을 다시 초기화합니까?
- 요청 파라미터를 멤버 변수로 옮겨 private 메서드가 참조합니까?
- 메일·파일·DB 저장용 임시 데이터가 빈의 생명주기와 함께 남습니까?
공유할 이유가 없는 값은 지역 변수로 내렸습니다. 주문 유형이 함께 써야 할 동작은 인터페이스로 분리하되, 본사와 해외법인의 업무 차이까지 억지로 하나로 합치지는 않았습니다.