seungriyou / spring-study

자바 스프링 부트를 배워봅시다 🔥
0 stars 0 forks source link

[강의 정리] 02. 스프링 핵심 원리 - 기본편 #5

Open seungriyou opened 10 months ago

seungriyou commented 10 months ago

스프링 핵심 원리 - 기본편

Contents

seungriyou commented 10 months ago

1. 객체 지향 설계와 스프링

객체지향

스프링의 핵심

객체 지향 애플리케이션을 개발할 수 있도록 도와주는 프레임워크이다.


객체지향의 장점

객체지향을 통해 유연하고 변경이 용이한 소프트웨어를 설계할 수 있으며, 이는 다형성에 의해 달성된다.

다형성에 대해서 알아보기 위해 세상을 역할(= 인터페이스)구현으로 나누어보자.

image

운전자는 자동차 역할에 대해서만 의존하고 있기 때문에, 자동차 구현이 바뀌어도 운전자에게 영향을 미치지 않는다. 즉, 새로운 자동차가 나와도 운전자는 새로운 것을 배우지 않아도 된다.

더 나아가 클라이언트(= 운전자)에게 영향을 주지 않고 새로운 기능을 제공할 수도 있게 된다.


이처럼 역할과 구현을 분리하는 것의 장점을 정리하면 다음과 같다.

  1. 클라이언트는 대상의 역할(= 인터페이스)만 알면 된다.
  2. 클라이언트는 구현 대상의 내부 구조를 몰라도 된다.
  3. 클라이언트는 구현 대상의 내부 구조가 변경되어도 영향을 받지 않는다.
  4. 클라이언트는 구현 대상 자체를 변경해도 영향을 받지 않는다.

[!Note] 따라서 객체 설계 시 역할을 먼저 부여하고, 그 역할을 수행하는 구현 객체를 만드는 것이 좋다.


다형성

자바의 오버라이딩을 생각해보면 오버라이딩 된 메서드가 실행되게 되는데, 이러한 다형성을 통해서 인터페이스를 구현한 객체를 실행 시점에 유연하게 변경할 수 있다. (클래스 상속 관계에서도 적용)

다형성의 본질을 이해하려면 협력이라는 객체 사이의 관계에서 시작해야 한다.

객체의 협력 관점에서 살펴보면, 클라이언트는 요청, 서버는 응답을 하는 객체이고, 서로 협력 관계를 가지게 된다.

[!Note] 다형성의 본질 = 클라이언트를 변경하지 않고, 서버의 구현 기능을 유연하게 변경할 수 있다.


역할과 구현을 분리하는 것

  1. 실세계의 역할과 구현이라는 컨셉을 다형성을 통해 객체 세상으로 가져올 수 있다.
  2. 유연하고 변경이 용이해진다.
  3. 확장 가능한 설계가 가능하다.
  4. 클라이언트에 영향을 주지 않는 변경이 가능하다.
  5. 인터페이스를 안정적으로 잘 설계하는 것이 중요하다.


스프링과 객체지향

스프링은 다형성을 활용해서 역할과 구현을 편리하게 다룰 수 있도록 지원한다.

ex. 제어의 역전(IoC), 의존관계 주입(DI)


SOLID 원칙

  1. 단일 책임 원칙 (SRP, Single Responsibility Principle)

    • 한 클래스는 하나의 책임만 가져야 한다.
    • 기준은 변경이다. (변경이 있을 때 파급 효과가 적으면 단일 책임 원칙을 잘 따른 것)
  2. 개방-폐쇄 원칙 (OCP, Open/Closed Principle)

    • 소프트웨어 요소는 확장에는 열려있으나 변경에는 닫혀있어야 한다.
    • 다형성을 활용해서, 인터페이스를 구현한 새로운 클래스를 하나 만들어서 새로운 기능을 구현한다.
    • 역할과 구현의 분리를 떠올리자.
    • OCP를 따르기 위해 구현 객체를 변경할 때 클라이언트 코드를 변경하지 않으려면 객체를 생성하고 연관관계를 맺어주는 별도의 조립, 설정자가 필요한데, 바로 이러한 역할을 하는 것이 스프링 컨테이너이다.
  3. 리스코프 치환 원칙 (LSP, Liskov Substitution Principle)

    • 프로그램의 객체는 프로그램의 정확성을 깨뜨리지 않으면서 하위 타입의 인스턴스로 바꿀 수 있어야 한다.
    • 다형성에서 하위 클래스는 인터페이스 규약을 다 지켜야한다는 것이다. (기능적으로)
    • 다형성을 지원하기 위한 원칙으로, 인터페이스를 구현한 구현체를 믿고 사용하기 위한 원칙이다.
  4. 인터페이스 분리 원칙 (ISP, Interface Segregation Principle)

    • 특정 클라이언트를 위한 인터페이스 여러 개가 범용 인터페이스 하나보다 낫다.
    • 인터페이스를 잘 분리하면 인터페이스가 명확해지고 대체 가능성이 높아진다.
  5. 의존관계 역전 원칙 (DIP, Dependency Inversion Principle)

    • 추상화에 의존해야하며, 구체화에 의존하면 안 된다.

      의존 = 내가 어떤 코드에 대해 알기만 해도 의존하는 것이다!

    • 의존성 주입(DI) 은 이 원칙을 따르는 방법 중 하나이다.

    • 즉, 구현 클래스에 의존하지 말고, 인터페이스(= 역할)에 의존하라는 뜻이다.


[!Note] 이때, OCP와 DIP가 매우 중요하다. 다음의 코드에서…

public class MemberService {

  private MemberRepository memberRepository = new MemoryMemberRepository();

}
  • 인터페이스(MemberRepository) 뿐만 아니라 구현 클래스(MemoryMemberRepository)에도 의존하고 있어 DIP를 위반하고 있다. (직접 선택 = 의존)
  • 따라서 구현 클래스를 MemoryMemberRepository에서 JdbcMemberRepository로 변경하려면 코드를 변경해야하므로 OCP도 위반된다.


[!Tip] 다형성만으로는 구현 객체를 변경할 때 클라이언트 코드도 함께 변경되므로 OCP, DIP를 지킬 수 없다. (위의 코드 예시 참고)

따라서 무언가가 더 필요한데, 이때 필요한 것은 의존성 주입(DI) 이다!


스프링과 객체지향

스프링에서 다형성 + OCP & DIP를 지원하는 방법

스프링은 DI(Dependency Injection, 의존성 주입) 컨테이너를 제공함으로써, 다형성, OCP, DIP를 가능하게 한다.

이를 통해 클라이언트 코드의 변경 없이 기능의 확장이 가능하다. (부품 교체하듯이)

peerloop에서 FastAPI로 스프링 컨테이너를 직접 만들려고 했었다..^^


정리

  1. 모든 설계에 역할과 구현을 분리하자. 언제든지 유연하게 변경할 수 있도록 만들자! (변경의 범위가 작아지도록)
  2. 이상적으로는 모든 설계에 인터페이스를 부여하자.

    구체적인 기술이 정해지지 않은 경우, 구체적인 할인 정책이 정해지지 않은 경우 등에도 개발이 가능하다.

  3. 하지만 인터페이스를 도입하면 추상화라는 비용이 발생하기 때문에, 기능을 확장할 가능성이 없다면 구체 클래스를 직접 사용하고 향후 꼭 필요할 때 리팩터링하여 인터페이스를 도입하는 것도 방법이다.

    추상화 → 한 번 더 타고 들어가야 코드를 확인할 수 있다는 단점이 있다.

seungriyou commented 10 months ago

2. 스프링 핵심 원리 이해1 - 예제 만들기 (스프링 X, 자바 O)

회원 도메인

설계


주문, 할인 도메인

주문 도메인 설계


[!Tip] 스프링, DB 없이 자바 코드로만 수행하는 단위 테스트가 중요하다.


[!Note] 자바 primitive type long이 아닌 Long을 사용하면 null을 넣을 수 있다.

seungriyou commented 10 months ago

3. 스프링 핵심 원리 이해2 - 객체 지향 원리 적용 (스프링 O)

새로운 할인 정책 개발 상황을 가정해보자.

새로운 할인 정책 (RateDiscountPolicy) 적용의 문제점

문제점

역할(인터페이스)과 구현(클래스)도 분리했고, 다형성도 활용했으나,

사실 OCP, DIP와 같은 원칙을 위반하고 있으므로 다음과 같이 코드를 고쳐야 할인 정책을 변경할 수 있다.

public class OrderServiceImpl implements OrderService {

    private final MemberRepository memberRepository = new MemoryMemberRepository();
    // private final DiscountPolicy discountPolicy = new FixDiscountPolicy();
    private final DiscountPolicy discountPolicy = new RateDiscountPolicy();

