안녕하세요!

너무 오랜만에 돌아왔죠? SW마에스트로를 지원하면서 코딩테스트 공부를 처음 시작해 보느라 블로그 쓸 시간이 부족했네요ㅠ

이 얘기는 나중에 따로 적기로 하고 저희는 오늘 싱글톤 패턴에 대해 알아보고 적용할 거예요.

(혼자야?)

싱글인 것도 서러운데 싱글톤 패턴은 뭘까요ㅠㅠ 싱글톤 패턴이라는 말이 생소한 분들이 많을 것 같아요! 그럼 제일 먼저 싱글톤 패턴이 무엇인지와 우리 프로젝트에 왜 필요한지에 대해 설명드릴게요~

👉이전 글 보러가기


싱글톤 패턴이란?

얘는 일단 소프트웨어의 디자인 패턴 중 하나입니다.

디자인 패턴

많은 프로그래머들이 코드를 짜면서 좀 빈번하게 나왔던 문제점들이 있을 거예요.
그럼 이런 문제와 해결방법이 많은 프로그래머들에게 익숙해지면서 얘는 이런 식으로 해결하면 돼~라고 반사적으로 나오게 되겠죠?
그런 자주 쓰이는 해결방법들을 소프트웨어의 디자인 패턴이라고 합니다.

그럼 싱글톤 패턴은 문제점을 해결한 해결법이라는 것인데 어떤 문제점이었는지 확인해 봅시다.

문제점 파악

우리가 만들던 프로젝트에서 예시를 만들겠습니다.

저희가 예를 들어 MemberServiceImpl을 만든 걸 한 곳이 아닌 여러 곳에서 사용한다고 가정할게요. 그리고 그걸 스프링의 기능이 아니라 우선 순수 자바의 방식으로 불러와볼게요!

singleton 테스트 패키지를 만들어서 SingletonTest 클래스를 만들어보아요~

package com.naver.shopping.singleton;
 
import com.naver.shopping.AppConfig;
import com.naver.shopping.member.MemberService;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
 
import static org.assertj.core.api.Assertions.assertThat;
 
public class SingletonTest {
    @Test
    @DisplayName("스프링 없는 순수한 DI 컨테이너")
    void pureContainer() {
        AppConfig appConfig = new AppConfig();
        //1. 조회: 호출할 때 마다 객체를 생성
        MemberService memberService1 = appConfig.memberService();
        //2. 조회: 호출할 때 마다 객체를 생성
        MemberService memberService2 = appConfig.memberService();
        //참조값이 다른 것을 확인
        System.out.println("memberService1 = " + memberService1);
        System.out.println("memberService2 = " + memberService2);
        assertThat(memberService1).isNotSameAs(memberService2);
    }
}

테스트 코드를 확인해 보시면 isNotSameAs 그러니까 두 개가 다르면 테스트가 성공하는 것입니다. 똑같은 appConfig내의 memberService()를 불러왔는데 과연 테스트가 성공할까요?

이전에 설정했던 AppConfig는 기억하시죠? 기억 안 나시다면 복습해 보는 것도 좋아요!

package com.naver.shopping;
 
import com.naver.shopping.member.MemberRepository;
import com.naver.shopping.member.MemberService;
import com.naver.shopping.member.MemberServiceImpl;
import com.naver.shopping.member.MemoryMemberRepository;
import com.naver.shopping.order.OrderService;
import com.naver.shopping.order.OrderServiceImpl;
import com.naver.shopping.discount.DiscountPolicy;
import com.naver.shopping.discount.FixDiscountPolicy;
import com.naver.shopping.discount.RateDiscountPolicy;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
 
@Configuration
public class AppConfig {
    @Bean
    public MemberRepository memberRepository() {
        return new MemoryMemberRepository();
    }
 
    @Bean
    public DiscountPolicy discountPolicy() {
        return new RateDiscountPolicy();
    }
 
    @Bean
    public MemberService memberService() {
        return new MemberServiceImpl(memberRepository());
    }
 
    @Bean
    public OrderService orderService() {
        return new OrderServiceImpl(memberRepository(), discountPolicy());
    }
}

실행 결과

출력결과의 memberService1, memberService2 맨 뒤를 주의깊게 봐주세요!

각각의 생성된 클래스는 고유 아이디값을 가지게 됩니다. 지금 memberSerivce1과 memberService2는 각각 @cecf639, @183ec003으로 다른 아이디값을 가진 같은 기능을 하지만 엄연히 분리된 클래스입니다. 이렇게 우리가 해당 서비스를 쓰기 위해 클래스를 계속 새로 만들게 된다면 클래스가 엄청나게 많이 만들어지고, 엄청나게 소멸되면서 우리의 프로젝트에 엄청난 부하를 주게 될 수 있습니다.

싱글톤 패턴이란 해당 문제를 해결하기 위해 프로그램이 실행되면서 바로 딱 클래스당 하나의 클래스만 새로 생성되면서 여기저기서 클래스를 쓸 때는 이미 만들어진 클래스를 불러와서 이용하기만 하도록 적용시키는 것입니다.


