본문 바로가기

싱글톤과 공유 상태

독립 컨텍스트에서 관찰되는 기본 싱글톤 참조와 공유 필드의 호출 값 혼합을 구분하고, 호출 상태를 인자·지역 변수·반환값에 두는 수정안을 완전한 동시 테스트로 검증합니다.

웹 요청이 올 때마다 컨트롤러, 서비스, 리포지토리를 새로 만들 필요는 없습니다.

이 객체들은 대개 설정과 협력 객체를 들고 있고 요청 데이터는 메서드 인자로 처리합니다.

Spring의 기본 싱글톤은 한 컨테이너가 관리하는 특정 빈을 반복 조회하고 주입할 때 같은 참조를 재사용합니다.

문제는 재사용 자체가 아니라 여러 호출이 함께 보는 객체에 호출별 변경 상태를 저장할 때 시작됩니다.

앞 절의 같은 학습 프로젝트에서 각 완전한 테스트를 src/test/java에 넣습니다. 첫 장의 Java 25 도구 체인과 JUnit·AssertJ를 유지하세요. 가상 스레드 executor를 쓰지만 웹 서버나 HTTP 요청은 필요하지 않습니다. 먼저 ./gradlew test --tests board.container.SingletonStateOwnershipTest, 이어 ./gradlew test --tests board.application.StatelessPageCalculatorTest를 프로젝트 루트에서 실행합니다.

같은 싱글톤 참조는 공유 필드의 동시성 안전을 보장하지 않는다

Alice 40 저장 뒤 Bob 90 덮어쓰기와 두 반환값 9의 래치 고정 순서에서 공유 필드가 변한다.

독립적으로 연 두 컨텍스트의 현재 구성에서 같은 컨텍스트의 반복 조회와 다른 컨텍스트의 객체를 구분하고, 한 객체의 lastTotalItems 필드가 Alice 40과 Bob 90을 섞어 Alice가 9를 반환하는 순서를 설명합니다.

CONTAINER IDENTITY · SHARED MUTABLE STATE

같은 싱글톤 참조는 공유 필드의 동시성 안전을 보장하지 않는다

참조 identity와 호출 상태의 안전성은 서로 다른 증거가 필요합니다. 같은 컨텍스트가 같은 객체를 반환해도 그 객체의 변경 필드가 호출별 값을 소유하면 두 실행이 서로를 덮어쓸 수 있습니다.

한 공유 필드가 Alice의 40에서 Bob의 90으로 덮이는 순서동일 객체의 lastTotalItems에서 래치가 순서를 고정한다. Alice가 40을 저장하고 기다린 뒤 Bob이 90을 저장한다. Bob은 90을 10으로 나누어 9를 반환하고 finally에서 완료 래치를 내린다. 그 뒤 Alice도 90을 읽어 9를 반환한다. 화살표는 이 Java 테스트가 강제한 순서이며 Spring의 HTTP 요청 실행 순서를 뜻하지 않는다.같은 객체의 lastTotalItems · 래치로 고정한 실행 순서Alice가 저장·대기lastTotalItems = 40Bob 완료 래치를 기다림Bob이 같은 필드를 덮음lastTotalItems = 90Alice는 아직 대기Bob이 반환·대기 해제90 / 10 = 9finally에서 완료 래치 내림Alice가 재개·반환공유 필드의 90을 읽음90 / 10 = 9 · 자기 입력은 40
한 공유 필드가 Alice의 40에서 Bob의 90으로 덮이는 순서동일 객체의 lastTotalItems에서 래치가 순서를 고정한다. Alice가 40을 저장하고 기다린 뒤 Bob이 90을 저장한다. Bob은 90을 10으로 나누어 9를 반환하고 finally에서 완료 래치를 내린다. 그 뒤 Alice도 90을 읽어 9를 반환한다. 화살표는 이 Java 테스트가 강제한 순서이며 Spring의 HTTP 요청 실행 순서를 뜻하지 않는다.한 객체의 공유 필드Alice가 저장·대기lastTotalItems = 40Bob 완료 래치를 기다림Bob이 같은 필드를덮음lastTotalItems = 90Alice는 아직 대기Bob이 반환대기 해제90 / 10 = 9finally에서완료 래치 내림Alice가 재개·반환공유 필드의 90을 읽음90 / 10 = 9자기 입력은 40

화살표는 이 래치 테스트가 고정한 순서입니다. Alice 입력40의 값4가 나오는 대신 Bob이 덮어쓴90을 읽어9를 반환합니다.

EXACT TEST CONFIGURATION

독립 컨텍스트 A와 B가 각자 현재 구성을 등록한다