    // 할인 정책을 변경하려면 클라이언트인 `OrderServiceImpl` 코드를 고쳐야 한다.
  1. DIP: OrderServiceImpl은 추상(인터페이스 DiscountPolicy)뿐만 아니라 구체(구현) 클래스(FixDiscountPolicy, RateDiscountPolicy)에도 의존하고 있다.
  2. OCP: 기능을 확장하여 변경하려면 클라이언트 OrderServiceImpl의 코드를 변경해야 한다.


image


해결 방법

DIP를 위반하지 않도록 인터페이스에만 의존하도록 변경한다.

  1. 인터페이스에만 의존하도록 코드를 변경한다.

    final 은 무조건 변수에 값이 할당되거나 생성자로 초기화되어야 하므로, 여기에서는 final은 지워주자.

    public class OrderServiceImpl implements OrderService {
    
        private final MemberRepository memberRepository = new MemoryMemberRepository();
        private DiscountPolicy discountPolicy;
  2. 누군가가 클라이언트인 OrderServiceImplDiscountPolicy 구현 객체를 대싱 생성 & 주입해준다.


관심사의 분리

AppConfig에서 모든 구성 요소를 생성하고 주입해주자.

public class AppConfig {

    public MemberService memberService() {
        return new MemberServiceImpl(new MemoryMemberRepository());
    }

    public OrderService orderService() {
        return new OrderServiceImpl(new MemoryMemberRepository(), new FixDiscountPolicy());
    }

}

대상이 되는 곳에서는

  1. 인터페이스에만 의존하고
  2. 생성자 주입을 받는다.
// DIP 준수: 인터페이스에만 의존
private final MemberRepository memberRepository;
private final DiscountPolicy discountPolicy;

// 생성자 주입
public OrderServiceImpl(MemberRepository memberRepository, DiscountPolicy discountPolicy) {
    this.memberRepository = memberRepository;
    this.discountPolicy = discountPolicy;
}
// DIP 준수: 이제 추상화 MemberRepository에만 의존하므로
private final MemberRepository memberRepository;

// 생성자 주입
public MemberServiceImpl(MemberRepository memberRepository) {
    this.memberRepository = memberRepository;
}

[!Note] AppConfig의 역할

  1. 실제 동작에 필요한 구현 객체를 생성한다.
  2. 생성한 객체 인스턴스의 참조(레퍼런스)를 생성자를 통해 주입해준다.


[!Tip] 구현 클래스는 이제부터 의존관계에 대한 고민은 외부에 맡기고, 실행에만 집중할 수 있다.


image

image


정리

  1. 객체의 생성과 주입은 AppConfig가 담당한다.
  2. DIPImpl 클래스는 추상(인터페이스)에만 의존하면 된다. 즉, 구체 클래스를 몰라도 된다.
  3. 객체를 생성하고 연결하는 역할과 실행하는 역할이 분리되어 관심사의 분리가 명확하게 이루어졌다.


[!Note] 의존성 주입 (Dependency Injection)


AppConfig 리팩터링

역할이 잘 드러나고 중복이 없도록 리팩터링한다.

public class AppConfig {

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

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

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

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

}


새로운 구조와 할인 정책 적용

구성 영역의 코드만 고치면 된다.

image

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


SOLID 원칙의 적용

SRP

한 클래스는 하나의 책임만 가져야 한다.

관심사를 분리함으로써 준수한다.

AppConfig는 구현 객체를 생성 / 연결하는 책임, 클라이언트 객체는 실행하는 책임만 담당한다.


DIP

추상화에 의존해야하며, 구체화에 의존하면 안된다.

의존성 주입을 통해 준수한다.

AppConfig가 객체 인스턴스를 클라이언트 코드 대신 생성해서 클라이언트 코드에 의존관계를 주입한다.


OCP

소프트웨어 요소는 확장에는 열려있으나 변경에는 닫혀있어야 한다.

애플리케이션을 사용 영역과 구성 영역으로 나눈다.

AppConfig가 의존관계를 변경해서 클라이언트 코드에 주입하므로 클라이언트 코드는 변경할 필요가 없다.


IoC, DI, 컨테이너

IoC (Inversion of Control, 제어의 역전)

AppConfig의 도입으로 프로그램의 제어권이 외부(AppConfig)로 넘어가는데, 이를 제어의 역전이라고 한다.


[!Note] 프레임워크 vs. 라이브러리

  • 프레임워크가 내가 작성한 코드를 제어하고, 대신 실행하면 그것은 프레임워크이다. (JUnit)
  • 내가 작성한 코드가 직접 제어의 흐름을 담당한다면 그것은 프레임워크가 아니라 라이브러리이다.


DI (Dependency Injection, 의존성 주입)

DiscountPolicy 인터페이스에 의존하는 OrderServiceImpl은 실제 어떤 구현 객체가 사용될지 모른다.

[!Tip] 의존관계 주입을 사용하면 정적인 클래스 의존관계를 변경하지 않고, 동적인 객체 인스턴스 의존관계를 쉽게 변경할 수 있다.


IoC 컨테이너 / DI 컨테이너

AppConfig 처럼 객체를 생성하고 관리하면서 의존관계를 연결해주는 것을 IoC 컨테이너 또는 DI 컨테이너라 한다. (요즘에는 주로 DI 컨테이너)

혹은 어셈블러, 오브젝트 팩토리 등으로 불리기도 한다.


스프링으로 전환하기

  1. AppConfig@Configuration, @Bean 어노테이션을 달아준다.
  2. 어플리케이션 코드에서 memberService를 불러오는 부분을 다음과 같이 변경한다.

    getBean() 시에 필요한 이름은 메서드 이름이다.

    // AppConfig appConfig = new AppConfig();
    // MemberService memberService = appConfig.memberService();
    // OrderService orderService = appConfig.orderService();
    
    ApplicationContext applicationContext = new AnnotationConfigApplicationContext(AppConfig.class);
    MemberService memberService = applicationContext.getBean("memberService", MemberService.class);
    OrderService orderService = applicationContext.getBean("orderService", OrderService.class);


스프링 컨테이너


[!Note] 스프링 컨테이너를 사용하면 어떠한 장점이 있을까?


다형성만으로는 안 되는구나, DIP랑 OCP를 지키려면 뭔가가 더 필요하구나!로부터 시작해서, AppConfig보다 스프링 컨테이너가 낫다는 것까지 살펴보았다.

seungriyou commented 10 months ago

4. 스프링 컨테이너와 스프링 빈

스프링 컨테이너 생성

다음의 코드로 스프링 컨테이너를 생성하며, ApplicationContext가 스프링 컨테이너가 된다.

사실 스프링 컨테이너는 BeanFactory, ApplicationContext로 구분해서 이야기하는데, BeanFactory는 직접 사용되는 일이 거의 없으므로 일반적으로는 ApplicationContext를 스프링 컨테이너라 한다.

ApplicationContext applicationContext = new AnnotationConfigApplicationContext(AppConfig.class);


스프링 컨테이너 생성 과정

  1. 스프링 컨테이너 생성

    • AppConfig.class를 구성 정보로 지정하여 스프링 컨테이너를 생성한다.

    image

  2. 스프링 빈 등록

    • 파라미터로 넘어온 설정 클래스 정보를 사용해서 스프링 빈을 등록한다.

    • 빈 이름

      • 메서드 이름으로 지정된다. @Bean(name = "")을 통해 직접 이름을 부여할 수도 있다.
      • 항상 유일한 이름을 부여해야 한다.

    image

  3. 스프링 빈 의존 관계 설정

    image


[!Tip] 스프링은 빈을 생성하고 의존관계를 주입하는 단계가 나누어져 있는데, 이처럼 자바 코드로 스프링 빈을 등록하면 생성자를 호출하면서 의존관계 주입도 한 번에 처리된다.


확인하는 코드

public class ApplicationContextInfoTest {

    AnnotationConfigApplicationContext ac = new AnnotationConfigApplicationContext(AppConfig.class);

    @Test
    @DisplayName("모든 빈 출력하기")
    void findAllBean() {
        String[] beanDefinitionNames = ac.getBeanDefinitionNames();
        for (String beanDefinitionName : beanDefinitionNames) {
            Object bean = ac.getBean(beanDefinitionName);
            System.out.println("name = " + beanDefinitionName + " object = " + bean);
        }
    }

