싱글톤과 공유 상태
독립 컨텍스트에서 관찰되는 기본 싱글톤 참조와 공유 필드의 호출 값 혼합을 구분하고, 호출 상태를 인자·지역 변수·반환값에 두는 수정안을 완전한 동시 테스트로 검증합니다.
웹 요청이 올 때마다 컨트롤러, 서비스, 리포지토리를 새로 만들 필요는 없습니다.
이 객체들은 대개 설정과 협력 객체를 들고 있고 요청 데이터는 메서드 인자로 처리합니다.
Spring의 기본 싱글톤은 한 컨테이너가 관리하는 특정 빈을 반복 조회하고 주입할 때 같은 참조를 재사용합니다.
문제는 재사용 자체가 아니라 여러 호출이 함께 보는 객체에 호출별 변경 상태를 저장할 때 시작됩니다.
CONTAINER IDENTITY · SHARED MUTABLE STATE
같은 싱글톤 참조는 공유 필드의 동시성 안전을 보장하지 않는다
참조 identity와 호출 상태의 안전성은 서로 다른 증거가 필요합니다. 같은 컨텍스트가 같은 객체를 반환해도 그 객체의 변경 필드가 호출별 값을 소유하면 두 실행이 서로를 덮어쓸 수 있습니다.
EXACT TEST CONFIGURATION
독립 컨텍스트 A와 B가 각자 현재 구성을 등록한다
| 관찰 | 결과 | 말할 수 있는 범위 |
|---|---|---|
| A 반복 조회 | pageCalculator → instance α |
컨텍스트 A의 현재 기본 싱글톤 bean 반복 조회 |
| B 독립 등록 | pageCalculator → instance β |
이 구성 메서드를 따로 실행한 컨텍스트 B의 객체 |
| 경계 | instance α ≠ instance β |
alias, registerSingleton(), 종료 뒤 도달 가능성의 일반 규칙이 아님 |
DETERMINISTIC INTERLEAVING
컨텍스트 A의 같은 객체에서 Alice 40 → Bob 90 → Alice 9
Alice가 40을 저장하고 대기
lastTotalItems = 40뒤aliceStored.countDown()Bob이 같은 필드를 90으로 덮음
lastTotalItems = 90Bob이 9를 반환하고 Alice를 해제
90 / 10 = 9, release는finally에서 실행Alice가 재개해 9를 관찰
자기 입력 40의 결과 4가 아니라 공유 필드의 90을 읽음
CountDownLatch는 이 순서의 happens-before를 만들지만 Spring의 웹 요청 배치나 임의 서비스의 thread safety를 증명하지 않습니다.
기본 싱글톤은 참조 공유 범위를 설명합니다. alias·수동 singleton 등록·컨텍스트 종료와 lifecycle은 이 관찰에 포함하지 않으며, 공유 객체의 동시성 계약은 별도로 검증해야 합니다.
컨테이너 identity와 동시성 안전을 분리
다음 테스트의 CalculatorConfig는 독립적으로 연 두 AnnotationConfigApplicationContext에 StatefulPageCalculator를 각각 등록합니다.
같은 컨텍스트의 반복 조회는 같은 객체를 반환하고, 이 구성 메서드를 각자 실행한 두 컨텍스트의 객체는 서로 다릅니다.
이 관찰을 alias, 외부에서 같은 객체를 넣는 registerSingleton(), 컨텍스트 종료 뒤 Java 객체의 도달 가능성까지 일반화하지 않습니다.
package board.container;
import static java.util.concurrent.TimeUnit.SECONDS;
import static org.assertj.core.api.Assertions.assertThat;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.Executors;
import org.junit.jupiter.api.Test;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
final class SingletonStateOwnershipTest {
@Test
void 같은_context의_반복_조회는_같고_독립_context는_다르다() {
try (var first = context();
var second = context()) {
var firstLookup =
first.getBean(StatefulPageCalculator.class);
var repeatedLookup =
first.getBean(StatefulPageCalculator.class);
var secondContextLookup =
second.getBean(StatefulPageCalculator.class);
assertThat(firstLookup).isSameAs(repeatedLookup);
assertThat(firstLookup)
.isNotSameAs(secondContextLookup);
}
}
@Test
void 공유_field가_두_호출의_값을_섞는_순서를_고정한다()
throws Exception {
try (var context = context();
var executor =
Executors.newVirtualThreadPerTaskExecutor()) {
var calculator =
context.getBean(StatefulPageCalculator.class);
var alice = executor.submit(
() -> calculator.calculate("alice", 40));
assertThat(calculator.awaitAliceStored()).isTrue();
var bob = executor.submit(() -> {
try {
return calculator.calculate("bob", 90);
} finally {
calculator.markBobFinished();
}
});
assertThat(bob.get(5, SECONDS)).isEqualTo(9);
assertThat(alice.get(5, SECONDS)).isEqualTo(9);
}
}
private static AnnotationConfigApplicationContext context() {
return new AnnotationConfigApplicationContext(
CalculatorConfig.class);
}
@Configuration(proxyBeanMethods = false)
static class CalculatorConfig {
@Bean
StatefulPageCalculator pageCalculator() {
return new StatefulPageCalculator();
}
}
static final class StatefulPageCalculator {
private int lastTotalItems;
private final CountDownLatch aliceStored =
new CountDownLatch(1);
private final CountDownLatch bobFinished =
new CountDownLatch(1);
int calculate(String member, int totalItems)
throws InterruptedException {
lastTotalItems = totalItems;
if ("alice".equals(member)) {
aliceStored.countDown();
if (!bobFinished.await(5, SECONDS)) {
throw new IllegalStateException(
"Bob did not finish in time");
}
}
return lastTotalItems / 10;
}
boolean awaitAliceStored() throws InterruptedException {
return aliceStored.await(5, SECONDS);
}
void markBobFinished() {
bobFinished.countDown();
}
}
}래치는 우연한 경합을 기다리지 않고 Alice가 40을 저장한 뒤 Bob이 90을 저장하고 반환하도록 순서를 고정합니다.
Bob 작업의 finally가 Alice를 반드시 풀고, latch와 Future.get의 제한 시간은 실패가 무한 대기로 바뀌지 않게 합니다.
이 테스트는 Spring이 스레드를 만들거나 웹 요청을 배치한다고 증명하지 않습니다.
한 컨텍스트에서 같은 객체를 얻은 뒤 두 가상 스레드가 그 객체를 호출했을 때 변경 필드가 섞이는 정확한 Java 실행만 증명합니다.
volatile은 쓰기 가시성을 높여도 lastTotalItems의 소유자가 어느 호출인지 정하지 못합니다.
또한 위 calculate 전체에 synchronized를 붙이면 Alice가 monitor를 가진 채 Bob 완료를 기다리고 Bob은 메서드에 들어오지 못해 이 fixture에서는 교착됩니다.
동기화 장치를 덧붙이기 전에 호출별 값이 공유 필드에 있어야 하는지부터 제거합니다.
호출 상태를 인자·지역 변수·반환값으로 이동
수정한 계산기는 생성 뒤 바뀌지 않는 정책 참조만 필드에 두고 호출 값은 반환 객체로 묶습니다.
다음 파일은 필요한 정책, 결과, 계산기를 test source 안에 모두 정의한 완전한 단위입니다.
package board.application;
import static java.util.concurrent.TimeUnit.SECONDS;
import static org.assertj.core.api.Assertions.assertThat;
import java.util.concurrent.Executors;
import java.util.stream.LongStream;
import org.junit.jupiter.api.Test;
final class StatelessPageCalculatorTest {
@Test
void 같은_calculator가_호출별_세_결과를_보존한다()
throws Exception {
var calculator = new PageCalculator(
totalItems -> totalItems / 10);
try (var executor =
Executors.newVirtualThreadPerTaskExecutor()) {
var tasks = LongStream.rangeClosed(1, 100)
.mapToObj(requestId -> executor.submit(
() -> calculator.calculate(
requestId,
Math.toIntExact(requestId * 10))))
.toList();
for (int index = 1; index <= 100; index++) {
var result = tasks.get(index - 1)
.get(5, SECONDS);
assertThat(result.requestId())
.isEqualTo(index);
assertThat(result.totalItems())
.isEqualTo(index * 10);
assertThat(result.totalPages())
.isEqualTo(index);
}
}
}
@FunctionalInterface
interface PageSizePolicy {
int totalPages(int totalItems);
}
record PageResult(
long requestId,
int totalItems,
int totalPages
) {
}
static final class PageCalculator {
private final PageSizePolicy policy;
PageCalculator(PageSizePolicy policy) {
this.policy = policy;
}
PageResult calculate(long requestId, int totalItems) {
int totalPages = policy.totalPages(totalItems);
return new PageResult(
requestId, totalItems, totalPages);
}
}
}이 테스트가 사용하는 정책 lambda에는 변경 상태가 없고 계산기는 매 호출의 requestId, totalItems, totalPages를 새 PageResult에 담습니다.
final은 policy 참조가 바뀌지 않는다는 뜻이지 임의의 PageSizePolicy 구현이 자동으로 스레드 안전하다는 뜻은 아닙니다.
CALL OWNERSHIP · COLLABORATOR CONTRACT
호출 상태는 인자·지역 변수·반환값이 소유하고 공유 협력자는 별도 계약이 필요하다
필드가 있다는 사실만으로 안전하거나 위험하다고 판정하지 않습니다. 호출별 값은 호출 스택과 결과가 소유하고, 공유 collaborator는 그 구현의 불변성·동시 접근 규칙을 따로 확인합니다.
ONE CALL · ONE RESULT
호출 값은 필드를 거치지 않고 새 PageResult로 이동한다
인자
requestId와totalItems는 현재 호출이 소유지역 계산
totalPages는policy.totalPages(totalItems)의 지역 변수반환
새
PageResult가 세 값을 함께 전달
같은 calculator를 여러 작업이 호출해도 이 세 값은 인스턴스 필드에 저장되지 않습니다.
FIELD REVIEW
공유 여부와 안전 조건을 분리해 판정한다
| 대상 | 소유권 | 판정 근거 |
|---|---|---|
| 호출 값 | requestId, totalItems, totalPages, PageResult |
인자·지역 변수·새 반환 객체에 머물러 호출끼리 덮어쓰지 않음 |
| final policy | 모든 호출이 같은 참조를 읽음 | final은 참조 고정만 보장하며 실제 PageSizePolicy 구현의 thread-safety 계약이 추가로 필요 |
| final repository | 서비스가 같은 collaborator 참조를 보유 | 현재 MemoryMemberRepository는 순차 학습용이며 필드가 final이어도 동시 접근 안전이 생기지 않음 |
| 호출별 변경 필드 | 여러 호출이 한 저장 위치를 공유 | lastTotalItems, 현재 사용자, 마지막 결과는 호출 소유권을 잃음 |
이 판정은 scope 변경, transaction, persistence, lifecycle을 대신하지 않습니다. prototype·request scope·scoped proxy·provider의 상세는 ch3-9에서 다룹니다.
공유 필드는 collaborator 자체의 계약까지 확인
싱글톤 서비스에도 필드가 있을 수 있지만 필드 선언만 보고 안전성을 판정하지 않습니다.
호출별 입력과 중간 결과는 인자·지역 변수·반환값이 소유합니다.
모든 호출이 함께 쓰는 collaborator는 그 구현 자체의 불변성이나 동시 접근 계약을 별도로 확인합니다.
현재 교재의 MemoryMemberRepository는 LinkedHashMap과 long sequence를 사용하는 순차 학습용 구현입니다.
서비스가 이를 final MemberRepository로 참조해도 저장소 내부의 동시 접근이나 existsByEmail → save 원자성이 생기지 않습니다.
공유 캐시는 원자성뿐 아니라 만료, 크기 제한, 실패 정책까지 소유하는 별도 컴포넌트여야 합니다.
데이터베이스 transaction, 영속성, 자원 종료는 이 문서의 싱글톤 identity나 계산기 테스트가 보장하지 않습니다.
다른 scope로 상태 소유권을 숨기지 않기
공유 필드 충돌을 발견했다는 사실만으로 서비스를 다른 scope로 바꾸지 않습니다.
prototype은 컨테이너가 조회하거나 주입을 해소할 때마다 새 인스턴스를 만들고, request scope는 활성 HTTP 요청 하나 안에서 같은 인스턴스를 사용합니다.
먼저 호출 값을 메서드 인자와 지역 변수, 반환값으로 옮길 수 있는지 확인합니다.
prototype, request scope, scoped proxy, provider 조회와 종료 책임의 상세는 ch3-9에서 별도 실행 증거로 다룹니다.
이 문서는 alias, registerSingleton(), lifecycle callback, 컨텍스트 종료와 Java 객체의 수명도 일반화하지 않습니다.
다음 문서의 @Configuration 프록시와 직접 @Bean 메서드 호출 역시 별도 문제입니다.
연습 문제
PostTitleFormatter가 마지막 제목을 필드에 저장하지 않고, 50개의 가상 스레드가 각자 받은 ID와 제목을 그대로 형식하게 만드세요.
해설 보기
다음 완전한 테스트는 호출 중 정규화한 제목을 지역 변수에만 둡니다.
package board.application;
import static java.util.concurrent.TimeUnit.SECONDS;
import static org.assertj.core.api.Assertions.assertThat;
import java.util.concurrent.Executors;
import java.util.stream.LongStream;
import org.junit.jupiter.api.Test;
final class PostTitleFormatterTest {
@Test
void 각_호출의_id와_title이_섞이지_않는다()
throws Exception {
var formatter = new PostTitleFormatter();
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(5, SECONDS))
.isEqualTo(index + ":post-" + index);
}
}
}
static final class PostTitleFormatter {
String format(long id, String title) {
var normalized = title.strip();
return id + ":" + normalized;
}
}
}각 작업은 같은 formatter를 호출하지만 자기 스택의 normalized만 사용합니다.
다음 문서에서는 Java 구성 클래스 안에서 @Bean 메서드를 직접 호출할 때 전체 구성 프록시가 어떻게 싱글톤 참조를 보장하며, 프록시를 끈 구성에서는 왜 파라미터 주입이 더 안전한지 확인합니다.