[Backend] 3. 싱글톤 컨테이너

김재혁·2023년 10월 31일

BackENd

목록 보기
3/7

1. 무한한 객체 생성?


우리는 지난번까지 @Bean과 스프링 컨테이너가 외부에서 DI를 주입하는 방식을 알아보았다. 스프링 컨테이너가 어떻게 DIP원칙과 OCP원칙을 지켜 더 나은 객체 지향 프로그래밍을 할 수 있게 하는지 알 수 있었다. 허나 여기에는 문제가 있다.

@Configuration //애플리케이션 구성정보
public class AppConfig {


    @Bean
    public MemberService memberService(){
        System.out.println("call AppConfig.memberService");
        return new MemberServiceImpl(memberRepository());
    } 
    @Bean
    public MemberRepository memberRepository() {
        System.out.println("call AppConfig.memberRepository");
        return new MemorymemberRepository(); 
    }
    @Bean
    public OrderService orderService(){ 
        System.out.println("call AppConfig.orderService");
        return new OrderServiceImpl(
                memberRepository(),
                discountPolicy());

    }
    @Bean
    public DiscountPolicy discountPolicy() {

        //return new FixDiscountPolicy();
        return new RateDiscountPolicy();
    }
}

위 코드는 지난번 스프링 컨테이너가 외부에서 DI를 주입시켜 객체를 생성하는 코드이다. 만약에 이 웹 애플리케이션을 사용하는 유저가 100명, 1000명, 10000명이라면 어떻게 될까? 객체가 100개, 1000개, 10000개가 생성이 될거다.

딱 봐도 느끼기에 좋은 현상은 아니다, 객체가 많아지니 메모리 부담과 GC가 계속해서 동작해야하니 성능면에서 매우 좋지 않다. 아래는 객체생성 메서드를 호출할 때마다 다른 객체가 생성됨을 보여주는 코드이다.

  @Test
    @DisplayName("스프링 없는 순수한 DI 컨테이너")
    void pureContainer() {
        AppConfig appConfig = new AppConfig();

        //1. 조회1 : 호출 할 때마다 객체 생성
        MemberService memberService1 = appConfig.memberService();

        //2. 조회2 : 호출 할 때마다 객체 생성
        MemberService memberService2 = appConfig.memberService();

        System.out.println("memberService1 = " + memberService1);
        System.out.println("memberService2 = " + memberService2);

        assertNotSame(memberService1, memberService2);
    }

memberService1과 memberService2 객체의 뒤의 해시코드를 보면 다른 객체임을 알 수 있다.

그렇다면 어떻게 이 문제를 해결해야 할까?

1-1. 싱글톤 패턴

싱글톤 패턴은 자바의 대표적인 디자인 패턴 중 하나이다. 싱글톤 패턴의 정의는 객체의 인스턴스를 오직 단 하나만 생성하는 패턴을 의미한다.

public class SingletonService {

    private static final SingletonService instance = new SingletonService();

    public static SingletonService getInstance(){
        return instance;
    } //조회시 사용

    private SingletonService(){}

    public void logic(){
        System.out.println("싱글톤 객체 로직 호출");
    }
}

싱글톤을 사용하는 이유는 위의 메모리 해결책을 포함하여 데이터 공유가 쉽다는 장점이 있다.

물론 문제점도 있다

  • 코드양이 많아지면서 비용 증가
  • 의존관계상 클라이언트가 구현체에 의존하면서 DIP 를 위반하게 된다
  • DIP를 위반하면 자연스래 OCP를 위반하고 SOLID 원칙 실패에 따라서 객체지향 설계가 무너진다
  • 유연성이 떨어진다
  • 안티패턴이라고 불린다

이러한 문제점이 있는 싱글톤 패턴이지만 스프링에서는 이 문제점들을 모두 배제하여 장점들만 모아놓은 싱글톤 컨테이너로 위의 문제를 해결했다!


2.싱글톤 컨테이너 (== 스프링 컨테이너?)