    @Test
    @DisplayName("애플리케이션 빈 출력하기")
    void findApplicationBean() {
        String[] beanDefinitionNames = ac.getBeanDefinitionNames();
        for (String beanDefinitionName : beanDefinitionNames) {
            BeanDefinition beanDefinition = ac.getBeanDefinition(beanDefinitionName);

            // ROLE_APPLICATION: 직접 등록한 스프링 빈만 출력
            // ROLE_INFRASTRUCTURE: 스프링이 내부에서 사용하는 빈
            if (beanDefinition.getRole() == BeanDefinition.ROLE_INFRASTRUCTURE) {
                Object bean = ac.getBean(beanDefinitionName);
                System.out.println("name = " + beanDefinitionName + " object = " + bean);
            }
        }
    }
}


스프링 빈 조회

기본


동일한 타입이 둘 이상


상속 관계

중요!

[!Note] 기본 원칙: 부모 타입으로 조회하면, 자식 타입도 함께 조회된다.


실제로는 getBean()을 사용할 일은 거의 없을 것이다!


BeanFactoryApplicationContext


다양한 설정 형식 지원: 자바 코드, XML

우리는 지금까지 설정을 자바 코드인 AppConfig를 이용하여 작성해왔다. 하지만 스프링 컨테이너는 자바 코드 뿐만 아니라 XML, Groovy 등을 모두 지원한다.

image


어노테이션 기반 자바 코드 설정


XML 설정


스프링 빈 설정 메타 정보 - BeanDefinition

image


실무에서 직접 BeanDefinition을 다룰 일은 거의 없다.

seungriyou commented 10 months ago

5. 싱글톤 컨테이너

싱글톤 패턴

스프링 없는 순수한 DI 컨테이너인 AppConfig는 요청을 할 때마다 객체를 새로 생성한다.

싱글톤 패턴을 적용하면 객체가 단 1개만 생성되고, 공유될 수 있다.

싱글톤 패턴


[!Note] 스프링 컨테이너를 사용하면 스프링 컨테이너가 기본적으로 객체를 다 싱글톤으로 만들어서 관리해준다.


단점


[!Tip] 스프링은 싱글톤이 가진 단점을 모두 해결해준다!


싱글톤 컨테이너

스프링 컨테이너는 싱글톤의 문제점을 해결하고, 스프링 빈을 싱글톤으로 관리한다.

  • 스프링 컨테이너는 싱글톤 패턴을 적용하지 않아도 객체 인스턴스를 싱글톤으로 관리한다.
  • 스프링 컨테이너처럼 싱글톤 객체를 생성하고 관리하는 기능을 싱글톤 레지스트리라 한다.
  • 스프링 컨테이너는 싱글톤 패턴의 모든 단점을 해결하면서 객체를 싱글톤으로 유지할 수 있다.

image


[!Note] 스프링의 기본 빈 등록 방식은 싱글톤(99% 이상) 이나, 이것만 지원하는 것은 아니다. 요청할 때마다 객체를 생성해서 반환하는 기능도 제공한다. (→ 빈 스코프)


싱글톤 방식의 주의점

[!Caution] 싱글톤 객체는 stateful하게 설계하면 안되고, stateless로 설계해야 한다.


무상태 (stateless)

스프링 빈의 필드에 공유 값을 설정하면 큰 장애가 발생할 수 있다!

  1. 특정 클라이언트에 의존적인 필드가 있으면 안 된다.
  2. 특정 클라이언트가 값을 변경할 수 있는 필드가 있으면 안 된다.
  3. 가급적 읽기만 가능해야 한다.
  4. 필드 대신, 자바에서 공유되지 않는, 지역변수, 파라미터, ThreadLocal을 사용해야 한다.


[!Note] 웹에서 요청이 들어오면 보통 요청마다 thread가 할당된다. 따라서 싱글톤 객체의 필드는 클라이언트에서 변경할 수 없어야 한다.

→ 공유 필드 XXXXX


Stateful 예시

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;
    }
}
class StatefulServiceTest {

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

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

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

        assertThat(statefulService1.getPrice()).isEqualTo(20000);
    }

    static class TestConfig {

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

    }

}


Stateless 하도록 변경하기

공유 필드였던 price를 필드로 사용하는 것이 아니라 order()의 반환값으로 설정한다.


@Configuration과 싱글톤

@Configuration은 싱글톤을 위해 존재한다.

AppConfig에서 의문점

memberRepository()memberService()orderService()에서 각각 호출한다. 그렇다면 각각 다른 2개의 MemoryMemberRepository가 생성되어 싱글톤이 깨지는 것이 아닐까?


동작 원리

  1. 테스트를 해보면 memberRepository 인스턴스는 모두 같은 인스턴스가 공유되어 사용된다.

    memberRepository1 = hello.core.member.MemoryMemberRepository@165b8a71
    memberRepository2 = hello.core.member.MemoryMemberRepository@165b8a71
    memberRepository = hello.core.member.MemoryMemberRepository@165b8a71
  2. AppConfig 의 스프링 빈 메서드에 메서드 이름 출력 코드를 추가하고, 다시 실행해본다.

    AppConfig.memberRepository
    AppConfig.memberService
    AppConfig.orderService

    AppConfig.memberRepository 메서드가 세 번 호출되는 것이 아닌, 각 메서드가 단 한 번씩만 호출되는 것을 확인할 수 있다.


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

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


@Configuration 어노테이션을 제거한다면?

다음과 같이 memberRepository()가 세 번 호출된다.

AppConfig.memberRepository
AppConfig.memberService
AppConfig.memberRepository
AppConfig.orderService
AppConfig.memberRepository

그리고 각각의 호출에서 생성된 객체가 모두 다른 객체가 된다. (싱글톤 X)

스프링 컨테이너가 관리하지 않아 new 로 생성하는 것과 동일해진다.

memberRepository1 = hello.core.member.MemoryMemberRepository@505a9d7c
memberRepository2 = hello.core.member.MemoryMemberRepository@758c83d8
memberRepository = hello.core.member.MemoryMemberRepository@129b4fe2

또한, AppConfig의 클래스 이름도 다음과 같이 CGLIB이 없다.

bean.getClass() = class hello.core.AppConfig


[!Tip] 추후 다룰 @Autowired를 이용하여 빈으로 등록된 것을 주입시켜줄 수는 있다.

@Autowired MemberRepository memberRepository;


정리

  1. @Bean만 사용해도 스프링 빈으로 등록되지만, 싱글톤을 보장하지 않는다.

    의존관계 주입을 위해 메서드를 직접 호출할 때(ex. memberRepository()) 싱글톤을 보장하지 않는다.

  2. 스프링 설정 정보는 항상 @Configuration을 사용하자.

seungriyou commented 10 months ago

6. 컴포넌트 스캔

컴포넌트 스캔과 의존관계 자동 주입

@Bean이나 <bean>으로 직접 스프링 빈을 등록하는 것은 귀찮다. 이러한 설정 정보 없이도 자동으로 스프링 빈을 등록하는 컴포넌트 스캔 기능을 제공하며, 의존관계를 자동으로 주입하는 @Autowired 기능도 제공한다.


컴포넌트 스캔을 사용하려면 @ConponentScan 을 설정 정보에 붙여준다.

이렇게 컴포넌트 스캔을 사용하면 @Configuration이 붙은 설정 정보도 자동으로 등록되므로, 기존 예제 코드를 지우지 않고 보존하기 위해서 컴포넌트 스캔 시 다음과 같이 @Configuration 어노테이션이 붙은 것은 제외한다. (보통 실무에서는 제외하지 않으나, 지금은 예제 코드를 지우지 않기 위함이다.)

@Configuration
@ComponentScan(
        // 기존의 AppConfig을 제거하기 위함
        excludeFilters = @ComponentScan.Filter(type = FilterType.ANNOTATION, classes = Configuration.class)
)
public class AutoAppConfig {

}


컴포넌트 스캔을 이용하면 @Component 어노테이션이 붙은 클래스를 스캔해서 스프링 빈으로 등록한다.

따라서 구현체 클래스들에 @Configuration 어노테이션을 붙여준다.


의존관계를 주입하기 위해서는 해당 부분(ex. 생성자)에 @Autowired 어노테이션을 붙여준다.


동작 과정

  1. @ComponentScan@Component가 붙은 모든 클래스를 스프링 빈으로 등록한다.

    • 스프링 빈 이름의 기본 전략: 클래스명을 사용하되, 맨 앞글자만 소문자로
    • 스프링 빈 이름 직접 지정: @Component("new-name")

    image

  2. 생성자에 @Autowired 를 지정하면, 스프링 컨테이너가 자동으로 해당 스프링 빈을 찾아서 주입한다.

    • 스프링 컨테이너가 자동으로 해당 스프링 빈을 찾아서 주입해주며, 이때 기본 전략은 타입이 같은 빈을 찾아서 주입해주는 것이다. (getBean(MemberRepository.class))
    • 더 자세한 내용은 뒤에서 다룬다.
    • 생성자에 파라미터가 많아도 다 찾아서 자동으로 주입해준다.

    image


탐색 위치와 기본 스캔 대상

컴포넌트 스캔 범위 설정

컴포넌트 스캔 대상이 되는 최상위 패키지를 다음과 같이 설정할 수 있다.


[!Tip] 패키지 위치를 지정하지 않고, 설정 정보 클래스(@ComponentScan)의 위치를 프로젝트 최상단에 두는 것을 권장한다.

  • 스프링 부트를 사용하면, 대표 시작 정보인 @SpringBootAppilcation을 프로젝트 루트 위치에 두는 것이 관례인데, 해당 어노테이션 안에 @ComponentScan이 포함되어 있다. 그래서 자동으로 스프링 빈으로 등록된 것이다. (부트에서 알아서 해준다.)
  • 뺄 것만 exclude 해서 사용하자.