CalculatorConfig를 독립적으로 등록한 두 컨텍스트에서 관찰한 참조 identity
관찰 결과 말할 수 있는 범위
A 반복 조회 pageCalculator → instance α 컨텍스트 A의 현재 기본 싱글톤 bean 반복 조회
B 독립 등록 pageCalculator → instance β 이 구성 메서드를 따로 실행한 컨텍스트 B의 객체
경계 instance α ≠ instance β alias, registerSingleton(), 종료 뒤 도달 가능성의 일반 규칙이 아님

CalculatorConfig를 독립적으로 등록한 두 컨텍스트에서 관찰한 참조 identity

A 반복 조회

결과

pageCalculator → instance α

말할 수 있는 범위

컨텍스트 A의 현재 기본 싱글톤 bean 반복 조회

B 독립 등록

결과

pageCalculator → instance β

말할 수 있는 범위

이 구성 메서드를 따로 실행한 컨텍스트 B의 객체

경계

결과

instance α ≠ instance β

말할 수 있는 범위

alias, registerSingleton(), 종료 뒤 도달 가능성의 일반 규칙이 아님

DETERMINISTIC INTERLEAVING

컨텍스트 A의 같은 객체에서 Alice 40 → Bob 90 → Alice 9

  1. Alice가 40을 저장하고 대기

    lastTotalItems = 40 뒤 aliceStored.countDown()

  2. Bob이 같은 필드를 90으로 덮음

    lastTotalItems = 90

  3. Bob이 9를 반환하고 Alice를 해제

    90 / 10 = 9, release는 finally에서 실행

  4. 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의 래치 고정 순서에서 공유 필드가 변한다.

독립적으로 연 두 컨텍스트의 현재 구성에서 같은 컨텍스트의 반복 조회와 다른 컨텍스트의 객체를 구분하고, 한 객체의 lastTotalItems 필드가 Alice 40과 Bob 90을 섞어 Alice가 9를 반환하는 순서를 설명합니다.

CONTAINER IDENTITY · SHARED MUTABLE STATE

같은 싱글톤 참조는 공유 필드의 동시성 안전을 보장하지 않는다

참조 identity와 호출 상태의 안전성은 서로 다른 증거가 필요합니다. 같은 컨텍스트가 같은 객체를 반환해도 그 객체의 변경 필드가 호출별 값을 소유하면 두 실행이 서로를 덮어쓸 수 있습니다.

한 공유 필드가 Alice의 40에서 Bob의 90으로 덮이는 순서동일 객체의 lastTotalItems에서 래치가 순서를 고정한다. Alice가 40을 저장하고 기다린 뒤 Bob이 90을 저장한다. Bob은 90을 10으로 나누어 9를 반환하고 finally에서 완료 래치를 내린다. 그 뒤 Alice도 90을 읽어 9를 반환한다. 화살표는 이 Java 테스트가 강제한 순서이며 Spring의 HTTP 요청 실행 순서를 뜻하지 않는다.같은 객체의 lastTotalItems · 래치로 고정한 실행 순서Alice가 저장·대기lastTotalItems = 40Bob 완료 래치를 기다림Bob이 같은 필드를 덮음lastTotalItems = 90Alice는 아직 대기Bob이 반환·대기 해제90 / 10 = 9finally에서 완료 래치 내림Alice가 재개·반환공유 필드의 90을 읽음90 / 10 = 9 · 자기 입력은 40
한 공유 필드가 Alice의 40에서 Bob의 90으로 덮이는 순서동일 객체의 lastTotalItems에서 래치가 순서를 고정한다. Alice가 40을 저장하고 기다린 뒤 Bob이 90을 저장한다. Bob은 90을 10으로 나누어 9를 반환하고 finally에서 완료 래치를 내린다. 그 뒤 Alice도 90을 읽어 9를 반환한다. 화살표는 이 Java 테스트가 강제한 순서이며 Spring의 HTTP 요청 실행 순서를 뜻하지 않는다.한 객체의 공유 필드Alice가 저장·대기lastTotalItems = 40Bob 완료 래치를 기다림Bob이 같은 필드를덮음lastTotalItems = 90Alice는 아직 대기Bob이 반환대기 해제90 / 10 = 9finally에서완료 래치 내림Alice가 재개·반환공유 필드의 90을 읽음90 / 10 = 9자기 입력은 40

화살표는 이 래치 테스트가 고정한 순서입니다. Alice 입력40의 값4가 나오는 대신 Bob이 덮어쓴90을 읽어9를 반환합니다.

EXACT TEST CONFIGURATION

독립 컨텍스트 A와 B가 각자 현재 구성을 등록한다

CalculatorConfig를 독립적으로 등록한 두 컨텍스트에서 관찰한 참조 identity
관찰 결과 말할 수 있는 범위
A 반복 조회 pageCalculator → instance α 컨텍스트 A의 현재 기본 싱글톤 bean 반복 조회
B 독립 등록 pageCalculator → instance β 이 구성 메서드를 따로 실행한 컨텍스트 B의 객체
경계 instance α ≠ instance β alias, registerSingleton(), 종료 뒤 도달 가능성의 일반 규칙이 아님

