Spring

스프링 기본편 4

woohap 2025. 1. 13. 16:22

노션 링크
https://earthy-grouse-d42.notion.site/1765723e06ee80adb19ddd059cde05e5

웹 애플리케이션과 싱글톤

- 스프링은 태생이 기업용 온라인 서비스 기술을 지원하기 위해 개발
- 대부분의 스프링 애플리케이션은 웹 애플리케이션
  웹이 아닌 애플리케이션 개발도 얼마든지 가능 

[웹 애플리케이션은 보통 여러 고객이 동시에 요청을한다.]
- 스프링 없는 순수한 DI 컨테이너를 사용하면 요청을 할 떄마다 객체를 새로 생성 
  -> 만약 고객 트래픽이 초당 100이라면, 초당 100개의 객체가 생성되고 소멸되어 
     메모리 낭비 발생 
// 순수한 DI 컨테이너
    @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);

        // 두 객체가 다른 경우 테스트 통과
        Assertions.assertThat(memberService1).isNotSameAs(memberService2);
    }
 /*
  결과 - 서로 다른 객체 생성됨
    memberService1 = hello.core.member.MemberServiceImpl@709ba3fb
    memberService2 = hello.core.member.MemberServiceImpl@3d36e4cd
 */
이를 해결하려면 객체를 1개만 생성하고, 이를 공유하도록 설계하면 됨 
-> 이를 [싱글톤 디자인 패턴] 이라고 한다.

싱글톤 패턴

- 클래스의 인스턴스가 딱 1개만 생성되는 것을 보장하는 디자인 패턴
  -> 한 자바 JVM 안에서 객체 인스턴스가 하나만 생성됨

- 객체 인스턴스를 2개 이상 생성하지 못하도록 막아야 한다.
  private 생성자를 사용해서 외부에서 임의로 new 키워드를 사용해 객체를 생성하지 못하도록 해야 함 
package hello.core.singleton;

public class SingletonService {

    // private - 해당 클래스 파일에서만 접근 가능
    // static - 클래스가 메모리에 올라갈 때 사용 가능 (인스턴스를 생성하지 않고도 사용 가능)
    // final - 상수화
    // SingletonService 클래스가 로드될 때 자기 자신의 인스턴스를 딱 한 번 생성하여, 이후 변경 불가능한 클래스 변수로 참조하게 함
    private static final SingletonService instance = new SingletonService();

    // instance 참조 변수의 Getter
    // 해당 인스턴스를 꺼낼 수 있는 방법은 이 Getter 밖에 없다.
    public static SingletonService getInstance() {
        return instance;
    }

    // private 생성자
    // 접근 제어자를 private으로 줌으로써 클래스 밖에서 생성하지 못하도록 함
    private SingletonService() {

    }

    public void logic() {
        System.out.println("싱글톤 객체 로직 호출");
    }
}
1. static 영역에 객체 instance를 미리 하나 생성해서 메모리에 적재
2. 이 객체 인스턴스가 필요하면 getInstance() 메서드를 통해서만 조회하도록 구현 
   이 메서드를 호출하면 메모리에 미리 생성된 인스턴스를 반환하도록 함 
3. 생성자를 private으로 만들어 줌으로써, 
   클래스 외부에서 new 연산자를 사용해 새로운 객체를 생성하지 못하도록 막음 
    @Test
    @DisplayName("싱글톤 패턴을 적용한 객체 사용")
    void singletonServiceTest() {
        SingletonService singletonService1 = SingletonService.getInstance();
        SingletonService singletonService2 = SingletonService.getInstance();

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

        // isSameAs - ==와 동일 
        // isEqualTo - equals() 메서드를 어떻게 정의하느냐에 따라 다름
        Assertions.assertThat(singletonService1).isSameAs(singletonService2);
    }
/*
결과 - 동일한 객체 출력 됨
singletonService1 = hello.core.singleton.SingletonService@70b0b186
singletonService2 = hello.core.singleton.SingletonService@70b0b186
*/
- 이 방식은 메모리에 클래스가 올라갈 때,
  instance가 생성되어 올라간다.(클래스 변수이므로)