컴포넌트 스캔 기본 대상

@Component 뿐만 아니라…


필터

FilterType 옵션

image

@Component를 사용하면 되므로 includeFilters를 사용할 일은 거의 없으나, excludeFilters는 간혹 사용할 때가 있다. 하지만 옵션을 변경하기보다는 스프링의 기본 설정에 최대한 맞추어 사용하는 것을 권장한다.


중복 등록과 충돌

컴포넌트 스캔 시 같은 빈 이름을 등록하면 어떻게 될까?


자동 빈 등록 vs 자동 빈 등록

컴포넌트 스캔에 의해 자동으로 등록된 스프링 빈들이 서로 이름이 같은 경우, 오류가 발생된다. (ConflictingBeanDefinitionException)


수동 빈 등록 vs 자동 빈 등록

다음과 같이 수동으로 이름이 같은 스프링 빈을 등록해보자.

@Configuration
@ComponentScan(
        // 컴포넌트 스캔 대상이 되는 최상위 패키지 설정
        basePackages = "hello.core",
        // 기존의 AppConfig을 제거하기 위함
        excludeFilters = @ComponentScan.Filter(type = FilterType.ANNOTATION, classes = Configuration.class)
)
public class AutoAppConfig {

    @Bean(name = "memoryMemberRepository")
    public MemberRepository memberRepository() {
        return new MemoryMemberRepository();
    }

}

수동 빈 등록이 우선권을 가진다.

하지만 이로 인해 잡기 어려운 버그가 발생할 수 있으므로 최근 스프링 부트에서는 에러를 발생하도록 한다. (CoreApplication 실행)

***************************
APPLICATION FAILED TO START
***************************

Description:

The bean 'memoryMemberRepository', defined in class path resource [hello/core/AutoAppConfig.class], could not be registered. A bean with that name has already been defined in file [/Users/seungri/workspace/spring-study/spring-study/02-core_basic/core/build/classes/java/main/hello/core/member/MemoryMemberRepository.class] and overriding is disabled.

Action:

Consider renaming one of the beans or enabling overriding by setting spring.main.allow-bean-definition-overriding=true


[!Note] 명확한 코드가 베스트다!!!

seungriyou commented 10 months ago

7. 의존관계 자동 주입

다양한 의존관계 주입 방법

[!Note] 스프링의 두 가지 사이클

  1. 스프링 빈 생성 및 등록
  2. 의존관계 주입 (@Autowired)


생성자 주입

스프링 빈 생성 시점에 의존관계 주입이 일어난다.


수정자 주입 (setter 주입)

[!Note] 자바빈 프로퍼티 규약

자바에서는 과거부터 필드의 값을 직접 변경하기 않고 setter와 getter를 통해서 접근하는 규칙을 만들었다.


필드 주입


일반 메서드 주입

[!Tip] 의존관계 자동 주입은 스프링 컨테이너가 관리하는 스프링 빈이어야 동작한다.


옵션 처리

자동 주입 대상이 되는 스프링 빈이 없다면 옵션으로 처리해야 한다.

세 가지 방법

  1. @Autowired(required = false) : 자동 주입할 대상이 없다면 메서드 자체가 호출이 안 된다.

    메서드 호출 자체가 안 된다!

  2. @Nullable : 자동 주입할 대상이 없다면 메서드는 호출되나 null로 입력된다.
  3. Optional<> : (Java 8 문법) 자동 주입할 대상이 없다면 메서드는 호출되나 Optional.empty가 입력된다.
public class AutowiredTest {

    @Test
    void AutowiredOption() {
        ApplicationContext ac = new AnnotationConfigApplicationContext(TestBean.class);
    }

    static class TestBean {

        // 1. 의존관계 자동 주입할 스프링 빈이 없다면 메서드 자체가 호출이 안 된다.
        @Autowired(required = false)
        public void setNoBean1(Member noBean1) {
            System.out.println("noBean1 = " + noBean1);
        }

        // 2. 자동 주입할 대상이 없다면 메서드는 호출되나 null로 입력된다.
        @Autowired
        public void setNoBean2(@Nullable Member noBean2) {
            System.out.println("noBean2 = " + noBean2);
        }

        // 3. (Java 8 문법) 자동 주입할 대상이 없다면 메서드는 호출되나 Optional.empty가 입력된다.
        @Autowired
        public void setNoBean3(Optional<Member> noBean3) {
            System.out.println("noBean3 = " + noBean3);
        }
    }
}
noBean2 = null
noBean3 = Optional.empty


[!Note] @Nullable, Optional은 스프링 전반에 걸쳐 지원된다. (ex. 생성자 자동 주입 등에서 특정 필드에만 사용)


생성자 주입을 선택하자

다른 DI 프레임워크 대부분이 생성자 주입을 권장한다. 그 이유는?

불변

대부분의 의존관계 주입은 한 번 일어나면 애플리케이션 종료 시점까지 의존관계를 변경할 일이 없다.

수정자 주입을 사용하면 setXxxx 메서드를 public으로 열어두어야 하므로, 실수로 변경할 수 있다.

생성자 주입을 사용하면 객체를 생성할 때 단 1번만 호출되므로 불변하게 설계할 수 있다.


누락

프레임워크 없이 순수한 자바 코드를 단위 테스트 하는 경우, 수정자 의존 관계의 경우에는 주입 데이터를 누락했을 때 컴파일 오류(ex. NPE)가 발생하게 된다.


final 키워드

생성자 주입을 사용하게 되면 필드에 final 키워드를 사용할 수 있다. 따라서 생성자에서 값이 설정되지 않는 경우를 컴파일 시점에 감지할 수 있다.

[!Note] final 키워드

한 번 생성할 때 정해지만 값이 바뀌지 않는다.

→ 생성자에서만 값을 넣어줄 수 있다!

수정자 주입을 포함한 나머지 주입 방식은 모두 생성자 이후에 호출되기 때문에, 필드에 final 키워드를 사용할 수 없다. 오직 생성자 주입만이 사용 가능하다!


정리

항상 생성자 주입을 선택해라. 가끔 옵션이 필요한 경우에는 수정자 주입을 선택하자.


롬복과 최신 트렌드

대부분이 다 불변이므로 필드에 final 키워드를 사용하게 된다. 생성자도 만들고, 주입 받은 값을 대입하는 코드도 만들어야 하므로, 이를 필드 주입처럼 편리하게 사용하는 방법은 없을까?

롬복을 사용하면 다음과 같이 편리하게 사용할 수 있다. 실무에서 기본으로 깔고 쓴다!

@Getter
@Setter
@ToString
public class HelloLombok {

    private String name;
    private int age;

    public static void main(String[] args) {
        HelloLombok helloLombok = new HelloLombok();
        helloLombok.setName("hahahahaha");

        String name = helloLombok.getName();
        System.out.println("name = " + name);

        System.out.println("helloLombok = " + helloLombok);
    }
}

롬복의 @RequiredArgsConstructor 를 사용하면 required argument인 final이 붙은 필수 필드 값을 파라미터로 받는 생성자를 컴파일 시점에 자동으로 만들어준다.

즉, 다음의 코드는 동일한 코드이다.

@Component
public class OrderServiceImpl implements OrderService {

    // DIP 준수: 인터페이스에만 의존
    private final MemberRepository memberRepository;
    private final DiscountPolicy discountPolicy;

    // 생성자 주입
    @Autowired
    public OrderServiceImpl(MemberRepository memberRepository, DiscountPolicy discountPolicy) {
        this.memberRepository = memberRepository;
        this.discountPolicy = discountPolicy;
    }
@Component
@RequiredArgsConstructor
public class OrderServiceImpl implements OrderService {

    // DIP 준수: 인터페이스에만 의존
    private final MemberRepository memberRepository;
    private final DiscountPolicy discountPolicy;

롬복을 사용한다면 정말 필요할 때만 생성자를 직접 작성하면 된다.


[!Tip] 최근에는 생성자를 딱 하나만 두고, @Autowired를 생략하는 방법을 주로 사용한다. 여기에 롬복의 @RequiredArgsConstructor 를 사용하면 코드를 더욱 깔끔하게 유지할 수 있다!


[문제 상황] 조회하는 빈이 2개 이상인 경우

@Autowired는 타입으로 조회하기 때문에, 같은 타입의 스프링 빈이 2개 이상이라면 문제가 발생하게 된다.

예를 들어

