Spring Core/02. Spring Container와 DI

03. DI 처리 - 명시적 DI

  • -
반응형

스프링은 DI를 어떻게 처리할까? 다음의 그림을 살펴보자.

스프링의 DI 과정

  1. 개발자는 비즈니스 로직을 담은 빈(Bean)들을 POJO 형태로 작성한다. 
  2. 개발자는 각 빈들을 어떻게 생성하고 has-a 관계를 맺어줄지에 대한 설명서인 메타정보를 작성해서 스프링 컨테이너에게 전달한다.
  3. 스프링 컨테이너는 메타정보에 근거해 빈 객체를 만들고 setter/생성자를 호출해서 의존성을 주입한다.
  4. 클라이언트 코드에서 컨테이너에게 빈을 요청하면
  5. 컨테이너는 관리하고 있던 빈 객체를 반환해 준다.

이런 DI를 처리하는 방식은 메타정보를 만드는 방식에 따라 명시적 DI와 묵시적 DI로 나뉜다.

 

Java Config를 활용한 명시적 DI

 

명시적 DI

명시적 DI란 빈을 생성하고 의존성을 주입하는 코드를 별도의 파일에 "이것은 빈입니다." 라고 작성해주는 것이다. 이런 명시적인 DI를 위한 방법으로는 XML을 이용할 수도 있고 Java 코드를 이용할 수도 있다. XML을 이용한 방식은 최초의 스프링에서부터 사용되었으나 SpringBoot로 넘어오면서는 거의 자취를 감추고 있다.

명시적 DI를 위해서는 @Configuration과 @Bean이라는 annotation이 사용된다.

 

@Configuration

Java Config를 이용해서 메타 정보를 설정하기 위해서는 클래스에 @Configuration 애너테이션을 선언해주어야 한다.

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Component
public @interface Configuration {}

@Configuration은 @Target 정보가 ElementType.TYPE이기 때문에 class 선언부에 사용할 수 있는 애너테이션이다.

@Bean

@Configuration클래스에 빈을 선언하기 위해서는 @Bean 애너테이션을 사용한다.

