싱글톤과 공유 상태
독립 컨텍스트에서 관찰되는 기본 싱글톤 참조와 공유 필드의 호출 값 혼합을 구분하고, 호출 상태를 인자·지역 변수·반환값에 두는 수정안을 완전한 동시 테스트로 검증합니다.
웹 요청이 올 때마다 컨트롤러나 서비스, 저장소를 새로 만들 필요는 없습니다. 이런 객체들은 대개 설정과 협력 객체만 들고 있고, 요청마다 다른 데이터는 메서드 인자로 처리하기 때문입니다. Spring의 기본 싱글톤은 한 컨테이너가 관리하는 빈을 여러 번 조회하거나 주입할 때 같은 참조를 다시 씁니다. 문제는 객체를 다시 쓰는 것 자체가 아니라, 여러 호출이 함께 보는 객체에 호출마다 바뀌는 상태를 저장할 때 생깁니다.
앞 글과 같은 학습 프로젝트의 src/test/java에 아래의 테스트 파일을 넣습니다. 1장의 Java 25 도구 체인과 JUnit, AssertJ를 그대로 씁니다. 테스트는 작업마다 가상 스레드(virtual thread)를 하나씩 시작하는 실행기를 쓰지만 웹 서버나 HTTP 요청은 필요 없습니다. 가상 스레드와 이 실행기는 가상 스레드와 ScopedValue에서 다룹니다. 먼저 ./gradlew test --tests board.container.SingletonStateOwnershipTest를, 이어서 ./gradlew test --tests board.application.StatelessPageCalculatorTest를 프로젝트 루트에서 실행합니다.
같은 싱글톤 참조는 공유 필드의 동시성 안전을 보장하지 않는다
Alice 40 저장 뒤 Bob 90 덮어쓰기와 두 반환값 9의 래치 고정 순서에서 공유 필드가 변한다.
CONTAINER IDENTITY · SHARED MUTABLE STATE
같은 싱글톤 참조는 공유 필드의 동시성 안전을 보장하지 않는다
참조 identity와 호출 상태의 안전성은 서로 다른 증거가 필요합니다. 같은 컨텍스트가 같은 객체를 반환해도 그 객체의 변경 필드가 호출별 값을 소유하면 두 실행이 서로를 덮어쓸 수 있습니다.
화살표는 이 래치 테스트가 고정한 순서입니다. Alice 입력40의 값4가 나오는 대신 Bob이 덮어쓴90을 읽어9를 반환합니다.
EXACT TEST CONFIGURATION
독립 컨텍스트 A와 B가 각자 현재 구성을 등록한다
| 관찰 | 결과 | 말할 수 있는 범위 |
|---|---|---|
| A 반복 조회 | pageCalculator → instance α |
컨텍스트 A의 현재 기본 싱글톤 bean 반복 조회 |
| B 독립 등록 | pageCalculator → instance β |
이 구성 메서드를 따로 실행한 컨텍스트 B의 객체 |
| 경계 | instance α ≠ instance β |
alias, registerSingleton(), 종료 뒤 도달 가능성의 일반 규칙이 아님 |
CalculatorConfig를 독립적으로 등록한 두 컨텍스트에서 관찰한 참조 identity
A 반복 조회
결과
pageCalculator → instance α말할 수 있는 범위
B 독립 등록
결과
pageCalculator → instance β말할 수 있는 범위
경계
결과
instance α ≠ instance β말할 수 있는 범위
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은 이 관찰에 포함하지 않으며, 공유 객체의 동시성 계약은 별도로 검증해야 합니다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- 동일 객체의 lastTotalItems에서 래치가 순서를 고정한다. Alice가 40을 저장하고 기다린 뒤 Bob이 90을 저장한다. Bob은 90을 10으로 나누어 9를 반환하고 finally에서 완료 래치를 내린다. 그 뒤 Alice도 90을 읽어 9를 반환한다. 화살표는 이 Java 테스트가 강제한 순서이며 Spring의 HTTP 요청 실행 순서를 뜻하지 않는다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 29 | 화살표는 이 래치 테스트가 고정한 순서입니다. Alice 입력 40의 값 4가 나오는 대신 Bob이 덮어쓴 90을 읽어 9를 반환합니다. |
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| Alice가 40 저장 동일 객체의 lastTotalItems에 Alice가 40을 저장하고 래치를 기다립니다. | lastTotalItems: 40 · Alice: 대기 · Bob: 저장 전 | 공유 필드가 40이 됩니다. |
| Bob이 90으로 교체 Bob이 같은 필드에 90을 쓰고 10으로 나눠 9를 반환합니다. | lastTotalItems: 90 · Bob 반환: 9 · Alice: 대기 | Alice의 값이 덮어써지고 Bob 완료 래치가 내려갑니다. |
| Alice도 9 반환 Bob 완료 뒤 Alice가 공유 필드의 90을 읽어 9를 반환합니다. | Alice 반환: 9 · Bob 반환: 9 · 순서 근거: Java 테스트 래치 | Alice 결과가 자신의 40 기반 예상과 달라집니다. |
같은 싱글톤 참조는 공유 필드의 동시성 안전을 보장하지 않는다
Alice 40 저장 뒤 Bob 90 덮어쓰기와 두 반환값 9의 래치 고정 순서에서 공유 필드가 변한다.
CONTAINER IDENTITY · SHARED MUTABLE STATE
같은 싱글톤 참조는 공유 필드의 동시성 안전을 보장하지 않는다
참조 identity와 호출 상태의 안전성은 서로 다른 증거가 필요합니다. 같은 컨텍스트가 같은 객체를 반환해도 그 객체의 변경 필드가 호출별 값을 소유하면 두 실행이 서로를 덮어쓸 수 있습니다.
화살표는 이 래치 테스트가 고정한 순서입니다. Alice 입력40의 값4가 나오는 대신 Bob이 덮어쓴90을 읽어9를 반환합니다.
EXACT TEST CONFIGURATION
독립 컨텍스트 A와 B가 각자 현재 구성을 등록한다
| 관찰 | 결과 | 말할 수 있는 범위 |
|---|---|---|
| A 반복 조회 | pageCalculator → instance α |
컨텍스트 A의 현재 기본 싱글톤 bean 반복 조회 |
| B 독립 등록 | pageCalculator → instance β |
이 구성 메서드를 따로 실행한 컨텍스트 B의 객체 |
| 경계 | instance α ≠ instance β |
alias, registerSingleton(), 종료 뒤 도달 가능성의 일반 규칙이 아님 |
CalculatorConfig를 독립적으로 등록한 두 컨텍스트에서 관찰한 참조 identity
A 반복 조회
결과
pageCalculator → instance α말할 수 있는 범위
B 독립 등록
결과
pageCalculator → instance β말할 수 있는 범위
경계
결과
instance α ≠ instance β말할 수 있는 범위
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은 이 관찰에 포함하지 않으며, 공유 객체의 동시성 계약은 별도로 검증해야 합니다.
이 예의 조건
- 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
- 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
- 동일 객체의 lastTotalItems에서 래치가 순서를 고정한다. Alice가 40을 저장하고 기다린 뒤 Bob이 90을 저장한다. Bob은 90을 10으로 나누어 9를 반환하고 finally에서 완료 래치를 내린다. 그 뒤 Alice도 90을 읽어 9를 반환한다. 화살표는 이 Java 테스트가 강제한 순서이며 Spring의 HTTP 요청 실행 순서를 뜻하지 않는다.
관계도의 보충 설명
| 순번 | 원문 표시 |
|---|---|
| 29 | 화살표는 이 래치 테스트가 고정한 순서입니다. Alice 입력 40의 값 4가 나오는 대신 Bob이 덮어쓴 90을 읽어 9를 반환합니다. |
| 장면 | 관찰 값 | 전환 이유 |
|---|---|---|
| Alice가 40 저장 동일 객체의 lastTotalItems에 Alice가 40을 저장하고 래치를 기다립니다. | lastTotalItems: 40 · Alice: 대기 · Bob: 저장 전 | 공유 필드가 40이 됩니다. |
| Bob이 90으로 교체 Bob이 같은 필드에 90을 쓰고 10으로 나눠 9를 반환합니다. | lastTotalItems: 90 · Bob 반환: 9 · Alice: 대기 | Alice의 값이 덮어써지고 Bob 완료 래치가 내려갑니다. |
| Alice도 9 반환 Bob 완료 뒤 Alice가 공유 필드의 90을 읽어 9를 반환합니다. | Alice 반환: 9 · Bob 반환: 9 · 순서 근거: Java 테스트 래치 | Alice 결과가 자신의 40 기반 예상과 달라집니다. |
그림은 이 글의 첫 테스트가 강제하는 실행 순서입니다. 같은 객체의 lastTotalItems 필드에 Alice가 40을 저장하고 기다리는 동안 Bob이 같은 필드를 90으로 덮어쓰고, Bob이 90 / 10 = 9를 돌려준 뒤 Alice가 깨어나 같은 필드의 90을 읽어 자신의 입력 40에 맞는 4가 아니라 9를 돌려줍니다. 화살표는 Java 테스트가 래치로 정한 순서이며, Spring이 HTTP 요청을 처리하는 순서를 뜻하지 않습니다.
컨테이너 identity와 동시성 안전을 분리
다음 테스트의 CalculatorConfig는 따로 연 두 AnnotationConfigApplicationContext에 StatefulPageCalculator를 각각 등록합니다. 같은 컨텍스트에서 여러 번 조회하면 같은 객체가 나오고, 이 구성 메서드를 각자 실행한 두 컨텍스트의 객체는 서로 다릅니다. 이 관찰을 별칭(alias)이나, 바깥에서 같은 객체를 넣는 registerSingleton(), 컨텍스트를 닫은 뒤의 객체 수명까지 넓혀 일반화하지는 않습니다.
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();
}
}
}CountDownLatch는 한 번 내려가면 기다리던 스레드를 풀어 주는 장치입니다. 둘째 테스트는 aliceStored와 bobFinished 두 래치로 우연한 경합을 기다리지 않고 다음 순서를 강제하므로, 같은 공유 필드를 어떤 값으로 읽는지 정확히 따라갈 수 있습니다.
| 순서 | 호출이 한 일 | 그때의 lastTotalItems | 반환값 |
|---|---|---|---|
| 1 | Alice가 40을 쓰고 Bob이 끝나기를 기다림 | 40 | 아직 반환하지 않음 |
| 2 | Bob이 90으로 덮어씀 | 90 | 아직 반환하지 않음 |
| 3 | Bob이 나누고 finally에서 래치를 내림 | 90 | 90 / 10 = 9 |
| 4 | Alice가 깨어나 같은 필드를 나눔 | 90 | 자기 입력의 4가 아닌 9 |
계산식은 같아도, 40이 Alice의 값이라는 정보는 공유 필드에 남지 않습니다. 그러니 기대값을 4로 바꾸는 것으로는 이 테스트를 고칠 수 없습니다. Bob 작업의 finally는 예외가 나더라도 Alice를 풀어 주고, 래치와 Future.get의 5초 제한은 실패가 끝없는 대기로 바뀌지 않게 합니다.
이 테스트는 Spring이 스레드를 만들거나 웹 요청을 나누어 맡긴다는 것을 보여 주지 않습니다. 한 컨텍스트에서 같은 객체를 얻은 뒤 두 가상 스레드가 그 객체를 호출하면 바뀌는 필드가 섞인다는 것, 그 정확한 Java 실행만 보여 줍니다. 필드를 volatile로 바꾸면 다른 스레드의 쓰기가 더 잘 보이게 될 뿐, lastTotalItems가 어느 호출의 값인지는 여전히 정하지 못합니다. 가시성과 volatile, synchronized가 각각 무엇을 지켜 주는지는 가시성·원자성·임계 영역에서 다룹니다.
그렇다고 calculate 전체에 synchronized를 붙이면 Alice가 모니터를 잡은 채로 Bob이 끝나기를 기다리고, Bob은 메서드에 들어가지 못합니다. 두 작업이 서로를 기다리게 되는데, 이 테스트에는 5초 제한이 있으므로 Alice가 예외로 대기를 끝내는 실패가 됩니다. 동기화 장치를 덧붙이기 전에, 호출마다 다른 값을 공유 필드에서 빼낼 수 있는지부터 확인해야 합니다.
호출 상태를 인자·지역 변수·반환값으로 이동
고친 계산기는 만든 뒤 바뀌지 않는 정책 참조만 필드에 두고, 호출마다 다른 값은 반환 객체로 묶습니다. 다음 파일은 정책과 결과, 계산기를 테스트 소스 안에 모두 정의한 완전한 단위입니다.
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);
}
}
}이 테스트의 정책 람다에는 바뀌는 상태가 없고, 계산기는 호출마다 requestId, totalItems, totalPages를 새 PageResult에 담아 돌려줍니다. 필드의 final은 policy 참조가 바뀌지 않는다는 뜻일 뿐, 아무 PageSizePolicy 구현이나 저절로 스레드 안전해진다는 뜻은 아닙니다.
첫 작업은 (requestId=1, totalItems=10)을 넣어 (1, 10, 1)을 받고, 마지막 작업은 (100, 1000)을 넣어 (100, 1000, 100)을 받습니다. 그 사이의 각 작업도 자신의 입력과 결과를 한 묶음으로 확인하므로, 페이지 수만 맞고 식별자나 전체 개수가 섞이는 오류도 놓치지 않습니다.
여기서 totalItems / 10은 정수 나눗셈 정책이고 입력이 모두 10의 배수입니다. 예를 들어 41을 넣으면 이 정책은 4를 계산합니다. 한 페이지에 최대 10개를 넣고 남는 것이 있으면 한 페이지를 더 만드는 업무 규칙이라면 기대값은 5가 되므로, 정책과 그 경곗값 테스트를 따로 정해야 합니다. 이 동시성 테스트는 그런 업무 규칙까지 확인하지 않습니다.
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, 현재 사용자, 마지막 결과는 호출 소유권을 잃음 |
싱글톤 필드와 호출 값의 소유권·동시성 판정
호출 값
소유권
requestId, totalItems, totalPages, PageResult판정 근거
final policy
소유권
판정 근거
final은 참조 고정만 보장하며 실제 PageSizePolicy 구현의 thread-safety 계약이 추가로 필요final repository
소유권
판정 근거
MemoryMemberRepository는 순차 학습용이며 필드가 final이어도 동시 접근 안전이 생기지 않음호출별 변경 필드
소유권
판정 근거
lastTotalItems, 현재 사용자, 마지막 결과는 호출 소유권을 잃음이 판정은 scope 변경, transaction, persistence, lifecycle을 대신하지 않습니다. prototype·request scope·scoped proxy·provider의 상세는 ch3-9에서 다룹니다.
그림 위쪽은 같은 PageCalculator를 두 번 호출해도 인자와 지역 변수, 반환값이 호출마다 따로 있다는 것을 보여 줍니다. 지역 계산을 두 칸으로 그렸다고 계산기가 둘이라는 뜻은 아니며, 두 호출의 순서도 정해져 있지 않습니다. 아래쪽 표는 필드를 종류별로 나누어, 호출마다 다른 값과 final 정책 참조, final 저장소 참조, 호출마다 바뀌는 필드가 각각 무엇을 공유하고 무엇을 따로 확인해야 하는지 정리합니다.
공유 필드는 협력 객체의 구현까지 확인
싱글톤 서비스에도 필드가 있을 수 있으므로, 필드가 있다는 것만 보고 안전한지 판단하지 않습니다. 호출마다 다른 입력과 중간 결과는 인자, 지역 변수, 반환값에 둡니다. 모든 호출이 함께 쓰는 협력 객체는 그 구현이 불변인지, 동시에 접근해도 되는지를 따로 확인합니다.
예를 들어 이 교재의 MemoryMemberRepository는 LinkedHashMap과 long 번호를 쓰는 순차 학습용 구현입니다. 서비스가 이것을 final MemberRepository 필드로 참조하더라도, 저장소 안의 동시 접근 안전성이나 existsByEmail 다음 save의 원자성이 생기지는 않습니다. 공유 캐시를 둔다면 원자성뿐 아니라 만료와 크기 제한, 실패 정책까지 맡는 별도의 컴포넌트여야 합니다. 데이터베이스 트랜잭션과 영속화, 자원의 정리도 이 글의 싱글톤 참조나 계산기 테스트가 보장하지 않습니다.
다른 scope로 상태 소유권을 숨기지 않기
공유 필드의 충돌을 발견했다고 해서 바로 서비스의 스코프를 바꾸지는 않습니다. 프로토타입 스코프는 컨테이너에서 조회하거나 주입할 때마다 새 인스턴스를 만들고, 요청(request) 스코프는 진행 중인 HTTP 요청 하나 안에서 같은 인스턴스를 씁니다. 하지만 그보다 먼저, 호출마다 다른 값을 메서드의 인자와 지역 변수, 반환값으로 옮길 수 있는지를 확인합니다.
프로토타입과 요청 스코프, 스코프 프록시, 프로바이더 조회와 정리 방법은 프로토타입과 요청 스코프에서 따로 실행해 보며 다룹니다. 이 글은 별칭이나 registerSingleton(), 생명 주기 콜백, 컨텍스트 종료와 Java 객체의 수명도 일반화하지 않습니다. 다음 글에서 볼 @Configuration 프록시와 @Bean 메서드의 직접 호출도 또 다른 문제입니다.
정리
- 기본 싱글톤은 한 컨텍스트 안에서 같은 빈 정의의 참조를 다시 쓴다는 뜻이며, 따로 연 컨텍스트는 서로 다른 객체를 만듭니다.
- 같은 객체를 여러 호출이 함께 쓰면, 호출마다 다른 값을 담은 필드는 서로 덮어써져 결과가 섞일 수 있습니다.
volatile이나 메서드 전체의synchronized보다 먼저, 호출마다 다른 값을 인자와 지역 변수, 반환값으로 옮길 수 있는지 확인합니다.final필드는 참조가 바뀌지 않는다는 뜻일 뿐이며, 함께 쓰는 협력 객체가 동시 접근에 안전한지는 그 구현을 따로 확인해야 합니다.- 상태 충돌을 스코프 변경으로 감추기 전에, 그 상태가 정말 객체에 있어야 하는지부터 따집니다.
연습 문제
1. 제목 형식 만들기
PostTitleFormatter가 마지막 제목을 필드에 저장하지 않고, 50개의 가상 스레드가 각자 받은 식별자와 제목을 정해진 형식으로 돌려주게 만드세요. 입력 식별자는 1부터 50이고 제목은 " post-" + id + " "입니다. 앞뒤 공백을 strip()으로 지운 뒤 id:제목 형식으로 돌려줍니다. 해설을 열기 전에 첫 작업의 1:post-1과 마지막 작업의 50:post-50을 예상하고, 그 사이의 작업들도 자신의 식별자와 제목이 짝지어지는지 확인할 수 있게 테스트를 작성하세요.
해설 보기
다음 테스트는 호출 중에 정규화한 제목을 지역 변수에만 둡니다.
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만 씁니다. 그래서 몇 개의 스레드가 동시에 호출하더라도 식별자와 제목이 섞이지 않습니다.
2. volatile이면 될까
첫 테스트의 lastTotalItems를 volatile int로 바꾸면 둘째 테스트에서 Alice의 결과는 무엇이 될까요?
해설 보기
여전히 9입니다. volatile은 한 스레드가 쓴 값을 다른 스레드가 바로 볼 수 있게 할 뿐, 그 필드를 한 호출만 쓰게 하지는 않습니다. 래치가 Bob의 쓰기를 Alice의 읽기보다 앞에 두므로, Alice는 volatile이든 아니든 Bob이 쓴 90을 읽습니다. 값이 섞이는 원인은 가시성이 아니라 호출마다 다른 값을 함께 쓰는 필드에 둔 설계에 있습니다.
다음 글 Configuration과 싱글톤에서는 Java 구성 클래스 안에서 @Bean 메서드를 직접 호출할 때 구성 프록시가 어떻게 같은 싱글톤 참조를 돌려주는지, 그리고 프록시를 끈 구성에서는 왜 파라미터 주입이 더 안전한지 확인합니다.