** 객체가 메모리를 많이 먹는 것이 아니라면 이 방법이 구현하기 간단하고 안전함 **
** 스프링 컨테이너를 사용하면 기본적으로 싱글톤으로 객체를 만들어서 관리 **

[참고]
- 싱글톤 패턴을 구현하는 방법은 여러가지
- 싱글톤 패턴을 적용하면, 고객이 요청이 올 때마다 새로운 객체를 생성하지 않고
  이미 만들어진 객체를 공유해서 효율적으로 사용 가능 

** 하지만 싱글톤 패턴은 많은 문제점을 가지고 있다.

싱글톤 패턴 문제점

- 싱글톤 패턴을 구현하는 코드 자체가 많이 들어간다.
    → 로직이 적어도 싱글톤 패턴을 구현해야 함

- 의존관계상 클라이언트가 구체 클래스에 의존한다. → DIP 위반 
    → singletonService.getInstance()를 사용해서 꺼내야 하므로 구체 클래스에 의존함

- 클라이언트가 구체 클래스에 의존해서 OCP 원칙을 위반할 가능성이 높다.

- 테스트 하기 어렵다.
    → 인스턴스가 이미 만들어져 있어, 유연하게 테스트하기 어렵다.

- 내부 속성을 변경하거나 초기화하기 어렵다.

- private 생성자로 자식 클래스를 만들기 어렵다.

- 유연성이 떨어진다.

- 안티패턴으로 불리기도 한다.

** 스프링 컨테이너는 이런 싱글톤 패턴 문제점을 전부 해결하고 객체를 싱글톤으로 관리해준다. **

싱글톤 컨테이너

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

[싱글톤 컨테이너]
- 스프링 컨테이너는 싱글턴 패턴을 적용하지 않아도, 객체 인스턴스를 싱글톤으로 관리

- 스프링 컨테이너는 싱글톤 컨테이너 역할을 한다.
  싱글톤 객체를 생성하고 관리하는 기능을 싱글톤 레지스트리라고 한다.

- 싱글톤 컨테이너 덕분에 싱글톤 패턴의 모든 단점을 해결하면서
  객체를 싱글톤으로 유지
  -> 싱글톤 패턴을 위한 코드를 작성하지 않아도 되어 코드 가독성 증가
  -> DIP, OCP, 테스트, private 생성자로부터 자유롭게 싱글톤을 사용 
@Test
    @DisplayName("스프링 컨테이너와 싱글톤")
    void springContainer() {
        ApplicationContext ac =  new AnnotationConfigApplicationContext(AppConfig.class);

        MemberService memberService1 = ac.getBean("memberService", MemberService.class   );

        MemberService memberService2 = ac.getBean("memberService", MemberService.class   );

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

        assertThat(memberService1).isSameAs(memberService2);
    }
/*
결과
memberService1 = hello.core.member.MemberServiceImpl@26ceffa8
memberService2 = hello.core.member.MemberServiceImpl@26ceffa8
*/
- memberServiceImpl 코드에 싱글톤 패턴과 관련된 코드가 존재하지 않음
  싱글톤 컨테이너를 사용하면 싱글톤 패턴을 사용할 수 있음

- 스프링 컨테이너 때문에 고객의 요청이 올 때 마다 객체를 생성하는 것이 아닌
  이미 만들어진 객체를 공유해서 효율적으로 재사용할 수 있다.

[참고]
- 스프링 기본 빈 등록 방식은 싱글톤 
  싱글톤 방식만 지원하지 않음 

- scope 방식은 요청이 들어올 때마다 새로운 객체를 생성햇서 반환 
  이후 강의에서 자세하게 설명

싱글톤 방식 주의점

- 싱글톤 패턴이든, 스프링 같은 싱글톤 컨테이너를 사용하든
  객체 인스턴스를 하나만 생성해서 공유하는 싱글톤 방식은
  여러 클라이언트가 하나의 같은 객체 인스턴스를 공유하기 때문에
  싱글톤 객체는 상태를 유지하게 설계해서는 안 된다.