CalculatorConfig를 독립적으로 등록한 두 컨텍스트에서 관찰한 참조 identity

A 반복 조회

결과

pageCalculator → instance α

말할 수 있는 범위

컨텍스트 A의 현재 기본 싱글톤 bean 반복 조회

B 독립 등록

결과

pageCalculator → instance β

말할 수 있는 범위

이 구성 메서드를 따로 실행한 컨텍스트 B의 객체

경계

결과

instance α ≠ instance β

말할 수 있는 범위

alias, registerSingleton(), 종료 뒤 도달 가능성의 일반 규칙이 아님

DETERMINISTIC INTERLEAVING

컨텍스트 A의 같은 객체에서 Alice 40 → Bob 90 → Alice 9

  1. Alice가 40을 저장하고 대기

    lastTotalItems = 40 뒤 aliceStored.countDown()

  2. Bob이 같은 필드를 90으로 덮음

    lastTotalItems = 90

  3. Bob이 9를 반환하고 Alice를 해제

    90 / 10 = 9, release는 finally에서 실행

  4. 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 기반 예상과 달라집니다.

컨테이너 identity와 동시성 안전을 분리

다음 테스트의 CalculatorConfig는 독립적으로 연 두 AnnotationConfigApplicationContext에 StatefulPageCalculator를 각각 등록합니다.

같은 컨텍스트의 반복 조회는 같은 객체를 반환하고, 이 구성 메서드를 각자 실행한 두 컨텍스트의 객체는 서로 다릅니다.

이 관찰을 alias, 외부에서 같은 객체를 넣는 registerSingleton(), 컨텍스트 종료 뒤 Java 객체의 도달 가능성까지 일반화하지 않습니다.

board-core/src/test/java/board/container/SingletonStateOwnershipTest.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을 저장하고 반환하도록 순서를 고정합니다.

예시

aliceStored와 bobFinished는 한 번 내려가면 대기자를 풀어 주는 래치입니다. 이 테스트는 다음 순서를 강제하므로 같은 공유 필드를 어떤 값으로 읽는지 따라갈 수 있습니다.

순서호출이 한 일그때의 lastTotalItems반환값
1Alice가 40을 쓰고 Bob 완료를 기다림40아직 반환하지 않음
2Bob이 90을 덮어씀90아직 반환하지 않음
3Bob이 나누고 finally에서 래치를 내림9090 / 10 = 9
4Alice가 깨어나 같은 필드를 나눔90자기 입력의 4가 아닌 9

계산식이 같아도 40의 소유자가 Alice라는 정보는 공유 필드에 남지 않습니다. 이 때문에 기대값을 단순히 4로 바꾸는 것으로는 테스트를 고칠 수 없습니다.

Bob 작업의 finally가 Alice를 반드시 풀고, latch와 Future.get의 제한 시간은 실패가 무한 대기로 바뀌지 않게 합니다.

이 테스트는 Spring이 스레드를 만들거나 웹 요청을 배치한다고 증명하지 않습니다.

한 컨텍스트에서 같은 객체를 얻은 뒤 두 가상 스레드가 그 객체를 호출했을 때 변경 필드가 섞이는 정확한 Java 실행만 증명합니다.

volatile은 쓰기 가시성을 높여도 lastTotalItems의 소유자가 어느 호출인지 정하지 못합니다.

또한 위 calculate 전체에 synchronized를 붙이면 Alice가 monitor를 가진 채 Bob 완료를 기다리고, Bob은 메서드에 들어오지 못합니다. 두 작업이 서로를 기다리는 구조가 생기지만 이 테스트에는 5초 제한 시간이 있어 Alice가 예외로 대기를 끝내는 실패 경로가 있습니다. 정상 반환 순서를 얻으려면 이 래치 구성에 메서드 잠금만 덧붙여서는 안 됩니다.

동기화 장치를 덧붙이기 전에 호출별 값을 공유 필드에서 제거할 수 있는지 먼저 확인합니다.


호출 상태를 인자·지역 변수·반환값으로 이동

수정한 계산기는 생성 뒤 바뀌지 않는 정책 참조만 필드에 두고 호출 값은 반환 객체로 묶습니다.

다음 파일은 필요한 정책, 결과, 계산기를 test source 안에 모두 정의한 완전한 단위입니다.

board-core/src/test/java/board/application/StatelessPageCalculatorTest.java
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 구현이 자동으로 스레드 안전하다는 뜻은 아닙니다.

