싱글톤 컨테이너
싱글톤 패턴은 안티 패턴이고 피해야 할 패턴이라는 이야기를 들어본 적 있을 겁니다.
하지만 스프링 컨테이너는 스프링 빈을 기본적으로 싱글톤으로 관리합니다.
스프링을 사용하는 환경은 많은 클라이언트의 요청을 받아내는 환경입니다.
이런 환경에서 클라이언트의 요청 하나하나에 새로운 객체를 생성한다면 메모리가 심각하게 낭비됩니다.
따라서 스프링에서는 스프링 빈을 싱글톤으로 관리해서 수많은 클라이언트 요청에 같은 메모리 주소값을 리턴합니다.
아래 그림은 싱글톤 스프링 컨테이너의 동작 방식입니다.
스프링 컨테이너 덕분에 클라이언트 요청이 올 때마다 객체를 생성하지 않고 최초에 만들어진 스프링 빈을 공유하게 됩니다.
개발자가 직접 싱글톤 패턴을 구현하지 않아도 스프링 컨테이너가 자동으로 스프링 빈을 싱글톤으로 유지합니다.
하지만 일반적인 싱글톤 방식은 여러 문제를 가지고 있어 안티 패턴으로 여겨집니다.
아래 코드는 싱글톤을 직접 구현한 코드입니다.
package hello.core.singleton;
public class SingletonService {
//1. static 영역에 객체를 딱 1개만 생성해둔다.
private static final SingletonService instance = new SingletonService();
//2. public으로 열어서 객체 인스턴스가 필요하면 이 static 메서드를 통해서만 조회하도록 허용한
다.
public static SingletonService getInstance() {
return instance;
}
//3. 생성자를 private으로 선언해서 외부에서 new 키워드를 사용한 객체 생성을 못하게 막는다.
private SingletonService() {
}
public void logic() {
System.out.println("싱글톤 객체 로직 호출");
}
}
위 코드처럼 static 영역에 객체 인스턴스를 미리 올려두고 SingletonService 타입의 객체를 새롭게 생성하지 못하도록 private 생성자를 만드는 방식 말고도 싱글턴을 구현하는 방법은 여러 가지입니다.
싱글톤을 구현하기 위해 추가적인 코드가 필요하고 private 생성자로 자식 클래스를 만들기 어렵습니다.
또한 의존관계상 클라이언트가 구체 클래스에 의존하게 됩니다. SingletonService.instance 와 같은 방식으로 접근해야 하기 때문에 구체 클래스인 SingletonService에 클라이언트가 의존해야 합니다. 때문에 DIP를 위반하고 OCP 원칙도 위반할 가능성이 높습니다.
스프링 컨테이너는 싱글톤 패턴의 모든 단점을 해결하면서 스프링 빈을 싱글톤으로 관리할 수 있습니다.
하지만 스프링 컨테이너를 사용해 싱글톤 스프링 빈을 사용하더라도 주의해야 할 부분이 있습니다.
싱글톤 스프링 빈에 여러 클라이언트가 접근하기 때문에 스프링 빈은 상태를 유지하게 설계하면 안 됩니다.
다시 말해, 싱글톤 스프링 빈은 무상태로 설계해야 합니다.
스프링 빈에 클라이언트가 조작 가능한 필드(인스턴스 변수)가 있다면 굉장히 큰 버그로 이어질 수 있습니다.
필드 대신 자바에서 공유되지 않는 지역변수, 파라미터, ThreadLocal 등을 사용해서 스프링 빈을 설계해야 합니다.
아래 코드는 싱글톤 객체가 특정 클라이언트에 의존적인 필드를 가진 클래스입니다.
어떤 문제가 발생하는지 살펴봅시다.
package hello.core.singleton;
public class StatefulService {
private int price; //상태를 유지하는 필드
public void order(String name, int price) {
System.out.println("name = " + name + " price = " + price);
this.price = price; //여기가 문제!
}
public int getPrice() {
return price;
}
}
StatefulService 클래스에서 price 변수가 private으로 선언되어 있지만 public order 함수로 외부 클라이언트가 조작 가능하게 됩니다.
싱글톤 객체에 클라이언트에 의존적인 필드가 있다면 클라이언트 A가 해당 필드 값을 조작하면 클라이언트 B는 동일한 객체에 접근하여 조작된 필드 값을 얻게 됩니다.
아래 코드로 살펴봅시다.
public class StatefulServiceTest {
@Test
void statefulServiceSingleton() {
ApplicationContext ac = new
AnnotationConfigApplicationContext(TestConfig.class);
StatefulService statefulService1 = ac.getBean("statefulService", StatefulService.class);
StatefulService statefulService2 = ac.getBean("statefulService", StatefulService.class);
//ThreadA: A사용자 10000원 주문
statefulService1.order("userA", 10000);
//ThreadB: B사용자 20000원 주문
statefulService2.order("userB", 20000);
//ThreadA: 사용자A 주문 금액 조회
int price = statefulService1.getPrice();
//ThreadA: 사용자A는 10000원을 기대했지만, 기대와 다르게 20000원 출력
System.out.println("price = " + price);
Assertions.assertThat(statefulService1.getPrice()).isEqualTo(20000);
}
static class TestConfig {
@Bean
public StatefulService statefulService() {
return new StatefulService();
}
}
}
우리가 사용하던 AppConfig 클래스를 떠올려보면 공유필드가 없습니다.
꼭 스프링 빈은 무상태로 설계합시다.
아래 코드는 StatefulService 클래스를 파라미터를 사용해 무상태로 설계한 코드입니다.
package hello.core.singleton;
public class StatefulService {
public int order(String name, int price) {
System.out.println("name = " + name + " price = " + price);
return price;
}
// ... 생략
}
위 코드처럼 스프링 빈으로 등록되는 클래스라면 필드를 사용해 상태를 갖도록 설계하지 말고 지역변수, 파라미터, ThreadLocal 등을 사용해 무상태로 설계합시다.
싱글톤을 위한 @Configuration 애노테이션
우리가 스프링 컨테이너에 넘기는 설정 정보(자바 코드)인 AppConfig 코드에는 아래와 같이 @Configuration 애노테이션을 붙입니다.
@Configuration 애노테이션을 왜 붙일까요?
스프링 빈의 싱글톤을 유지하기 위해 @Configuration을 AppConfig 클래스에 붙이는 것입니다. 만약 @Configuration을 붙이지 않으면 스프링 빈의 싱글톤이 깨질 수도 있습니다.
AppConfig 클래스를 순수한 자바 코드로 해석하면 memberService 함수와 orderService 함수 모두 memberRepository 함수를 호출하여 MemoryMemberRepository 객체를 생성합니다.
만약 @Configuration을 붙이지 않았다면 두 개의 MemoryMemberRepository 객체가 생성되며 싱글톤이 깨집니다.
하지만 @Configuration을 통해 스프링 컨테이너는 이와 같은 상황에 하나의 객체를 생성해 인스턴스를 공유합니다.
그렇다면 @Configuration은 어떻게 스프링 빈의 싱글톤을 보장하게 될까요?
CGLIB라는 바이트코드를 조작하는 라이브러리를 사용해서 AppConfig 클래스를 상속받은 임의의 다른 클래스(AppConfig@CGLIB)를 만들고, 그 다른 클래스를 스프링 빈으로 등록하도록 동작하여 스프링 빈의 싱글톤을 보장합니다.
따라서 스프링 빈으로 등록된 빈의 클래스 타입을 조회하면 순수한 자바 클래스 타입 뒤에 CGLIB가 만든 식별자가 붙어서 새로운 클래스 타입으로 조회됩니다.
CGLIB는 내부적으로 굉장히 복잡하게 동작하지만, 결과적으로 싱글톤을 보장하기 위해 스프링 빈을 생성하기 전에 스프링 컨테이너에서 해당 빈이 이미 존재하는지 찾고 이미 존재하는 빈이라면 스프링 컨테이너의 빈을 리턴하고 없다면 새롭게 빈을 생성하는 로직을 포함합니다.
아래 코드는 AppConfig@CGLIB 클래스를 예상한 코드입니다.
CGLIB가 바이트코드를 조작해서 기존 AppConfig 클래스를 상속한 AppConfig@CGLIB 클래스를 만들어 스프링 빈으로 등록해도 우리는 getBean(AppConfig.class)와 같이 조회할 때 AppConfig@CGLIB 타입인 스프링 빈을 리턴 받아왔습니다.
이는 부모 타입으로 스프링 빈을 조회할 때 자식 타입도 함께 조회되기 때문입니다. @Configuration을 붙이지 않은 설정 정보 클래스는 클래스 속 @Bean을 스프링 빈으로 등록하지만 스프링 빈의 싱글톤을 보장하진 못합니다.
또한 스프링 빈이 아닌 순수한 자바 객체가 주입된다는 문제도 발생합니다.
AppConfig 클래스의 memberService 함수에서 호출되는 memberRepository 함수는 스프링 빈이 아닌 순수한 MemoryMemberRepository 자바 객체를 리턴합니다.
@Configuration에 의해 동작하는 CGLIB는 이미 스프링 컨테이너에 존재하는 빈을 생성하려 할 때 스프링 컨테이너의 빈을 리턴해서 빈 객체를 주입할 수 있지만 @Configuration이 없는 상황에서는 순수한 자바 객체가 주입될 수 있습니다.
큰 고민 없이 스프링 빈을 등록하는 설정 정보 클래스라면 @Configuration을 사용하면 됩니다.
'Spring > Spring Basic' 카테고리의 다른 글
스프링 빈 조회 (1) | 2024.10.15 |
---|---|
스프링 컨테이너와 스프링 빈 (0) | 2024.10.15 |
스프링 핵심 원리 이해 - 객체 지향 원리 적용 (3) | 2024.10.15 |
자동 컴포넌트 스캔 vs 수동 컴포넌트 스캔 (1) | 2024.09.28 |