본문으로 건너뛰기
  1. Posts/

왜 Spring 싱글톤 빈은 무상태여야 할까? 스레드, Entity, @Configuration까지

NineKoo9
작성자
NineKoo9
목차

이 문서는 의문을 가지고 있던 지점을 AI 에이전트와 대화하며 정리한 메모입니다. Spring 싱글톤 빈이 무상태여야 하는 이유와 지역 변수, Entity, @Configuration 동작을 함께 정리합니다.

왜 Spring 싱글톤 빈은 무상태여야 할까? 스레드, Entity, @Configuration까지
#

💡 Spring의 싱글톤 빈, JVM 스레드 구조, DTO/Entity의 생명주기에 대한 의문과 정리


1. 싱글톤 빈은 왜 무상태(Stateless)여야 하는가?
#

의문
#

싱글톤은 무상태로 사용해야 한다. 멀티스레드로 동작하면 값이 변경될 여지가 있기 때문이다. 만약 값을 설정해놓고 특정 클라이언트가 값을 변경할 수 있는 필드가 있으면 안 된다.

답변
#

Spring 컨테이너는 기본적으로 빈을 싱글톤으로 관리한다. 정확히는 보통 Spring 컨테이너 안에서 빈 정의(bean definition)마다 하나의 인스턴스를 공유한다는 뜻이고, 일반적인 Spring MVC 서버 애플리케이션에서는 여러 요청 스레드가 이 인스턴스를 함께 사용한다.

@Service
public class OrderService {

    // ❌ 절대 이렇게 하면 안 됨 - 인스턴스 변수 = 공유 상태
    private int orderCount = 0;

    public void createOrder() {
        orderCount++; // 스레드 A와 B가 동시에 접근하면? -> 레이스 컨디션
    }
}
  • 스레드 A가 orderCount를 읽고 1을 더하려는 사이에 스레드 B도 같은 값을 읽어버리면, 두 번 호출했는데 1만 증가하는 문제가 발생한다.
  • 그래서 싱글톤 빈에는 상태를 변경할 수 있는 인스턴스 필드가 있으면 안 된다.
  • 꼭 필요하다면 ThreadLocal 같은 더 좁은 저장 범위를 고려할 수 있다. 다만 스레드 풀 환경에서는 정리(remove)가 필요하다.
@Service
public class UserContextHolder {

    // ✅ 각 스레드마다 독립된 저장공간
    private static final ThreadLocal<Long> currentUserId = new ThreadLocal<>();

    public void set(Long userId) {
        currentUserId.set(userId);  // 스레드 A가 set해도 스레드 B에는 영향 없음
    }

    public Long get() {
        return currentUserId.get();
    }

    public void clear() {
        currentUserId.remove();
    }
}

핵심 정리
#

  • 싱글톤 빈 = 보통 컨테이너 안에 인스턴스가 하나 -> 여러 스레드가 공유
  • 인스턴스 변수에 상태를 저장하면 레이스 컨디션 발생
  • 해결책: 무상태로 설계하거나, 불가피한 경우 더 좁은 scope나 ThreadLocal 등을 고려

2. 일반적인 도메인 객체는 왜 이 규칙에 해당하지 않는가?
#

의문
#

우리가 일반적으로 정의한 도메인은 왜 적용 안 됨…? 싱글톤으로 관리하는 것이 아니니까 그렇지.

답변
#

대체로 맞다. 도메인 객체(Entity, VO 등)는 Spring 컨테이너가 관리하는 기본 싱글톤 빈이 아니다. 보통은 애플리케이션 코드가 new로 생성하거나, JPA가 영속성 컨텍스트 안에서 관리한다.

// 도메인 객체 - Spring이 관리하지 않음 (싱글톤 아님)
Order order = new Order(1L, "치킨", 20000);

// Spring 빈 - 싱글톤으로 관리됨
@Service
public class OrderService { ... }

싱글톤으로 관리되는 것들은 Spring IoC 컨테이너에 의해 DI(의존성 주입)되는 것들이다. @Component, @Service, @Repository, @Controller 등의 어노테이션이 붙은 클래스가 여기에 해당한다.

