저번에 DI와 IoC container에 대해서 공부하다 보면 자연스럽게 IoC Container의 동작 원리에 대해 궁금해진다. 오늘은 그 중 싱글톤 패턴에 대해서 살펴볼 것이다.
먼저 스프링에 대한 생각을 잠시 내려두고 싱글톤이라는 패턴 자체에 대해서 고민해보자. 이후 스프링에서 어떤 방식으로 싱글톤 패턴을 적용하는지 알아보자.
c언어 같이 동적 메모리 할당을 할 수 있는 언어를 다뤄보았다면 객체를 생성할 때와 소멸할 때 메모리 누수가 발생하지 않는지 체크해본 경험이 있을 것이다. 실제로 대학교 3학년 때 과제하면서 메모리 누수 때문에 감점을 받은 적도 있었다!
아무튼 여기서 얘기하고 싶은 것은 객체 인스턴스를 생성하는 행위가 특정 메모리 영역을 할당하고 있다는 것이다. GC 때문에, 그리고 컴퓨터 성능 때문에 일반적인 상황에서 우리는 이정도 메모리 영역 할당에 대한 위험을 느끼지 못한다.
하지만 웹 어플리케이션을 위한 서비스라면 이야기가 다를 수 있다. controller - service - repository 세 개의 layer로 나뉘어진 서비스라고 생각해보자. 클라이언트로부터 요청이 들어오면 controller를 생성하기 위해 service 인스턴스가 생성되고, service 인스턴스를 생성하기 위해서 repository 인스턴스가 생성되어야 한다.
그렇다면 만약 단 기간에 수 십개의 요청이 발생하면 어떻게 될까? 예를 들어 점심 피크 시간대의 배달 서비스, 특가 진행 중인 커머스 서비스 등과 같은 상황을 떠올려보면 초당 몇 만개의 요청이 들어올 것이다. 그 요청마다 매번 새로운 인스턴스를 생성한다면 아무리 좋은 서버라도 메모리가 힘들어 할 것이다.
싱글톤 패턴은 인스턴스를 단 하나만 생성하고 모든 클라이언트의 요청이 오면 이 인스턴스를 공유해서 사용하도록 설계하는 것이다.
그래서 싱글톤 패턴에서 중요하게 다루어야 할 것은 단 하나이다.
그렇다면 이러한 클래스는 어떻게 만들 수 있을까? 순수 Kotlin 코드로 생각보다 어렵지 않게 만들 수 있다.
class Singleton private constructor() {
companion object {
private val instance = Singleton()
fun getInstance() = instance
}
fun logic(){
println("called singleton logic : ${this.hashCode()}")
}
}
사실 Kotlin에서는 object
를 활용해서 곧바로 싱글톤 클래스를 만들 수 있지만 싱글톤을 구현하기 위해 어떤 과정이 필요한 지 살펴보기 위해 위와 같이 작성하였다.
기본적으로 객체 1개 생성해두고, 외부에서 인스턴스가 필요한 경우에는 getInstance()
함수만 활용하여 조회하도록 하는 것이다. 인스턴스가 2개 이상 생성되는 것을 막아야 하기 때문에 당연히 생성자를 private
으로 두었다.
이제 test를 돌려서 진짜로 단 하나의 객체를 공유하게 되었는지 확인해보자.
package singleton
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
class SingletonTest {
@Test
@DisplayName("싱글톤 패턴 객체")
fun singletonTest(){
val singleton1 = Singleton.getInstance()
val singleton2 = Singleton.getInstance()
assertEquals(singleton1.hashCode(), singleton2.hashCode())
singleton1.logic()
singleton2.logic()
}
}
외부에서 메소드 호출을 통해 얻은 두 객체는 같으며, 내부 함수에서도 이를 확인할 수 있다.
자 그럼 싱글톤 패턴은 메모리 관점에서 무조건 좋은 것이냐? 그건 아니다. 언제나 그렇듯 좋은 것에는 trade-off가 따른다.
객체지향적인 관점에서 싱글톤 패턴이 가지는 문제가 무엇인지 생각해보자.
일단 가장 큰 문제는 클라이언트가 구체적인 클래스에 의존한다는 것이다. 객체가 필요해서 사용하는 경우에 getInstance()
함수를 활용해서 조회하지만, 이 때 이미 해당 객체는 구체적인 객체로 인스턴스화가 되어있다.
지난번 SOLID 원칙에서도 이야기 했지만 구체적인 객체의 존재에 의존하게 되면 OCP와 DIP를 지키기 어려워진다.
또한, 싱글톤 객체의 경우 생성자가 반드시 private이기 때문에 상속을 통해 자식 클래스를 만들기가 어렵다.
그 외에 객체를 싱글톤으로 만들기 위해 필요한 작업이나 코드가 들어가기도 하고, 변경에 유연하지 못한 구조가 만들어진다.
자, 이제 여기까지 흐름을 한 번 정리해보자.
Spring은 웹 어플리케이션을 위해서 만들어졌다. 그렇기 때문에 당연히 위에서 언급했던 동시다발적인 요청이 들어올 때 매번 객체를 절대 생성하지 않는다. 그렇다면 OCP나 DIP를 위반하면서 싱글톤을 구현하나? 그것도 아니다.
Spring 컨테이너는 Bean을 활용해서 싱글톤 패턴을 적용하지 않고 객체 인스턴스를 싱글톤으로 관리한다. 그래서 우리는 위에서 언급한 것 처럼 별 다른 코드가 필요 없고, DIP, OCP, 상속을 신경쓰지 않고도 프로그램을 작성할 수 있다.
Spring에 등록된 Bean을 ApplicationContext
의 getBean()
을 통해 호출하면 모두 같은 인스턴스를 가져오는 것을 알 수 있다.
이제 프레임워크를 활용했기 때문에 별 다른 어려움 없이 싱글톤 패턴을 프로젝트에 적용된 채로 사용할 수 있다. 하지만 이때 굉장히 조심해야 할 부분이 존재한다.
바로 싱글톤 객체에 상태를 주면 안된다는 것이다.(Stateless)
싱글톤 객체 인스턴스의 경우 수 많은 클라이언트가 공유한다. 그렇기 때문에 특정 클라이언트에 의존하는 상태(필드)가 있는 경우 이는 프로젝트에 위험을 초래할 수도 있다.
그렇기 때문에 싱글톤 객체의 경우 웬만해서 읽기만 가능하도록 구현하며, 내부에 상태를 저장할 수 있는 필드를 없애자.