스프링 컨테이너는 싱글톤 패턴의 문제점을 해결하면서, 객체 인스턴스를 싱글톤(only one)으로 관리한다.

  • 스프링 컨테이너는 싱글톤 패턴을 적용하지 않아도, 객체 인스턴스들을 알아서 싱글톤으로 관리한다
  • 스프링 컨테이너느 싱글톤 컨테이너의 역할도 한다. 싱글톤 객체를 관리하고 생성하는 기능을 싱글톤 레지스트리 라고 한다.
  • 스프링 컨테이너의 이러한 기능 덕분에 싱글톤 패턴의 모든 단점을 해결하면서 객체를 싱글톤으로 유지할 수 있다.
    • 싱글톤 패턴을 위한 지저분한 코드 놉
    • DIP, OCP, 테스트, private 생성자로 부터 자유롭게 싱글톤을 사용할 수 있다.
    @Test
    @DisplayName("싱글톤 패턴을 적용한 객체 사용")
    void singletonServiceTest() {
        SingletonService singletonService1 = SingletonService.getInstance();
        SingletonService singletonService2 = SingletonService.getInstance();

        System.out.println("singletonService1 = " + singletonService1);
        System.out.println("singletonService2 = " + singletonService2);

        assertSame(singletonService1, singletonService2);
        // same ==
        // equal
    }

출력 결과를 보면 해시코드가 같아, 하나의 인스턴스만 생성된걸 볼 수 있다.

싱글톤 방식의 주의점

  • 싱글톤 패턴 또는 스프링 컨테이너를 사용하든 객체 인스턴스를 하나만 생성해서 공유하는 싱글톤 방식은 여러 클라이언트가 하나의 같은 객체 인스턴스를 공유하기 때문에 싱글톤 객체를 상태를 유지(Stateful)하게 설계하면 안된다!
  • Stateless 로 설계해야한다!
  • 특정 클라이언트에 의존적인 필드가 있으면 안된다
  • 필드 대신에 자바에서 공유되지 않는, 지역변수, 파라미터, ThreadLocal 등을 사용해야함

Stateful & Stateless ?

stateful

  • 상태 유지라고 하며 클라이언트와 서버 관계에서 서버가 클라이언트의 상태를 보전하는 것을 의미한다.

  • 즉 클라이언트와 서버 간에 송수신을 하며 단계별 과정을 진행하는데 있어, 서버에서 클라이언트가 단계에서 제공한 값을 저장하고 다음 단게에서도 저장한 상태이다.

  • 대표적으로 홈페이지에서 한번 로그인을 하면 페이지를 이동해도 로그인이 풀리지 않고 계속 유지되는 것이 바로 서버가 클라이언트의 상태를 유지하고 있으니까 가능한 것이다.

stateless

  • 무상태는 반대로 클라이언트와 서버 관계에서 서버가 클라이언트의 상태를 보전하지 않음을 의미한다.
  • statelsee한 구조에서 서버는 단순히 요청이 오면 응답을 보내는 역할만 수행하며, 상태 관리는 전적으로 클라이언트에게 책임이 있다.
  • 즉, 클라이언트와 서버간의 통신에 필요한 모든 상태 정보들은 클라이언트에서 가지고 있다가 서버와 통신할 때 데이터를 실어보내느 것이 stateless 구조이다.

아무튼 계속해서 싱글톤으로 이어가서 아래 코드로 문제점 예시를 들어보면

package hello.core.singleton;

public class StatefulService {
    private int price; // 상태를 유지하는 필드

    public void order(String name, int price) {
        System.out.println("name = " + name + " price = " + price);
        this.price = price;
    }

    public int getPrice() {
        return price;
    }
}
package hello.core.singleton;

import org.junit.jupiter.api.Test;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.Bean;

import static org.assertj.core.api.Assertions.assertThat;

public class StatefulServiceTest {

    @Test
    void statefulServiceSingleton() {
        ApplicationContext ac = new AnnotationConfigApplicationContext(TestConfig.class);
        StatefulService statefulService1 = ac.getBean("statefulService", StatefulService.class);
        StatefulService statefulService2 = ac.getBean("statefulService", StatefulService.class);

        statefulService1.order("userA", 10000);
        statefulService2.order("userB", 20000);

        System.out.println("statefulService1.getPrice() = " + statefulService1.getPrice());
        assertThat(statefulService1.getPrice()).isEqualTo(20000);
    }