다만 “도메인 객체는 무조건 요청마다 생성되고 해당 스레드에서만 쓰인다"처럼 일반화하면 과하다. 실제 생명주기는 어디서 생성했고, 어디에 참조를 보관하느냐에 따라 달라진다. 그래도 일반적인 웹 요청 처리 코드에서는 Spring 싱글톤 빈처럼 공유되는 객체로 취급하지 않는 경우가 대부분이다.


3. StringBuffer를 안 쓰는 이유 - 메서드 지역 변수의 스레드 안전성
#

의문
#

StringBuffer와 같이 동기화가 적용된 자료구조를 실제로 안 쓴다는 것은… 보통 메서드 안에서 하나의 스레드가 처리하는 지역변수로써 사용되기 때문이구나?

답변
#

정확하다. StringBuffersynchronized가 걸려 있어서 멀티스레드에서 안전하지만, Spring 환경에서 문자열 조작은 거의 항상 메서드 내 지역 변수로 이루어진다.

@Service
public class ReportService {

    public String generateReport(List<Item> items) {
        // ✅ 이 sb는 이 스레드의 실행 흐름에서만 사용됨
        // 다른 스레드가 접근할 방법이 없음
        StringBuilder sb = new StringBuilder();
        for (Item item : items) {
            sb.append(item.getName()).append("\n");
        }
        return sb.toString();
    }
}
  • 지역 변수이므로 애초에 공유가 안 된다.
  • 더 정확히는 StringBuilder 객체는 힙에 생성되더라도, 그 참조가 다른 스레드에 공유되지 않는다.
  • 동기화할 이유 자체가 없으니, 불필요한 synchronized 오버헤드가 있는 StringBuffer보다 StringBuilder를 쓴다.

4. 싱글톤 객체인데 메서드 호출은 왜 안전한가 - JVM 스택의 비밀
#

의문
#

싱글톤 객체라 할지라도 하나만 생성될 뿐이지 실행될 때는 각자 JVM에서 각 스레드마다 할당된 스택에서 연산이 이루어지니까 그런 것이구나?

답변
#

정확하다. 핵심은 JVM의 메모리 구조다. 다만 조금만 더 정확히 말하면, 객체 자체는 힙에 있고 메서드의 지역 변수와 매개변수 참조가 각 스레드의 스택 프레임에 놓인다.

┌──────────────────────────────────────────────┐
│                     Heap                      │
│                                               │
│   ┌────────────────┐                          │
│   │  OrderService   │  ← 싱글톤, 딱 하나       │
│   │  (인스턴스)      │                          │
│   └────────────────┘                          │
│                                               │
│   ┌──────┐  ┌──────┐  ┌──────┐               │
│   │DTO-1 │  │DTO-2 │  │DTO-3 │ ← 요청마다 생성 │
│   └──────┘  └──────┘  └──────┘               │
└──────────────────────────────────────────────┘

┌───────────────┐    ┌───────────────┐
│   Thread A    │    │   Thread B    │
│   Stack       │    │   Stack       │
│ ┌───────────┐ │    │ ┌───────────┐ │
│ │createOrder│ │    │ │createOrder│ │
│ │price=1000 │ │    │ │price=2000 │ │
│ │dto (ref)  │ │    │ │dto (ref)  │ │
│ └───────────┘ │    │ └───────────┘ │
└───────────────┘    └───────────────┘
  • OrderService힙(Heap) 에 하나만 존재한다.
  • 그러나 createOrder() 메서드가 호출될 때, 그 안의 지역 변수와 매개변수는 각 스레드의 스택 프레임에 독립적으로 생성된다.
  • 싱글톤 객체라 해도, 메서드 내 지역 변수는 스레드마다 격리되어 전혀 문제가 없다.
@Service
public class OrderService {

    public OrderResponse createOrder(OrderRequest request) {
        // price는 스레드 A의 스택 프레임에서만 사용 -> 안전
        int price = request.getPrice();

        // dto 객체는 힙에 생성되지만, 현재 스레드의 참조로만 다루면 안전
        OrderDto dto = new OrderDto(price);

        return new OrderResponse(dto);
    }
}

5. 웹 요청 스레드 = JVM 스레드인가?
#

의문
#

이 웹 요청을 위한 스레드와 JVM의 이 스레드가 같은 녀석인가…? 같은 녀석이구나? 각자 콜 스택을 가진다.

답변
#