첫 번째 작업은 (requestId=1, totalItems=10)을 넣어 (1, 10, 1)을 받고, 마지막 작업은 (100, 1000)을 넣어 (100, 1000, 100)을 받습니다. 중간의 각 작업도 자기 입력과 결과를 한 묶음으로 검증하므로 페이지 수만 맞고 ID나 전체 개수가 섞이는 오류를 놓치지 않습니다.

여기서 totalItems / 10은 정수 나눗셈 정책이고 모든 입력이 10의 배수입니다. 예를 들어 41을 넣으면 이 정책은 4를 계산합니다. 한 페이지에 최대 10개를 넣고 나머지가 있으면 페이지를 하나 더 만드는 업무 규칙이라면 기대값은 5가 되므로 정책과 그 경계 테스트를 별도로 정해야 합니다. 현재 동시성 테스트가 그 업무 규칙까지 확인한 것은 아닙니다.

requestId와 totalItems를 호출 인자로 받고 totalPages를 지역 변수로 계산해 PageResult로 반환하는 호출 소유권을 보여 주며, final collaborator 참조와 collaborator 자체의 동시성 계약을 구분합니다.

CALL OWNERSHIP · COLLABORATOR CONTRACT

호출 상태는 인자·지역 변수·반환값이 소유하고 공유 협력자는 별도 계약이 필요하다

필드가 있다는 사실만으로 안전하거나 위험하다고 판정하지 않습니다. 호출별 값은 호출 스택과 결과가 소유하고, 공유 collaborator는 그 구현의 불변성·동시 접근 규칙을 따로 확인합니다.

같은 계산기를 불러도 인자·지역값·반환값은 호출별 소유같은 PageCalculator와 상태 없는 정책을 사용하는 두 호출의 독립 경로를 나누어 표시한다. 호출1은 requestId1과 totalItems10에서 지역 totalPages1을 계산하고 PageResult1,10,1을 반환한다. 호출2는 requestId2와 totalItems20에서 지역 totalPages2를 계산하고 PageResult2,20,2를 반환한다. 두 지역 계산 영역은 객체 두 개가 아니라 각 호출의 메서드 실행이며 서로의 순서를 고정하지 않는다.하나의 PageCalculator호출 1의 인자id = 1 · totalItems = 10호출 1의 지역 변수10 / 10 = 1totalPages = 1새 PageResult(1, 10, 1)호출 2의 인자id = 2 · totalItems = 20호출 2의 지역 변수20 / 10 = 2totalPages = 2새 PageResult(2, 20, 2)
같은 계산기를 불러도 인자·지역값·반환값은 호출별 소유같은 PageCalculator와 상태 없는 정책을 사용하는 두 호출의 독립 경로를 나누어 표시한다. 호출1은 requestId1과 totalItems10에서 지역 totalPages1을 계산하고 PageResult1,10,1을 반환한다. 호출2는 requestId2와 totalItems20에서 지역 totalPages2를 계산하고 PageResult2,20,2를 반환한다. 두 지역 계산 영역은 객체 두 개가 아니라 각 호출의 메서드 실행이며 서로의 순서를 고정하지 않는다.한 계산기의 지역값호출 1의 인자id = 1 · 개수 = 10호출 2의 인자id = 2 · 개수 = 20호출1의 지역 변수10 / 10 = 1totalPages = 1호출2의 지역 변수20 / 10 = 2totalPages = 2호출1의 PageResult(1, 10, 1)호출2의 PageResult(2, 20, 2)두 호출의 실행 순서는고정하지 않습니다

한 객체의 메서드를 두 번 부르는 독립 경로입니다. 지역 계산을 두 칸으로 그려도 계산기 객체가 둘이라는 뜻은 아니며 호출 순서는 고정하지 않습니다.

ONE CALL · ONE RESULT

호출 값은 필드를 거치지 않고 새 PageResult로 이동한다

  1. 인자

    requestId와 totalItems는 현재 호출이 소유

  2. 지역 계산

    totalPages는 policy.totalPages(totalItems)의 지역 변수

  3. 반환

    새 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

소유권

서비스가 같은 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와 제목을 그대로 지정된 형식으로 반환하게 만드세요.

입력 ID는 1부터 50이고 제목은 " post-" + id + " "입니다. 앞뒤 공백을 strip()으로 제거한 뒤 id:제목 형식으로 반환합니다. 해설을 열기 전에 첫 작업의 1:post-1과 마지막 작업의 50:post-50을 예상하고, 그 중간 작업도 자기 ID와 제목의 대응을 확인할 수 있게 테스트를 작성하세요.

해설 보기

다음 완전한 테스트는 호출 중 정규화한 제목을 지역 변수에만 둡니다.

board-core/src/test/java/board/application/PostTitleFormatterTest.java
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 메서드를 직접 호출할 때 전체 구성 프록시가 어떻게 싱글톤 참조를 보장하며, 프록시를 끈 구성에서는 왜 파라미터 주입이 더 안전한지 확인합니다.