[Spring] 스프링 기본 2 - 싱글톤과 패턴과 싱글톤 컨테이너
💡Refferecne [외부 링크]’스프링 핵심원리 - 기본편’
- 스프링 기본 1 - DIP, OCP 원칙과 스프링 컨테이너
- 스프링 기본 2 - 싱글톤 패턴과 싱글톤 컨테이너
- 스프링 기본 3 - 빈 스코프와 생명주기
1. 개요
열심히 개발하여 런칭한 사이드 프로젝트 서비스가 입소문을 타서 이용자 수가 폭발적으로 증가하고 있다고 생각해보자. 엄청난 트래픽을 감당해야 하는 상황. 스프링은 어떻게 이러한 수많은 동시 요청을 감당할 수 있을까? 웹 애플리케이션을 위해 탄생했던 스프링이 취했던 생존 전략은 무엇이었을까? 보통 아무리 디자인 패턴에 관심이 없는 개발자라도, 스프링을 사용하고 있다면 한번쯤 들어본 싱글톤 패턴, 그리고 더 나아가서 스프링이 어떠한 방식으로 싱글톤을 유지하는지 싱글톤 컨테이너까지 학습해보도록 하자.
2. 싱글톤의 개요
싱글톤 패턴이란 이름에서 유추할 수 있듯, 클래스의 인스턴스가 한개만 생성되도록 보장하는 디자인 패턴이다. 기본 생성자를 private 으로 제한하고 스태틱 영역에 생성해둔 하나의 인스턴스를 통해 객체를 사용하게 함으로써 하나의 객체를 계속해서 사용하게 한다. 아주 유명한 디자인패턴이므로 쉽게 예제 코드를 통해 직접 확인해보자.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class SingletonService {
// 1. static 영역에 불변 객체를 미리 한개 생성한다.
private static final SingletonService instance = new SingletonService();
// 2. public 접근 메서드를 통해 오직 이 메서드를 통해서만 객체에 접근 가능하도록 한다.
public static SingletonService getInstance() {
return instance;
}
// 3. 생성자를 private 접근제어자로 막음으로써 외부에서 new 키워드를 통한 객체 생성을 방지한다.
private SingletonService() {};
public void singletonObjectCall() {
System.out.println("["+this+"] Singleton Object Call");
}
}
그렇다면 싱글톤 패턴을 통해 정말 하나의 객체만을 이용하는지 테스트코드로 확인해보자.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class SingletonServiceTest {
@Test
@DisplayName("[테스트] 자바 싱글톤 테스트")
void singletonTest() {
// private 기본생성자를 통해 다음과 같이 신규 객체를 선언할 수 없다.
// SingletonService singletonService = new SingletonService();
// 다음과 같이 두개의 싱글톤 서비스 객체를 선언한다고 가정하자.
SingletonService singletonServiceA = SingletonService.getInstance();
SingletonService singletonServiceB = SingletonService.getInstance();
// 선언해 둔 메서드를 통해 참조값도 실제로 확인해보자.
singletonServiceA.singletonObjectCall();
singletonServiceB.singletonObjectCall();
// isSameAs 메서드를 통해 참조값이 같은지 테스트한다.
assertThat(singletonServiceA).isSameAs(singletonServiceB);
}
}
테스트 코드를 통해 확인한 결과, 싱글톤 패턴을 통해 하나의 참조값을 가지는 여러 객체를 생성할 수 있음을 확인했다. 하지만 이렇게 JAVA 싱글톤 패턴만을 이용하는 것은 여러가지 단점을 가진다.
순수 싱글톤 패턴의 단점
1. 구현이 번거롭다.
다른 객체들과 달리 싱글톤 패턴 설계를 위해서만 static 영역 초기화, private 기본생성자, 인스턴스 획득 메서드 등 여러 부품이 필요하다.
2. 객체지향적인 설계가 어렵다.
저번 편의 내용에서 살펴보았듯, 객체지향적인 설계를 위해서는 구현체 클래스에 의존하면 안된다. 하지만 앞서 우리가 작성한 싱글톤 패턴은, 인스턴스를 얻는 과정에서 구현체 클래스에 의존하고 있다.
3. 유연성이 떨어진다.
싱글톤 패턴은 private 접근제한자를 이용하여 기본 생성자를 제한한다. 이는 상속을 불가능 하게 하며, static 영역에 초기화가 이루어지기 때문에, 필드의 값을 초기화 하거나 수정하는 것이 어려운 구조이다.
4. 테스트가 어렵다.
3번과 연계되는 내용이지만, 동적으로 객체를 주입할 수 없고 무조건 정해진 방식대로 인스턴스를 획득해야 하기 때문에 다양한 테스트에 어려움이 있다.
다음과 같은 단점들로 인해 싱글톤은 안티패턴이 될 수도 있다.
안티패턴 : 습관적으로 많이 사용되지만 생산성이 떨어지는 패턴을 의미한다.
하지만 스프링은 스프링 컨테이너를 통해 싱글톤 패턴을 유지하면서도 앞서 기술한 단점을 극복할 수 있게 해준다.
3. 스프링 싱글톤
웹 애플리케이션에서는 동시에 많은 트래픽이 발생할 수 있고, 많은 객체가 재사용 될 가능성이 높다. 예를 들어, 특정 웹 페이지를 호출하기 위해 사용하는 컨트롤러 객체는 해당 페이지에 대한 방문이 발생할 때 마다 생성하고 제거하기에는 너무 소요가 심한 성격의 객체이다.
하지만 스프링 컨테이너에 객체를 등록할 경우 스프링 컨테이너는 해당 객체를 한개만 보유하여 요청이 있을때마다 사용자에게 반환해준다.
앞서 작성한 Config 객체를 활용하여 정말 컴포넌트 스캔을 통해 스프링 컨테이터에 등록 된 객체들이 동일한 참조값을 가지고 있는지 테스트를 해보자.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class ConfigurationSingletonTest {
@Test
@DisplayName("[테스트] 스프링 컨테이너 싱글톤 테스트")
void autoConfigurationSingletonTest() {
// given
AnnotationConfigApplicationContext ac
= new AnnotationConfigApplicationContext(MemberAutoConfig.class);
MemberService memberServiceA = ac.getBean(MemberService.class);
MemberService memberServiceB = ac.getBean(MemberService.class);
// then
System.out.println("memberServiceA = " + memberServiceA);
System.out.println("memberServiceB = " + memberServiceB);
assertThat(memberServiceA).isSameAs(memberServiceB);
}
}
컴포넌트 스캔을 통해 스프링 컨테이너에 등록 된 객체들은 모두 같은 참조값을 가지고 있음을 확인하였다.
스프링 컨테이너는 이처럼 객체들을 싱글톤으로 유지하도록 도와주기 때문에 싱글톤 컨테이너라고도 하며, 싱글톤으로 객체를 생성하고 관리하는 기능을 싱글톤 레지스트리라고 한다.
부록: Configuration 객체와 CGLIB Proxy
1편에서 스프링 컨테이너에 객체를 등록하는 방법으로 컴포넌트 스캔을 통한 방법 외에도 수동 설정을 통한 등록이 있음을 학습하였다. 그때 작성한 수동 등록 설정 Config 파일의 소스는 다음과 같다.
1
2
3
4
5
6
7
8
9
10
11
12
13
@Configuration
public class MemberConfig {
@Bean
public MemberRepository memberRepository() {
return new MemoryMemberRepository();
}
@Bean
public MemberService memberService() {
return new MemberServiceImpl(memberRepository());
}
}
해당 소스를 잘 살펴보면 이상한 점이 하나 있다. 바로 memberRepository 메소드를 두 번 호출하는 것 처럼 보인다. MemberService
객체 내에 있는 MemberRepository
에 대한 의존성을 주입하기 위해 다음과 같은 상황이 됐는데, 스프링에서 실제로 MemberRepository
객체를 두번 생성하는지 직접 확인해보자.
우선 Config 객체를 다음과 같이 수정한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Configuration
public class MemberConfig {
@Bean
public MemberRepository memberRepository() {
System.out.println("MemberConfig.memberRepository Generated");
return new MemoryMemberRepository();
}
@Bean
public MemberService memberService() {
System.out.println("MemberConfig.memberService Generated");
return new MemberServiceImpl(memberRepository());
}
}
짐작한 것이 맞다면 MemberConfig
객체 생성시에 출력되는 문자열이 MemberRepository
에 의존성을 주입하면서 한번, 그리고 MemberService
에 의존성을 주입하면서 다시 한번 총 **두 번 **출력되어야 할 것이다.
다음과 같이 간단한 스프링 컨테이너 호출 테스트를 작성하고 실행해보자.
1
2
3
4
5
@Test
void configurationTest() {
AnnotationConfigApplicationContext ac = new AnnotationConfigApplicationContext(MemberConfig.class);
ac.close();
}
테스트를 실행해보면 예상과는 다르게 memberRepository 생성은 한번만 호출되고 싱글톤이 유지되고 있음을 알 수 있다. 그렇다면 어떻게 스프링 컨테이너는 두번 호출을 시도하는 코드에서 한번만 객체 생성을 하여서 싱글톤을 유지할 수 있었을까?
위의 테스트 코드를 다음과 같이 조금 수정하여 Config
객체의 클래스를 한번 확인해보자.
1
2
3
4
5
6
7
8
9
@Test
void configurationTest() {
AnnotationConfigApplicationContext ac = new AnnotationConfigApplicationContext(MemberConfig.class);
MemberConfig memberConfig = ac.getBean(MemberConfig.class);
//MemberConfig 객체를 확인해보자.
System.out.println("memberConfig = " + memberConfig);
ac.close();
}
분명 기대한 객체의 클래스는 MemberConfig 인데 EnHancerBySpringCGLIB 가 붙어있는 특이한 형태의 객체가 출력되고 있다.
이는 바이트코드를 조작하는 스프링 라이브러리의 하나로써 일종의 프록시 객체이다.
프록시 객체란 해당 객체를 직접적으로 사용하지 않고 상속을 통한 대체 객체이다. 이를 이용하는 디자인 패턴을 프록시 패턴이라 하며, 하이버네이트, Mockito, 스프링 등 여러 프레임워크에서도 사용되고 있는 경우를 쉽게 찾아볼 수 있다.
추가로 MemberConfig 객체에 생성자를 private 으로 정의하면 프록시 객체를 생성하지 못하여 예외가 발생한다 (상속을 이용할 수 없기 때문).
정리하자면 MemberConfig 를 상속받은 프록시객체가 같은 객체가 두번 생성되는지를 확인하여 스프링 컨테이너에 등록해주는 역할을 하는 것이다. 이를 통해 수동 등록 방식 또한 싱글톤을 유지할 수 있다.
4. 싱글톤 주의사항
싱글톤 패턴(혹은 싱글톤 컨테이너)은 하나의 객체를 여러 클라이언트가 공유하기 때문에 항상 동시성 문제를 주의해야 한다. 이를 통해 주의해야 할 사항을 몇가지 정리해보면
-
특정 클라이언트에 의존적이면 안된다.
-
특정 클라이언트가 필드의 값을 변경할 수 있으면 안된다.
-
필드 대신 지역변수, 파라미터, ThreadLocal 등을 사용한다.
싱글톤 객체의 필드에서 값을 직접 다루어 생길수 있는 문제를 살펴보기 위해 간단한 예시를 작성해보자.
이메일을 보내는 서비스 로직에서, 클라이언트가 요청하는 이메일 주소를 공유 필드에서 처리하고 있다고 가정한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class EmailSenderService {
// Stateful Field
private String userEmail;
public void send(String userEmail) {
System.out.println("[EMAIL SEND LOGIC] " + userEmail);
// Stateful Logic
this.userEmail = userEmail;
}
public String getResult() {
return "[EMAIL SEND LOG] " + this.userEmail;
}
}
해당 메일 발송 서비스에 어떠한 식으로 문제가 발생하는지 테스트 하는 코드도 작성해보자.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
class EmailSenderServiceTest {
@Test
void statefulTest() {
AnnotationConfigApplicationContext ac
= new AnnotationConfigApplicationContext(EmailSenderConfig.class);
// 두명의 클라이언트가 각자 호출하는 상황이라고 가정한다
EmailSenderService serviceA = ac.getBean(EmailSenderService.class);
EmailSenderService serviceB = ac.getBean(EmailSenderService.class);
String emailA = "addrA@email.com";
String emailB = "addrB@email.co.kr";
// 서로 다른 이메일을 대상으로 로직을 수행한다.
serviceA.send(emailA);
serviceB.send(emailB);
String resultA = serviceA.getResult();
String resultB = serviceB.getResult();
System.out.println("resultA = " + resultA);
assertThat(resultA).isNotEqualTo(emailA);
ac.close();
}
//스프링 컨테이너를 위한 Config 객체
static class EmailSenderConfig {
@Bean
public EmailSenderService emailSenderService() {
return new EmailSenderService();
}
}
}
이메일을 발송 후 결과A 가 자신의 결과값이 아닌 다른 클라이언트의 결과를 출력하고 있는 것을 확인할 수 있다.
상태 유지로 인한 문제는 실제 상용 코드에서는 추적도 어려울 뿐더러 운영에 막대한 손실을 가져올 수 있으므로 절대로 Stateful 한 설계는 지양해야 한다.
5. 마치며
스프링 컨테이너가 싱글톤 방식으로 작동하고 있다는 사실은 쉽게 접할 수 있지만 왜 싱글톤 방식을 사용해야 하는지, 그리고 싱글톤 방식의 주의점에는 어떠한 것들이 있는지를 확인해보았다. 무상태로 설계하는 것은 비단 동시성 이슈로 인한 장애를 방지하는데에도 도움이 되지만, 수평적 확장에도 많은 도움을 준다. 따라서 개발자 순간의 편의를 위해 상태 유지를 남발하는 코드 작성은 앞으로도 최대한 자중하도록 하자.