개요
현재 프로젝트에서 스프링을 사용하고 있습니다. 스프링을 사용하다 보면, 스프링은 IOC(Inversion Of Control -제어의 역전)컨테이너를 이용해, 스프링 Bean을 관리한다고 설명하고, 스프링Bean은 기본적으로 Singleton으로 Bean 객체를 생성한다고 알고 있었습니다. 그럼 이와 관련된 내용중 Singleton방식이 무엇인지 먼저 디자인 패턴에서의 Singleton 패턴에 대해 알아보고, 스프링에서는 Singleton 패턴을 어떻게 사용하고 있는지 알아보도록 하겠습니다.
디자인 패턴 에서의 Singeleton Pattern
싱글톤 패턴을 객체를 생성할 때, 인스턴스를 오직 한개만 제공하는 클래스입니다. 즉 객체를 처음 생성한 후 그 후에 해당 객체를 재생성할때는 동일한 객체를 반환하게되는 방식입니다.
그럼 이러한 싱글톤 패턴을 사용함으로써 얻는 이득은 무엇이 있기에 싱글톤 패턴을 사용할까요?
앞서 설명한 내용에서 알 수 있듯이, 싱글톤은 객체를 ‘오직 한개만’제공하기 때문에, 불필요한 객체의 재 생성으로 인한 메모리 낭비를 방지할 수 있습니다. 또한 환경 세팅에 대한 정보 등, 인스턴스가 여러 개 일 때 문제가 생길 수 있는 상황에서, 싱글톤 패턴을 이용해 인스턴스를 오직 한개만 만들어 제공하는 클래스를 만들 때 사용합니다.
그럼 싱글톤을 코드를 통해 알아보겠습니다.
먼저 기본생성자로 같은 객체를 두번 생성시, 두 객체가 동일한지 알아보는 테스트를 발전시켜나가면서 싱글톤에 대해 알아보겠습니다.(편의상, Singleton을 구현하는 클래스를 테스트 클래스와 같은 파일에 위치시켰습니다.)
public class SingletonTest {
@Test
@DisplayName("싱글톤이면 객체를 재생성 시, 동일한 객체를 반환해야한다.")
void give_singletonObject_when_constructObject_return_sameObject(){
SingletonObj singletonObj1 = new SingletonObj();
SingletonObj singletonObj2 = new SingletonObj();
assertThat(singletonObj1).isEqualTo(singletonObj2);
}
}
class SingletonObj {
}
위 테스트는 SingletonObj 객체를 자바에서 제공해주는 기본 생성자를 통해 두번 생성한 후 두 객체가 동일한지 알아보는 테스트 입니다. 테스트의 결과로는 다음과 같이 출력이 됩니다.
expected: .app.SingletonObj@2474f125
but was: .app.SingletonObj@7e19ebf0
org.opentest4j.AssertionFailedError:
expected: .app.SingletonObj@2474f125
but was: .app.SingletonObj@7e19ebf0
at java.base@17.0.3/jdk.internal.reflect.NativeConstructorAccessorImpl.newInstance0(Native Method)
at java.base@17.0.3/jdk.internal.reflect.NativeConstructorAccessorImpl.newInstance(NativeConstructorAccessorImpl.java:77)
at java.base@17.0.3/jdk.internal.reflect.DelegatingConstructorAccessorImpl.newInstance(DelegatingConstructorAccessorImpl.java:45)
at java.base@17.0.3/java.lang.reflect.Constructor.newInstanceWithCaller(Constructor.java:499)
at app//kr..SingletonTest.give_singletonObject_when_constructObject_return_sameObject(SingletonTest.java:15)
기본 생성자를 통해 생성한 두객체의 값이 동일하지 않다는 테스트 결과를 보여주고 있습니다.
그럼 싱글톤 패턴을 이용해, 객체를 재 생성시 동일한 객체를 반환하도록 코드를 수정해보도록 하겠습니다.
public class SingletonTest {
@Test
@DisplayName("싱글톤이면 객체를 재생성 시, 동일한 객체를 반환해야한다.")
void give_singletonObject_when_constructObject_return_sameObject() {
SingletonObj singletonObj1 = SingletonObj.getInstance();
SingletonObj singletonObj2 = SingletonObj.getInstance();
assertThat(singletonObj1).isEqualTo(singletonObj2);
System.out.printf("singletonObj1 = %s, singletonObj2 = %s \\n",singletonObj1,singletonObj2);
}
}
class SingletonObj {
private static SingletonObj singletonObj;
private SingletonObj() { //기본 생성자를 private으로 설정 해, new 키워드로 객체를 생성하지 못하도록 막는다.
}
public static SingletonObj getInstance() {
if (singletonObj == null) {
singletonObj = new SingletonObj();
}
return singletonObj;
}
}
- 기본 생성자를 private로 설정합니다. 기본 생성자를 private로 설정한 이유는 해당 객체를 외부에서 생성할 때, new 키워드를 통해 객체를 새로 생성할 수 없도록 막고, 이어서 설명한 getInstance()와 같이 객체를 반환해주는 메서드를 통해 객체를 생성하도록 강제하기 위해 기본 생성자를 private로 설정했습니다.
- 객체를 반환해주는 getInstance() 메서드를 static으로 선언해줍니다. 그럼 getInstance()메서드는 왜 static으로 선언해주었을까요? static으로 선언하게 되면 JVM의 메모리 영역 중 메서드 영역(JVM이 동작해서 클래스가 로딩될때 생성되는 영역)에 해당 객체의 메서드가 위치하게 됩니다. 그렇게 되면 getInstance()메서드는 SingletonObj라는 객체를 생성하지 않고도 해당 메서드를 통해 객체를 생성할 수 있게 되기 때문에 static으로 선언해 주었습니다.(static으로 선언하지 않으면 외부에서 해당 객체를 생성할 방법이 없기도 합니다.)
- 그럼 현재 생성된 getInstance()메서드는 Method Area에 위치하게 됩니다. 이 영역은 다시말해 여러 객체에서 자원을 동시에 사용할수 있는 공유 자원 영역입니다. 그렇다는 건 멀티 쓰레드 환경에서도 getInstance()메서드가 정상적으로 동작할까요? 그에 대해 알아보도록 하겠습니다.
- getInstance()메서드가 동시성 상황에서도 정상적으로 작동하는지 새로운 테스트 코드를 통해 알아보겠습니다.
@Test
@DisplayName("싱글톤이면 동시성 상황에서도 객체를 재생성 시, 동일한 객체를 반환해야한다.")
void give_singletonObject_when_ConcurrencyEnvironmentConstructObject_return_sameObject() throws InterruptedException {
int threadNum = 10;
ExecutorService executorService = Executors.newFixedThreadPool(threadNum);
for (int i = 0; i < threadNum; i++) {
executorService.execute(()-> {
SingletonObj singletonObj = SingletonObj.getInstance();
assertThat(singletonObj).isSameAs(SingletonObj.getInstance());
});
}
executorService.shutdown();
executorService.awaitTermination(1, TimeUnit.MINUTES);
}
class SingletonObj {
private static SingletonObj singletonObj;
private SingletonObj() { //기본 생성자를 private으로 설정 해, new 키워드로 객체를 생성하지 못하도록 막는다.
}
public static SingletonObj getInstance() {
if (singletonObj == null) {
singletonObj = new SingletonObj();
System.out.println("new Instance created");
}
System.out.println("exist Instance created");
return singletonObj;
}
}
동시성 상황을 위해 ExeucterService 인터페이스를 통해 현재 threadNum인 10개의 쓰레드를 가지는 고정된 크기를 가진 쓰레드 풀을 생성하고, threadNum만큼 쓰레드 풀에서 쓰레드를 꺼내 execute 메소드 안에 적힌 코드를 실행하는 테스트 코드를 작성했습니다.
또한 singletonObj가 null 일경우 “new Instance create”라는 문구를 출력하고, singletonObj가 존재할 경우 “exist Instance created”라는 문구를 출력하도록 singletonObj 클래스의 getInstace() 메서드를 수정했습니다.
그럼 해당 테스트 코드의 실행 결과를 살펴 보겠습니다.
exist Instance created
exist Instance created
exist Instance created
new Instance created
exist Instance created
new Instance created
exist Instance created
new Instance created
exist Instance created
exist Instance created
exist Instance created
exist Instance created
exist Instance created
exist Instance created
exist Instance created
exist Instance created
exist Instance created
exist Instance created
exist Instance created
exist Instance created
exist Instance created
exist Instance created
exist Instance created
예상 했던 결과와 달리 처음 singletonObj가 생성될 때 new instance created가 나오지 않고, 중간에 출력됩니다. 또한 new Instance created가 출력된 이후에도 다시 new instance created가 출력되면서, 싱글톤 패턴이 정상적으로 작동하지 않음을 확인할 수 있었습니다.
왜 이런 현상이 발생하는 걸까요? 바로 Race-Condition(경쟁 상태) 때문에 그렇습니다, 경쟁 상태는 여러 스레드 또는 프로세스가 한정된 공유 자원에 동시에 접근하는 경우 생기는 문제입니다. 경쟁 상태는 앞서 본 테스트 코드에서의 예시 처럼 데이터의 불일치(inconsistency)문제를 야기할 수 있습니다.
위 그림처럼 각각의 쓰레드가 SingletonObj.getInstance() 메서드를 호출하면 처음에는 new SingletonObj() 를 통해 SingletonObj 객체를 새로 생성해주고, 그 후에는 처음에 생성한 객체를 반환해주는 방식으로 진행되기를 예상했습니다.
하지만 앞서 살펴본 테스트 코드와, 아래 그림과 같이 객체를 재 생성할 시에, 처음에 생성한 객체를 반환해주는 것이 아닌 객체를 새로 생성해주게 됩니다.
이러한 싱글톤 패턴의 동시성 문제를 어떻게 해결해야 할까요? 먼저 자바에서 제공해주는 synchronized 키워드를 이용한 방법이 있습니다. synchronized 키워드를 사용하는 방법으로는 public synchronized static SingletonObj getInstance(){}
과 같이 메소드에 직접붙여주는 방법이 있습니다. 그럼 synchronized 키워드를 메소드에 직접 붙이면 어떤 일이 발생하는지 그림을 통해 알아보도록 하겠습니다.
자바의 모든 인스턴스는 Monitor를 가지고 있습니다(Object 내부에 갖고 있음) 이 Monitor를 통해 Thread 동기화를 수행합니다. (Monitor와, 그와 관련된 Mutex, Semaphore와 관련된 내용은 추후 포스팅에서 자세히 다루도록 하겠습니다)
synchronized 키워드가 붙은 메서드를 사용하려면 Lock을 가지고 있어야 합니다.(그림에서 Lock은 열쇠 아이콘으로 표시 했습니다) 그럼 Lock을 가진 객체가 모니터 속에 있는 해당 코드 블럭을 수행하고 나올때 까지 다른 객체들은 해당 코드블럭으로의 진입을 하지 못하며, wait(그림에서 자물쇠로 표시)을 하며 대기하고 있어야 합니다. 이렇게 synchronized 키워드를 사용하면 경쟁 상태는 해결할 수 있지만, 다른 스레드들은 작업을 이어서 진행하지 못하고, 대기하고 있어야하기 때문에 쓰레드를 사용함에도 성능적인 문제가 발생할 수 있습니다.
synchronized 키워드를 메서드에 직접 붙여줄 때와 붙이지 않았을 때 정말 성능적인 차이가 발생하는지 아래 테스트 코드를 통해 알아보겠습니다.
@Test
@DisplayName("싱글톤이면 동시성 상황에서도 객체를 재생성 시, 동일한 객체를 반환해야한다.")
void give_singletonObject_when_ConcurrencyEnvironmentConstructObject_return_sameObject() throws InterruptedException {
int threadNum = 10000;
long startTime = System.nanoTime();
ExecutorService executorService = Executors.newFixedThreadPool(threadNum);
for (int i = 0; i < threadNum; i++) {
executorService.execute(()-> {
SingletonObj singletonObj = SingletonObj.getInstance();
assertThat(singletonObj).isSameAs(SingletonObj.getInstance());
});
}
executorService.shutdown();
executorService.awaitTermination(1, TimeUnit.MINUTES);// 지정된 시간 동안대기 하며 모든 작업이 모든 중지되었는지 체크 한다
long endTime = System.nanoTime();
double executionTime = (endTime - startTime)/1000000000.0;
System.out.println("Total execution Time :"+ executionTime +" sec");
}
}
class SingletonObj {
private static SingletonObj singletonObj;
private SingletonObj() { //기본 생성자를 private으로 설정 해, new 키워드로 객체를 생성하지 못하도록 막는다.
}
public static SingletonObj getInstance() {
if (singletonObj == null) {
singletonObj = new SingletonObj();
// System.out.println("new Instance created");
}
// System.out.println("exist Instance created");
return singletonObj;
}
}
먼저 threadNum의 숫자를 10000으로 늘려, 더 많은 쓰레드가 동시에 SingletonObj 객체를 생성하려 하는 상황을 가정해보았습니다. 그리고 코드의 실행시간 측정을 위해 executionTime 변수를 통해 총 실행 시간을 출력해주고 있습니다.
Total execution Time :1.1341728 sec
BUILD SUCCESSFUL in 3s
4 actionable tasks: 2 executed, 2 up-to-date
총 실행시간은 1.72… 초가 소요되었습니다. 다음으로 synchronized 키워드를 붙였을 때의 실행시간을 측정해 보겠습니다.
Total execution Time :1.7511714 sec
BUILD SUCCESSFUL in 2s
4 actionable tasks: 1 executed, 3 up-to-date
synchronized 키워드를 붙였을 때, 실제로 약 0.6초 정도 차이가 난다는 것을 알 수 있습니다. 현재 가용 스레드의 숫자를 10000개로 설정해서 그렇지만, 실제 서비스 상황에서 사용자의 수가 10만, 100만이라 가정했을 때는 더 큰 차이를 보여줄것으로 예상 됩니다.
그럼 싱글턴 패턴의 경쟁 상태 문제를 해결할 수 있는 다른 방법으로는 무엇이 있을까요?
class SingletonObj {
private static final SingletonObj INSTANCE = new SingletonObj();
private SingletonObj() { //기본 생성자를 private으로 설정 해, new 키워드로 객체를 생성하지 못하도록 막는다.
}
public static SingletonObj getInstance() {
return INSTANCE;
}
}
위 코드와 같이 이른 초기화(eager initialization)을 이용하는 방법이 있습니다.
자바의 static과 final 키워드를 이용해 해당 객체 클래스가 메모리에 로딩됨과 동시에 초기화를 시켜주고 초기화된 객체의 변경을 막아 줄 수 있습니다.
하지만 이른 초기화 방식도 단점이 있습니다. 해당 객체 클래스가 사용되어지지 않더라도 클래스 로더가 클래스를 로딩하는 시점에 해당 클래스가 초기화되어 메모리의 힙영역에 자리잡게 되므로, 메모리의 낭비를 발생시킬수도 있습니다.
또한 이른 초기화 방식으로 인스턴스를 생성했을 때는 해당 객체가 클래스 로더에 로딩시 초기화되어 생성되고, 기본 생성자를 사용하지 않기 때문에, 생성 시 예외 처리가 불가능하다는 단점이 있습니다.
이어서 앞서 설명한 synchronized 방식을 보완한 double checked Locking 방식으로 동기화 코드 블럭을 만드는 방법이 있습니다.
double checked Locking 방식은 synchronized 방식에 접근하기 위한 Lock을 획득하기 전에 Lock을 획득하는 기준을 체크하여 Lock 획득을 위한과정에서 발생하는 오버헤드를 줄이기 위해 사용되는 패턴입니다.
class SingletonObj {
private static volatile SingletonObj singletonObj;
private SingletonObj() { //기본 생성자를 private으로 설정 해, new 키워드로 객체를 생성하지 못하도록 막는다.
}
public static SingletonObj getInstance() {
if (singletonObj == null) {
synchronized (SingletonObj.class) {
if (singletonObj == null) {
singletonObj = new SingletonObj();
System.out.println("new Instance created");
}
}
}
System.out.println("exist Instance created");
return singletonObj;
}
}
위 코드에서 singletonObj가 null일 경우에만 synchronized를 SingletonObj.class에만 해줘서, 해당 객체를 사용할 때만 synchronized 키워드가 적용되도록 했습니다. 위 코드에서 volatile이라는 키워드로 SingletonObj 필드를 선언하고 있습니다. 해당 키워드를 사용한 이유는 동시성 환경에서 발생할수 있는 캐시 불일치 문제(cache incoherence issues)를 방지하기 위해 해당 키워드를 선언해주었습니다.
캐시 불일치 문제는 다음과 같이 세개의 CPU에서, 각각의 cache를 가지는 상황에서
CPU1번이 x라는 변수를 읽습니다 : 24라는 값을 메모리로 부터 가져오고 캐싱 합니다.
이어서 CPU2번이 메모리로부터 24라는 값을 메모리로 부터 가져오고 캐싱합니다.
CPU1번이 X변수에 64라는 값을 기록(write)합니다. CPU 1번이 가지고 있는 로컬 캐시에는 X변수의 값이 24에서 64로 업데이트 되었습니다. 이제 CPU3번이 X변수의 값을 읽어옵니다. 그럼 어떤값이 읽어지게 될까요?
메모리(그림에서의 SHARED MEMORY)와 CPU 2는 24라고 생각하고 CPU 1은 64라고 생각하게 됩니다.
이런 문제는 여러 CPU가 병렬로 작동하고 각각의 CPU가 독립적으로 가지는 여러 캐시가 하나의 값에 대해 다른 복사본을 가질 수 있으므로 캐시 일관성 문제가 발생하게 됩니다.
이렇게 CPU에 Cache되는 과정에서 캐시 불일치 문제가 발생하는 것을 volatile 키워드를 변수에 붙임으로써 해당 변수는 CPU cache에 캐싱되는 것이 아닌 바로 SHARED MEMORY에 저장되게 함으로써 해당 문제를 해결해줍니다.
스프링 에서의 싱글톤
설명을 이어서 static inner 클래스를 이용해 싱글톤 패턴을 구현하는 방법이 있습니다.
class SingletonObj {
private SingletonObj() { //기본 생성자를 private으로 설정 해, new 키워드로 객체를 생성하지 못하도록 막는다.
}
private static class SingletonObjHolder {
private static final SingletonObj SINGLETON_OBJ = new SingletonObj();
}
public static SingletonObj getInstance() {
return SingletonObjHolder.SINGLETON_OBJ;
}
}
이 방법은 얼핏 보기에 static final을 이용해 객체 생성을 초기화 해주기 때문에 이른 초기화(eager initialization)이라고 보실 수도 있습니다. 하지만 이른 초기화와는 달리 SingletonObjHolder 클래스의 변수가 SingletonObj 클래스에 존재하지 않기 때문에, JVM이 클래스를 로딩할시에 SingletonObjHolder 클래스를 초기화하지 않습니다. SingletonObj클래스의 getInstance()메서드가 호출될 시에 초기화되기 때문에 지연 초기화(Lazy initialization)이라고 볼 수 있습니다.
마지막으로 enum을 이용해 싱글톤을 구현하는 방법이 있습니다.
@Test
@DisplayName("싱글톤이면 동시성 상황에서도 객체를 재생성 시, 동일한 객체를 반환해야한다.")
void give_singletonObject_when_ConcurrencyEnvironmentConstructObject_return_sameObject() throws InterruptedException {
int threadNum = 10;
long startTime = System.nanoTime();
ExecutorService executorService = Executors.newFixedThreadPool(threadNum);
for (int i = 0; i < threadNum; i++) {
executorService.execute(() -> {
SingletonObj singletonObj = SingletonObj.SINGLETON_OBJ;
assertThat(singletonObj).isSameAs(SingletonObj.SINGLETON_OBJ);
});
}
executorService.shutdown();
executorService.awaitTermination(1, TimeUnit.MINUTES);
long endTime = System.nanoTime();
double executionTime = (endTime - startTime) / 1000000000.0;
System.out.println("Total execution Time :" + executionTime + " sec");
}
}
enum SingletonObj {
SINGLETON_OBJ;
}
enum 타입으로 선언할 시에 해당 클래스는 static클래스로 선언됩니다 그렇기 때문에 해당 enum 클래스의 인스턴스가 JVM 내에 하나만 존재한다는 것이 보장된다는 장점이 있습니다.
하지만 enum은 다른 상위 클래스를 사용해야 한다면 사용할 수 없다는 단점이 있습니다.(인터페이스는 구현할 수 있습니다)
현재까지는 디자인패턴에서의 Singleton 패턴에 대해 알아보았습니다. 이어서 Spring에서는 싱글턴 패턴을 어떻게 구현하고 있는지 알아보도록 하겠습니다.
스프링에서는 @Component 와 @Repository, @Service 같은 애노테이션을 통해 빈을 자동 등록하거나 또는 @Bean 을 통해 빈을 수동등록함으로써 스프링 빈을 관리하고 있습니다. 이렇게 빈을 등록하는 행위를 함으로써 Spring에서 제공하는 ApplicationContext라는 IOC 컨테이너에서 빈을 관리할 수 있게 됩니다.
이러한 점이 Java로 구현한 Singleton Design패턴과의 큰 차이점이라고 볼 수 있습니다. 자바로 구현한 싱글톤 패턴은 JVM의 클래스 로더가 클래스를 로딩하는 시점에 작업이 진행되니, JVM과 관련이 있다면, 스프링의 싱글톤은 ApplicationContext 라는 컨테이너에 빈을 등록하고, 관리하는 행위가 일어나기 때문에 ApplicationContext와 밀접한 관련이 있다는 점이 가장 큰 차이점입니다.
추가 내용
또한 추가로 스프링 빈을 등록할 시에 다음과 같이 등록하는 경우가 있을 수 있습니다.
@Configuration
class TestAppConfig {
@Bean
public TestObject testObject(){
System.out.println("TestAppConfig.testObject");
return new TestObject();
}
}
위와 같이 @Configuration 애노테이션을 이용해 스프링 빈을 컨테이너에 등록하게 되면 해당 빈은 스프링에서 관리한다는 점 뿐만아니라, CGLIB이라는 라이브러리를 통해 바이트 코드를 조작할 수 있게 됩니다.
이와 관련된 내용은 본 포스팅의 범위를 넘어간다고 생각해 추후 포스팅에서 다뤄보도록 하겠습니다.
'프로그래밍 > Java' 카테고리의 다른 글
함수형 프로그래밍 (0) | 2023.04.11 |
---|---|
G1 GC에 대해 (0) | 2023.03.31 |
Long 과 AtomicLong은 어떤 차이가 있을까? (0) | 2023.01.24 |
ConcurrentHashMap은 어떻게 동시성 문제를 해결할까? (0) | 2023.01.24 |
Java Thread Pool? (0) | 2022.12.28 |