싱글톤과 공유 상태
요청마다 객체를 만들 때의 비용과 Spring 싱글톤 레지스트리의 역할을 확인하고 공유 필드가 사용자 데이터를 섞는 실패를 동시 실행으로 재현합니다.
웹 요청이 올 때마다 컨트롤러, 서비스, 리포지토리를 새로 만들 필요는 없습니다.
이 객체들은 대개 설정과 협력 객체만 들고 있고 요청 데이터는 메서드 인자로 처리합니다.
Spring은 기본 스코프를 싱글톤으로 두어 같은 빈을 반복 사용합니다.
문제는 재사용 자체가 아니라 재사용되는 객체에 사용자별 변경 상태를 저장할 때 시작됩니다.
컨테이너 싱글톤
Spring의 싱글톤은 하나의 ApplicationContext와 하나의 빈 이름을 기준으로 합니다.
JVM 전체에 무조건 한 객체만 존재한다는 뜻이 아닙니다.
컨텍스트를 둘 만들면 각 컨텍스트에 인스턴스가 하나씩 생깁니다.
package board.container;
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.Test;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
class SingletonRegistryTest {
static final class PageCalculator {
int calculate(int totalItems) {
return totalItems / 10;
}
}
@Configuration(proxyBeanMethods = false)
static class CalculatorConfig {
@Bean
PageCalculator pointCalculator() {
return new PageCalculator();
}
}
@Test
void context_안에서는_같고_context가_다르면_다르다() {
try (var first =
new AnnotationConfigApplicationContext(
CalculatorConfig.class);
var second =
new AnnotationConfigApplicationContext(
CalculatorConfig.class)) {
var firstLookup =
first.getBean(PageCalculator.class);
var repeatedLookup =
first.getBean(PageCalculator.class);
var otherContext =
second.getBean(PageCalculator.class);
assertThat(firstLookup).isSameAs(repeatedLookup);
assertThat(firstLookup).isNotSameAs(otherContext);
}
}
}컨테이너는 생성된 싱글톤을 레지스트리에 보관하고 이후 조회와 주입에 같은 참조를 사용합니다.
직접 싱글톤 패턴을 구현해 private 생성자와 정적 인스턴스를 둘 필요가 없습니다.
직접 구현하면 상속과 테스트 대역 교체가 어려워지고, 생성 시점과 초기화 인자를 정적 코드가 소유하며, 클래스 로더 단위의 전역 상태가 됩니다.
Spring의 싱글톤 스코프는 평범한 클래스를 컨테이너가 관리하는 방식입니다.
클래스 자체는 독립적으로 new할 수 있어 순수 단위 테스트도 유지됩니다.
싱글톤의 객체 절약
게시판에 초당 100개의 조회 요청이 들어오고 컨트롤러-서비스-리포지토리 세 객체를 매 요청마다 만든다고 가정합니다.
객체가 가볍더라도 의존성 그래프 조립, 후처리기 적용, 프록시 생성, 리소스 연결을 반복할 이유가 없습니다.
변경 상태가 없는 협력 객체는 한 번 완성해 안전하게 공유할 수 있습니다.
다만 “싱글톤이라서 스레드-안전”인 것은 아닙니다.
싱글톤은 참조 개수를 정할 뿐 동기화 규칙을 제공하지 않습니다.
한 객체를 여러 요청 스레드가 동시에 호출하므로 오히려 상태 설계가 더 엄격해야 합니다.
공유 필드의 요청 혼합
다음 계산기는 마지막 게시글 수를 필드에 저장합니다.
단일 스레드에서 한 번 호출하면 그럴듯하지만 두 요청의 실행 순서가 겹치면 Alice가 Bob의 값을 읽습니다.
package board.container;
import static org.assertj.core.api.Assertions.assertThat;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.Executors;
import org.junit.jupiter.api.Test;
class StatefulSingletonFailureTest {
static final class StatefulPageCalculator {
private int lastTotalItems;
private final CountDownLatch aliceStored;
private final CountDownLatch bobFinished;
StatefulPageCalculator(
CountDownLatch aliceStored,
CountDownLatch bobFinished) {
this.aliceStored = aliceStored;
this.bobFinished = bobFinished;
}
int calculate(String member, int totalItems)
throws InterruptedException {
lastTotalItems = totalItems;
if (member.equals("alice")) {
aliceStored.countDown();
bobFinished.await();
}
return lastTotalItems / 10;
}
}
@Test
void 공유_field가_다른_요청의_값으로_덮인다()
throws Exception {
var aliceStored = new CountDownLatch(1);
var bobFinished = new CountDownLatch(1);
var calculator =
new StatefulPageCalculator(aliceStored, bobFinished);
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
var alice = executor.submit(
() -> calculator.calculate("alice", 40));
aliceStored.await();
var bob = executor.submit(
() -> calculator.calculate("bob", 90));
assertThat(bob.get()).isEqualTo(9);
bobFinished.countDown();
assertThat(alice.get())
.as("Alice의 40분이 Bob의 90분으로 덮임")
.isEqualTo(9);
}
}
}이 테스트는 우연한 경쟁을 기다리지 않습니다.
래치로 Alice가 40을 저장한 뒤 멈추고, Bob이 90을 저장하고 반환한 다음 Alice를 재개합니다.
실패 순서를 고정했기 때문에 반복 실행해도 공유 상태 결함을 같은 결과로 관찰합니다.
StatefulSingletonFailureTest
> 공유_field가_다른_요청의_값으로_덮인다() PASSED
Alice expected own result 4, observed shared result 9volatile을 붙여도 해결되지 않습니다.
가시성은 높이지만 어느 요청의 값이어야 하는지라는 소유권 문제는 그대로입니다.
메서드 전체를 synchronized로 묶으면 섞임은 막을 수 있어도 모든 요청을 한 줄로 세워 처리량을 잃습니다.
애초에 저장할 필요가 없는 값을 공유 필드에 둔 설계가 원인입니다.
상태 소유권 진단
계산에 필요한 값은 메서드 인자로 받고 결과는 반환합니다.
인스턴스 필드에는 생성 뒤 바뀌지 않는 협력 객체만 둡니다.
package board.application;
public final class PageCalculator {
private final PageSizePolicy policy;
public PageCalculator(PageSizePolicy policy) {
this.policy = policy;
}
public PageResult calculate(
long requestId, int totalItems) {
int totalPages = policy.totalPages(totalItems);
return new PageResult(requestId, totalItems, totalPages);
}
}PageSizePolicy 참조는 생성 후 바뀌지 않고, requestId·totalItems·totalPages는 호출 스택과 반환 객체에 속합니다.
같은 페이지 계산기를 여러 스레드가 호출해도 서로의 값을 가리키는 공유 필드가 없습니다.
package board.application;
import static org.assertj.core.api.Assertions.assertThat;
import java.util.concurrent.Executors;
import java.util.stream.IntStream;
import org.junit.jupiter.api.Test;
class StatelessPageCalculatorTest {
@Test
void 하나의_calculator를_동시에_안전하게_공유한다()
throws Exception {
var calculator = new PageCalculator(
totalItems -> totalItems / 10);
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
var tasks = IntStream.rangeClosed(1, 100)
.mapToObj(index -> executor.submit(
() -> calculator.calculate(
index, index * 10)))
.toList();
for (int index = 1; index <= 100; index++) {
var result = tasks.get(index - 1).get();
assertThat(result.requestId()).isEqualTo(index);
assertThat(result.totalPages()).isEqualTo(index);
}
}
}
}StatelessPageCalculatorTest
> 하나의_calculator를_동시에_안전하게_공유한다() PASSED
BUILD SUCCESSFUL무상태 설계의 기준
싱글톤 서비스에도 필드가 있을 수 있습니다.
핵심은 값의 수명과 소유권입니다.
| 필드 종류 | 싱글톤에 적합한가 | 이유 |
|---|---|---|
final MemberRepository | 적합 | 조립 후 바뀌지 않는 협력 객체 |
| 불변 설정값 | 적합 | 모든 요청이 같은 값으로 읽음 |
| 스레드-안전 캐시 | 조건부 | 만료·크기·원자성 정책이 명시돼야 함 |
| 현재 사용자 ID | 부적합 | 요청마다 소유자가 다름 |
| 마지막 계산 결과 | 부적합 | 호출끼리 덮어씀 |
수정 가능한 ArrayList | 대개 부적합 | 동시 수정 규칙과 경계가 없음 |
리포지토리처럼 내부에 변경 상태가 필요한 객체는 더 신중해야 합니다.
메모리 기반 구현은 ConcurrentHashMap과 원자적 ID를 사용해 동시 접근 규칙을 구현할 수 있습니다.
DB 리포지토리는 요청별 데이터를 연결 풀과 트랜잭션 경계에 맡깁니다.
“필드가 있다”와 “요청 상태를 서비스에 보관한다”는 같은 문제가 아닙니다.
스코프보다 무상태 설계
상태 충돌을 보고 서비스를 프로토타입이나 요청 스코프로 바꾸면 요청별 객체가 생겨 증상은 사라질 수 있습니다.
그러나 불필요한 객체 생성과 프록시가 늘고, 백그라운드 작업에서는 요청 스코프가 활성화되지 않으며, 테스트와 호출 위치가 스코프에 묶입니다.
다음 순서로 판단합니다.
- 값이 메서드 인자·지역 변수·반환값으로 이동 가능한가.
- 업무 상태라면 리포지토리나 트랜잭션이 소유해야 하는가.
- 공유 캐시라면 동시성·만료 정책을 가진 별도 컴포넌트인가.
- 정말 HTTP 요청 수명에 결박된 인프라 정보인가.
앞의 세 방법으로 해결되지 않고 네 번째 조건일 때 요청 스코프를 검토합니다.
사용자 입력을 편하게 저장하려고 스코프를 바꾸는 것은 설계 경계를 감춥니다.
연습 문제
다음 싱글톤 포매터의 lastTitle 필드를 제거하고, 50개의 가상 스레드가 서로 다른 제목을 형식해도 결과가 섞이지 않는 테스트를 작성하세요.
final class PostTitleFormatter {
private String lastTitle;
String format(long id, String title) {
lastTitle = title.strip();
return id + ":" + lastTitle;
}
}해설 보기
호출 중 계산한 값은 지역 변수로 둡니다.
final class PostTitleFormatter {
String format(long id, String title) {
var normalized = title.strip();
return id + ":" + normalized;
}
}동시 테스트는 각 작업이 자신에게 준 ID와 제목을 그대로 포함하는지 검증하면 됩니다.
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
var futures = LongStream.rangeClosed(1, 50)
.mapToObj(id -> executor.submit(
() -> formatter.format(id, " post-" + id + " ")))
.toList();
for (int index = 1; index <= 50; index++) {
assertThat(futures.get(index - 1).get())
.isEqualTo(index + ":post-" + index);
}
}동기화를 추가하지 않아도 되는 이유는 각 호출이 자기 스택의 normalized만 사용하고 공유 변경 상태가 없기 때문입니다.
다음 문서에서는 Java 구성 클래스 안에서 @Bean 메서드를 직접 호출할 때 전체 구성 프록시가 어떻게 싱글톤 참조를 보장하며, 프록시를 끈 구성에서는 왜 파라미터 주입이 더 안전한지 확인합니다.