  1. DiscountPolicy의 하위 타입인 FixDiscountPolicyRateDiscountPolicy를 모두 @Component을 달아 스프링 빈으로 선언하고,
  2. @Autowired를 통해 의존관계 자동 주입을 실행하게 되면

NoUniqueBeanDefinitionException 오류가 발생한다.

Caused by: org.springframework.beans.factory.NoUniqueBeanDefinitionException: No qualifying bean of type 'hello.core.discount.DiscountPolicy' available: expected single matching bean but found 2: fixDiscountPolicy,rateDiscountPolicy

하위 타입으로 지정하게 되면 DIP를 위반하고 유연성이 떨어지게 된다. 또한, 이름만 다르고 완전히 같은 타입의 스프링 빈이 2개 있을 때 해결이 안 된다.

스프링 빈을 수동 등록해서 해결해도 되지만, 자동 주입에서 해결하는 세 가지 방법에 대해 알아보자!


[1] @Autowired 필드명 매칭

Autowired는 매칭은 다음의 순서로 진행된다.

  1. 타입 매칭을 시도한다.
  2. 그 결과가 2개 이상이면 필드 이름, 파라미터 이름으로 빈 이름을 추가 매칭한다.

생성자 주입이든, 필드 주입이든 간에!

@Component
public class OrderServiceImpl implements OrderService {

    // DIP 준수: 인터페이스에만 의존
    private final MemberRepository memberRepository;
    private final DiscountPolicy discountPolicy;

