포스트

스프링 핵심 원리 (13) - List, Map으로 전략 패턴 구현하기와 자동/수동 등록 기준

스프링 핵심 원리 (13) - List, Map으로 전략 패턴 구현하기와 자동/수동 등록 기준

1. 개요

앞 글에서는 같은 타입의 빈이 여러 개일 때 그중 하나만 골라서 주입받는 방법을 다뤘다. 그런데 반대로 같은 타입의 빈을 하나만 고르는 게 아니라 전부 다 받아서 써야 하는 경우도 있다. 스프링은 이럴 때 ListMap으로 한 번에 주입받는 방법을 지원한다. 이번 글에서는 이 기능으로 전략 패턴을 얼마나 간단하게 구현할 수 있는지 살펴보고, 마지막으로 컴포넌트 스캔 같은 자동 등록과 @Configuration 기반의 수동 등록을 실무에서 각각 어떤 기준으로 나눠 쓰면 좋을지 정리한다.


2. 같은 타입의 빈을 전부 주입받기 - List, Map

할인 정책을 예로 들어보자. 지금까지는 DiscountPolicy 구현체 중 하나만 골라서 주입받았지만, 이번에는 클라이언트가 요청할 때마다 “정액 할인”과 “정률 할인” 중 원하는 정책을 골라 쓸 수 있게 만들고 싶다고 해보자. 이런 요구사항은 전형적인 전략 패턴 구조인데, 스프링에서는 별도의 팩토리 클래스를 만들지 않고도 컨테이너의 자동 주입 기능만으로 풀 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
static class DiscountService {

    private final Map<String, DiscountPolicy> policyMap;
    private final List<DiscountPolicy> policies;

    public DiscountService(Map<String, DiscountPolicy> policyMap, List<DiscountPolicy> policies) {
        this.policyMap = policyMap;
        this.policies = policies;
    }

    public int discount(Member member, int price, String discountCode) {
        DiscountPolicy discountPolicy = policyMap.get(discountCode);
        return discountPolicy.discount(member, price);
    }
}

생성자 파라미터로 Map<String, DiscountPolicy>List<DiscountPolicy>를 동시에 받고 있다. 두 컬렉션 모두 DiscountPolicy 타입으로 등록된 빈이라면 별도의 @Qualifier 지정 없이도 컨테이너에 등록된 만큼 전부 채워진다.

  • List<DiscountPolicy>DiscountPolicy 타입으로 조회되는 빈을 순서대로 모두 담아준다.
  • Map<String, DiscountPolicy>는 빈 이름을 key로, 빈 인스턴스를 value로 담아준다. 그래서 policyMap.get("fixDiscountPolicy")처럼 빈 이름을 알고 있으면 원하는 구현체를 바로 꺼낼 수 있다.

discount() 메서드는 클라이언트가 넘긴 discountCode 문자열을 그대로 policyMap의 key로 사용해서, 런타임에 어떤 할인 정책을 쓸지 결정한다. 즉 새로운 할인 정책 클래스를 추가하더라도 DiscountService 쪽 코드는 전혀 손댈 필요가 없고, @Component만 붙여서 빈으로 등록하면 자동으로 policyMap에 합류한다.


3. 동작 확인

컨테이너를 띄우고 fixDiscountPolicy를 코드로 지정해서 호출하면 의도한 대로 동작하는지 테스트로 확인할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
@Test
void findAllBean() {
    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(discountPrice).isEqualTo(1000);
}

만약 컨테이너 안에 해당 타입의 빈이 하나도 없다면 예외가 나는 게 아니라, 비어 있는 ListMap이 그대로 주입된다는 점도 함께 알아두면 좋다.

참고: new AnnotationConfigApplicationContext(AutoAppConfig.class, DiscountService.class)처럼 컨테이너 생성자에 클래스를 여러 개 넘기면, 그 클래스들이 모두 스프링 빈으로 등록된 상태에서 컨테이너가 만들어진다. 위 테스트에서는 컴포넌트 스캔 설정 클래스인 AutoAppConfig와 함께 테스트 안에 정의한 DiscountService도 같이 빈으로 등록한 것이다.


4. 자동 등록을 기본값으로 삼자

여기까지 오면서 컴포넌트 스캔으로 자동 등록하는 방식과 @Configuration + @Bean으로 수동 등록하는 방식을 둘 다 다뤘다. 그렇다면 실무에서는 어느 쪽을 기본으로 삼아야 할까? 답을 먼저 말하면 자동 등록 쪽이다. @Controller, @Service, @Repository처럼 계층별로 세분화된 애노테이션이 계속 추가되어 온 것도, 스프링 부트가 컴포넌트 스캔을 기본값으로 깔고 들어가는 것도 결국 스프링 진영이 자동화를 더 신뢰하는 방향으로 발전해왔다는 방증이다.