    static class TestConfig{
        @Bean
        public StatefulService statefulService() {
            return new StatefulService();
        }
    }
}

여기서 테스트가 통과된다면 잘못되고 있는거다. 왜냐하면 statefulService1의 price값은 10000이 되야하는데 20000이랑 같냐고 묻는 테스트에서 통과됐으니,, 20000원이 된 상태라 볼 수 있다.

코드에서 볼 수 있는데 statefulService1과 statefulService2는 같은 객체 인스턴스(싱글톤) 객체를 바라보기 때문에 이러한 문제가 발생한 것이다.

  1. userA, 10000 생성 --> price: 10000
  2. userB, 20000 생성 --> price: 20000

여기서 price는 싱글톤으로 하나의 객체 인스턴스를 통해 공유되기 때문에 userA는 자신이 주문한 뒤 주문한 userB의 금액이 입력되는 것이다.

즉, 싱글톤을 사용할 때는 Stateless 상태가 되어야한다.


3. @Configuration과 싱글톤


스프링 컨테이너(싱글톤 컨테이너)는 빈들을 하나만 생성해 관리한다.

@Configuration
public class AppConfig {

    @Bean
    public MemberService memberService() {
        return new MemberServiceImpl(memberRepository());
    }

    @Bean
    public OrderService orderService() {
        return new OrderServiceImpl(memberRepository(), discountPolicy());
    }

    @Bean
    public MemberRepository memberRepository() {
        return new MemoryMemberRepository();
    }

    @Bean
    public DiscountPolicy discountPolicy() {
        return new RateDiscountPolicy();
    }
}

위 코드를 보면 memberService , orderService 모두 각각 자신의 구현체를 반환하는데 그 내부 파라미터를 보면 둘 다 memberRepository를 호출하고 있다.

memberRepositoryMemoryMemberRepository 인스턴스를 반환한다. 그렇다면 총 세번 내부 코드가 실행되어 MemoryMemberRepository 인스턴스가 3번 생성되는데 어떻게 싱글톤을 유지할 수 있을까?

답은 바이트 코드 조작 에 있다.

바이트 코드 조작

이 의문점에 대한 정답은 @Configuration 어논테이션에 있다!

@Configuration

앞선 과정에서 보다시피 Sping에서 Bean을 수동으로 등록하기 위해서는, 설정 class위에 @Confoguration 을 추가하고 @Bean을 사용해 수동으로 빈을 등록할 수 있었다.

하지만 @Confoguration단순히 Bean을 등록하기 위한 애논테이션이 아니다

  • Bean을 등록 시 싱글톤 보장
  • 스프링 컨테이너에서 Bean을 관리할 수 있게됨

아래 테스트 코드에서 AppConfig.class가 어떻게 생성되는지 보자

 @Test
 void configurationDeep(){
        AnnotationConfigApplicationContext ac = new AnnotationConfigApplicationContext(AppConfig.class);
        AppConfig bean = ac.getBean(AppConfig.class);

        System.out.println("bean = " + bean.getClass());
    }

AppConfig면 AppConfig였지 뒤에 이상한 단어가 엄청 붙었다. 우리는 CGLIB에 집중할 필요가 있다.

스프링에서 CGLIB라는 바이트코드 조작 라이브러리를 사용해서 AppConfig 클래스를 상속받은 임의의 다른 클래스를 만들고 그 다른 클래스를 스프링 빈으로 등록한 것이다.

이렇게 바이트코드 조작으로 만들어진 AppConfig@CGLIB는 싱글톤이 보장된다. 그러면서 AppConfig를 상속했으니 당연히 AppConfig 타입으로도 조회가 가능하다.


후기

  • 싱글톤 패턴은 객체가 무한히 생성되는 것을 막아주지만 그에 따른 부작용도 있다
  • 스프링에선 스프링 컨테이너를 이용하여 싱글톤의 단점을 보완하여 객체지향과 SOLID 원칙 모두 만족하여 프로그래밍 가능하게 한다
  • @Configuration 어논테이션은 싱글톤을 보장하게 하고 스프링 컨테이너에서 Bean을 관리해준다
profile
아웃라이어 :)

0개의 댓글