전통적인 동기식 Spring MVC/Servlet 처리 기준으로는 맞다. Tomcat의 스레드 풀에 있는 스레드는 java.lang.Thread의 인스턴스이고, 각각 JVM이 관리하는 독립된 콜 스택을 가진다.

HTTP 요청 도착
  → Tomcat 스레드 풀에서 java.lang.Thread 하나 할당
  → 이 Thread가 Filter → DispatcherServlet → Controller → Service → Repository 호출
  → 모두 이 Thread의 콜 스택 위에서 실행
  → 응답 반환 후 Thread는 풀에 반납

웹 서버(Tomcat)의 스레드와 JVM의 스레드는 별개의 존재가 아니라, Tomcat이 JVM의 스레드를 풀로 관리하며 요청에 할당하는 구조다.

다만 이것은 동기식 요청 처리 설명으로 이해하는 것이 맞다.

  • Servlet async (Callable, DeferredResult, WebAsyncTask)를 사용하면 처리 스레드가 바뀔 수 있다.
  • @Async나 reactive 흐름에서는 한 요청이 여러 스레드에 걸칠 수 있다.
  • 그래서 ThreadLocal 기반 상태는 비동기 경계를 넘을 때 추가 고려가 필요하다.

6. Entity는 상태를 변경하는데 왜 괜찮은가?
#

의문
#

엔티티들은 우째 처리되는 것인가? 값을 변경하기도 할 텐데… 왜…? 인스턴스 변수로 사용되지? 아… 그냥 싱글톤이 아니라서 그런 거야.

답변
#

맞다. Entity는 싱글톤이 아니기 때문에 상태를 변경해도 괜찮다.

@Entity
public class Order {
    @Id @GeneratedValue
    private Long id;
    private String status;  // 인스턴스 변수지만, 싱글톤이 아니므로 OK

    public void cancel() {
        this.status = "CANCELLED";  // 이 인스턴스의 상태 변경
    }
}
  • 요청 A에서 조회한 Order 인스턴스와 요청 B에서 조회한 Order 인스턴스는 서로 다른 객체인 경우가 일반적이다.
  • 같은 DB 행(row)을 조회하더라도, 일반적인 transaction-scoped persistence context에서는 각 트랜잭션이 별도의 영속성 컨텍스트를 가지므로 각자 다른 Entity 인스턴스를 다루게 된다.
  • 동시에 같은 행을 수정하는 동시성 문제는 Entity의 인스턴스 공유 문제가 아니라, DB 레벨의 락/트랜잭션 격리 문제다.

즉, Entity의 상태 변경 자체는 자연스럽다. 주의할 점은 EntityManager와 영속성 컨텍스트의 thread-safety를 전제로 공유하면 안 된다는 것이다.


7. 왜 Controller, Service, Repository는 싱글톤이고, DTO와 Entity는 아닌가?
#

의문
#

왜 리포지토리와 컨트롤러 서비스 이런 녀석들은 싱글톤으로 관리하고… DTO와 같은 녀석이나 도메인 객체는…? 이 기준은 뭐지…?

답변
#

실무적으로 자주 쓰는 기준은 “이 객체가 행위를 제공하는가, 데이터를 담는가” 다. 이 구분은 학습용으로 꽤 유용하다. 다만 더 엄밀한 Spring 기준은 bean으로 등록되었는가, 그리고 어떤 scope를 가졌는가다.

싱글톤으로 관리하는 것: 행위(로직) 중심 객체
#

@Controller   // 싱글톤 - 요청을 라우팅하는 "행위"
@Service      // 싱글톤 - 비즈니스 로직을 수행하는 "행위"
@Repository   // 싱글톤 - 데이터 접근을 수행하는 "행위"
@Component    // 싱글톤 - 기타 인프라 로직

이들의 공통점은 보통 상태 없이 행위만 제공한다는 것이다. 요청이 100개 들어와도 OrderServicecreateOrder() 로직 자체는 동일하다. 그래서 하나만 만들어두고 공유해도 된다.

싱글톤으로 관리하면 안 되는 것: 데이터(상태) 중심 객체
#

// DTO - 요청마다 다른 데이터를 담아야 함
OrderRequest request1 = new OrderRequest("치킨", 20000);  // 스레드 A의 요청
OrderRequest request2 = new OrderRequest("피자", 30000);  // 스레드 B의 요청

