singleton bean에 state를 넣으면 안 되는 이유 — 스코프와 프로파일
웹 애플리케이션에서 @Service UserContext bean에 사용자 ID를 저장했다. 동시에 100명의 사용자가 접속하면 — 마지막 사용자의 ID가 모든 요청에 보인다. singleton bean은 모든 스레드가 공유하므로, 상태를 넣으면 경쟁 조건이 발생한다. 이 글은 bean 스코프의 종류, prototype의 함정, 그리고 @Profile로 환경을 분리하는 방법을 풀어간다.
Bean 스코프 — bean의 생명 주기 정책
flowchart TD
S["singleton (기본)<br/>컨테이너당 1개 인스턴스"] --> P["prototype<br/>요청마다 새 인스턴스"]
P --> R["request (웹)<br/>HTTP 요청마다 1개"]
R --> SESS["session (웹)<br/>HTTP 세션마다 1개"]
SESS --> APP["application (웹)<br/>ServletContext 수명"]
| 스코프 | 설명 | 사용 시기 |
|---|---|---|
| singleton (기본) | 컨테이너당 1개 인스턴스 | 대부분의 bean (stateless) |
| prototype | getBean()마다 새 인스턴스 |
상태를 가진 객체, 매번 새 객체 필요 |
| request | HTTP 요청마다 1개 | 웹 요청 컨텍스트 |
| session | HTTP 세션마다 1개 | 사용자별 상태 |
| application | ServletContext 수명 |
애플리케이션 전역 캐시 |
singleton 스코프 — 기본이면서 대부분의 bean
// Spring 7 / Java 25 — singleton (기본값, 생략 가능)
@Service
public class OrderService {
// 상태 없음 (stateless) → singleton 안전
private final OrderRepository repo;
public OrderService(OrderRepository repo) { this.repo = repo; }
}
singleton bean에 mutable 상태를 두면 경쟁 조건이 발생한다. 여러 스레드가 동시에 같은 필드에 접근한다. 상태는 메서드 매개변수나 지역 변수로 전달하고, bean 자체는 stateless로 유지한다.
왜 상태를 넣으면 안 되는가 — 내부 메커니즘: Spring의 singleton bean은 물리적으로 하나의 객체다. Tomcat의 각 요청 처리 스레드가 같은 객체의 같은 필드에 동시에 접근한다. 스레드 A가 this.currentUserId = 1L을 쓰는 동안 스레드 B가 this.currentUserId = 2L을 덮어쓰면 — 스레드 A의 다음 읽기에서 2L이 나온다. synchronized로 막을 수 있지만 병목이 된다. 근본 해결은 상태를 bean에서 빼는 것이다.
비유: singleton bean은 공용 화이트보드다. 한 사람이 "회의실 A"라고 적었는데, 다른 사람이 와서 "회의실 B"로 지우고 고치면 — 첫 번째 사람은 자기가 적은 게 사라진 것을 나중에 알게 된다. 각자 자기 노트(지역 변수)에 적으면 이런 일이 없다.
prototype 스코프 — 매번 새 객체
// Spring 7 / Java 25 — prototype
@Component
@Scope("prototype")
public class ReportBuilder {
private final List<String> sections = new ArrayList<>(); // 상태 가짐
public void addSection(String title) { sections.add(title); }
public String build() { return String.join("\n", sections); }
}
prototype bean은 주입받을 때마다 새 인스턴스가 생성된다. 하지만 함정이 있다:
prototype bean이 singleton bean에 주입되면, 주입 시점에 한 번만 생성된다. singleton의 수명 동안 같은 prototype 인스턴스가 사용된다. 매번 새 prototype을 원하면
@Lookup또는ObjectFactory를 쓴다. (Spring Framework Reference - Scoped Beans)
// Spring 7 / Java 25 — 매번 새 prototype bean을 가져오는 방법
@Service
public class ReportService {
private final ObjectProvider<ReportBuilder> builderProvider;
public ReportService(ObjectProvider<ReportBuilder> builderProvider) {
this.builderProvider = builderProvider;
}
public String generateReport() {
ReportBuilder builder = builderProvider.getObject(); // 매번 새 인스턴스
builder.addSection("Header");
builder.addSection("Body");
return builder.build();
}
}
@Profile — 환경별 bean 분리
// Spring 7 / Java 25 — @Profile로 환경 분리
@Configuration
public class DataSourceConfig {
@Bean
@Profile("dev")
public DataSource devDataSource() {
// 개발: H2 인메모리
return new EmbeddedDatabaseBuilder()
.setType(EmbeddedDatabaseType.H2)
.build();
}
@Bean
@Profile("prod")
public DataSource prodDataSource() {
// 운영: PostgreSQL
HikariConfig config = new HikariConfig();
config.setJdbcUrl("jdbc:postgresql://prod-db:5432/myapp");
config.setUsername("appuser");
config.setPassword("secret");
return new HikariDataSource(config);
}
}
활성 프로파일 설정:
# application.yml (Spring Boot)
spring:
profiles:
active: dev # dev, prod, staging 등
@Profile은@Conditional의 특수 형태다 —spring.profiles.active에 지정된 프로파일이 활성일 때만 bean이 등록된다. 여러 프로파일을 동시에 활성화할 수 있다:spring.profiles.active=dev,fast(장 09 - 외부 설정에서 상세).
Environment — 프로퍼티와 프로파일의 통합 접근
// Spring 7 / Java 25 — Environment로 프로퍼티 접근
@Service
public class ConfigService {
private final Environment env;
public ConfigService(Environment env) { this.env = env; }
public void info() {
String dbUrl = env.getProperty("spring.datasource.url");
String[] activeProfiles = env.getActiveProfiles();
System.out.println("DB URL: " + dbUrl);
System.out.println("Active profiles: " + Arrays.toString(activeProfiles));
}
}
@ConfigurationProperties — 타입 안전 프로퍼티 바인딩 (Spring Boot)
Spring Framework(비 Boot)에서는 Environment로 직접 접근하지만, Spring Boot에서는 @ConfigurationProperties로 타입 안전하게 바인딩한다 (Spring Boot 장 02, 09 상세).
실습 — singleton vs prototype
// Spring 7 / Java 25 — ScopeDemo.java
public class ScopeDemo {
static class Counter {
static int nextId = 0;
final int id;
Counter() { id = nextId++; }
}
public static void main(String[] args) {
// singleton 시뮬레이션: 같은 객체
Counter singleton = new Counter();
System.out.println("singleton.id=" + singleton.id);
// prototype 시뮬레이션: 매번 새 객체
Counter p1 = new Counter();
Counter p2 = new Counter();
System.out.println("prototype1.id=" + p1.id);
System.out.println("prototype2.id=" + p2.id);
}
}
java ScopeDemo.java
singleton.id=0
prototype1.id=1
prototype2.id=2
확인할 것: singleton은 한 번만 생성되어 재사용된다. prototype은 요청마다 새 인스턴스가 생성된다.
웹 스코프 메타 어노테이션 (Spring 5+)
Spring은 @RequestScope, @SessionScope, @ApplicationScope 메타 어노테이션을 제공한다 — @Scope("request")보다 간결하다:
// Spring 7 / Java 25 — 웹 스코프 메타 어노테이션
@Component
@RequestScope
public class RequestContext {
private Long userId; // HTTP 요청마다 새 인스턴스
private String requestId;
}
@Component
@SessionScope
public class UserSession {
private ShoppingCart cart = new ShoppingCart(); // 세션마다 1개
}
웹 스코프 bean은 HTTP 요청/세션이 끝나면 자동으로 소멸한다.
@PreDestroy가 호출되므로 자원 해제도 안전하다. 단, 웹 애플리케이션 컨텍스트(WebApplicationContext)에서만 동작한다.
@Lookup — 메서드 주입으로 매번 새 prototype 가져오기
// Spring 7 / Java 25 — @Lookup 메서드 주입
@Service
public abstract class ReportService {
@Lookup // Spring이 서브클래스를 생성하여 override
public abstract ReportBuilder createBuilder();
public String generate() {
ReportBuilder builder = createBuilder(); // 매번 새 prototype 인스턴스
builder.addSection("Header");
return builder.build();
}
}
@Lookup은 Spring이 CGLIB으로 메서드를 override하여, 호출 시마다 컨테이너에서 새 bean을 반환하도록 만든다. 추상 메서드로 선언하는 것이 일반적이다.
다중 프로파일과 프로파일 표현식
// Spring 7 / Java 25 — 프로파일 표현식
@Bean
@Profile("!prod & !cloud") // prod도 아니고 cloud도 아닌 환경
public DataSource localDevDataSource() { ... }
@Bean
@Profile("prod | cloud") // prod 또는 cloud
public DataSource remoteDataSource() { ... }
프로파일 표현식으로
&(AND),|(OR),!(NOT)을 조합할 수 있다. 복잡한 환경 매트릭스(dev/staging/prod/cloud/on-prem)를 세밀하게 제어할 수 있다.
singleton에 상태를 넣으면 안 되는 이유 — 재정리
// Spring 7 / Java 25 — 절대 하지 말 것
@Service
public class BadUserService {
private Long currentUserId; // mutable 상태 in singleton!
public void setUser(Long id) {
this.currentUserId = id; // 스레드 A가 1로 설정 → 스레드 B가 2로 덮어씀
}
public String getProfile() {
return repo.findById(this.currentUserId); // 누구의 프로필?
}
}
올바른 패턴: 상태를 매개변수로 전달하거나, 요청 스코프를 사용한다.
// Spring 7 / Java 25 — stateless singleton
@Service
public class GoodUserService {
public String getProfile(Long userId) { // 상태를 매개변수로
return repo.findById(userId);
}
}
커스텀 스코프 — 자신만의 스코프 정의
Spring은 Scope 인터페이스를 구현하여 커스텀 스코프를 등록할 수 있다:
// Spring 7 / Java 25 — 커스텀 스코프 등록 (개념)
// context.getBeanFactory().registerScope("tenant", new TenantScope());
// @Scope("tenant")으로 사용
커스텀 스코프는 멀티테넌트 환경(테넌트별 bean 격리), 스레드 풀 격리 등 특수한 시나리오에서 유용하다. 하지만 복잡도가 높으므로, 표준 스코프로 해결할 수 있는지 먼저 검토한다.
요약 — 이 글의 결론
- singleton은 기본 스코프이며 stateless여야 한다. mutable 상태를 singleton bean에 두면 경쟁 조건이 발생한다 — 상태는 매개변수나 지역 변수로 관리한다.
- prototype은 매번 새 인스턴스를 만든다. 하지만 singleton에 주입되면 한 번만 생성되는 함정이 있다. 매번 새 객체가 필요하면
ObjectProvider또는@Lookup을 쓴다. @Profile로 환경별 bean을 분리한다. 개발(dev)과 운영(prod)에서 다른 구현체를 사용할 수 있다.Environment로 프로퍼티와 프로파일에 접근한다. Spring Boot에서는@ConfigurationProperties로 타입 안전 바인딩을 제공한다.- 웹 스코프(request, session, application)는 웹 애플리케이션에서만 사용 가능하다. 비웹 환경에서는
IllegalStateException이 발생한다.
생각해 볼 문제
- singleton bean에서 prototype bean을 매번 새로 받아야 할 때,
@Lookup어노테이션은 어떻게 동작하는가? @Scope("request")bean이 비웹 환경의 애플리케이션에서 주입되면 어떻게 되는가?@Profile("dev")와@Profile("prod")bean이 모두 등록되는 경우는 언제인가?- singleton bean에
ThreadLocal을 사용하면 thread safety를 얻을 수 있는가? 어떤 위험이 있는가? - Spring 7에서
@RequestScope,@SessionScope등의 메타 어노테이션이 추가된 이유는? @Profile("dev")빈이 활성화되지 않은 환경에서@Autowired로 주입하려고 하면 어떻게 되는가?
참고
- Spring Framework 7 - Bean Scopes - 접근 2026-07-10
- Spring Framework 7 - Singleton Scope - 접근 2026-07-10
- Spring Framework 7 - Prototype Scope - 접근 2026-07-10
- Spring Framework 7 - Environment - 접근 2026-07-10
- Spring Framework 7 - @Profile - 접근 2026-07-10
'Develop Artifacts > Spring' 카테고리의 다른 글
| Spring - 07. spring mvc (0) | 2026.07.12 |
|---|---|
| Spring - 06. aop (0) | 2026.07.12 |
| Spring - 04. configuration component-scan (0) | 2026.07.12 |
| Spring - 03. dependency-injection (0) | 2026.07.12 |
| Spring - 02. container bean lifecycle (0) | 2026.07.12 |
