본문으로 건너뛰기

안동민 개발노트

본문 시작

싱글톤과 공유 상태

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

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

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

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

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

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

CONTAINER IDENTITY · SHARED MUTABLE STATE

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

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

EXACT TEST CONFIGURATION

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

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 = 40aliceStored.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은 이 관찰에 포함하지 않으며, 공유 객체의 동시성 계약은 별도로 검증해야 합니다.


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

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

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

이 관찰을 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을 저장하고 반환하도록 순서를 고정합니다.

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

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

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

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

또한 위 calculate 전체에 synchronized를 붙이면 Alice가 monitor를 가진 채 Bob 완료를 기다리고 Bob은 메서드에 들어오지 못해 이 fixture에서는 교착됩니다.

동기화 장치를 덧붙이기 전에 호출별 값이 공유 필드에 있어야 하는지부터 제거합니다.


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

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

다음 파일은 필요한 정책, 결과, 계산기를 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에 담습니다.

finalpolicy 참조가 바뀌지 않는다는 뜻이지 임의의 PageSizePolicy 구현이 자동으로 스레드 안전하다는 뜻은 아닙니다.

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

CALL OWNERSHIP · COLLABORATOR CONTRACT

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

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

ONE CALL · ONE RESULT

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

  1. 인자

    requestIdtotalItems는 현재 호출이 소유

  2. 지역 계산

    totalPagespolicy.totalPages(totalItems)의 지역 변수

  3. 반환

    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는 그 구현 자체의 불변성이나 동시 접근 계약을 별도로 확인합니다.

현재 교재의 MemoryMemberRepositoryLinkedHashMaplong 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와 제목을 그대로 형식하게 만드세요.

해설 보기

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

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