- 중요 !!!!!!
  무상태로 설계해야 한다.
  특정 클라이언트에 의존적인 필드가 있으면 안 된다.
  즉, 특정 클라이언트가 값을 변경할 수 있는 필드가 있으면 안 된다.
  필드 대신 자바에서 공유되지 않는 지역변수, 파라미터, ThreadLocal 등을 사용해야 한다.

- 스프링 빈 필드에 공유 값을 설정하면 큰 장애가 발생할 수 있음 
// 상태를 유지하는 서비스 클래스
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.assertj.core.api.Assertions;
import org.junit.jupiter.api.Test;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.Bean;

class StatefulServiceTest {

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

        // ThreadA: A 사용자 10000원 주문
        statefulService1.order("userA", 10000);

        // ThreadBL B 사용자 20000원 주문
        statefulService1.order("userB", 20000);

        // ThreadA : 사용자A가 주문 금액을 조회
        System.out.println("statefulService1.getPrice() = " + statefulService1.getPrice());

        // 싱글톤 패턴을 사용해 동일한 객체의 price 변수에 값을 저장하므로 마지막으로 주문한 사용자의 값이 저장됨
        Assertions.assertThat(statefulService1.getPrice()).isNotEqualTo(10000);

        // ThreadB : 사용자B가 주문 금액을 조회
        System.out.println("statefulService2.getPrice() = " + statefulService2.getPrice());
        Assertions.assertThat(statefulService2.getPrice()).isEqualTo(20000);
    }

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

    }
}
- 단순히 설명하기 위해 실제 쓰레드를 사용하지 않은 코드임 
- ThreadA가 사용자 A 코드를 호출하고 ThreadB가 사용자 B 코드를 호출한다고 가정했을 때,
  StatefulService의 price 필드는 공유되는 필드인데,
  특정 클라이언트가 값을 변경하면
  그 결과 사용자 A의 주문 금액이 10000원이 아닌 20000원이 출력된다.

- 즉, 사용자 A와 사용자 B가 순서대로 접근하는 것이 아닌
  스레드가 CPU를 번갈아 가며 점유하여 코드가 실행되는 경우
  공유 자원인 price 변수의 값에 다른 사용자가 접근하게 
  되어 다른 사용자의 값으로 변경될 수 있따.

- 그러므로 공유 필드를 조심해야 한다.
  스프링 빈은 항상 무상태로 설계해야 한다.
// 무상태로 설계
package hello.core.singleton;

public class StatefulService {
    public int order(String name, int price){
        System.out.println("name = " + name + " price = " + price);
        return price;
    }
}
package hello.core.singleton;

import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.Test;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.Bean;

class StatefulServiceTest {

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

        // ThreadA: A 사용자 10000원 주문
        int userAPrice = statefulService1.order("userA", 10000);

        // ThreadBL B 사용자 20000원 주문
        int userBPrice = statefulService1.order("userB", 20000);

        // ThreadA : 사용자A가 주문 금액을 조회
        System.out.println("statefulService1.getPrice() = " + userAPrice);
        // 싱글톤 패턴을 사용해 동일한 객체의 price 변수에 값을 저장하므로 마지막으로 주문한 사용자의 값이 저장됨
        Assertions.assertThat(userAPrice).isEqualTo(10000);

        // ThreadB : 사용자B가 주문 금액을 조회
        System.out.println("statefulService2.getPrice() = " + userBPrice);
        Assertions.assertThat(userBPrice).isEqualTo(20000);
    }

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

    }
}

Configuration과 싱글톤

- @Configuration은 싱글톤을 위해서 존재하는 것 
@Configuration
public class AppConfig {

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

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

    @Bean DiscountPolicy discountPolicy2() {
        return new RateDiscontPolicy();
    }

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

    @Bean
    public OrderService orderService() {
        return new OrderServiceImpl(memberRepository(), discountPolicy2());
    }
}
@Bean -> new MemoryMemberRepository()
@Bean -> new MemoryMemberRepository()
이러면 싱글톤이 아닌게 아닌가 ?? 
각각 다른 2개의 MemoryMemberRepository가 생성되면서 싱글톤이 깨지는 것처럼 보임
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.annotation.AnnotationConfigApplicationContext;