// Entity - 각각 다른 행(row)을 표현
Order order1 = new Order(1L, "치킨", 20000);
Order order2 = new Order(2L, "피자", 30000);

만약 OrderRequest를 싱글톤으로 만들면?

// ❌ 절대로 이러면 안 됨
@Component  // 싱글톤으로 등록되면...
public class OrderRequest {
    private String itemName;
    private int price;

    // 스레드 A: setItemName("치킨")
    // 스레드 B: setItemName("피자")
    // 스레드 A: getItemName() -> "피자" ???  -> 치킨 주문했는데 피자가 나옴
}

비교 정리표
#

구분싱글톤 빈매번 생성하는 객체
본질행위를 제공데이터를 담음
상태무상태 (stateless)상태가 존재 (stateful)
예시Controller, Service, RepositoryDTO, Entity, VO
생명주기앱 시작 ~ 종료요청 처리 중 또는 필요 시점에만 존재
생성 비용한 번만 만들어서 절약가볍고, 요청마다 달라야 하므로 매번 생성

8. 매번 생성하면 메모리 낭비 아닌가?
#

의문
#

싱글톤으로 관리되지 않는 것들은 계속해서 생성될 여지가 있다는 것인데… DTO나 도메인 객체는…? 이 정도는 허락하자는 것인가…? 이 기준은 뭐지…?

답변
#

DTO나 Entity는 대부분 수명이 매우 짧다. JVM GC는 이런 단명(short-lived) 객체를 처리하는 데 최적화되어 있기 때문에, 요청마다 DTO 몇 개 만드는 건 성능에 거의 영향이 없다.

public OrderResponse createOrder(OrderRequest request) {
    Order order = Order.create(request);        // 생성
    orderRepository.save(order);                // 저장
    return OrderResponse.from(order);           // 응답 DTO 생성
}   // 메서드 종료   이상 참조되지 않으면 GC 대상
  • 이 객체들은 대개 짧게 사용되고, JVM의 Young Generation GC에 의해 빠르게 수거된다.
  • 반면 Service, Repository 같은 빈은 내부에 다른 빈에 대한 참조, 커넥션 풀, 설정 값 등을 들고 있어서 초기화 비용이 훨씬 크다. 이런 걸 요청마다 만들면 진짜 낭비다.

불변 객체로 만들면 더 좋다
#

// ✅ 불변 DTO - record 사용 (Java 16+)
public record OrderRequest(String itemName, int price) {}

// ✅ 불변 DTO - 클래스 사용
public class OrderResponse {
    private final Long orderId;
    private final String status;

    public OrderResponse(Long orderId, String status) {
        this.orderId = orderId;
        this.status = status;
    }

    // getter만 제공, setter 없음
}

불변 객체는 생성 이후 상태가 변경되지 않으므로, 설사 공유되더라도 안전하다. DTO를 가능한 한 불변으로 만드는 것이 좋은 습관이다.


9. @Configuration과 CGLIB 프록시 - 싱글톤 보장의 비밀
#

의문
#

AppConfig는 CGLIB을 이용한 프록시 객체네? @Bean만 사용하면 스프링 빈으로 등록은 되지만 싱글톤을 보장하지는 않음.

답변
#

@Configuration
public class AppConfig {

    @Bean
    public MemberRepository memberRepository() {
        return new MemoryMemberRepository();
    }

    @Bean
    public MemberService memberService() {
        return new MemberServiceImpl(memberRepository()); // memberRepository() 호출
    }

    @Bean
    public OrderService orderService() {
        return new OrderServiceImpl(memberRepository());  // memberRepository() 또 호출
    }
}

자바 코드만 보면 memberRepository()가 두 번 호출되니까 인스턴스가 2개 생길 것 같지만, Spring은 기본적으로 @Configuration(proxyBeanMethods = true) 클래스를 CGLIB으로 프록시한다.

CGLIB 프록시의 동작 원리
#

memberRepository() 호출 시:
  if (스프링 컨테이너에 memberRepository 빈이 이미 있으면)
      return 기존 빈;       ← 싱글톤 보장
  else
      새로 생성 → 컨테이너에 등록 → return;
  • memberService()orderService()가 받는 memberRepository동일한 인스턴스다.
  • Spring이 AppConfig 클래스를 그대로 사용하지 않고, CGLIB 라이브러리로 AppConfig를 상속한 프록시 클래스를 동적으로 생성한다.
  • 이 프록시가 @Bean 메서드를 가로채서, 이미 컨테이너에 등록된 빈이면 기존 인스턴스를 반환한다.

