[Spring] Chapter 5. 싱글톤 컨테이너

joyful·2021년 7월 13일
0

Java/Spring

목록 보기
15/28
post-thumbnail

들어가기 앞서

이 글은 김영한 님의 스프링 핵심 원리 - 기본편(https://www.inflearn.com/스프링-핵심-원리-기본편/dashboard)을 수강하며 학습한 내용을 정리한 글입니다. 모든 출처는 해당 강의에 있습니다.


📖 웹 애플리케이션과 싱글톤

  • 스프링은 기업용 온라인 서비스 기술을 지원하기 위해 탄생함
  • 대부분의 스프링 애플리케이션 → 웹 애플리케이션
    • 보통 여러 고객이 동시에 요청


✅ 스프링 없는 순수 DI 컨테이너 테스트

[src/test/java/hello/core/singleton/SingletonTest.java]

package hello.core.singleton;

import hello.core.AppConfig;
import hello.core.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);

        //memberService1 != memberService2
        assertThat(memberService1).isNotSameAs(memberService2);
    }
}
  • 문제점 : 요청 할 때마다 객체를 새로 생성 → 심한 메모리 낭비
    ex) 고객 트래픽: 초당 100 → 객체가 초당 100개 생성 및 소멸
  • 해결방안 : 해당 객체를 1개만 생성하여 공유하도록 설계 → 싱글톤 패턴


📖 싱글톤 패턴

클래스의 인스턴스가 1개만 생성되는 것을 보장하는 디자인 패턴

  • 객체 인스턴스를 2개 이상 생성하지 못하도록 해야 함
    private 생성자를 사용하여 외부에서의 new 사용 방지
  • 장점 : 생성된 객체를 공유하므로 효율적으로 사용 가능

💻 싱글톤 패턴을 적용한 예제 코드

[src/test/java/hello/core/singleton/SingletonService.java]

package hello.core.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. static 영역에 객체 instance를 미리 하나 생성하여 올려둔다.
  2. 해당 객체 인스턴스가 필요한 경우, getInstance() 메서드를 호출하여 조회 가능하다. 이 메소드는 항상 같은 인스턴스를 반환한다.
  3. 생성자를 private으로 지정하여 외부에서 new 키워드로 객체 인스턴스가 생성되는 것을 방지한다. ★

💻 싱글톤 패턴을 사용하는 테스트 코드

[src/test/java/hello/core/singleton/SingletonService.java]

package hello.core.singleton;

...

public class SingletonServiceTest {

    ...

    @Test
    @DisplayName("싱글톤 패턴을 적용한 객체 사용")
    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);

        //memberService1 == memberService2
        assertThat(singletonService1).isSameAs(singletonService2);
    }

    singletonService1.logic();
}
  • private으로 new 키워드 막아둠
  • 호출할 때마다 같은 객체 인스턴스 반환 확인 가능

✅ 문제점 : 낮은 유연성

  • 싱글톤 패턴 구현 코드 자체의 양이 많음
  • 클라이언트가 구체 클래스에 의존 → DIP, OCP 위반
    ex) memberServiceImpl.getInstance
         → memberServiceImpl은 구체 클래스임
  • 테스트하기 어려움
  • 내부 속성 변경 및 초기화가 어려움
  • private 생성자로 자식 클래스를 생성하기 어려움

=> 안티패턴으로 불리기도 함



📖 싱글톤 컨테이너

✅ 싱글톤 컨테이너

  • 스프링 컨테이너는 싱글턴 패턴을 적용하지 않아도 객체 인스턴스를 싱글톤으로 관리함
  • 싱글톤 레지스트리 : 스프링 컨테이너는 싱글톤 컨테이너의 역할(싱글톤 객체 생성 및 관리)을 수행함
    → 싱글톤 패턴의 문제점 해결 + 객체 인스턴스를 싱글톤으로 관리
    • 싱글톤 패턴을 위한 지저분한 코드가 감소
    • DIP, OCP, 테스트, private 생성자로부터 자유로운 싱글톤 사용 가능
  • 스프링 빈 → 싱글톤으로 관리되는 빈

✅ 스프링 컨테이너를 사용하는 테스트 코드

[src/test/java/hello/core/singleton/SingletonService.java]

package hello.core.singleton;

...

public class SingletonTest {

    ...

    @Test
    @DisplayName("스프링 컨테이너와 싱글톤")
    void singletonContainer() {

        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);

        //memberService1 == memberService2
        assertThat(memberService1).isSameAs(memberService2);
    }
}

✅ 싱글톤 컨테이너 적용 후

  • 스프링 컨테이너 덕분에 고객의 요청이 올 때마다 객체를 생성하지 않고, 이미 생성된 객체를 공유하여 효율적으로 재사용 가능해짐

💡 스프링의 기본 빈 등록 방식은 싱글톤이지만, 요청할 때마다 새로운 객체를 생성해서 반환하는 기능도 제공한다.