public class ConfigurationSingletonTest {

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

        // 구체 타입으로 꺼내면 안 좋지만 getMemberRepository() 메서드를 사용하기 위해 구체 타입으로 꺼낸다.
        MemberServiceImpl memberService = ac.getBean("memberService", MemberServiceImpl.class);
        OrderServiceImpl orderService = ac.getBean("orderService", OrderServiceImpl.class);
        MemberRepository memberRepository = ac.getBean("memberRepository", MemberRepository.class);

        MemberRepository memberRepository1 = memberService.getMemberRepository();
        MemberRepository memberRepository2 = orderService.getMemberRepository();

        System.out.println("memberRepository = " + memberRepository);
        System.out.println("memberRepository1 = " + memberRepository1);
        System.out.println("memberRepository2 = " + memberRepository2);  

        Assertions.assertThat(memberRepository1).isSameAs(memberRepository);
        Assertions.assertThat(memberRepository2).isSameAs(memberRepository);
    }
}
/*
결과 - 동일한 MemoryMemberRepository 객체를 사용 
memberRepository = hello.core.member.MemoryMemberRepository@63f259c3
memberRepository1 = hello.core.member.MemoryMemberRepository@63f259c3
memberRepository2 = hello.core.member.MemoryMemberRepository@63f259c3
*/
- 자바 코드만 보면 new 연산자가 세 번 호출되므로 서로 다른 MemoryMemberRepository 객체가 생성되어야 함 
- 아이러니 하게도 동일한 객체 인스턴스가 사용되고 있음 

Configuration과 바이트코드(기계어) 조작

- 스프링 컨테이너는 싱글톤 레지스트리이다. (싱글톤 객체를 관리하는 컨테이너)
  따라서 스프링 빈이 싱글톤이 되도록 보장해줘야 한다.
  스프링이라고 해도 자바 코드를 건드리기 어렵다.

- 스프링은 클래스의 바이트 코드를 조작하는 라이브러리를 사용한다.
- 모든 것은 @Configuration을 적용한 AppConfig에 있다.
@Test
    void configurationDeep() {
        AnnotationConfigApplicationContext ac = new AnnotationConfigApplicationContext(AppConfig.class);
        AppConfig been = ac.getBean(AppConfig.class);

        System.out.println("been.getClass() = " + been.getClass());
    }
/*
출력 결과
been.getClass() = class hello.core.AppConfig$$SpringCGLIB$$0
*/
- 순수한 클래스라면 class hello.core.AppConfig 처럼 출력되어야 함

- 예상과 달리 xxxCGLIB가 붙으면서 상당히 복잡해짐
  이 클래스는 내가 만든 클래스가 아니라 스프링이 CGLIB라는 바이트코드 조작 라이브러리를
  사용해 AppConfig 클래스를 상속받은 임의의 다른 클래스를 만들고
  [ 다른 클래스를 스프링 빈으로 등록한 것 ]

- AppConfig를 상속 받은 다른 클래스를 만든다.(AppConfig@CGLIB)
  원본 AppConfig 코드 동작을 감싸고, 싱글톤 보장을 위해 추가적인 로직을 삽입

- 즉, AppConfig@CGLIB는 기존 AppConfig의 @Bean 애너테이션이 붙은 메서드를
  오버라이딩(재정의)해서 싱글톤 보장을 위한 로직을 삽입함

- 스프링 컨테이너에는 이름은 appConfig이지만 
  AppConfig@CGLIB 객체 인스턴스가 등록됨 

- 그 임의의 다른 클래스가 바로 싱글톤이 보장되도록 해준다.
// AppConfig@CGLIB 내부 예상도
@Bean

public MemberRepository memberRepository() {
    if (memoryMemberRepository가 이미 스프링 컨테이너에 등록되어 있다면 ?) {
        return 스프링 컨테이너에서 찾아서 반환;
    } else { // 컨테이너에 없으면
        기존 로직을 호출해서 MemoryMemberRepository를 생성하고 스프링 컨테이너에 등록
        return 반환 
    }
}
- @Bean이 붙은 메서드마다 이미 스프링 빈이 존재하면 존재하는 빈을 반환 
- 스프링 빈이 없으면, 생성해서 스프링 빈으로 등록하고 반환하는 코드가 동적으로 만들어짐
- 덕분에 싱글톤 보장