@Target({ElementType.METHOD, ElementType.ANNOTATION_TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Bean {
  @AliasFor("name")
  String[] value() default {};
}

@Bean은 메서드 레벨에서 사용되는 애너테이션이다.  이때 메서드의 이름은 생성되는 빈의 이름이 된다. 메서드 이름과 빈의 이름을 달리하려는 경우 @Bean의 name(==value)속성을 이용할 수 있다.

 

빈 생성 실습

 

사용할 POJO 클래스 작성

POJO는 비지니스 로직을 구성하는 객체들이다. 앞서 작성한 LPhone, SPhone, PhoneUser등은 스프링과 전혀 상관 없이 작성된 그냥 오래된 평범한 자바 객체(POJO)들이다.

기존의 UML과 크게 달라진 점은 관계를 설정했던 PhoneFactory가 없다는 점이다. 이 부분을 누가 가져갔을까?

 

메타 정보의 작성

다음으로 빈 구성을 담당하는 메타 정보를 작성해보자. 코드에 대한 설명은 주석으로 갈음한다.

package com.doding.di.phone.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import com.doding.di.phone.LPhone;
import com.doding.di.phone.PhoneUser;
import com.doding.di.phone.SPhone;
import com.doding.di.phone.SmartPhone;

import lombok.extern.slf4j.Slf4j;

@Configuration // 여기는 설정 정보가 담겨있습니다.
@Slf4j
public class PhoneConfig {
  @Bean // 빈 생성 선언
  public SPhone sPhone() { // SPhone 타입의 빈 생성, 빈 이름은 sPhone
    log.debug("sphone 빈 생성");
    return new SPhone(); // 실제로 사용될 빈 객체 반환
  }

  @Bean(name = "mylPhone") // 빈 생성 선언: 빈의 이름은 mylPhone
  public LPhone lPhone() {
    log.debug("lphone 빈 생성");
    return new LPhone();
  }

  @Bean
  public PhoneUser phoneUser() {
    log.debug("phoneuser 빈 생성");
    SmartPhone phone = sPhone();
    return new PhoneUser(phone); // 생성자를 통해 의존성인 phone 주입(sPhone)
  }
}

 

@SpringBootApplication

위 예에서는 명시적인 빈 등록을 위해서 @Configuration이 들어간 클래스를 만들고 작업했는데 사실 그렇게 하지 않아도 된다. 최초 프로젝트를 생성할 때 main 메서드를 가지고 생성된 시작 클래스를 살펴보면 @SpringBootApplication이 설정되어있는데 이 annotation은 내부적으로 @SpringBootConfiguration을 가지고 있고 이것이 다시 @Configuration을 가지고 있다.

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
@SpringBootConfiguration
@EnableAutoConfiguration
public @interface SpringBootApplication {}
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Configuration
@Indexed
public @interface SpringBootConfiguration {}

따라서 PhoneConfig를 생성하지 않고  이 클래스에 직접에 @Bean들을 설정해도 무방하다. 하지만 많은 설정들이 @SpringBootConfiguration 클래스에 작성되면 복잡도가 높아지기 때문에 전문적인 설정파일을 만들고 분산하는 것이 좋다.

작성된 SpringBoot 애플리케이션을 실행시키면 @SpringBootConfiguration 클래스의 main 메서드가 동작하면서 프로그램이 동작하게 된다.

 

단위테스트

 

빈 생성 확인

방금 구성했던 빈들이 잘 만들어졌는지 확인해보자.

package com.doding.di.phone;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.ApplicationContext;

import java.util.Arrays;

@SpringBootTest
public class BootPhoneTest {

  @Autowired
  ApplicationContext ctx; // IOC container 인 ApplicationContext를 가져온다.

  @Test
  public void printBeanNames(){
    Arrays.stream(ctx.getBeanDefinitionNames())
          .filter(name-> name.matches("(?i).*phone.*"))  // 대소문자 없이 phone이 들어간 문자열
          .forEach(System.out::println);
  }
}

스프링에서 JUnit 기반의 단위테스트를 가장 쉽게 처리하기 위해서는 @SpringBootTest를 선언하는 것이다. @Autowired는 아직 배우지 않았지만 다음 장에서 금방 배울꺼니까 궁금증을 뒤로 남겨두자. ApplicationContext는 Application 자체를 나타내는 객체로 앞으로 Spring Container라고 부르자.

코드의 실행 결과는 아래와 같다.

08:28:05 [DEBUG] c.d.d.p.c.PhoneConfig.sPhone.18 - sphone 빈 생성
08:28:05 [DEBUG] c.d.d.p.c.PhoneConfig.lPhone.24 - lphone 빈 생성
08:28:05 [DEBUG] c.d.d.p.c.PhoneConfig.phoneUser.30 - phoneuser 빈 생성
08:28:05 [INFO ] c.d.d.p.BootPhoneTest.logStarted.56 - Started BootPhoneTest in 0.699 seconds (process running for 1.507)
phoneConfig
sPhone
mylPhone
phoneUser

실행 결과를 살펴보면 3개의 빈을 만드는것을 확인할 수 있다. 이때 빈의 생성 순서를 살펴보면 phoneUser가 Phone을 사용해야 하기 때문에 의존성인 sPhone이 먼저 생성된 것을 볼 수 있다. 이처럼 스프링은 설정 파일의 내용을 참고하여 빈의 생성 시점을 결정해준다.

출력된 빈들의 이름을 보면 @Bean이 선언된 메서드의 이름(sPhone, phoneUser) 또는 @Bean의 name 속성(mylPhone)인 것도 알 수 있다.

 

빈 객체 관리

다음으로 Spring Container에 생성된 빈을 요청해보고 어떻게 관리되는지 살펴보자. Spring Container에 빈을 요청할 때에는 getBean 메서드를 사용한다. getBean 메서드는 타입 기반으로 관리하는 빈을 반환한다. 이 방식은 Spring Container의 기본적인 빈 관리 방식이다.

여기서 확인할 내용은 빈으로 만든 대상이 주로 무상태(stateless)한 객체라는 점이다. Spring Container는 이런 빈들을 위해 별도로 Singleton Design Pattern을 적용하지 않아도 자동으로 하나의 객체만 만들어 재사용 한다.

@Test
@DisplayName("스프링은 빈들은 Singleton으로 관리한다.")
public void usePhoneTest() {
  SPhone phone1 = ctx.getBean(SPhone.class);
  SPhone phone2 = ctx.getBean(SPhone.class);
  assertSame(phone1, phone2);
  
  SmartPhone phone3 = ctx.getBean(PhoneUser.class).getPhone();
  assertSame(phone1, phone3);
}

위 테스트에서는 여러번 SPhone 타입의 빈을 요청했는데 결과로 phone1과 phone2는 같은 객체임이 확인된다. PhoneUser를 만들 때는 SPhone 타입의 phone을 등록했는데 이때의 phone 객체 역시 phone1과 같은 객체이다.  객체가 재사용되고 있다는 것을 알 수 있다.

그런데 여기서 한가지 지나치지 말아야 할 사실은 PhoneUser를 생성할 때의 코드이다.

@Bean
public PhoneUser phoneUser() {
  SmartPhone phone = sPhone();
  return new PhoneUser(phone); 
}
@Bean
public SPhone sPhone() {
  return new SPhone(); 
}

여기서 분명히 sPhone() 메서드를 호출하고 있고 sPhone() 안에서는 new SPhone()을 하고 있다. 즉 메서드가 호출 될 때마다 매번 새로운 객체를 생성하고 있는데 어떻게 하나의 객체가 재사용 되고 있을까? 아직은 때가 아니니 궁금증만 유지하자.

 

타입 기반의 빈 관리

앞서 이야기 했듯이 Spring Container는 빈을 타입 기반으로 관리한다. 그럼 다음의 테스트를 실행해보면 어떻게 될까?

@Test
public void getPhoneTestByType() {
    SmartPhone phone = ctx.getBean(SmartPhone.class);
}

SmartPhone 타입의 빈을 달라고 요청했는데.. 결과는..실패다. 실패한 이유를 한번 들어보자.

org.springframework.beans.factory.NoUniqueBeanDefinitionException: 
No qualifying bean of type 'com.doding.di.phone.SmartPhone' 
available: expected single matching bean but found 2: sPhone,mylPhone

메시지를 살펴보니 실패의 이유는 SmartPhone 타입의 빈이 하나만 매칭되기를 기대했는데 sPhone과 mylPhone이라는 2개의 객체가 발견되어 누구를 반환할지 모르겠다는 이야기다.  

Spring Container는 빈을 타입 기반으로 관리하는데 동일한 타입의 빈이 여러 개 있다면 어떤 빈을 원하는지 알수 없다. 이런 경우 타입 이외에 빈을 구분할 수 있는 요소가 필요한데 그것이 바로 name이다. 

아래의 테스트는 빈의 이름을 확인하고 이름 기반의 조건을 추가해서 호출하고 있다.

@Test
@DisplayName("Spring Container는 기본적으로 타입 기반으로 빈 관리")
public void getPhoneTestByType() {
  // 동일 타입의 빈이 2개 이상 있을 때 문제 발생
  assertThrows(NoUniqueBeanDefinitionException.class, () -> ctx.getBean(SmartPhone.class));
  
  // 이름 기반으로 보완
  SmartPhone phone = ctx.getBean("sPhone", SmartPhone.class);
  assertEquals(phone.getClass().getName(), SPhone.class.getName());
}

이제 SmartPhone을 가져올 때 sPhone이라는 빈의 name이 보조수단으로 사용되고 있음을 알 수 있다.

반응형

'Spring Core > 02. Spring Container와 DI' 카테고리의 다른 글

06. 빈의 스코프  (0) 2024.02.22
05. Profile  (0) 2020.06.17
04. DI 처리 - 묵시적 DI  (0) 2020.06.17
02. Dependency와 Dependency Injection  (0) 2020.06.15
01. Spring Application의 구성 요소  (0) 2020.06.15
Contents

포스팅 주소를 복사했습니다

이 글이 도움이 되었다면 공감 부탁드립니다.