📖 싱글톤 방식의 주의점 ★★★

  • 싱글톤 객체는 항상 무상태(stateless)로 설계해야 함
    • 특정 클라이언트에 의존적인 필드가 존재하면 안 됨
    • 특정 클라이언트에 값 변경이 가능한 필드가 존재하면 안 됨
    • 가급적 읽기만 가능해야 함
    • 필드 대신 자바에서 공유되지 않는 지역변수, 파라미터, ThreadLocal 등을 사용해야 함
  • 스프링 빈의 필드에 공유 값 설정 시 큰 장애가 발생할 수 있음

✅ 상태를 유지(stateful)할 경우 발생하는 문제점 예시

[src/test/java/hello/core/singleton/StatefulService.java]

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;
    }
}

[src/test/java/hello/core/singleton/StatefulServiceTest.java]

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);

        //ThreadA : A사용자가 10000원 주문
        statefulService1.order("userA", 10000);
        //ThreadB : B사용자가 20000원 주문
        statefulService2.order("userB", 20000);

        //ThreadA : A사용자 주문 금액 조회
        int price = statefulService1.getPrice();
        //ThreadA : A사용자 기대금액 10000원, 출력 20000원
        System.out.println("price = " + price);

        assertThat(statefulService1.order("userA", 10000)).isEqualTo(10000);
    }

    static class TestConfig {

        @Bean
        public StatefulService statefulService() {
            return new StatefulService();
        }
    }
}
  • 코드 호출 : ThreadA → 사용자A, ThreadB → 사용자B
  • StatefulServiceprice 필드
    • 공유되는 필드
    • 특정 클라이언트가 값을 변경
  • 결과 : 사용자A의 주문금액이 10000원이 아닌 20000원이 나옴

✅ 해결 방안 예시

[src/test/java/hello/core/singleton/StatefulService.java]

package hello.core.singleton;

public class StatefulService {

    public int order(String name, int price) {
        System.out.println("name = " + name + ", price = " + price);
        return price;  // 필드 대신 파라미터 사용
    }
}

[src/test/java/hello/core/singleton/StatefulServiceTest.java]

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);

        //ThreadA : A사용자가 10000원 주문 및 금액 조회
        int price1 = statefulService1.order("userA", 10000);
        //ThreadB : B사용자가 20000원 주문 및 금액 조회
        int price2 = statefulService2.order("userB", 20000);

        //ThreadA : A사용자 기대금액 10000원, 출력 20000원
        System.out.println("price1 = " + price1);

        assertThat(statefulService1.order("userA", 10000)).isEqualTo(10000);
    }
    
    ...
}


📖 @Configuration과 싱글톤

[src/main/java/hello/core/AppConfig.java]

@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();
    }

    ...
}
  • 서로 다른 2개의 MemoryMemberRepository가 생성되면서 싱글톤이 깨지는 것처럼 보임
    • memberService()에서 memberRepository() 호출
      new MemoryMemberRepository() 호출
    • orderService()에서 memberRepository() 호출
      new MemoryMemberRepository() 호출

✅ 검증 용도의 코드 추가

테스트를 위해 MemberRepository를 조회할 수 있는 기능을 추가한다.

[src/main/java/hello/core/member/MemberServiceImpl.java]

public class MemberServiceImpl implements MemberService {

    private final MemberRepository memberRepository;  //실제 값을 확인해보면 됨

    //테스트 용도
    public MemberRepository getMemberRepository() {
        return memberRepository;
    }
}

[src/main/java/hello/core/order/OrderServiceImpl.java]

public class OrderServiceImpl implements OrderService {

    private final MemberRepository memberRepository;

    //테스트 용도
    public MemberRepository getMemberRepository() {
        return memberRepository;
    }
}

✅ 테스트 코드

[src/test/java/hello/core/singleton/ConfigurationSingletonTest.java]

package hello.core.singleton;

import hello.core.AppConfig;
import hello.core.member.MemberRepository;
import hello.core.member.MemberServiceImpl;
import hello.core.order.OrderServiceImpl;
import org.junit.jupiter.api.Test;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;

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

public class ConfigurationSingletonTest {

    @Test
    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);
    }

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

        AppConfig bean = ac.getBean(AppConfig.class);

        System.out.println("bean = " + bean.getClass());
    }
}
  • memberRepository 인스턴스는 모두 같은 인스턴스가 공유되어 사용된다.

AppConfig에 호출 로그 남김

[src/main/java/hello/core/AppConfig.java]

package hello.core;

import hello.core.discount.DiscountPolicy;
import hello.core.discount.RateDiscountPolicy;
import hello.core.member.MemberService;
import hello.core.member.MemberServiceImpl;
import hello.core.member.MemoryMemberRepository;
import hello.core.order.OrderService;
import hello.core.order.OrderServiceImpl;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class AppConfig {

    @Bean
    public MemberService memberService() {
    	//1번 호출
        System.out.println("Call AppConfig.memberService");
        return new MemberServiceImpl(memberRepository());
    }

    @Bean
    public OrderService orderService(){
    	//1번 호출
        System.out.println("Call AppConfig.orderService");
        return new OrderServiceImpl(memberRepository(), discountPolicy());
    }
    
    @Bean
    public MemberRepository memberRepository() {
    	//2번 호출? 3번 호출?
        System.out.println("Call AppConfig.memberRepository");
        return new MemoryMemberRepository();
    }

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

}