@Configuration 없이 @Bean만 쓰면?
#

// ⚠️ @Configuration 없이 @Bean만 사용
@Component  // @Configuration이 아님
public class AppConfig {

    @Bean
    public MemberRepository memberRepository() {
        return new MemoryMemberRepository();
    }

    @Bean
    public MemberService memberService() {
        // CGLIB 프록시가 없으므로 순수 자바 메서드 호출
        // -> 여기서 memberRepository()를 직접 호출하면 새 객체가 생성될 수 있음
        return new MemberServiceImpl(memberRepository());
    }
}
  • 이 경우에도 각 @Bean이 등록되는 bean 자체의 기본 scope는 여전히 singleton이다.
  • 다만 CGLIB 프록시가 적용되지 않아서, 같은 설정 클래스 안에서 memberRepository()를 직접 호출하면 컨테이너를 우회하는 일반 메서드 호출이 된다.
  • 즉, 엄밀히 말하면 “싱글톤 기본값 자체가 사라진다"기보다, @Bean 메서드 간 직접 호출에 대한 컨테이너 보장이 깨지는 것이다.

최종 정리: 한눈에 보는 핵심 요약
#

                        Spring 컨테이너 (Heap)
                    ┌─────────────────────────────┐
    싱글톤 빈        │  Controller ─── Service ─── Repository  │
    (행위 중심,      │     (무상태, 하나만 존재, 모든 스레드 공유) │
     @Component 계열) │                                         │
                    └─────────────────────────────┘
                            ↑ 공유 사용 ↑
                    ┌───────┴───────────┴───────┐
                    │                           │
            ┌───────────────┐           ┌───────────────┐
            │   Thread A    │           │   Thread B    │
            │   (요청 #1)    │           │   (요청 #2)    │
            │               │           │               │
            │  지역변수:     │           │  지역변수:     │
            │  - DTO-A      │           │  - DTO-B      │
            │  - Entity-A   │           │  - Entity-B   │
            │  (이 스레드만   │           │  (이 스레드만   │
            │   사용, GC 대상)│           │   사용, GC 대상)│
            └───────────────┘           └───────────────┘
                  데이터 객체                데이터 객체
                  (상태 중심,               (상태 중심,
                   매번 new 생성)            매번 new 생성)

핵심 한 문장
#

Spring이 싱글톤으로 관리하는 기준은 보통 “공유해도 되는 로직 빈인가"에 가깝고, 스레드 안전성의 핵심은 결국 공유 가변 상태를 두지 않는 것이다.

의문 -> 결론 흐름 정리
#

  1. 싱글톤은 왜 무상태? -> 모든 스레드가 공유하므로 상태를 가지면 레이스 컨디션 발생
  2. 도메인 객체는 왜 해당 안 됨? -> 기본 Spring 싱글톤 빈이 아니기 때문
  3. StringBuffer를 왜 안 씀? -> 메서드 내 지역 변수로 사용되므로 애초에 공유가 안 됨
  4. 싱글톤인데 메서드는 왜 안전? -> 메서드 실행은 각 스레드의 스택에서 독립적으로 이루어짐
  5. 웹 스레드 = JVM 스레드? -> 동기식 MVC에서는 대체로 동일하다. 다만 async/reactive까지 일반화하면 안 됨
  6. Entity는 상태 변경하는데 괜찮음? -> 싱글톤이 아니니까 괜찮고, DB 동시성은 트랜잭션과 락으로 해결
  7. 싱글톤의 기준? -> 실무적으로는 행위 객체(Service 등) vs 데이터 객체(DTO, Entity) 구분이 유용하고, 엄밀히는 bean 등록 여부와 scope가 기준
  8. 매번 생성하면 낭비? -> 단명 객체는 Young Gen GC에 최적화. 불변으로 만들면 더 좋음
  9. @Configuration의 CGLIB? -> @Bean 메서드 간 직접 호출에서도 컨테이너 semantics를 유지하게 해 줌

참고 자료
#