    // 생성자 주입
    @Autowired
    public OrderServiceImpl(MemberRepository memberRepository, DiscountPolicy **rateDiscountPolicy**) {
        this.memberRepository = memberRepository;
        this.discountPolicy = **rateDiscountPolicy**;
    }

위의 코드에서는 생성자의 파라미터 이름을 rateDiscountPolicy로 지정하여 DiscountPolicy 타입의 빈들 중 하나를 지정한다.


[2] @Qualifier 끼리 매칭

추가 구분자를 붙여주는 방법이다.

다음과 같이 추가 구분자를 부여할 클래스에 @Qualifier("...") 어노테이션을 붙여주고,

@Component
@Qualifier("mainDiscountPolicy")
public class RateDiscountPolicy implements DiscountPolicy {

의존관계 주입이 일어나는 곳(ex. 생성자, 필드, 수정자)에서 파라미터 앞에 동일한 어노테이션을 붙여준다.

// 생성자 주입
@Autowired
public OrderServiceImpl(MemberRepository memberRepository, @Qualifier("mainDiscountPolicy") DiscountPolicy discountPolicy) {

지정한 추가 구분자를 찾을 수 없다면, 해당 이름의 스프링 빈을 추가로 찾게 된다. 하지만 이렇게 사용하기보다는 항상 명확하게 사용해야 한다!


@Qualifier의 동작은 다음의 순서로 이루어진다.

  1. @Qualifier끼리 매칭한다.
  2. 빈 이름을 매칭한다.
  3. NoSuchBeanDefinitionException이 발생한다.


[3] @Primary 사용

@Autowired 시에 여러 빈이 매칭되면 @Primary가 우선권을 가진다.

@Component
@Primary
public class RateDiscountPolicy implements DiscountPolicy {


[!Note] @Primary, @Qualifier 활용

  • 메인을 @Primary로 설정하고, 특별하게 가끔 사용하는 것을 @Qualifier로 지정하여 명시적으로 설정하면 코드를 깔끔하게 유지할 수 있다.
  • 우선순위는 @Qualifier@Primary보다 높다.


어노테이션 직접 만들기

@Qualifier("mainDiscountPolicy")와 같이 문자를 적으면 컴파일 시 타입 체크가 불가능하다.

따라서 다음과 같이 어노테이션을 직접 만들어서 사용할 수 있다.

@Target({ElementType.FIELD, ElementType.METHOD, ElementType.PARAMETER, ElementType.TYPE, ElementType.ANNOTATION_TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Inherited
@Documented
@Qualifier("mainDiscountPolicy")    // 대신하고 싶은 어노테이션
public @interface MainDiscountPolicy {
}

이렇게 생성한 @MainDiscountPolicy 어노테이션을 “[2] @Qualifier 끼리 매칭“ 방법에서 @Qualifier("mainDiscountPolicy") 대신 사용할 수 있다.

[!Tip] @Primary로 해결이 어려운 상황이라면 이렇게!


[!Note] 본래 자바의 어노테이션에는 상속이라는 개념이 없다. 이처럼 어노테이션을 여러 개 모아서 사용하는 기능은 스프링이 지원해주는 기능이다. 따라서 다른 어노테이션도 함께 조합해서 사용할 수 있다. 하지만 무분별한 재정의는 지양해야 한다.


조회한 해당 타입의 빈이 모두 필요할 때: List, Map (전략 패턴)

전략 패턴을 간단하게 구현하는 방법 (ex. 할인의 종류를 rate , fix 중 선택하도록)


[!Tip] 동적으로 스프링 빈을 선택하는 방법!


public class AllBeanTest {

    @Test
    void findAllBean() {
        // 스프링 빈으로 등록 (AutoAppConfig, DiscountService 모두)
        ApplicationContext ac = new AnnotationConfigApplicationContext(AutoAppConfig.class, DiscountService.class);

        DiscountService discountService = ac.getBean(DiscountService.class);
        Member member = new Member(1L, "userA", Grade.VIP);

        int discountPrice = discountService.discount(member, 10000, "fixDiscountPolicy");
        assertThat(discountService).isInstanceOf(DiscountService.class);
        assertThat(discountPrice).isEqualTo(1000);

        int rateDiscountPrice = discountService.discount(member, 20000, "rateDiscountPolicy");
        assertThat(rateDiscountPrice).isEqualTo(2000);
    }

    static class DiscountService {
        private final Map<String, DiscountPolicy> policyMap;
        private final List<DiscountPolicy> policies;

        @Autowired  // 생략 가능
        public DiscountService(Map<String, DiscountPolicy> policyMap, List<DiscountPolicy> policies) {
            this.policyMap = policyMap;
            this.policies = policies;
            System.out.println("policyMap = " + policyMap);
            System.out.println("policies = " + policies);
        }

        public int discount(Member member, int price, String discountCode) {
            // discountCode와 매칭되는 빈을 꺼낸다.
            DiscountPolicy discountPolicy = policyMap.get(discountCode);

            return discountPolicy.discount(member, price);
        }
    }
}


자동, 수동의 올바른 실무 운영 기준

  1. 자동 등록 기능을 기본으로 사용하자

  2. 어플리케이션에 광범위하게 영향을 미치는 기술 지원 빈수동으로 등록하여 설정 정보에 바로 나타나게 한다.

    ▼ 어플리케이션의 구성

    구분 사용 시점 예시 특징
    업무 로직 빈 비즈니스 요구사항을 개발할 때 웹을 지원하는 컨트롤러, 비즈니스 로직이 담긴 서비스, 데이터 계층의 로직을 처리하는 리포지토리 등 - 개수가 많음
    - 유사한 패턴
    - 어떤 곳에서 문제가 발생했는지 파악 용이
    기술 지원 빈 기술적인 문제나 공통 관심사(AOP)를 처리할 때 데이터베이스 연결, 공통 로그 처리 등 업무 로직을 지원하기 위한 기술 - 개수가 적음
    - 어플리케이션 전반에 걸쳐 광범위하게 영향
    - 적용이 잘 되고 있는지조차 파악이 어려움

    단, 스프링, 스프링 부트가 자동으로 등록하는 수많은 빈들은 자동으로 그대로 두자.

  3. 비즈니스 로직 중에서도 다형성을 적극적으로 활용하는 경우, 어떤 빈들이 주입될지 코드만 보고서는 쉽게 파악이 불가능하므로 수동 등록을 고려해보자.

    핵심은 수동이든 자동이든 다형성(ex. DiscountPolicy)을 이루는 빈들을 특정 패키지에 같이 묶어두어 이해가 쉽도록 해야 한다는 것이다!

    그리고 수동 등록의 경우에는, 다음과 같이 별도의 설정 정보로 만들어 둔다. 이렇게 하면 어떤 빈들이 주입될지 한 눈에 파악이 가능하다.

    @Configuration
     public class DiscountPolicyConfig {
    
         @Bean
         public DiscountPolicy rateDiscountPolicy() {
             return new RateDiscountPolicy();
         }
    
         @Bean
         public DiscountPolicy fixDiscountPolicy() {
             return new FixDiscountPolicy();
         }
    
    }
seungriyou commented 10 months ago

8. 빈 생명주기 콜백

빈 생명주기 콜백

데이터베이스 커넥션 풀이나, 네트워크 소켓처럼 어플리케이션 시작 시점에 필요한 연결을 미리 해두고, 어플리케이션 종료 시점에 연결을 모두 종료하는 작업을 진행하려면 객체의 초기화와 종료 작업이 필요하다.

이러한 작업을 어떻게 진행하는지 알아보자.

public class BeanLifeCycleTest {

    @Test
    public void lifeCycleTest() {
        ConfigurableApplicationContext ac = new AnnotationConfigApplicationContext(LifeCycleConfig.class);
        NetworkClient client = ac.getBean(NetworkClient.class);
        // 보통은 직접 close() 할 일이 없으므로, ApplicationContext에서는 제공하지 않는다.
        // 따라서 ConfigurableApplicaionContext를 사용해야 한다.
        ac.close();
    }

    @Configuration
    static class LifeCycleConfig {
        @Bean
        public NetworkClient networkClient() {
            // 객체를 생성하는 단계에서는 url이 없고, 객체를 생성한 다음에 외부에서 수정자 주입을 통해 url을 설정한다.
            NetworkClient networkClient = new NetworkClient();
            networkClient.setUrl("http://hello-spring.dev");
            return networkClient;
        }
    }
}
생성자 호출, url = null
connect: null
call: null message = 초기화 연결 메시지


스프링 빈의 이벤트 라이프사이클

초기화는 의존관계 주입이 완료된 후에 수행해야 하는데, 스프링은 의존관계 주입이 완료되면 스프링 빈에게 콜백 메서드를 통해 그 시점을 알려준다. 마찬가지로 스프링 컨테이너가 종료되기 직전에도 소멸 콜백을 주어 안전하게 종료 작업을 진행할 수 있는 시점을 알려준다.

스프링 컨테이너 생성 → 스프링 빈 생성 (생성자 주입의 경우, 여기에서 의존관계 주입) → 의존관계 주입 → 초기화 콜백 → 사용 → 소멸전 콜백 → 스프링 종료


[!Tip] 객체의 생성과 초기화를 분리하자.

생성자는 객체를 생성하는 책임을, 초기화는 이전에 생성된 값들을 활용해서 커넥션을 연결하는 등 무거운 동작을 수행한다. 따라서 생성자 안에서 무거운 초기화 작업을 함께 하는 것보다는, 객체를 생성하는 부분과 초기화 하는 부분을 명확하게 나누는 것이 유지보수 관점에서 좋다.

또한, 초기화 동작이 필요한 시점까지 그 동작을 지연시킬 수도 있다. (ex. 커넥션 → 액션이 주어지면 그때 초기화 수행)


[1] 인터페이스(InitializingBean, DisposableBean)

public class NetworkClient implements InitializingBean, DisposableBean {

    private String url;

    public NetworkClient() {
        System.out.println("생성자 호출, url = " + url);
        // connect();
        // call("초기화 연결 메시지");
    }

    public void setUrl(String url) {
        this.url = url;
    }

    // 서비스 시작 시 호출
    public void connect() {
        System.out.println("connect: " + url);
    }

    public void call(String message) {
        System.out.println("call: " + url + " message = " + message);
    }

    // 서비스 종료 시 호출
    public void disconnect() {
        System.out.println("close " + url);
    }

    @Override
    public void afterPropertiesSet() throws Exception {
        // 의존관계 주입이 완료되면 호출
        System.out.println("NetworkClient.afterPropertiesSet");
        connect();
        call("초기화 연결 메시지");
    }

    @Override
    public void destroy() throws Exception {
        // 빈 종료 시 호출
        System.out.println("NetworkClient.destroy");
        disconnect();
    }
}
생성자 호출, url = null
NetworkClient.afterPropertiesSet
connect: http://hello-spring.dev
call: http://hello-spring.dev message = 초기화 연결 메시지
NetworkClient.destroy
close http://hello-spring.dev


단점


[!Note] 요즘은 거의 사용하지 않는다.


[2] 설정 정보에 초기화 메서드, 종료 메서드 지정

@Configuration
static class LifeCycleConfig {
    @Bean(initMethod = "init", destroyMethod = "close")
    public NetworkClient networkClient() {
        // 객체를 생성하는 단계에서는 url이 없고, 객체를 생성한 다음에 외부에서 수정자 주입을 통해 url을 설정한다.
        NetworkClient networkClient = new NetworkClient();
        networkClient.setUrl("http://hello-spring.dev");
        return networkClient;
    }
}


장점

  1. 메서드 이름을 자유롭게 줄 수 있다.
  2. 스프링 빈이 스프링 코드에 의존하지 않는다.
  3. 코드가 아니라 설정 정보를 사용하기 때문에 코드를 고칠 수 없는 외부 라이브러리에도 초기화, 종료 메서드를 적용할 수 있다.

    외부 라이브러리 문서에서 어떤 메서드를 사용하도록 지정하고 있는 경우도 있다.


@BeandestroyMethod 속성

라이브러리들은 대부분 close, shutdown이라는 이름의 종료 메서드를 사용한다.

@BeandestroyMethoddefault 값이 (inferred) 로 등록되어 있는데, 이는 close, shutdown이라는 이름의 메서드를 자동으로 호출해준다.

따라서 직접 스프링 빈으로 등록하면 종료 메서드는 따로 적어주지 않아도 잘 동작한다.

이러한 추론 기능을 사용하기 싫다면 destroyMethod="" 처럼 빈 공백을 지정하자.


[3] 어노테이션 @PostConstruct, @PreDestroy

[!Tip] 이 방법을 쓰면 된다.

@PostConstruct
public void init() {
    // 의존관계 주입이 완료되면 호출
    System.out.println("NetworkClient.init");
    connect();
    call("초기화 연결 메시지");
}

@PreDestroy
public void close() {
    // 빈 종료 시 호출
    System.out.println("NetworkClient.close");
    disconnect();
}


장점


단점

외부 라이브러리에는 적용하지 못하므로, 외부 라이브러리를 초기화, 종료해야 한다면 @Bean의 기능을 사용해야 한다.


정리

  1. @PostConstruct, @PreDestroy 어노테이션을 사용하자.
  2. 코드를 고칠 수 없는 외부 라이브러리를 초기화, 종료해야 한다면 @Bean(initMethod, destroyMethod)를 사용하자.
seungriyou commented 10 months ago

9. 빈 스코프

빈 스코프

스프링이 지원하는 스코프의 종류

  1. 싱글톤: (기본 스코프) 스프링 컨테이너의 시작~종료까지 유지되는 가장 넓은 범위의 스코프
  2. 프로토타입: 스프링 컨테이너가 프로토타입 빈의 생성~의존관계 주입까지만 관여하고 더 이상 관리하지 않는 매우 짧은 범위의 스코프
  3. 웹 관련 스코프
    1. request: 웹 요청이 들어오고 나갈 때까지 유지되는 스코프
    2. session: 웹 세션이 생성되고 종료될 때까지 유지되는 스코프
    3. application: 웹의 서블릿 컨텍스트와 같은 범위로 유지되는 스코프


[!Note] 스프링 빈은 기본적으로 싱글톤 스코프로 생성된다.


빈 스코프 지정 방법

  1. 자동 등록 (컴포넌트 스캔)

    @Scope("prototype")
    @Component
    public class HelloBean {}
  2. 수동 등록

    @Scope("prototype")
    @Bean
    PrototypeBean HelloBean() {
       return new HelloBean();
    }


[1] 싱글톤 스코프

[!Tip] 싱글톤 스코프는 스프링 컨테이너의 시작부터 끝까지 함께하는 긴 스코프이다. 따라서 싱글톤 스코프의 빈을 조회하면 스프링 컨테이너는 항상 같은 인스턴스의 스프링 빈을 반환한다.


image

public class SingletonTest {

    @Test
    void singletonBeanFind() {
        AnnotationConfigApplicationContext ac = new AnnotationConfigApplicationContext(SingletonBean.class);

        SingletonBean singletonBean1 = ac.getBean(SingletonBean.class);
        SingletonBean singletonBean2 = ac.getBean(SingletonBean.class);
        System.out.println("singletonBean1 = " + singletonBean1);
        System.out.println("singletonBean2 = " + singletonBean2);

        assertThat(singletonBean1).isEqualTo(singletonBean2);

        ac.close();
    }

    @Scope("singleton")
    static class SingletonBean {
        @PostConstruct
        public void init() {
            System.out.println("SingletonBean.init");
        }

        @PreDestroy
        public void destroy() {
            System.out.println("SingletonBean.destroy");
        }
    }
}
SingletonBean.init
singletonBean1 = hello.core.scope.SingletonTest$SingletonBean@3fa2213
singletonBean2 = hello.core.scope.SingletonTest$SingletonBean@3fa2213
SingletonBean.destroy


[2] 프로토타입 스코프

[!Tip] 프로토타입 스코프를 스프링 컨테이너에 조회하면 스프링 컨테이너는 항상 새로운 인스턴스를 생성해서 반환한다.


image

image


[!Tip] 프로토타입 스코프 빈의 핵심은 스프링 컨테이너는 <프로토타입 빈 생성 → 의존관게 주입 → 초기화> 까지만 처리한다는 것이다. 따라서 스프링 컨테이너는 클라이언트에 빈을 반환한 후에는 생성된 빈을 관리하지 않기 때문에, @PreDestroy 같은 종료 메서드가 호출되지 않는다.


public class PrototypeTest {

    @Test
    void prototypeBeanFind() {
        AnnotationConfigApplicationContext ac = new AnnotationConfigApplicationContext(PrototypeBean.class);

        System.out.println("find prototypeBean1");
        PrototypeBean prototypeBean1 = ac.getBean(PrototypeBean.class);
        System.out.println("find prototypeBean2");
        PrototypeBean prototypeBean2 = ac.getBean(PrototypeBean.class);

        System.out.println("prototypeBean1 = " + prototypeBean1);
        System.out.println("prototypeBean2 = " + prototypeBean2);
        assertThat(prototypeBean1).isNotSameAs(prototypeBean2);

        ac.close();
    }

    // @Component를 붙이지 않아도 컴포넌트 스캔 대상이 된다.
    // 이는 new AnnotationConfigApplicationContext(PrototypeBean.class)로 지정하면 클래스 자체를 등록하기 때문이다.
    @Scope("prototype")
    static class PrototypeBean {
        @PostConstruct
        public void init() {
            System.out.println("SingletonBean.init");
        }

        @PreDestroy
        public void destroy() {
            System.out.println("SingletonBean.destroy");
        }
    }
}
find prototypeBean1
SingletonBean.init
find prototypeBean2
SingletonBean.init
prototypeBean1 = hello.core.scope.PrototypeTest$PrototypeBean@3fa2213
prototypeBean2 = hello.core.scope.PrototypeTest$PrototypeBean@3e7634b9


문제점: 싱글톤 빈과 프로토타입 스코프를 함께 사용하는 경우

프로토타입 스코프 빈을 사용하는 본래의 의도는 “프로토타입 스코프의 빈을 요청하면 항상 새로운 객체 인스턴스를 생성해서 반환” 받기 위함이다.

하지만 싱글톤 스코프 빈에서 프로토타입 스코프 빈을 의존관계 주입을 통해 주입받는 경우, 이러한 의도대로 동작하지 않을 수 있다.

싱글톤 빈은 보통 스프링 컨테이너 생성 시점에 함께 생성되고, 이어서 의존관계 주입도 발생한다. 따라서 의존관계 주입 시점에 스프링 빈에게 프로토타입 빈을 요청하게 된다.

해당 시점에 스프링 컨테이너는 프로토타입 빈을 생성하여, 요청한 싱글톤 빈에게 반환한다. 싱글톤 빈은 내부 필드에 해당 프로토타입 빈의 참조값을 보관한다.

따라서 사용할 때마다 새로 생성되어야 한다는 본래의 의도와는 다르게 해당 프로토타입 빈은 싱글톤 빈의 의존관계 주입 시점에 단 한 번만 생성되어 계속 재활용 되게 된다. (이런 동작을 위해서라면 그냥 싱글톤 빈을 사용하는 것이 낫다.)

image


프로토타입 빈을 주입 시점에만 새로 생성하는 것이 아니라, 본래 의도대로 사용할 때마다 새로 생성해서 사용하려면 어떻게 해야할까?


해결 방법 세 가지

본래 의도대로 프로토타입 빈이 생성되었는지 테스트하는 코드

```java @Test void singletonClientUsePrototype() { AnnotationConfigApplicationContext ac = new AnnotationConfigApplicationContext(ClientBean.class, PrototypeBean.class); ClientBean clientBean1 = ac.getBean(ClientBean.class); int count1 = clientBean1.logic(); assertThat(count1).isEqualTo(1); ClientBean clientBean2 = ac.getBean(ClientBean.class); int count2 = clientBean2.logic(); assertThat(count2).isEqualTo(1); }` ```


  1. 싱글톤 빈이 프로토타입 빈을 사용할 때마다 스프링 컨테이너에 새로 요청하기

    단순 무식한 방법

    다음과 같이 직접 필요한 의존관계를 찾는 것을 DL(dependency lookup, 의존관계 조회)이라 한다.

    하지만, 이렇게 하면 스프링 컨테이너에 종속적이게 되어 단위 테스트도 어려워지므로, 딱 DL 정도의 기능만 제공하는 무언가로 대체해야 한다.

    @Scope("singleton")
    static class ClientBean {
        @Autowired
        private ApplicationContext ac;
    
        public int logic() {
            // 싱글톤 빈이 프로토타입 빈을 사용할 때마다 스프링 컨테이너에 새로 요청하기
            PrototypeBean prototypeBean = ac.getBean(PrototypeBean.class);
            prototypeBean.addCount();
            return prototypeBean.getCount();
        }
    }
  2. ObjectProvider, ObjectFactory 사용하기

    • ObjectProvider.getObject()를 호출하면, 내부에서는 스프링 컨테이너에게 해당 빈을 요청해서(= 찾아서) 반환한다. 따라서 getObject() 호출 시마다 새로운 프로토타입 빈이 생성된다.
    • 스프링이 제공하는 기능을 사용하는 것이긴 하지만, 기능이 단순하므로 단위테스트나 mock 코드를 만들기 쉬워진다.
    • ObjectFactory vs. ObjectProvider
      • ObjectFactory: 기능이 단순, 별도의 라이브러리 필요 없음, 스프링에 의존
      • ObjectProvider: ObjectFactory 상속, 옵션, 스트림 처리등 편의 기능이 많고, 별도의 라이브러리 필요 없음, 스프링에 의존
    @Scope("singleton")
    static class ClientBean {
        @Autowired
        private ObjectProvider<PrototypeBean> prototypeBeanProvider;
    
        public int logic() {
            PrototypeBean prototypeBean = prototypeBeanProvider.getObject();
            prototypeBean.addCount();
            return prototypeBean.getCount();
        }
    }
  3. JSR-330(자바 표준) Provider 사용하기

    • gradle에 라이브러리를 추가해야 한다.

      implementation 'jakarta.inject:jakarta.inject-api:2.0.1'
    • 딱 필요한 정도의 DL만 제공하며, get() 메서드 하나로 기능이 단순하다.

    • 별도의 라이브러리가 필요하기는 하나, 자바 표준이므로 다른 컨테이너에서도 사용할 수 있다.

    • 2번 코드에서 ObjectProviderProvider로, getObject()get()으로 바꾸면 된다.

      @Scope("singleton")
      static class ClientBean {
          @Autowired
          private Provider<PrototypeBean> prototypeBeanProvider;
      
          public int logic() {
              PrototypeBean prototypeBean = prototypeBeanProvider.get();
              prototypeBean.addCount();
              return prototypeBean.getCount();
          }
      }


정리

프로토타입 빈은 사용할 때마다 의존관계 주입이 완료된 새로운 객체가 필요할 때 사용하면 되는데, 실무에서 직접적으로 사용하는 일은 거의 없다.

Provider의 매뉴얼을 보면 다음과 같은 상황에서 사용할 수 있다고 소개한다.

ObjectProvider, Provider 등은 프로토타입 뿐만 아니라 DL이 필요한 경우는 언제든지 사용할 수 있다.


[!Tip] 자바 표준 vs. 스프링 제공

(거의 그럴 일은 없겠지만) 코드를 스프링이 아닌 다른 컨테이너에서도 사용할 수 있어야 한다면, 자바 표준 Provider를 사용해야 한다.

하지만 스프링이 제공하는 ObjectProvider는 DL을 위한 여러 편의기능을 추가로 제공해준다.

이처럼 자바 표준과 스프링 제공 기능이 겹치는 경우에는, 대부분 스프링이 더 편리한 기능을 제공해주기 때문에 다른 컨테이너를 사용할 일이 없다면 스프링이 제공하는 기능을 사용하는 것을 권장한다.


[3] 웹 스코프

웹 스코프의 특징


웹 스코프의 종류


다음의 그림에서, ControllerService는, HTTP request 단위로 같은 request 스코프 빈 객체를 참조하게 된다.

image


request 스코프 예제 (1차: 문제 상황)

동시에 여러 HTTP request가 올 때, 해당 request 마다 구별되도록 로그를 남기는 예제를 다뤄보자. 이때, 로그는 컨트롤러 계층과 서비스 계층에서 모두 남기도록 한다.

공통 포맷: [UUID][requestURL] {message}

사실 이런 동작은 컨트롤러보다는 공통 처리가 가능한 스프링 인터셉터나 서블릿 필터 같은 곳을 활용하는 것이 좋다.

  1. web 라이브러리를 추가한다.

    implementation 'org.springframework.boot:spring-boot-starter-web'
    • 해당 라이브러리를 통해 스프링 부트는 내장 톰캣 서버를 활용하여 웹 서버와 스프링을 함께 실행시킨다.

      Tomcat started on port 8080 (http) with context path ''
    • 스프링 부트는 웹 라이브러리가 없으면 AnnotationConfigApplicationContext를 기반으로 어플리케이션을 구동하고, 웹 라이브러리가 있으면 관련 추가 설정 및 환경이 필요하기 때문에 AnnotationConfigServletWebServerApplicationContext를 기반으로 어플리케이션을 구동한다.

  2. MyLogger, LogDemoController, LogDemoService 코드를 작성한다.

    • request 스코프 빈은 HTTP request 단위로 존재하며, 스프링 컨테이너에게 요청하는 시점에 생성되고 HTTP request가 끝나는 시점에 소멸된다.

      따라서 MyLogger는 다른 HTTP request 때문에 값이 섞일 일은 없다.

    • uuid는 빈이 생성되는 시점에 생성해서 저장할 수 있으나, requestURL은 빈 생성 시점에 알 수 없으므로 setter로 입력 받는다.
  3. 하지만 오류가 발생한다. 이는 request 스코프 빈은 실제 고객의 요청이 와야지만 생성할 수 있기에, 스프링 어플리케이션 실행 시점에는 아직 생성되지 않기 때문이다.

[!Tip] 스프링 컨테이너에게 request 스코프 빈(MyLogger)을 요청하는 것을 의존관계 주입 단계가 아닌, 실제 고객 요청이 왔을 때로 지연시켜야 한다.

Provider를 사용해야 한다!


[!Note] 비즈니스 로직이 있는 서비스 계층에서도 로그를 출력할 때, request 스코프를 사용하지 않는다면 모든 정보를 파라미터로 서비스 계층에 넘겨야 한다.

이렇게 되면 지저분해질 뿐만 아니라, 웹과 관련된 정보가 웹과 관련 없는 서비스 계층까지 넘어가게 된다는 것이다. 웹 관련 부분은 컨트롤러까지만 사용하여, 서비스 계층은 웹 기술에 종속되지 않도록 관리하는 것이 유지보수 관점에서 좋다.


request 스코프 예제 (2차: Provider를 통한 지연처리)

LogDemoController, LogDemoServiceMyLogger를 DL 할 수 있는 provider가 주입되도록 수정한다.

  1. ObjectProvider를 사용하여 LogDemoController, LogDemoService를 수정할 수 있다.

ObjectProvider를 통해 그 호출 시점까지 빈의 생성(엄밀히 말하면 스프링 컨테이너에게 해당 빈을 요청하는 것)을 지연시킬 수 있다.

```java
@Controller
@RequiredArgsConstructor
public class LogDemoController {

    /*
    request 스코프 빈은 실제 고객의 요청이 와야지만 생성할 수 있으므로,
    스프링 컨테이너에게 request 스코프 빈(MyLogger)을 요청하는 것을 의존관계 주입 단계가 아닌,
    실제 고객 요청이 왔을 때로 지연시켜야 한다.
     */
    private final LogDemoService logDemoService;
    private final ObjectProvider<MyLogger> myLoggerProvider;    // MyLogger를 DL 할 수 있는 provider가 주입된다.

    @RequestMapping("log-demo")
    @ResponseBody   // 데이터를 그대로 반환하기 위함
    public String logDemo(HttpServletRequest request) {
        MyLogger myLogger = myLoggerProvider.getObject();
                ...
```

```java
@Service
@RequiredArgsConstructor
public class LogDemoService {

    /*
    request 스코프 빈은 실제 고객의 요청이 와야지만 생성할 수 있으므로,
    스프링 컨테이너에게 request 스코프 빈(MyLogger)을 요청하는 것을 의존관계 주입 단계가 아닌,
    실제 고객 요청이 왔을 때로 지연시켜야 한다.
     */
    private final ObjectProvider<MyLogger> myLoggerProvider;

    public void logic(String id) {
        MyLogger myLogger = myLoggerProvider.getObject();
                ...
```
  1. url로 접속하면 다음과 같이 로그가 남게 된다. 요청 별로 uuid가 다르게 생성되는 것도 확인 가능하다.

    [b30dcdc9-dc2d-49b6-8408-b881d63edd09] request scope bean create: hello.core.common.MyLogger@626386c1
    [b30dcdc9-dc2d-49b6-8408-b881d63edd09][http://localhost:8080/log-demo] controller test
    [b30dcdc9-dc2d-49b6-8408-b881d63edd09][http://localhost:8080/log-demo] service id = testId
    [b30dcdc9-dc2d-49b6-8408-b881d63edd09] request scope bean close: hello.core.common.MyLogger@626386c1
    [7f134d0b-652b-42b0-9d03-d71ca95ab88c] request scope bean create: hello.core.common.MyLogger@662d847d
    [7f134d0b-652b-42b0-9d03-d71ca95ab88c][http://localhost:8080/log-demo] controller test
    [7f134d0b-652b-42b0-9d03-d71ca95ab88c][http://localhost:8080/log-demo] service id = testId
    [7f134d0b-652b-42b0-9d03-d71ca95ab88c] request scope bean close: hello.core.common.MyLogger@662d847d
  2. 동시에 여러 요청이 오더라도, 요청마다 각각 객체를 따로 관리된다는 것이 핵심이다. 이는 Thread.sleep(1000)을 추가하여 확인할 수 있다.

    [e422b0e1-8e27-412b-a4ff-0a0a4b095a1f] request scope bean create: hello.core.common.MyLogger@525c9694
    [e422b0e1-8e27-412b-a4ff-0a0a4b095a1f][http://localhost:8080/log-demo] controller test
    [b1e3eabe-5599-4120-a585-1e8493e6b6e5] request scope bean create: hello.core.common.MyLogger@5ac23cf9
    [b1e3eabe-5599-4120-a585-1e8493e6b6e5][http://localhost:8080/log-demo] controller test
    [852190ad-ce10-4bdc-82cb-46b29b99d751] request scope bean create: hello.core.common.MyLogger@6836b85e
    [852190ad-ce10-4bdc-82cb-46b29b99d751][http://localhost:8080/log-demo] controller test
    [e422b0e1-8e27-412b-a4ff-0a0a4b095a1f][http://localhost:8080/log-demo] service id = testId
    [e422b0e1-8e27-412b-a4ff-0a0a4b095a1f] request scope bean close: hello.core.common.MyLogger@525c9694


[!Note] ObjectProvider.getObject()를 컨트롤러와 서비스에서 각각 한 번씩 따로 호출하더라도, 같은 HTTP request이면 같은 스프링 빈이 반환된다. 이를 직접 구분하려면 스레드 로컬 등등.. 어렵다. (FastAPI로 context local 등등 직접 구현하려고 했었다^^)


request 스코프 예제 (3차: 프록시를 통한 지연처리)

provider를 사용하는 예제에서, provider로 받아서 거기에서 다시 꺼내는 것이 귀찮다! 이를 편리하게 개선하려면?

[!Tip] 프록시를 이용하여 provider에서 꺼내는 동작 없이도 마치 provider를 사용했을 때처럼 지연 생성이 가능하다.

  1. 2차 코드에서 ObjectProvider를 추가했던 부분을 다시 원래대로 돌려놓는다. (싱글톤 사용하듯이)
  2. request 스코프 빈인 MyLogger에 다음의 어노테이션을 추가한다.

    적용 대상이 인터페이스이면 INTERFACES를, 인터페이스가 아닌 클래스이면 TARGET_CLASS를 추가한다.

    @Scope(value = "request", proxyMode = ScopedProxyMode.TARGET_CLASS)


이렇게 하면 MyLogger의 가짜 프록시 클래스를 만들어두고 HTTP request와 상관 없이 가짜 프록시 클래스를 다른 빈에 미리 주입해둘 수 있다.

가짜 프록시 클래스를 출력해보면 다음과 같이, CGLIB이라는 라이브러리를 통해 생성되어 대신 등록되어 있는 것을 알 수 있다. ac.getBean("myLogger", MyLogger.class)로 조회해도 이 프록시 객체가 조회된다.

myLogger = class hello.core.common.MyLogger$$SpringCGLIB$$0

따라서 의존관계 주입 시점에서도 이 가짜 프록시 객체가 주입되며, 이는 실제 request 스코프와는 관계 없이 싱글톤처럼 동작한다. 내부에는 진짜 myLogger를 요청하는 위임 로직이 들어있고, 클라이언트가 myLogger.log()를 호출하면 사실은 가짜 프록시 객체의 메서드를 호출하게 된다.

가짜 프록시 객체는 원본 클래스를 상속 받아 만들어졌으므로, 클라이언트 입장에서는 원본인지 아닌지도 모르게 사용할 수 있다. (→ 다형성)


특징은 다음과 같다.

  1. 프록시 방식을 통해 클라이언트는 마치 싱글톤 빈을 사용하듯이 편리하게 request 스코프를 사용할 수 있다.
  2. 단지 어노테이션 설정 변경만으로 원본 객체를 프록시 객체로 대체할 수 있다. (→ 다형성, DI 컨테이너의 강점)
  3. 웹 스코프가 아니더라도 프록시를 사용할 수 있다.


주의할 점은 다음과 같다.

  1. 마치 싱글톤을 사용하는 것 같지만 다르게 동작하므로 주의해야 한다.
  2. 이러한 특별한 scope는 꼭 필요한 곳에서만 최소화해서 사용해야 한다. 안 그러면 유지보수가 어려워진다.


[!Tip] provider를 사용하든 프록시를 사용하든, 핵심 아이디어는 진짜 객체 조회를 꼭 필요한 시점까지 지연처리 한다는 점이다.