수동 등록을 마다하는 현실적인 이유도 있다. 빈 하나 추가하자고 @Configuration 클래스에 가서 @Bean 메서드를 새로 만들고, 생성자 파라미터를 일일이 나열해서 의존관계를 연결해주는 작업은 빈이 몇 개 안 될 때야 괜찮지만, 관리할 빈이 수십 개로 늘어나면 그 설정 클래스 자체가 새로운 유지보수 대상이 되어버린다. @Component만 붙이면 끝나는 자동 등록과 비교하면 번거로움의 차이가 크다. 게다가 자동 등록을 쓴다고 해서 OCP나 DIP 같은 설계 원칙을 포기해야 하는 것도 아니므로, 굳이 수동 등록을 고집할 이유가 크지 않다.


5. 그래도 수동 등록이 필요한 두 가지 상황

그렇다고 모든 빈을 무조건 자동으로 등록하는 것이 항상 정답은 아니다. 애플리케이션 코드를 크게 나눠보면, 컨트롤러·서비스·리포지토리처럼 비즈니스 요구사항에 따라 계속 추가되고 바뀌는 업무 로직과, 데이터베이스 연결이나 공통 로그 처리처럼 애플리케이션 전반에 걸쳐 영향을 주는 기술 지원 로직으로 구분할 수 있다. 이 둘은 성격이 달라서 등록 방식도 다르게 가져가는 편이 좋다.

기술 지원 빈은 수동 등록을 고려하자. 업무 로직은 개수가 많아도 패턴이 비슷해서 자동 등록으로 관리해도 문제가 생겼을 때 원인 파악이 어렵지 않다. 반면 기술 지원 로직은 숫자는 적지만 애플리케이션 곳곳에 은근하게 영향을 미치기 때문에, 뭔가 잘못됐을 때 어디서부터 살펴봐야 할지 감을 잡기가 훨씬 어렵다. 이런 빈들은 설정 클래스에 @Bean으로 콕 집어 등록해서, 코드를 열어보면 어떤 기술 지원 빈이 어떻게 구성되어 있는지 한눈에 드러나게 하는 편이 유지보수에 유리하다. 다만 스프링 부트가 DataSource처럼 내부적으로 자동 등록해주는 빈들은 예외로 두고, 스프링 부트의 관례를 믿고 편하게 사용하면 된다.

다형성을 적극 활용하는 비즈니스 로직도 수동 등록을 고민해보자. 앞서 본 DiscountServiceMap<String, DiscountPolicy> 주입을 다시 떠올려보자. DiscountService 클래스 하나만 봐서는 정확히 어떤 빈들이, 어떤 이름으로 이 맵에 채워지는지 알 수 없고, @Component가 붙은 구현체들을 프로젝트 곳곳에서 따로 찾아봐야 전체 그림이 맞춰진다. 처음부터 이 구조를 설계한 사람에게는 익숙한 흐름이겠지만, 코드를 처음 넘겨받는 입장에서는 그 추적 과정 자체가 진입장벽이 된다. 이런 경우에는 아래처럼 설정 클래스를 따로 만들어 수동으로 등록하는 편이 훨씬 친절하다.

1
2
3
4
5
6
7
8
9
10
11
12
13
@Configuration
public class DiscountPolicyConfig {

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

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

이렇게 한곳에 모아두면 설정 클래스 하나만 열어봐도 어떤 빈들이 DiscountPolicy 타입으로 등록되어 있는지, 이름은 무엇인지 바로 파악된다. 자동 등록의 편리함을 포기하기 어렵다면, 최소한 DiscountPolicy 구현체들만이라도 별도 패키지로 묶어두는 절충안도 있다. 핵심은 코드를 처음 보는 사람도 “무엇이 등록되어 있는지”를 빠르게 파악할 수 있어야 한다는 점이다.


6. 정리

같은 타입의 빈이 여러 개 있을 때 그중 하나만 필요한 게 아니라 전부 필요한 상황이라면, ListMap으로 한 번에 주입받아 전략 패턴처럼 활용할 수 있다. Map은 빈 이름을 key로 활용할 수 있어서, 런타임에 문자열 값 하나로 원하는 구현체를 골라 쓰는 구조를 별도의 팩토리 코드 없이 구현하게 해준다.

빈 등록 방식은 기본적으로 컴포넌트 스캔 같은 자동 등록을 우선 고려하되, 애플리케이션 전반에 영향을 주는 기술 지원 빈이나 다형성을 활용해 여러 구현체를 한 번에 주입받는 비즈니스 로직처럼 “코드만 보고 파악하기 어려운” 영역은 수동 등록으로 명시적으로 드러내는 것이 유지보수 측면에서 안전하다.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.