[WEB] 스프링 컨테이너와 싱글톤 패턴
보통의 웹 어플리케이션은 수많은 클라이언트의 요청들과 그에 따른 서버의 응답들로 구성되어 있다. 그런데 이때, 여러 클라이언트들이 같은 컨테이어를 요청하면 어떻게 될까? 그때 마다 새로운 컨테이너를 생성해서 클라이언트의 요청에 응답을 해야할까? 이번 포스팅에서는 스프링 컨테이너가 어떻게 이러한 상황을 어떻게 처리하는지에 대해서 알아보자.
이 포스팅은 김영한님의 '스프링 핵심 원리 - 기본편'강의에 의존하고 있습니다.
싱글톤 (Singleton) 패턴
클라이언트가 요청이 올때마다, 객체를 생성하는 것은 공간적으로 매우 낭비이다. 하나의 객체만을 유지하며, 그 객체를 여러 클라이언트가 공유해서 사용하는 것이 훨씬 좋은 방법일 것이다. 이것을 가능하게 하는 것이 바로 싱글톤이다. 싱글 톤 패턴은 객체가 프로그램에서 단 하나만 있을 수 있도록 강제하며, 다른 클라이언트(또는 객체)가 해당 객체를 사용하기를 원한다면 이미 만들어진 '하나의 객체'를 공유해서 사용해야 한다.
싱글톤 패턴 구현
class SingletonObj {
private static final SingletonObj instance = new SingletonObj();
public static SingletonObj getInstance() { return instance; }
private SingletonObj() {}
}
싱글톤 패턴에서는 크게 3가지 요소가 필요하다.
- 정적으로 선언된 공유객체
- 공유객체를 리턴 받을 수 있는 정적 메서드
- Private 권한으로 선언된 생성자
일단 공유객체는 정적으로 선언되어 프로그램 내에 한개만 존재한다. 또한 생성자는 private으로 선언하여, 외부에서 생성자를 통해 객체를 생성하지 못하도록 해야한다. 대신에 공유객체를 리턴 받을 수 있는 정적메서드 getInstance()를 정의하여 외부에서는 해당 메서드를 통해 공유 객체를 이용한다.
참고) 싱글톤 패턴은 공유 객체의 초기화 시점에 따라 다양한 방식으로 구현할 수 있는데, 이 글의 경우, 프로그램 시작 시점에 공유 객체가 초기화되도록 구현하였다.
싱글톤 패턴 테스트
실제 싱글톤 패턴이 1개의 객체만을 생성하고 이용하는지에 대해 테스트해보자.
@Test
void 공유객체확인() {
SingletonObj obj1 = SingletonObj.getInstance();
SingletonObj obj2 = SingletonObj.getInstance();
System.out.println("obj1 = " + obj1);
System.out.println("obj2 = " + obj2);
assertThat(obj1).isSameAs(obj2);
}
SingletonObj.getInstance()를 통해 2개의 객체를 리턴받았다. 그후, 2개의 객체가 서로 같은지 테스트한다.
같은 객체임을 확인할 수 있고, 실제로 싱글톤 패턴이 한개의 공유객체 만으로 이루어져 있음을 알 수 있다.
실제로 테스트 코드에 콘솔 출력을 하는 것은 좋지 못한 방법이나, 간단하게 실제 값을 확인하기 위해 사용함
싱글톤 패턴의 문제점
싱글톤 패턴은 불필요한 객체들을 추가로 생성하는 것을 방지하고, 하나의 객체를 공유하여 사용함으로써 자원의 낭비를 최소화 시킨다는 장점이 있다. 하지만 싱글톤 패턴은 매우 많은 단점을 가지고 있어, 실제로는 안티패턴이라고 불리기도 한다. 그 이유는 다음과 같다.
- 싱글톤 패턴을 구현하기 위해서 추가적인 코드가 너무 많이 필요하다.
- 클라이언트가 구현체의 메서드(getInstance)를 호출해야 하므로, 클라이언트와 구현체 사이의 의존관계가 발생한다.
- 2.에 의해 DIP를 위반하며, OCP를 위반할 가능성이 높아진다.
- private 생성자이므로 자식 클래스를 생성하기 어렵다
특히 의존관계와 관련된 문제점은 매우 치명적이므로 싱글톤 패턴 그 자체를 사용하는 경우는 매우 드물다. 그렇다면 싱글톤 패턴의 이러한 단점들은 보완하면서, 동시에 객체를 싱글톤으로 유지하는 방법은 없을까? 바로 스프링 컨테이너가 이러한 기능을 제공한다.
스프링 컨테이너와 싱글톤
스프링 컨테이너는 개발자가 직접 싱글톤 패턴을 작성하지 않아도, 객체를 싱글톤으로 관리한다. 이 과정에서 스프링 컨테이너는 기존의 싱글톤의 단점인 OCP/DIP 위반 문제, Private 생성자 문제 등을 일으키지 않는다.
AppConfig.class
@Configuration
class AppConfig {
@Bean
public BeanA beanA() {
return new BeanA(beanB(), beanC());
}
@Bean
public BeanB beanB() {
return new BeanB(beanD());
}
@Bean
public BeanC beanC() {
return new BeanC();
}
@Bean
public BeanD beanD() {
return new BeanD();
}
}
다음과 같이 @Bean 에노테이션을 통해 객체를 빈 저장소에 등록하면 스프링 컨테이너는 자동으로 객체를 싱글톤으로 관리한다. 이를 통해 개발자는 싱글톤의 생산성, 의존관계, Private 생성자 문제 등으로부터 자유로워질 수 있다.
싱글톤 사용시 주의할 점
싱글톤은 객체를 단 하나만 가지고 있고, 그 객체를 많은 클라이언트가 공유해서 사용하기 때문에 싱글톤 객체에 상태를 유지해서는 안된다. 쉽게 말해서 필드 값이 있어 그 값을 클라이언트가 변경할 수 있으면 안된다. 즉, 싱글톤 객체의 값은 읽기만 가능하게 만드는게 좋으며, 필드 값 대신 공유 되지 않는, 지역변수나, 파라미터 등을 사용해야 한다.
WrongService.class
public class WrongService() {
private int score; // 상태를 유지하는 필드
public void test(String student, int score) {
this.score = score; // 클라이언트가 필드값을 수정할 수 있음!!!
System.out.println(student + "'s socore : " + score);
}
public int getResult() {
return score;
}
}
WrongService 객체는 빈 저장소에 등록된 싱글톤 객체이다. 코드를 살펴보자.
일단 score라는 상태를 유지하는 필드가 존재한다. 이때 test 메서드를 보자. test 메서드는 클라이언트가 호출할 수 있는 메서드이다. 그런데 이 메서드는 score 필드의 값을 변경시킨다. 즉, 클라이언트가 싱글톤 객체의 필드값을 변경시키는 것이다. 이는 중대한 문제를 발생시키는데 아래의 코드를 살펴보자.
@Test
void wrongServiceTest() {
ApplicationContext ac = new AnnotationConfigApplicationContext(AppConfig.class);
WrongService wrongService1 = ac.getBean("wrongService", WrongService.class);
WrongService wrongService2 = ac.getBean("wrongService", WrongService.class);
wrongService1.test("민수", 100);
wrongService2.test("민지", 70);
System.out.println("민수의 점수는... " + wrongService1.getScore());
System.out.println("민지의 점수는... " + wrongService2.getScore());
// [expect]
// 민수의 점수는... 100
// 민지의 점수는... 70
}
WrongService객체를 스프링 컨테이너에서 각각 꺼내온다. 그후 test 메서드를 호출하여 민수는 100점, 민지는 70점으로 설정한다. 둘은 서로 다른 객체이므로 (WrongService1과 wrongService2) 당연히 다시 출력할때 내가 원한는 결과는
민수의 점수는... 100
영희의 점수는... 70
이다.
실제 결과를 살펴보자.
민수의 점수는... 70
민지의 점수는... 70
민수의 점수는 분명 100점으로 설정했는데 결과는 70이 나왔다. 뭐가 문제였을까? 바로 wrongService1과 wrongService2가 실제로는 같은 객체이기 때문이다. 왜냐하면 WrongSerivce가 싱글톤이므로 getBean을 통해 객체를 리턴받으면 항상 같은 객체를 공유해서 사용하게 된다. 즉, wrongService1이 score 값을 100으로 변경했지만, 다시 wrongService2가 이 값을 70으로 변경하면서 민수/민지의 점수가 모두 70이 된것이다.
이처럼 싱글톤 객체의 상태를 유지하는 필드값을 사용하고, 이 값이 클라이언트에 의존하면 많은 장애를 일으킨다. 그러므로 싱글톤 객체는 가급적 읽기만 가능하게 구현하고, 무상태(Statless)로 설계하는것이 중요하다.
@Configuration과 바이트 코드 조작 객체
@Bean 에노테이션을 통해 빈을 싱글톤 객체로 관리한다고 위에서 언급했다. 그렇다면 AppConfig 위에 붙어있는 @Configuration 에노테이션을 무슨 역할을 할까?
생성자가 여러번 호출되는 문제
AppConfig.class
@Configuration
class AppConfig {
@Bean
public BeanA beanA() {
return new BeanA(beanB(), beanC());
}
@Bean
public BeanB beanB() {
return new BeanB(beanD());
}
@Bean
public BeanC beanC() {
return new BeanC();
}
@Bean
public BeanD beanD() {
return new BeanD();
}
}
AppConfig 클래스를 다시 한번 살펴보자. 스프링 컨테이너는 프로그램이 실행되면 @Bean 에노테이션이 붙어있는 메서드를 실행하여 빈을 저장소에 등록한다. 그런데 beanA() 메서드를 보면 BeanA 객체만을 생성하는 것이 아니라 beanB(), beanC() 메서드를 실행하여 각각 BeanB, BeanC를 생성한다. 그후, beanB, beanC, beanD 메서드를 실행하면 같은 객체가 여러번 생성되게 된다. 위의 메서드가 순서대로 실행한다 가정하면 다음과 같은 상황이 벌어질 것이다.
나는 BeanA/BeanB/BeanC/BeanD 각각 1개씩 총 4개의 객체를 생성하려 했는데, 각 메서드에서의 생성자 주입으로 인해 무려, 8개의 객체가 생성되었다. 특히 BeanD는 무려 3개가 생성된다. 이렇게 되면 싱글톤 패턴이 깨지게 되는데 스프링은 이를 어떻게 방지할까? 이것을 알기 위해서는 @Configuration 애노테이션의 역할과 바이트 코드 조작에 대해서 알아봐야 한다.
실제로 AppConfig 클래스가 실행되면 각각의 메서드가 단 1번만 실행되어 오직 4개의 객체만이 생성된다!
@Configuration 애노테이션의 역할
@Configuration
public class AppConfig {
@Bean
public BeanA beanA() {
System.out.println("call beanA() and create BeanA");
return new BeanA(beanB(), beanC());
}
@Bean
public BeanB beanB() {
System.out.println("call beanB() and create BeanB");
return new BeanB(beanD());
}
@Bean
public BeanC beanC() {
System.out.println("call beanC() and create BeanC");
return new BeanC();
}
@Bean
public BeanD beanD() {
System.out.println("call beanD() and create BeanD");
return new BeanD();
}
}
메서드가 호출될때 마다 콘솔에 메세지를 남기도록 수정해서 실제 메서드가 몇번 실행되고, 빈이 몇번 생성되는지 알아보자.
AppConfig 파일 통해 ApplicationContext 생성하여 나온 로그이다. 보다시피 각각 메서드를 단 1번씩만 실행하고 각각의 객체를 단 한번만 생성한다. 실제 코드에서는 생성자를 여러번 호출하는데 실제 1번씩만 호출되는 이유는 무엇일까? 바로 @Configruation 애노테이션을 통해 바이트를 코드를 조작하여 조작된 객체를 등록하기 때문이다.
즉, @Configuration 애노테이션은 빈 객체가 생성자 주입을 통해 싱글톤 객체를 여러번 생성하는 것을 방지한다. 자세한 내용은 다음장에서 알아보자.
바이트 코드 조작을 통한 새로운 객체
앞서, AppConfig class 메서드가 실행되면 객체를 작성된 그대로 등록하는게 아니라 바이트 코드를 조작하여 조작된 새로운 객체를 등록한다고 하였다. 또한 이를 위해 @Configuration 애노테이션을 사용한다고 했다.
실제 어떤 객체가 빈 저장소에 등록되었는지 알아보기 위해 테스트 코드를 실행해 보자.
@Test
void 바이트코드조작검사() {
ApplicationContext ac = new AnnotationConfigApplicationContext(AppConfig.class);
AppConfig bean = ac.getBean(AppConfig.class);
System.out.println("bean = " + bean.getClass());
}
Output:
bean = class orlyworld.example.AppConfig$$EnhancerBySpringCGLIB$$a2d4fc3c
만약 내가 작성한 객체가 그대로 저장 되었다면 출력값이 "bean = class orlyworld.example.AppConfig"여야 한다. 그런데 뒤에 뭔가가 더 붙어있다. 뒤에 더 붙어있는 'EnhancerBySpringCGLIB"에서 CGLIB은 바이트코드 조작 라이브러리로써, 스프링은 해당 라이브러리를 통해 기존의 객체를 상속받은 새로운 조작된 객체를 생성하여 빈에 등록한다.
조작된 새로운 객체는 싱글톤으로 작성되어 만약 스프링 컨테이너에 여러번 생성자를 호출해도 기존의 생성된 객체를 리턴하여 싱글톤 객체가 여러번 생성되는 불상사를 방지한다.
- 생성자 최초 호출시 : 객체 생성
- 생성자 재 호출시 : 기존의 생성된 객체 반환
그리고 당연한 거지만 조작된 새로운 객체는 기존 객체를 상속한 자식타입이므로 기존의 객체 타입으로 조회가 가능하다.