[참고]
= AppConfig@CGLIB는 AppConfig의 자식 타입으로 AppConfig 타입으로 조회 가능 

자세한 처리 과정 (영한님 강의에 없는 내용 - GPT 활용)

[처리 과정]
1. 자바 코드 -> 바이트 코드
   - 개발자가 작성한 코드는 컴파일 되어 바이트 코드로 변환됨
   - 이 바이트 코드는 JVM의 중간 단계 코드로, 스프링이 동적으로 이 바이트코드를 조작

2. 바이트코드 조작(CGLIB)
   - 스프링은 @Configuration이 붙은 클래스에서 동적으로 프록시 클래스를 생성
   - 프록시 클래스는 원래 클래스의 메서드를 오버라이딩하면서, 추가적인 싱글톤 로직(캐싱 등)을 삽입

3. 결과적으로 실행되는 기계어는 동일한 자원 사용 
   - 실행 시에는 프록시 클래스에서 조작된 코드가 실행됨
   - 스프링 컨테이너에 존재하면 바로 반환
   - 존재하지 않으면 컨테이너에 등록하고 반환 

** 프록시 클래스는 바이트 코드 수준에서 생성되고 런타임에만 존재하는 코드 **
public class AppConfig$$EnhancerBySpringCGLIB extends AppConfig {
    private final Map<String, Object> singletonCache = new HashMap<>();

    @Override
    public MemberService memberService() {
        if (!singletonCache.containsKey("memberService")) {
            singletonCache.put("memberService", super.memberService());
        }
        return (MemberService) singletonCache.get("memberService");
    }

    @Override
    public OrderService orderService() {
        if (!singletonCache.containsKey("orderService")) {
            singletonCache.put("orderService", super.orderService());
        }
        return (OrderService) singletonCache.get("orderService");
    }
}

Configuration 애너테이션을 붙이지 않고 Bean 애너테이션만 붙이는 경우

- 붙이지 않아도 @Bean 애너테이션이 붙은 메서드의 객체들이 스프링 컨테이너에 등록됨

- 단, 우리가 작성한 자바 코드가 그대로 실행됨
  순수 AppConfig.class가 스프링 빈으로 등록됨
  @Bean이 붙은 메서드가 그래도 실행됨

- 각 역할에 주입된 객체가 스프링 빈이 주입되는 것이 아니라
  MebmerRepository = new MemoryMebmerRepository()처럼 생성된 객체가
  생성자를 통해 주입됨
  즉, 컨테이너에 등록된 객체를 주입하는 것이 아닌 new 연산자로 새로 만든 객체를 주입하는 것 
// 테스트 결과 
// memberRepository()를 세 번 실행해 서로 다른 객체가 3개 생성됨
call AppConfig.memberRepository
call AppConfig.memberService
call AppConfig.memberRepository
call AppConfig.orderService
call AppConfig.memberRepository
memberRepository = hello.core.member.MemoryMemberRepository@2dc9b0f5
memberRepository1 = hello.core.member.MemoryMemberRepository@6531a794
memberRepository2 = hello.core.member.MemoryMemberRepository@3b5fad2d

정리

- @Bean만 사용해도 스프링 빈으로 등록된다.
- 하지만 의존관계 주입이 필요해서 메서드를 직접 호출할 때, 싱글톤을 보장하지 않는다.
- 크게 고민할 것 없이 스프링 설정 정보는 항상 @Configuration을 사용하자

내용 출처

인프런 - 스프링 핵심 원리 (기본편)
강사명 - 김영한

'Spring' 카테고리의 다른 글

스프링 기본편 - 6  (1) 2025.01.19
스프링 기본편 5  (0) 2025.01.14
스프링 기본편 3  (0) 2025.01.11
스프링 기본편 2  (0) 2025.01.10
스프링 기본편 1  (0) 2025.01.09