이 문서는 의문을 가지고 있던 지점을 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와 같이 동기화가 적용된 자료구조를 실제로 안 쓴다는 것은… 보통 메서드 안에서 하나의 스레드가 처리하는 지역변수로써 사용되기 때문이구나?
답변#
정확하다. StringBuffer는 synchronized가 걸려 있어서 멀티스레드에서 안전하지만, 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개 들어와도 OrderService의 createOrder() 로직 자체는 동일하다. 그래서 하나만 만들어두고 공유해도 된다.
싱글톤으로 관리하면 안 되는 것: 데이터(상태) 중심 객체#
// 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, Repository | DTO, 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이 싱글톤으로 관리하는 기준은 보통 “공유해도 되는 로직 빈인가"에 가깝고, 스레드 안전성의 핵심은 결국 공유 가변 상태를 두지 않는 것이다.
의문 -> 결론 흐름 정리#
- 싱글톤은 왜 무상태? -> 모든 스레드가 공유하므로 상태를 가지면 레이스 컨디션 발생
- 도메인 객체는 왜 해당 안 됨? -> 기본 Spring 싱글톤 빈이 아니기 때문
- StringBuffer를 왜 안 씀? -> 메서드 내 지역 변수로 사용되므로 애초에 공유가 안 됨
- 싱글톤인데 메서드는 왜 안전? -> 메서드 실행은 각 스레드의 스택에서 독립적으로 이루어짐
- 웹 스레드 = JVM 스레드? -> 동기식 MVC에서는 대체로 동일하다. 다만 async/reactive까지 일반화하면 안 됨
- Entity는 상태 변경하는데 괜찮음? -> 싱글톤이 아니니까 괜찮고, DB 동시성은 트랜잭션과 락으로 해결
- 싱글톤의 기준? -> 실무적으로는 행위 객체(Service 등) vs 데이터 객체(DTO, Entity) 구분이 유용하고, 엄밀히는 bean 등록 여부와 scope가 기준
- 매번 생성하면 낭비? -> 단명 객체는 Young Gen GC에 최적화. 불변으로 만들면 더 좋음
- @Configuration의 CGLIB? -> @Bean 메서드 간 직접 호출에서도 컨테이너 semantics를 유지하게 해 줌
참고 자료#
- Spring Framework Reference - Bean Scopes: https://docs.spring.io/spring-framework/reference/core/beans/factory-scopes.html
- Spring Framework Reference - Basic Concepts:
@Beanand@Configuration: https://docs.spring.io/spring-framework/reference/core/beans/java/basic-concepts.html - Spring Framework Reference -
@ConfigurationClasses: https://docs.spring.io/spring-framework/reference/core/beans/java/configuration-annotation.html - Spring Framework Reference - Asynchronous Requests: https://docs.spring.io/spring-framework/reference/web/webmvc/mvc-ann-async.html
- The Java Virtual Machine Specification, Chapter 2: https://docs.oracle.com/javase/specs/jvms/se24/html/jvms-2.html
- Java API -
ThreadLocal: https://docs.oracle.com/en/java/javase/26/docs/api/java.base/java/lang/ThreadLocal.html - Java API -
StringBuffer: https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/lang/StringBuffer.html - Java API -
StringBuilder: https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/lang/StringBuilder.html - Jakarta Persistence API -
EntityManager: https://jakarta.ee/specifications/persistence/4.0/apidocs/jakarta.persistence/jakarta/persistence/entitymanager - Jakarta Persistence API -
PersistenceContextType: https://jakarta.ee/specifications/persistence/4.0/apidocs/jakarta.persistence/jakarta/persistence/persistencecontexttype
