‘클래스의 인스턴스가 딱 1개만 생성되는 것을 보장하는 디자인 패턴’ 으로 흔히 정의하는 싱글톤 패턴에 대해 구글에 검색하면 ‘싱글톤 패턴의 단점’ 또는 ‘싱글톤이 안티패턴인 이유’ 등으로 가장 자주 언급되는 3가지가 있어요.
하지만 ‘싱글톤 패턴’ 이라는 개념을 스프링 프레임워크를 통해 처음 접한 우리의 똥덩어리 백여우는 지금까지 ‘싱글톤 패턴’과 스프링에서 제공하는 기능인 ‘싱글톤 컨테이너’를 동일하게 생각하고 살아왔습니다.
‘클래스 위에 @Component만 붙여주면,, 짜잔~~~ 이 객체는 이제 싱글톤이다’ 이렇게 배운 저에게 위에 나열된 ‘싱글톤 패턴의 단점’은 정말 1도 와닿지 않는 내용들이었어요. 스프링 프레임워크를 사용한다면 자연스럽게 해결되는 내용들이거든요.
그것도 모르고 나는 ’스프링 컨테이너 열어보면 인스턴스 1개만 있는데요?’ ’스프링 빈으로 등록한 클래스들 테스트할 때 잘만 쓰는데요?’ ‘싱글톤 빈 사용할 때 인터페이스 잘만 쓰는데요옹~~? 크크루삥뽕’
반성하는 마음으로, 선배 개발자분들이 이야기하는 ‘싱글톤 패턴이 가진 문제점’ 이 구체적으로 어떤 것인지 공부하고자 했으나, 구체적이면서도 이해하기 쉬운 기술 블로그를 찾기가 힘들었어요. 그래서 이번에도 통장을 깨부수고 열심히 공부해 배운 강의와 책 내용을 정리하였습니다.
각각의 주제에 대해 그림과 예제와 함께 쉽고 간단히 이해해 봅시다.
(JAVA 코드를 사용합니다. 자바 안 쓴다면.. 미안하다!)
(패턴 자체의 문제점에 대해 다루므로, 스프링 프레임워크 또는 Mockito와 같은 외부 라이브러리를 통해 해결하는 방법은 언급하지 않도록 하겠습니다.)
참고한 내용
스프링 핵심 원리 - 기본편 - 인프런 | 강의
GoF의 디자인 패턴 - YES24
코딩으로 학습하는 GoF의 디자인 패턴 - 인프런 | 강의
java - Why is a singleton class hard to test? - Stack Overflow
I Asked 885 Developers: Is Singleton Bad? Here’s the Answer
보통 제일 간단하게 싱글톤 객체를 만드는 코드는 이렇게 생겼어요.
public class SingletonService {
private static SingletonService instance; <<-- 애플리케이션 전체에서 공유할 유일한 인스턴스
private SingletonService() {} <<-- 외부에서 생성자를 사용할 수 없도록 접근자를 private(같은 클래스 안에서만 사용 가능)으로 지정한다.
public static SingletonService getInstance() {
if (instance == null) {
instance = new SingletonService();
} <<-- 인스턴스가 없으면 하나 생성하여 instance 필드에 저장한다.
return instance; <<-- 유일한 인스턴스를 반환한다.
}
}
외부의 사용자는 new 명령어를 이용해 인스턴스를 생성할 수 없습니다.
대신 객체의 static 메서드 getInstance() 를 호출하여 객체 내부에 있는 유일한 인스턴스를 받아와 사용할 수 있어요.
public class Main {
public static void main(String[] args) {
// SingletonService service = new SingletonService(); <<-- 사용 불가
SingletonService service = SingletonService.getInstance();
}
}
코드만 보면 ‘인스턴스가 없을 때 하나 만들어 두고 그걸 돌려 쓴다’ 라는 개념을 쉽게 이해할 수 있고, 사용하는 입장에서도 딱히 문제가 없어 보입니다.
하지만 위 예제는 1초에 몇만 번씩 교대하면서 한 코드에 거의 동시에 접근하는 멀티 쓰레드 환경에서는 문제가 생길 수 있는 코드입니다.
많은 기술 블로그가 딱 여기까지 설명하고 글을 끝내버리죠. 우리는 조금만 더 시각적으로 이해해 봅시다.
간단히 ‘쓰레드 A’, ‘쓰레드 B’라는 두 쓰레드가 SingletonService 객체에서 인스턴스를 가져오는 getInstance() static 메서드에 접근하는 상황을 살펴봅시다. 이 때 instance에는 값이 없다고 가정할게요.
public static SingletonService getInstance() {
if (instance == null) {
instance = new SingletonService();
}
return instance;
}
두 쓰레드가 인스턴스를 가져오는 이상적인 절차는 아래와 같을 거에요.
쓰레드 A가 getInstance() 메서드를 호출하고 인스턴스를 반환받을 때까지 쓰레드 B가 착하게 기다려 준다면 싱글톤 패턴의 목적을 순조롭게 달성할 수 있을 거에요. 이 방식의 작업을 ’동기적이다’ (Synchronous) 라고 표현합니다.
하지만 멀티 쓰레드 환경에서는 쓰레드끼리 ‘기다려주지’ 않습니다. 엄청난 속도로 서로의 주도권이 왔다갔다하면서 한 코드에 거의 동시에 접근하기 때문입니다. 이 방식의 작업을 ’비동기적이다’ (Asynchronous) 라고 표현합니다. 이제 문제가 일어날 수 있는 비동기적 멀티 쓰레드 상황을 살펴 봅시다.
(쓰레드 A에서 인스턴스를 만들어 instance 변수에 할당하기 직전 쓰레드 B에서 if문을 실행했기 때문에, 쓰레드 B 입장에서 instance == null 구문이 true로 나옵니다.)
(멀티 쓰레드 환경이라고 해서 무조건 이 현상이 나타나는 것은 아니고, 무조건 저 순서대로 실행되는 것도 아니라는 점에 주의해 주세요! 쓰레드의 주도권을 왔다갔다 하는 Context Switching이 언제 어떻게 수행되느냐에 따라 달라질 수 있습니다.
다른 것보다, getInstance() 메서드가 위 예제와 같은 상황으로부터 안전한 코드가 아니라는 점에 주목해야 합니다.)
분명 1개의 인스턴스만 생성해서 공유하는 싱글톤 패턴의 코드를 작성하였는데도
쓰레드 A와 쓰레드 B가 각각 다른 인스턴스를 가지게 되었습니다.
싱글톤 패턴을 사용하는 객체가 중요한 정보를 담은 객체, 예를 들어 게임 환경설정 파일이라면, 환경설정 파일을 사용하는 객체들이 각각 다른 설정 파일을 갖게 된 것과 같죠.
게임을 진행하면서 캐릭터의 머리카락이 벗겨졌다 자랐다 하는데 도대체 왜 이러는지 추적하기도 힘든 가슴아프고 치명적인 상황이 올 수 있어요.
물론 이런 동시성 문제를 예방하는 코드를 작성할 수 있어요. 자바를 예로 들면 한 메서드에 여러 쓰레드가 동시에 접근하지 못하도록 synchronized 접근자를 사용하거나, 클래스 로더가 실행될 때 인스턴스도 미리 만들어 두거나···
(코딩으로 학습하는 GoF의 디자인 패턴 - 인프런 | 강의에서 동시성 문제로부터의 치열한 공방전을 아주 자세히 다루고 있습니다.)
다만 그 과정에서 일어나는 성능 저하 등을 추가적으로 고려해야 하기 때문에
실제 그 객체의 비즈니스 목적과는 상관없는, 싱글톤 패턴을 유지하기만을 위한 복잡한 코드가 자꾸 추가된다는 점이 개발자 입장에서는 매우 불편해요.
객체지향의 5원칙 중 OCP(개방-폐쇄 원칙), DIP(의존관계 역전 원칙)을 지키는 코드를 작성하기 위해, 보통 사용자 객체는 인터페이스를 사용하고 -> 구체 클래스는 인터페이스를 구현하도록 설계합니다.
public class MemberService {
private final Repository repository; <<-- 사용자는 구체 클래스에 의존하지 않고 인터페이스를 사용한다.
public MemberService(Repository repository) {
this.repository = repository; <<-- 객체지향의 다형성을 활용하여, 구체 클래스는 외부에서 주입받아 사용한다.
}
public void saveMember(Member member) {
/* ···비즈니스 로직··· */
repository.save(member);
/* ···비즈니스 로직··· */
}
}
헌데 싱글톤 패턴으로 만든 객체의 인스턴드 객체를 가져오기 위해서는 static 메서드인 getInstance() 를 직접 호출해야 하기 때문에, 구현체 대신 인터페이스를 사용하도록 만드는 것이 불가능합니다.
때문에 사용자 객체(= 클라이언트) 에서 구체 클래스를 직접 사용하는, 즉 구체 클래스에 의존하는 냄새나는 코드가 된다는 문제가 있어요.
Q. 인터페이스에 getInstance() static 메서드를 선언해놓고 구체 클래스에서 오버라이드하면 되지 않나요??
A. 좋은 질문입니다. 한번 해보세요! 인터페이스에서 static 메서드를 선언했을 때 나타나는 빨간 줄을 확인해보시면 답을 얻을 수 있으실 겁니다.
public class MemberService {
// private final Repository repository = Repository.getInstance(); <<-- 이런 거 불가능
private final SingletonRepository repository = SingletonRepository.getInstance(); <<-- 구체 클래스를 직접 사용한다. 즉 구체 클래스에 의존한다.
public void saveMember(Member member) {
/* ···비즈니스 로직··· */
repository.save(member);
/* ···비즈니스 로직··· */
}
}
결국 이 코드는 객체지향, 그 중에서도 OCP와 DIP를 지키지 않음으로써 발생하는 문제를 싸그리 끌고 오게 되는데, 그 중 하나가 바로 다음으로 이어지는 ‘테스트’에 대한 문제입니다.
예제로 사용한 SingletonRepository는 싱글톤 패턴으로 작성한 싱글톤 객체이고,
save(member) 메서드는 실제 DB에 insert 쿼리를 날려(중요) 회원 객체를 저장하는 간단한 crud 로직을 수행합니다.
public class SingletonRepository {
private static SingletonRepository instance;
private final JdbcTemplate template; <<-- DB에 접근하기 위한 객체. (중요하지 않으므로 모르셔도 무방합니다.)
private SingletonRepository() {
template = new JdbcTemplate(new HikariDataSource());
}
public static SingletonRepository getInstance() {
if (instance == null) {
instance = new SingletonRepository();
}
return instance; <<-- 싱글톤 패턴
}
public Member save(Member member) {
String sql = "insert into member(member_id, money) values(?, ?)";
template.update(sql, member.getMemberId(), member.getMoney()); <<-- DB에 쿼리를 날려 데이터를 저장한다.
return member;
}
}
그리고 SingletonRepository를 사용하는 MemberService의 saveMember() 메서드는 비즈니스 로직을 수행하면서 중간에 repository.save(member) 를 호출하여 회원 객체를 저장합니다.
public class MemberService {
private final SingletonRepository repository = SingletonRepository.getInstance();
public void saveMember(Member member) {
/* ···비즈니스 로직··· */
repository.save(member);
/* ···비즈니스 로직··· */
}
}
자! 우리는 이제 MemberService의 비즈니스 로직이 정상적으로 작동하는지 테스트하고 싶습니다.
class MemberServiceTest {
MemberService memberService = new MemberService();
@DisplayName("memberService.save()를 테스트 해보자!")
@Test
void memberService_save_success() {
Member member = new Member();
member.setMemberId(1);
member.setMoney(10000);
memberService.saveMember(member); <<-- saveMember() 의 비즈니스 로직을 테스트한다.
Assertions.assertThat(/* 테스트 검증 로직 */);
}
}
우리가 원하는 것이 테스트라는 것에 주목해야 해요.
테스트용으로 만든 member 객체가 실제 운영중인 DB에 영구적으로 저장되면 곤란합니다.
테스트하고자 하는 대상은 MemberService의 비즈니스 로직 뿐이니 리포지토리의 코드 때문에 테스트 결과가 달라지기를 원하지도 않구요.
아! 그럼 실제 DB에 접근하는 리포지토리가 아니라 흉내만 내는 가짜 리포지토리, Mock 리포지토리를 사용하면 되지 않을까요? MockRepository를 만들어 봅시다.
public class MockRepository {
private static MockRepository instance;
public static MockRepository getInstance() {
if (instance == null) {
instance = new MockRepository();
}
return instance;
}
public Member save(Member member) {
System.out.println("나는 DB에 접근하지 않고 오류가 나지도 않지! 빠밤빰");
return member;
}
}
자, 이제 MemberService를 테스트할 때 MockRepository를 사용하도록 주입해주면 순수하게 MemberService의 비즈니스 로직만 테스트할 수 있습니다. 한 번 주입해 봅시다!
····
····
····
public class MemberService {
private final SingletonRepository repository = SingletonRepository.getInstance();
public void saveMember(Member member) {
/* ···비즈니스 로직··· */
repository.save(member);
/* ···비즈니스 로직··· */
}
}
class MemberServiceTest {
MemberService memberService = new MemberService();
@DisplayName("memberService.save()를 테스트 해보자!")
@Test
void memberService_save_success() {
Member member = new Member();
member.setMemberId(1);
member.setMoney(10000);
memberService.saveMember(member);
Assertions.assertThat(/* 테스트 검증 로직 */);
}
}
···안 되잖아!.
만일 싱글톤 패턴을 사용하지 않는 대신, 인터페이스를 활용하여 객체지향적으로 작성한 리포지토리를 사용하였다면 아래처럼 테스트할 수 있었을 거에요.
<Repository 인터페이스>
public interface Repository {
Member save(Member member);
}
<Repository를 구현한 NonSingletonRepository>
public class NonSingletonRepository implements Repository {
private final JdbcTemplate template;
public NonSingletonRepository() {
template = new JdbcTemplate(new HikariDataSource());
}
@Override
public Member save(Member member) {
String sql = "insert into member(member_id, money) values(?, ?)";
template.update(sql, member.getMemberId(), member.getMoney());
return member;
}
}
<Repository를 구현한 MockRepository>
public class MockRepository implements Repository {
@Override
public Member save(Member member) {
System.out.println("나는 DB에 접근하지 않고 오류가 나지도 않지! 빠밤빰");
return member;
}
}
<Repository 인터페이스를 사용하는 MemberService>
public class MemberService {
private final Repository repository; <<-- 사용자는 구체 클래스에 의존하지 않고 인터페이스를 사용한다.
public MemberService(Repository repository) {
this.repository = repository; <<-- 객체지향의 다형성을 활용하여, 구체 클래스는 외부에서 주입받아 사용한다.
}
public void saveMember(Member member) {
/* ···비즈니스 로직··· */
repository.save(member);
/* ···비즈니스 로직··· */
}
}
<MemberService를 테스트하기 위해 MockRepository를 주입하는 테스트 코드>
class MemberServiceTest {
MockRepository mockRepository = new MockRepository();
MemberService memberService = new MemberService(mockRepository); <<-- MockRepository를 외부에서 주입할 수 있다.
@DisplayName("memberService.save()를 테스트 해보자!")
@Test
void memberService_save_success() {
Member member = new Member();
member.setMemberId(1);
member.setMoney(10000);
memberService.saveMember(member);
Assertions.assertThat(/* 테스트 검증 로직 */);
}
}
이와 같은 유연한 테스트가 가능했던 것은
하지만 싱글톤 패턴을 사용한 코드의 경우,
구체 클래스의 static 메서드를 직접 호출하는 특성상 사용자가 구체 클래스에 직접 의존하는 형태이기 때문에 다형성을 활용한 어떠한 이점도 누릴 수 없습니다.
MemberService와 SingletonRepository가 강하게 결합되어 있어 SingletonRepository를 모킹(Mocking)할 수단이 없습니다.
따라서 MemberService의 비즈니스 로직만 따로 떼서 테스트하는 유닛 테스트(Unit Test)를 진행하는 것이 불가능하고, 항상 SingletonRepository의 작동까지 한 번에 테스트하는 통합 테스트(Integration Test)만을 강제로 해야 합니다.
그러면 MemberService의 비즈니스 로직을 검사하고자 작성한 테스트가 SingletonRepository의 코드 때문에 실패할 수도 있고, DB에 쿼리를 날려 데이터베이스가 훼손되는 것을 막을 방법을 또 고민해야 합니다.
싱글톤 패턴과 테스트에 관한 내용은 구글에 ‘why singleton is hard to test’ 등의 키워드로 검색하여 나오는 해외 블로그에 다양한 설명이 있으므로 참고하시면 좋을 것 같아요!
이렇게, 기술 블로그에 대충 떠돌고 있는 싱글톤 패턴의 단점, 또는 한계, 또는 주의점들 중 대표적인 항목에 그림과 예제를 덧붙여서 사람들이 좀 더 읽고 이해하기 쉽게 가공해 보았습니다.
사실 지금까지 이야기한 내용들은 자바 사용자라면 스프링 프레임워크가 거의 완벽히 해결해주기 때문에 몰라도 되는 내용일 수도 있어요.
그치만 단순히 ‘프레임워크 사용법을 아는 사람’ 에서 ‘개발자’로 성장하는 과정에는 이렇게 원리적인 곳까지 파고드는 것도 있어야 하지 않나 싶어요. 그리고 이런 것도 알아두면 재밌잖아요!
검증된 자료만 참고하고 싶어서 이것저것 강의를 사느라 돈이 제법 깨졌는데···· 그래도 틀린 내용이 꽤 있을 것 같습니다.
글러먹었거나 개선해야 할 내용이 보이신다면, 댓글 또는 트위터 @backfox__ 로 연락 주세요.
읽어주셔서 고마워요. 안녕.
좋은 글 고맙습니다.
코틀린에서는 mockk를 활용하여 repository를 모킹하여 행동을 조작할 수 있고, 자바에서는 mockito가 이에 대응되는 프레임워크로 알고 있습니다.
즉 서비스 레이어의 유닛 테스트가 가능 한 것으로 아는데, 혹시 제가 잘못 알고 있는 것인가 해서 여쭤봅니다
안녕하세요! 글 너무 재밌게 잘 읽었습니다.
조금 잘못 작성하신 부분이 있어 보여서 말씀드려요!
"자, 이제 MemberService를 테스트할 때 MockRepository를 사용하도록 주입해주면 순수하게 MemberService의 비즈니스 로직만 테스트할 수 있습니다. 한 번 주입해 봅시다!"
이 문장 아래에 나오는 코드에 잘못 작성하신 부분이 있는 것 같아요.
코드 2번째 줄에
"private final SingletonRepository repository = SingletonRepository.getInstance();" 가 아니라
"private final MockRepository repository = MockRepository.getInstance();" 가 맞는 것 같습니다.
제가 잘못 이해했을 수도 있습니다. 좋은 글 감사합니다 ~!
좋은 글이네용 ^^ ㅎㅎ 잘 읽었습니당