싱글톤 패턴은 어떻게 쓰지?

이제 우리의 프로젝트를 위해 싱글톤 패턴을 써서 굳이 중복으로 새로 만들 필요 없는 클래스들을 하나로 사용하고 싶어 지셨을 거예요. 임의로 그럼 싱글톤 서비스를 간단하게 만들어볼까요?

지금 singleton 패키지 내에서 바로 서비스를 만들어볼게요. 실제 프로젝트에 쓸 건 아니고 확인용이니까요!

package com.naver.shopping.singleton;
 
public class SingletonService {
    //1. static 영역에 객체를 딱 1개만 생성해둔다.
    private static final SingletonService instance = new SingletonService();
    //2. public으로 열어서 객체 인스턴스가 필요하면 이 static 메서드를 통해서만 조회하도록허용한다.
    public static SingletonService getInstance() {
        return instance;
    }
    //3. 생성자를 private으로 선언해서 외부에서 new 키워드를 사용한 객체 생성을 못하게 막는다.
    private SingletonService() {
    }
    // 임의로 호출 잘 되는지 확인용 함수
    public void logic() {
        System.out.println("실행 성공!");
    }
}

정리해 보자면

  1. 본인 스스로의 클래스를 new를 통해 생성해서 private static final형태로 선언한다.
  2. 그렇게 선언한 결과물을 return 하는 함수를 public으로 외부에서 사용할 수 있도록 한다.
  3. 본인 클래스의 생성자를 private로 선언하여 외부에서 new를 통해 생성할 수 없도록 만든다.

그럼 다시 Test클래스로 돌아와서 아래 테스트를 추가해서 이번엔 isSameAs로 두 객체가 같은지 확인해 봅시다!

    @Test
    @DisplayName("싱글톤 패턴을 적용한 객체 사용")
    public void singletonServiceTest() {
        //private으로 생성자를 막아두었다. 컴파일 오류가 발생한다.
        //new SingletonService();
        //1. 조회: 호출할 때 마다 같은 객체를 반환
        SingletonService singletonService1 = SingletonService.getInstance();
        //2. 조회: 호출할 때 마다 같은 객체를 반환
        SingletonService singletonService2 = SingletonService.getInstance();
        //참조값이 같은 것을 확인
        System.out.println("singletonService1 = " + singletonService1);
        System.out.println("singletonService2 = " + singletonService2);
        assertThat(singletonService1).isSameAs(singletonService2);
        singletonService1.logic();
        singletonService2.logic();
    }

실행결과

아이디값 보이시나요!!! 두 서비스가 @4149c063으로 동일합니다!!!! 딱 한 번만 new 된 서비스 클래스를 singletonService1과 singletonService2가 이미 생성된 객체를 불러와 사용하도록 된 겁니다.


싱글톤 구현의 단점 해결하기

(아 그거 그렇게하는거 아닌데..)

아니 그 위대한 싱글톤 패턴을 적용해 놓고 왜 불편한 거죠? 왜냐면 사실 싱글톤 패턴을 위와 같이 적용하면 단점이 많이 따라옵니다

  • 싱글톤 패턴을 구현하는 코드 자체가 많이 들어간다.
  • 의존관계상 클라이언트가 구체 클래스에 의존한다. DIP를 위반한다.
  • 클라이언트가 구체 클래스에 의존해서 OCP 원칙을 위반할 가능성이 높다.
  • 테스트하기 어렵다. 내부 속성을 변경하거나 초기화하기 어렵다.
  • private 생성자로 자식 클래스를 만들기 어렵다.
  • 결론적으로 유연성이 떨어진다.
  • 안티패턴으로 불리기도 한다.

음… 대를 위해 이 정도는 감수해야 하는 법… 이라기엔 사실 감수를 안 해도 되는 방법이 있습니다. 바로 스프링을 사용하는 것!

(또 당신입니까… G.O.A.T)

그리고 사실 이와 같은 방법은 우리가 사실 써봤었습니다. 바로 @Configuration 데코레이터를 쓰고 그 이름 이상했던 AnnotationConfigApplicationContext을 썼던 지난 시간 기억하시나요? 이 방식으로 클래스를 사용하면 사실을 알아서 싱글톤패턴으로 불러왔었던 것입니다. 한번 해볼까요? 테스트를 하나 추가해 봅시다!

    @Test
    @DisplayName("스프링 컨테이너와 싱글톤")
    void springContainer() {
        ApplicationContext ac = new AnnotationConfigApplicationContext(AppConfig.class);
        //1. 조회: 호출할 때 마다 같은 객체를 반환
        MemberService memberService1 = ac.getBean("memberService", MemberService.class);
        //2. 조회: 호출할 때 마다 같은 객체를 반환
        MemberService memberService2 = ac.getBean("memberService", MemberService.class);
        //참조값이 같은 것을 확인
        System.out.println("memberService1 = " + memberService1);
        System.out.println("memberService2 = " + memberService2);
        assertThat(memberService1).isSameAs(memberService2);
    }