📝 예상

  • 스프링 컨테이너는 각 @Bean을 호출하여 스프링 빈을 생성함
    memberRepository() 3번 호출될 것으로 예상
    1. 스프링 컨테이너가 스프링 빈에 등록하기 위해 @Bean이 붙어있는 memberRepository() 호출
    2. memberService() 로직에서 memberRepository() 호출
    3. orderService() 로직에서 memberRepository() 호출

📝 결과

  • 모두 1번만 호출
call AppConfig.memberService
call AppConfig.memberRepository
call AppConfig.orderService


📖 @Configuration과 바이트코드 조작의 마법

  • 스프링 컨테이너는 싱글톤 레지스트리
    • 스프링 빈이 싱글톤이 되도록 보장해주어야 함
    • 자바 코드 자체를 조작할 수는 없음
      → 클래스의 바이트코드 조작 라이브러리"CGLIB" 사용

[src/test/java/hello/core/singleton/ConfigurationSingletonTest.java]

public class ConfigurationSingletonTest {

    ...

    @Test
    void configurationDeep() {
    	//AnnotationConfigApplicationContext에 파라미터로 넘긴 값은 스프링 빈으로 등록 됨
        ApplicationContext ac = new AnnotationConfigApplicationContext(AppConfig.class);

	//AppConfig도 스프링 빈으로 등록 됨
        AppConfig bean = ac.getBean(AppConfig.class);

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

📝 예상

순수한 클래스라면 다음과 같이 출력되어야 한다.

bean = class hello.core.AppConfig

📝결과

bean = class hello.core.AppConfig$$EnhanceBySpringCGLIB$$bd479d70

  • 스프링이 CGLIB를 사용하여 AppConfig 클래스를 상속받은 임의의 다른 클래스를 만들어 스프링 빈으로 등록 → 싱글톤 보장


📝 AppConfig@CGLIB 예상 코드

@Bean
public MemberRepository memberRepository() {

    if(memoryMemberRepository가 이미 스프링 컨테이너에 등록되어 있음) {
        return 스프링 컨테이너에서 찾아서 반환;
    }
    else {  //스프링 컨테이너에 없으면
        기존 로직을 호출하여 MemoryMemberRepository를 생성하고 스프링 컨테이너에 등록
        return 반환;
    }
}
  • @Bean이 붙은 메서드마다 빈을 반환하는 코드가 동적으로 만들어짐
    • 스프링 빈이 이미 존재하면 존재하는 빈을 반환
    • 스프링 빈이 없으면 생성하여 스프링 빈으로 등록하고 반환

💡 AppConfig@CGLIBAppConfig의 자식 타입이므로 AppConfig 타입으로 조회 할 수 있다.


@Configuration을 적용하지 않고 @Bean만 적용하는 경우

  • @Configuration → CGLIB 기술을 사용해서 싱글톤 보장

📝 AppConfig 수정

[src/main/java/hello/core/AppConfig.java]

//@Configuration 삭제
public class AppConfig {
    ...
}

📝 실행 결과

bean = class hello.core.AppConfig

AppConfig가 CGLIB 기술 없이 순수한 AppConfig로 스프링 빈에 등록 됨

Call AppConfig.memberService
Call AppConfig.memberRepository  //@Bean → 스프링 컨테이너에 등록
Call AppConfig.orderService
Call AppConfig.memberRepository  //memberRepository() 호출
Call AppConfig.memberRepository  //memberRepository() 호출

MemberRepository가 총 3번 호출됨

📝 동일한 인스턴스 판별 테스트 결과

memberService → memberRepository = hello.core.member.MemoryMemberRepository@6239aba6
orderService → memberRepository = hello.core.member.MemoryMemberRepository@3e6104fc
memberRepository = hello.core.member.MemoryMemberRepository@12359a82
  • 각각 다 다른 MemoryMemberRepository 인스턴스 소유
    • orderServiceImplMemberServiceImpl에 주입된 MemberRepository는 스프링 빈이 아님
      why? 스프링 컨테이너가 관리하지 않으므로
    • 사용자가 직접 new로 생성한 객체와 같음

🔍 정리

  • @Bean만 사용해도 스프링 빈으로 등록되지만, 싱글톤을 보장하지 않음
    ex) memberRepository() : 의존관계 주입 필요하여 메서드 직접 호출 → 싱글톤 보장x
  • 스프링 설정 정보는 항상 @Configuration을 사용하도록 한다.
profile
기쁘게 코딩하고 싶은 백엔드 개발자

0개의 댓글