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이 발생한다.

생각해 볼 문제

  1. singleton bean에서 prototype bean을 매번 새로 받아야 할 때, @Lookup 어노테이션은 어떻게 동작하는가?
  2. @Scope("request") bean이 비웹 환경의 애플리케이션에서 주입되면 어떻게 되는가?
  3. @Profile("dev")@Profile("prod") bean이 모두 등록되는 경우는 언제인가?
  4. singleton bean에 ThreadLocal을 사용하면 thread safety를 얻을 수 있는가? 어떤 위험이 있는가?
  5. Spring 7에서 @RequestScope, @SessionScope 등의 메타 어노테이션이 추가된 이유는?
  6. @Profile("dev") 빈이 활성화되지 않은 환경에서 @Autowired로 주입하려고 하면 어떻게 되는가?

참고

'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