실행결과

두 서비스가 @a486d78로 같은 모습입니다.

세상에 데코레이터 하나 썼다고 귀찮은 싱글톤 생성 과정 다 스킵하고 싱글톤 패턴으로 클래스를 불러오다니!! 이렇게 간편하게 싱글톤 패턴을 적용하게 할 수 있는 Spring을 보니 문득 롤 초창기부터 지금까지 현역으로 달려온 페이커가 새삼 대단하다고 느껴지는군요…

(항시 숭배하십시오)


Configuration에서 중복 new 해결하기

우리의 설정 파일인 AppConfig에서 궁금한 부분이 하나 있는데 한번 보러 가보시죠.

@Configuration
public class AppConfig {
    @Bean
    public MemberRepository memberRepository() {
        return new MemoryMemberRepository(); 
    }
 
    @Bean
    public DiscountPolicy discountPolicy() {
        return new RateDiscountPolicy();
    }
 
    @Bean
    public MemberService memberService() {
    	// 여기도 new MemoryMemberRepository()가 진행됨
        return new MemberServiceImpl(memberRepository());
    }
 
    @Bean
    public OrderService orderService() {
    	// 저기도 new MemoryMemberRepository()가 진행됨
        return new OrderServiceImpl(memberRepository(), discountPolicy());
    }
}

보시면 memberRepository()가 실행될 때마다 new MeoryMemberRepository()가 실행되죠? 근데 제가 주석으로 적은 두 부분에서 memberRepository()가 실행되는데 그렇다면 MeoryMemberRepository()가 두 번이나 new 된다는 건데… 그럼 MeoryMemberRepository가 여러 개 생기는 건데… 그럼 싱글톤이 안 지켜지는 게 아닐까요? 엇??!?!

한번 직접 돌려서 확인해 봅시다.

우선 MemberServiceImpl과 OrderServiceImpl에 해당 서비스에서 사용하고 있는 MemberRepository를 그대로 return해주는 함수를 각각 만들어줍시다.

public MemberRepository getMemberRepository() {
	return memberRepository;
}
 
  
 
그리고 우리의 테스트 코드에서 아래 테스트를 추가해 줍시다.
 
    @Test
    @DisplayName("Configuration 내에서 중복된 new 처리 확인")
    void configurationTest() {
        ApplicationContext ac = new AnnotationConfigApplicationContext(AppConfig.class);
        MemberServiceImpl memberService = ac.getBean("memberService", MemberServiceImpl.class);
        OrderServiceImpl orderService = ac.getBean("orderService", OrderServiceImpl.class);
        MemberRepository memberRepository = ac.getBean("memberRepository", MemberRepository.class);
        //모두 같은 인스턴스를 참고하고 있다.
        System.out.println("memberService -> memberRepository = " + memberService.getMemberRepository());
        System.out.println("orderService -> memberRepository = " + orderService.getMemberRepository());
        System.out.println("memberRepository = " + memberRepository);//모두 같은 인스턴스를 참고하고 있다.
        assertThat(memberService.getMemberRepository()).isSameAs(memberRepository);
        assertThat(orderService.getMemberRepository()).isSameAs(memberRepository);
    }

각각 memberService에서 쓰고 있는 memberRepository, orderService에서 쓰고 있는 memberRepository 그리고 AppConfig에서 바로 뽑아온 memberRepository 세 개를 비교하는 테스트입니다.

실행결과

(어캐했누?)

뭐지? 분명 따로 new가 되어서 id가 각각 다른 MemoryMemberRepository가 생겨야 할 텐데 아이디가 다 같습니다. 근데 이건 저도 어떻게 한 건지 잘 모르겠어서.. spring이 Configuration 내에서 알아서 중복으로 생성될 클래스를 중복으로 생성 안되게 해주는 기능이 있다 정도로 알아주시면 될 것 같습니다.

중요한 건!! @Configuration을 써서 스프링이 아! 얘는 설정파일이구나라고 알게 해 준 클래스한테만 이렇게 적용시켜 줍니다. @Configuration을 쓰지 않은 클래스 내에서 아무리 @Bean데코레이션을 써서 선언한다 한들 이렇게 완벽하게 싱글톤 패턴을 적용시켜주지 않습니다. 주의해 주세요!


마치며

오늘은 싱글톤 패턴의 장점과 이걸 편하게 잘 적용시켜 주는 Spring의 위대함을 살짝 맛보는 시간을 가져봤습니다. 앞으로 한동안 이렇게 구현보다는 스프링의 기능에 대해 자세하게 알아보도록 하겠습니다.

이번 글에서 작업한 코드 내역은 아래에서 확인하실 수 있습니다.

https://github.com/KIMB0B/blog_spring/commit/9ac17b97e698124db15b45f5cffbf0952c54b064

👉다음 글 보러가기