1. 빈 스코프란?

우리가 일반적으로 빈(Bean)이라고 생각하는 것은, 스프링 컨테이너가 시작될 때 함께 생성되고, 스프링 컨테이너가 종료될 때까지 유지되는 것이다. 이것은 스프링 빈이 기본적으로 싱글톤 스코프로 생성되기 때문이다. 스코프란 빈이 존재할 수 있는 범위를 뜻한다.

 

스프링은 다음과 같은 다양한 스코프를 지원한다.

1) 싱글톤 : 디폴트 스코프. 스프링 컨테이너의 시작과 종료까지 유지되는 가장 넓은 범위의 스코프이다.

2) 프로토타입 : 스프링 컨테이너는 프로토타입 빈의 생성과 의존 관계 주입까지만 관여하고, 더는 관리하지 않는 매우 짧은 범위의 스코프이다. 따라서 종료 메서드는 호출되지 않는다.

3) 웹 관련 스코프

  • request : 웹 요청이 들어오고 나갈 때까지 유지되는 스코프이다.
  • session : 웹 세션이 생성되고 종료될 때까지 유지되는 스코프이다.
  • application : 웹의 서블릿 컨텍스트와 같은 범위로 유지되는 스코프이다.

빈 스코프는 아래와 같이 지정할 수 있다.

@Scope("prototype")
@Component
public class NetworkClient {
}

@Configuration
class LifeCycleConfig {
    @Scope("prototype")
    @Bean
    public NetworkClient networkClient() {
        final NetworkClient networkClient = new NetworkClient();
        return networkClient;
    }
}

 

2. 프로토타입 스코프

싱글톤 스코프의 빈을 조회하면, 스프링 컨테이너는 항상 같은 인스턴스를 반환한다. 반면 프로토타입 스코프의 경우, 스프링 컨테이너는 항상 새로운 인스턴스를 생성해서 반환한다. 또한 프로토타입 스코프의 빈의 경우, 생성된 이후에는 스프링 컨테이너에 의해 관리되지 않는다는 점도 싱글톤 스코프와의 차이점이다. 따라서 종료 메서드는 호출되지 않는다.

아래 코드를 보면, 프로토타입 스코프 빈의 경우 빈을 조회할 때마다 빈의 생성과 의존 관계 주입, 초기화가 이뤄진다는 것을 확인할 수 있고, 스프링 컨테이너가 종료될 때도 destroy 메서드는 호출되지 않는 것을 볼 수 있다.

class PrototypeTest {
    @Test
    void prototypeBeanFind() {
        ConfigurableApplicationContext ac = new AnnotationConfigApplicationContext(PrototypeBean.class);
        System.out.println("find prototypeBean1");
        final PrototypeBean prototypeBean1 = ac.getBean(PrototypeBean.class);
        System.out.println("find prototypeBean2");
        final PrototypeBean prototypeBean2 = ac.getBean(PrototypeBean.class);
        assertThat(prototypeBean1).isNotSameAs(prototypeBean2);
        ac.close();
    }

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

        @PreDestroy
        public void destroy() {
            System.out.println("SingletonBean.destroy");
        }
    }
}

 

3. 프로토타입 스코프 - 싱글톤 빈과 함께 사용시 문제점

앞서 말했듯이 프로토타입 스코프의 빈은 해당 타입의 빈이 스프링 컨테이너에 요청될 때마다 생성된다. 헌데 싱글톤 스코프의 빈이 프로토타입의 빈을 주입 받는다면, 싱글톤 스코프의 빈이 생성되고 의존 관계가 주입되는 시점에만 프로토타입 빈이 조회될 것이고, 이후에는 계속 같은 빈이 사용될 것이다. 대부분의 경우, 개발자는 이를 의도하지 않았을 것이다. 애초에 매번 새로운 객체가 필요하기 때문에 프로토 타입으로 선언했을 것이기 때문이다.

따라서 아래와 같은 테스트 코드를 실행한다면, 테스트는 실패할 것이다.

class SingletonWithPrototypeTest1 {
    @Test
    void singletonClientUserPrototype() {
        final AnnotationConfigApplicationContext ac = new AnnotationConfigApplicationContext(PrototypeBean.class, ClientBean.class);
        final ClientBean clientBean1 = ac.getBean(ClientBean.class);
        final int count1 = clientBean1.logic();
        assertThat(count1).isEqualTo(1);

        final ClientBean clientBean2 = ac.getBean(ClientBean.class);
        final int count2 = clientBean2.logic();
        assertThat(count2).isEqualTo(1);

        ac.close();
    }

    @Scope("singleton")
    static class ClientBean {
        private final PrototypeBean prototypeBean; // 생성 시점에 주입

        public ClientBean(final PrototypeBean prototypeBean) {
            this.prototypeBean = prototypeBean;
        }

        public int logic() {
            prototypeBean.addCount();
            return prototypeBean.getCount();
        }
    }

    @Scope("prototype")
    static class PrototypeBean {
        private int count = 0;

        public void addCount() {
            count++;
        }

        public int getCount() {
            return count;
        }

        @PostConstruct
        public void init() {
            System.out.println("PrototypeBean.init " + this);
        }

        @PreDestroy
        public void destroy() {
            System.out.println("PrototypeBean.destroy");
        }
    }
}

 

4. 프로토타입 스코프 - 싱글톤 빈과 함께 사용시 Provider로 문제 해결

가장 간단한 방법은, 프로토타입의 의존 관계를 주입받지 않고 ApplicationContext에 매번 빈을 아래와 같이 요청하는 것이다. 하지만 이렇게 되면, 스프링에 종속적인 코드가 되고 단위테스트도 어려워 진다.

@Scope("singleton")
static class ClientBean {
    @Autowired
    private ApplicationContext ac;

    public int logic() {
        final PrototypeBean prototypeBean = ac.getBean(PrototypeBean.class);
        prototypeBean.addCount();
        return prototypeBean.getCount();
    }
}

 

1) ObjectFactory, ObjectProvider

ObjectProvider는 지정한 빈을 컨테이너에서 대신 찾아주는 DL(Dependency Lookup) 서비스를 제공한다. 원래는 ObjectFactory만 있었는데, 여기에 편의 기능을 추가해서 ObjectProvider가 만들어졌다. 아래와 같이 사용할 수 있다.

@Scope("singleton")
static class ClientBean {
    private final ObjectProvider<PrototypeBean> prototypeBeanProvider;

    public ClientBean(final ObjectProvider<PrototypeBean> prototypeBeanProvider) {
        this.prototypeBeanProvider = prototypeBeanProvider;
    }

    public int logic() {
        final PrototypeBean prototypeBean = prototypeBeanProvider.getObject();
        prototypeBean.addCount();
        return prototypeBean.getCount();
    }
}

 

특징

1) 스프링이 제공하는 기능을 사용하긴 하지만, 기능이 단순하므로 단위테스트를 만들거나 mock 코드를 만들기 쉬워진다.

2) 딱 필요한 DL(Dependency Lookup) 기능만 제공한다.

 

2) JSR-330 Provider

이 방법은 javax.inject.Provider 라는 JSR-330 자바 표준을 사용하는 방법이다. 이 방법을 사용하기 위해선 javax.inject:javax.inject:1 라이브러리를 gradle에 별도로 추가해야 한다. 아래와 같이 사용할 수 있다.

@Scope("singleton")
static class ClientBean {
    private final Provider<PrototypeBean> prototypeBeanProvider;

    ClientBean(final Provider<PrototypeBean> prototypeBeanProvider) {
        this.prototypeBeanProvider = prototypeBeanProvider;
    }

    public int logic() {
        final PrototypeBean prototypeBean = prototypeBeanProvider.get();
        prototypeBean.addCount();
        return prototypeBean.getCount();
    }
}

 

특징

1) 자바 표준이므로 스프링이 아닌 다른 컨테이너에서도 사용할 수 있다.

2) 기능이 단순하므로 단위테스트를 만들거나 mock 코드를 만들기 쉬워진다.

3) 딱 필요한 DL(Dependency Lookup) 기능만 제공한다.

4) 별도의 라이브러리가 필요하다.

 

5. 웹 스코프

웹 스코프는 웹 환경에서만 동작한다. 웹 스코프는 프로토타입과는 다르게, 스프링 컨테이너가 해당 스코프의 종료 시점까지 관리를 한다. 따라서 종료 메서드가 호출된다.

 

웹 스코프 종류

1) request : HTTP 요청 하나가 들어오고 나갈 때까지 유지되는 스코프. 각각의 HTTP 요청마다 별도의 빈 인스턴스가 생성되고 관리된다.

2) session : HTTP session 과 동일한 생명 주기를 가지는 스코프

3) application : 서블릿 컨텍스트과 동일한 생명 주기를 가지는 스코프

4) websocket : 웹 소켓과 동일한 생명 주기를 가지는 스코프

 

6. request 스코프 예제 만들기

 request 스코프의 경우, HTTP 요청이 들어왔을 때마다 빈이 생성되고 요청이 끝날 때까지 유지가 된다. 즉 요청이 들어왔을 때 생성이 되기 때문에, 일반적인 생성자/수정자 주입 방식 등으로 의존 관계를 주입하려고 하면 에러가 발생한다.

 

1) ObjectProvider를 사용

아래에서 하나의 HTTP 요청에서는 같은 빈 인스턴스를 공유한다는 것을 확인할 수 있다.

@Component
@Scope(value = "request")
public class MyLogger {

    private String uuid;
    private String requestUrl;

    public void setRequestUrl(final String requestUrl) {
        this.requestUrl = requestUrl;
    }

    public void log(String message) {
        System.out.println("[" + uuid + "]" + "[" + requestUrl + "] " + message);
    }

    @PostConstruct
    public void init() {
        uuid = UUID.randomUUID().toString();
        System.out.println("[" + uuid + "] request scope bean created : " + this);
    }

    @PreDestroy
    public void close() {
        System.out.println("[" + uuid + "] request scope bean closed  : " + this);
    }
}
@Controller
@RequiredArgsConstructor
public class LogDemoController {
    private final LogDemoService logDemoService;
    private final ObjectProvider<MyLogger> myLoggerProvider;

    @RequestMapping("/log-demo")
    @ResponseBody
    public String logDemo(HttpServletRequest request) throws InterruptedException {
        final String requestURL = request.getRequestURL().toString();
        final MyLogger myLogger = myLoggerProvider.getObject();
        myLogger.setRequestUrl(requestURL);
        myLogger.log("controller test");
        Thread.sleep(1000);
        logDemoService.logic("testId");

        return "OK";
    }
}
@Service
@RequiredArgsConstructor
public class LogDemoService {
    private final ObjectProvider<MyLogger> myLoggerProvider;

    public void logic(final String id) {
        final MyLogger myLogger = myLoggerProvider.getObject();
        myLogger.log("service id = " + id);
    }
}

 

2) 프록시 사용

proxyMode를 사용하면 마치 싱글톤 스코프 빈을 주입받는 것처럼 코드를 간결하게 만들 수 있다. 적용 대상이 클래스라면 proxyMode = ScopedProxyMode.TARGET_CLASS, 인터페이스라면 proxyMode = ScopedProxyMode.INTERFACES 를 선택하면 된다.

@Component
@Scope(value = "request", proxyMode = ScopedProxyMode.TARGET_CLASS)
public class MyLogger {

    private String uuid;
    private String requestUrl;

    public void setRequestUrl(final String requestUrl) {
        this.requestUrl = requestUrl;
    }

    public void log(String message) {
        System.out.println("[" + uuid + "]" + "[" + requestUrl + "] " + message);
    }

    @PostConstruct
    public void init() {
        uuid = UUID.randomUUID().toString();
        System.out.println("[" + uuid + "] request scope bean created : " + this);
    }

    @PreDestroy
    public void close() {
        System.out.println("[" + uuid + "] request scope bean closed  : " + this);
    }
}
@Controller
@RequiredArgsConstructor
public class LogDemoController {
    private final LogDemoService logDemoService;
    private final MyLogger myLogger;

    @RequestMapping("/log-demo")
    @ResponseBody
    public String logDemo(HttpServletRequest request) throws InterruptedException {
        final String requestURL = request.getRequestURL().toString();
        myLogger.setRequestUrl(requestURL);
        myLogger.log("controller test");

        System.out.println("myLogger = " + myLogger);

        Thread.sleep(1000);
        logDemoService.logic("testId");

        return "OK";
    }
}
@Service
@RequiredArgsConstructor
public class LogDemoService {
    private final MyLogger myLogger;

    public void logic(final String id) {
        myLogger.log("service id = " + id);
    }
}

 

프록시 모드를 사용하면, 스프링은 CGLIB이라는 라이브러리를 이용하여, 내 클래스를 상속 받은 프록시 객체를 만들어서 주입한다. 따라서 스프링 컨테이너가 생성되고 빈의 의존 관계를 주입할 때, 프록시 객체가 주입되는 것이다.

가짜 프록시 객체는, HTTP 요청이 들어왔을 때 내부에서 진짜 빈을 요청하는 위임 로직을 가지고 있다. 프록시 객체는 원본 클래스를 상속해서 만들어졌기 때문에, 이 객체를 사용하는 클라이언트 입장에서는 원본인지 아닌지 모르게 동일하게 사용할 수 있다.

 

주의점

1) 마치 싱글톤을 사용하는 것 같지만 다르게 동작하기 때문에 주의해서 사용해야 한다.

2) 이런 특별한 scope는 꼭 필요한 곳에만 최소화해서 사용하자. 무분별하게 사용하면 유지보수하기 어려워진다.

 

1. 빈 생명주기 콜백

데이터베이스 커넥션 풀이나, 네트워크 소켓과 같은 것들은 애플리케이션 시작 시점에 필요한 연결을 미리 해두고, 애플리케이션 종료 시점에 연결을 모두 종료할 필요가 있다. 아래의 NetworkClient라는 클래스에서 connect()를 통해 미리 연결을 하고, disconnect()를 통해 연결을 종료한다고 가정해보자.

public class NetworkClient {

    private String url;

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

    public void setUrl(final 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);
    }
}

 

NetworkClient를 통해 아래와 같은 테스트 코드를 작성하여 실행해보면, url이 모두 null이 나오는 걸 확인할 수 있다. 이는 사실 당연한 결과이다. setUrl을 통해 url을 지정하기 때문에, 객체 생성 시점에는 url이 null이기 때문이다.

class BeanLifeCycleTest {
    @Test
    void lifeCycleTest() {
        ConfigurableApplicationContext ac = new AnnotationConfigApplicationContext(LifeCycleConfig.class);
        final NetworkClient client = ac.getBean(NetworkClient.class);
        ac.close();
    }

    @Configuration
    static class LifeCycleConfig {
        @Bean
        public NetworkClient networkClient() {
            final NetworkClient networkClient = new NetworkClient();
            networkClient.setUrl("http://hello.dev");
            return networkClient;
        }
    }
}

 

스프링 빈은 다음과 같은 순서로 생성이 된다.

스프링 컨테이너 생성 -> 스프링 빈 생성 -> 의존 관계 주입

 

스프링 빈은 객체가 생성되고 의존 관계가 모두 주입이 된 이후에야 필요한 데이터를 사용할 준비가 완료된다. 따라서 초기화 작업은 의존 관계 주입이 모두 끝난 이후에 호출되어야 한다. 그런데 개발자는 의존 관계 주입이 끝난 시점을 어떻게 알 수 있을까?

스프링은 의존관계 주입이 완료되면, 스프링 빈에게 콜백 메서드를 통해 초기화 시점을 알려주는 다양한 기능을 제공한다. 또한 스프링 컨테이너가 종료되기 직전에 대한 소멸 콜백 기능도 제공한다. 따라서 개발자는 이를 이용하여 초기화와 소멸을 구현할 수 있다. 이를 포함한 스프링 빈의 라이프 사이클은 다음과 같다

스프링 컨테이너 생성 -> 스프링 빈 생성 -> 의존 관계 주입 -> 초기화 콜백 -> 사용 -> 소멸 전 콜백 -> 스프링 종료

 

* 참고 : 객체의 생성과 초기화를 분리하자.

사실 생성자를 통한 의존 관계 주입을 이용하면, 스프링 빈이 생성되면서 의존 관계도 같이 주입이 되기 때문에, 이 때 초기화를 할 수도 있다. 근데 만약 초기화 작업에 외부 커넥션 연결 등과 같은 무거운 동작이 포함되어 있다면, 생성자가 너무 많은 책임을 가지게 된다. 이는 SRP에 위배되고, 이보다는 객체의 생성과 초기화 부분을 명확하게 분리하는 것이 유지 보수 관점에서 좋다. 물론 내부 값들만 약간 변경하는 단순한 초기화의 경우, 생성자에서 한 번에 처리하는 게 나을 수도 있다.

 

스프링은 크게 3가지 방법으로 빈 생명주기 콜백을 지원한다.

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

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

3) @PostConstruct, @PreDestroy 애노테이션 지원

 

2. 인터페이스(InitializingBean, DisposableBean)

아래와 같이 NetworkClient 클래스가 InitializingBean, DisposableBean 인터페이스를 구현하도록 한 후, afterPropertiesSet()에서 초기화를, destroy()에서 소멸 로직을 넣어주면 된다.

public class NetworkClient implements InitializingBean, DisposableBean {

    private String url;

    public NetworkClient() {
        System.out.println("생성자 호출, url = " + url);
    }

    public void setUrl(final 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() {
        System.out.println("NetworkClient.afterPropertiesSet");
        connect();
        call("초기화 연결 메세지");
    }

    @Override
    public void destroy() {
        System.out.println("NetworkClient.destroy");
        disconnect();
    }
}
class BeanLifeCycleTest {
    @Test
    void lifeCycleTest() {
        ConfigurableApplicationContext ac = new AnnotationConfigApplicationContext(LifeCycleConfig.class);
        final NetworkClient client = ac.getBean(NetworkClient.class);
        ac.close();
    }

    @Configuration
    static class LifeCycleConfig {
        @Bean
        public NetworkClient networkClient() {
            final NetworkClient networkClient = new NetworkClient();
            networkClient.setUrl("http://hello.dev");
            return networkClient;
        }
    }
}

 

인터페이스 방식의 단점

1) 이 인터페이스들은 스프링 전용 인터페이스이다. 해당 코드가 스프링 전용 인터페이스에 의존하게 된다. 물론 스프링으로 개발하면서 아예 스프링에 의존적이지 않을 수는 없겠지만 인터페이스까지 의존하는 건 좀 부담된다.

2) 초기화, 종료 메서드 명을 변경할 수 없다.

3) 내가 코드를 고칠 수 없는 외부 라이브러리들에 적용할 수 없다.

 

따라서 이 방법들은 지금은 거의 사용되지 않는다고 한다.

 

3. 설정 정보에 초기화 메서드, 종료 메서드 지정

설정 정보에 @Bean(initMethod = "init", destroyMethod = "close")와 같이 초기화, 종료 메서드를 지정해줄 수 있다.

public class NetworkClient {

    private String url;

    public NetworkClient() {
        System.out.println("생성자 호출, url = " + url);
    }

    public void setUrl(final 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);
    }

    public void init() {
        System.out.println("NetworkClient.init");
        connect();
        call("초기화 연결 메세지");
    }

    public void close() {
        System.out.println("NetworkClient.close");
        disconnect();
    }
}
class BeanLifeCycleTest {
    @Test
    void lifeCycleTest() {
        ConfigurableApplicationContext ac = new AnnotationConfigApplicationContext(LifeCycleConfig.class);
        final NetworkClient client = ac.getBean(NetworkClient.class);
        ac.close();
    }

    @Configuration
    static class LifeCycleConfig {
        @Bean(initMethod = "init", destroyMethod = "close")
        public NetworkClient networkClient() {
            final NetworkClient networkClient = new NetworkClient();
            networkClient.setUrl("http://hello.dev");
            return networkClient;
        }
    }
}

 

설정 정보 방식의 특징

1) 메서드 이름을 자유롭게 할 수 있다.

2) 스프링 빈 클래스가 스프링 코드에 의존하지 않는다.

3) 코드가 아닌 설정 정보를 이용하기 때문에, 코드를 고칠 수 없는 외부 라이브러리에도 적용이 가능하다.

 

종료 메서드 추론

@Bean의 destroyMethod 속성에는 특별한 기능이 있는데, 바로 종료 메서드 추론 기능이다. 라이브러리는 대부분 close()나 shutdown()을 종료 메서드로서 가진다. 따라서 특별히 종료 메서드를 지정해주지 않으면, 알아서 close나 shutdown이라는 메서드를 자동으로 호출해준다. 참고로 destroyMethod의 디폴트 값은 (inferred)로 등록되어 있다.

 

4. @PostConstruct, @PreDestroy 애노테이션 지원

초기화 메서드와 종료 메서드 앞에 각각 @PostConstruct, @PreDestroy를 붙여주면 되는 아주 간단한 방법이다.

public class NetworkClient {

    private String url;

    public NetworkClient() {
        System.out.println("생성자 호출, url = " + url);
    }

    public void setUrl(final 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);
    }

    @PostConstruct
    public void init() {
        System.out.println("NetworkClient.init");
        connect();
        call("초기화 연결 메세지");
    }

    @PreDestroy
    public void close() {
        System.out.println("NetworkClient.close");
        disconnect();
    }
}

 

애노테이션 방식의 특징

1) 최신 스프링에서 가장 권장하는 방법이다.

2) 애노테이션 하나만 붙이면 되므로 매우 편리하다.

3) 패키지를 보면, 'javax.annotation.PostConstruct'이다. 이는 스프링에 종속적인 기술이 아니라 JSR-250이라는 자바 표준이다. 따라서 스프링이 아닌 다른 컨테이너에서도 잘 동작한다.

4) 컴포넌트 스캔과 잘 어울린다.

5) 유일한 단점은 외부 라이브러리에는 적용할 수 없다는 것이다. 따라서 외부 라이브러리의 경우, @Bean의 기능을 사용하자.

 

5. 정리

  • @PostConstruct, @PreDestroy 애노테이션을 사용하자
  • 코드를 고칠 수 없는 외부 라이브러리에 대한 경우에는 @Bean의 initMethod, destroyMethod를 사용하자.

1. 다양한 의존관계 주입 방법

 의존 관계 주입에는 크게 4가지 방법이 있다.

1) 생성자 주입

 생성자를 통해서 의존 관계를 주입하는 방법이다. 이것은 생성자 호출 시점에 딱 한 번만 주입되는 것이 보장 된다. 따라서 이는 불변 의존 관계를 보장할 수 있다(단, setter가 없어야 한다.). 생성자에 파라미터가 넘어와야하기 때문에 필수 의존 관계에도 사용된다.

@Component
public class MemberServiceImpl implements MemberService {

    private final MemberRepository memberRepository;

    @Autowired // 생성자가 1개일 때는 Autowired 생략 가능
    public MemberServiceImpl(final MemberRepository memberRepository) {
        this.memberRepository = memberRepository;
    }
}

 

2) 수정자 주입(setter 주입)

 setter를 통해 의존 관계를 주입하는 방법이다. 의존 관계를 런타임에 선택하거나 변경해야하는 상황일 때 사용된다.

@Component
public class MemberServiceImpl implements MemberService {

    private MemberRepository memberRepository;

    @Autowired
    public void setMemberRepository(final MemberRepository memberRepository) {
        this.memberRepository = memberRepository;
    }
}

 

3) 필드 주입

 필드에 그대로 의존 관계를 주입하는 방법이다. 이전에는 많이 사용되던 방법이나, 스프링 없이 테스트 코드를 작성할 때 의존 관계를 설정해줄 수 없다는 단점이 있다. 그래도 요즘은 안티 패턴으로 여겨진다. 단 테스트 코드에서는 사용해도 된다.

@Component
public class MemberServiceImpl implements MemberService {

    @Autowired
    private final MemberRepository memberRepository;
}

 

4) 일반 메서드 주입

 일반 메서드를 통해서 의존 관계를 주입하는 방법이다. 일반적으로는 거의 사용되지 않는다.

@Component
public class OrderServiceImpl implements OrderService {

    private MemberRepository memberRepository;
    private DiscountPolicy discountPolicy;

    @Autowired
    public void init(MemoryMemberRepository memberRepository, DiscountPolicy discountPolicy) {
        this.memberRepository = memberRepository;
        this.discountPolicy = discountPolicy;
    }
}

 

2. 옵션 처리

 주입할 스프링 빈이 없어도 동작해야 할 때가 있다. 그런데 @Autowired만 사용하면, required 옵션의 기본값이 true이기 때문에 오류가 발생한다. 자동으로 의존 관계를 주입할 대상을 옵션으로 처리할 수 있는 방법은 다음과 같다.

 

 1) @Autowired(required = false) : 자동 주입할 대상이 없으면, 수정자 메서드가 호출되지 않음

 2) org.springframework.lang.@Nullable : 자동 주입할 대상이 없으면, null이 입력된다.

 3) Optional<> : 자동 주입할 대상이 없으면, Optional.empty()가 입력된다.

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

    static class TestBean {
        @Autowired(required = false) // Member 타입의 빈이 없을 경우, 아예 호출이 안됨
        public void setNoBean1(Member noBean1) { 
            System.out.println("noBean1 = " + noBean1);
        }

        @Autowired // Member 타입의 빈이 없을 경우, null이 들어감
        public void setNoBean2(@Nullable Member noBean2) {
            System.out.println("noBean2 = " + noBean2);
        }

        @Autowired // Member 타입의 빈이 없을 경우, Optional.empty()가 들어감
        public void setNoBean3(@Nullable Optional<Member> noBean3) {
            System.out.println("noBean3 = " + noBean3);
        }
    }
}

 

3. 생성자 주입을 선택해라!

 과거에는 setter 주입과 필드 주입도 많이 사용하였지만, 최근에는 스프링을 포함한 DI 프레임워크 대부분이 생성자 주입을 권장한다. 그 이유는 다음과 같다.

 

1) 불변

  • 대부분의 의존 관계 주입은 한 번 일어나면, 애플리케이션 종료 시점까지 의존 관계를 변경할 일이 없다. 오히려 대부분의 의존 관계는 애플리케이션 종료 전까지 변하면 안된다.
  • 수정자 주입을 사용하면, setXxx 메서드를 public으로 열어둬야하는데, 누군가 실수로 변경할 수도 있기 때문에 이는 좋은 설계가 아니다.
  • 생성자 주입은 객체를 생성할 때 딱 1번만 호출되므로, 이후에 호출될 일은 없다. 따라서 불변하게 설계할 수 있다.

2) 누락

 만약 아래와 같이 수정자 주입을 사용한다면, 테스트 코드를 작성할 때 의존 관계를 누락시킬 가능성이 있다. 하지만 생성자를 통한 주입을 사용하여 기본 생성자를 제공하지 않는다면, 의존 관계를 누락시킬 수가 없다.

@Component
public class OrderServiceImpl implements OrderService {

    private MemberRepository memberRepository;
    private DiscountPolicy discountPolicy;

    @Override
    public Order createOrder(final Long memberId, final String itemName, final int itemPrice) {
        final Member member = memberRepository.findById(memberId);
        final int discountPrice = discountPolicy.discount(member, itemPrice);

        return new Order(memberId, itemName, itemPrice, discountPrice);
    }

    @Autowired
    public void setMemberRepository(final MemberRepository memberRepository) {
        this.memberRepository = memberRepository;
    }

    @Autowired
    public void setDiscountPolicy(final DiscountPolicy discountPolicy) {
        this.discountPolicy = discountPolicy;
    }
}
class OrderServiceImplTest {

    @Test
    void createOrder() {
        OrderServiceImpl orderService = new OrderServiceImpl();
        orderService.createOrder(1L, "itemA", 10000);
    }
}

3) final 키워드

 생성자 주입을 사용하면, 필드에 final 키워드를 사용할 수 있다. 이를 통해 의존 관계가 바뀌지 않음을 보장할 수 있고, 혹시라도 값이 설정되지 않는 오류를 컴파일 시점에 막아줄 수 있다.

@Component
public class OrderServiceImpl implements OrderService {

    private final MemberRepository memberRepository;
    private final DiscountPolicy discountPolicy;

    @Autowired
    public OrderServiceImpl(final MemberRepository memberRepository, final DiscountPolicy discountPolicy) {
        this.memberRepository = memberRepository;
        this.discountPolicy = discountPolicy;
    }
}

4) 정리

 항상 생성자 주입을 선택하자. 그리고 가끔 옵션이 필요하면 수정자 주입을 사용하자. 필드 주입은 사용하지 않는 게 좋다.

 

4. 같은 타입의 빈이 2개 이상일 때

 @Autowired는 타입에 의해 빈을 조회한다. 근데 만약 아래와 같이 타입의 빈이 2개 이상일 때는 어떻게 해야할까?

하위 타입으로 지정해서 빈을 주입할 수도 있겠지만 이는 DIP를 위배하고 유연성이 떨어진다. 또한 완전히 같은 타입의 2개 이상의 빈이 있는 경우도 있을 것이다.

@Component
public class FixDiscountPolicy implements DiscountPolicy {
}

@Component
public class RateDiscountPolicy implements DiscountPolicy {
}

 

해결 방법은 다음과 같다.

 

1) @Autowired 필드 명 매칭

 @Autowired는 타입 매칭을 시도하고, 이때 여러 개의 빈이 있을 경우, 필드 명, 파라미터 명으로 빈 이름을 추가 매칭한다. 아래와 같이 필드명 혹은 파라미터 명을 빈 이름과 같게해줄 경우, 그 빈을 주입한다.

@Component
public class OrderServiceImpl implements OrderService {

    private final MemberRepository memberRepository;
    private final DiscountPolicy discountPolicy;

    public OrderServiceImpl(final MemberRepository memberRepository, final DiscountPolicy rateDiscountPolicy) {
        this.memberRepository = memberRepository;
        this.discountPolicy = rateDiscountPolicy;
    }
}

 

 

2) @Qualifier 사용

@Qualifier 는 추가 구분자를 붙여주는 방법이다. 빈 이름이 변경되지는 않는다.

@Component
@Qualifier("fixDiscountPolicy")
public class FixDiscountPolicy implements DiscountPolicy {
}

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

@Component
public class OrderServiceImpl implements OrderService {

    private final MemberRepository memberRepository;
    private final DiscountPolicy discountPolicy;

    public OrderServiceImpl(final MemberRepository memberRepository, @Qualifier("mainDiscountPolicy") final DiscountPolicy discountPolicy) {
        this.memberRepository = memberRepository;
        this.discountPolicy = discountPolicy;
    }
}

 

3) @Primary 사용

@Primary는 우선 순위를 정하는 방법이다. @Autowired에 의해 여러 빈이 매칭이 되면, @Primary 를 갖는 빈이 우선권을 갖는다.

@Component
public class FixDiscountPolicy implements DiscountPolicy {
}

@Component
@Primary
public class RateDiscountPolicy implements DiscountPolicy {
}

@Component
public class OrderServiceImpl implements OrderService {

    private final MemberRepository memberRepository;
    private final DiscountPolicy discountPolicy;

    public OrderServiceImpl(final MemberRepository memberRepository, final DiscountPolicy discountPolicy) {
        this.memberRepository = memberRepository;
        this.discountPolicy = discountPolicy;
    }
}

 

5. 조회한 빈이 모두 필요할 때, List, Map

 같은 타입의 빈이 모두 필요할 때는 아래와 같이 List 또는 Map으로 모든 빈들을 주입받을 수 있다. Map의 경우, key는 빈의 이름이다.

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

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

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

 

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

 그러면 어떤 경우에 컴포넌트 스캔을 활용한 자동 주입을 사용하고, 어떤 경우에 별도의 설정 정보를 통해 수동으로 빈을 등록하고, 의존 관계도 수동으로 주입해야 할까?

 스프링이 나오고 시간이 갈 수록 점점 자동을 선호하는 추세다. 수동으로 설정 정보를 관리하면 매번 @Bean을 적고, 객체를 생성하고, 주입할 대상을 적어줘야하는 번거로운 작업을 해줘야한다. 그렇다면 언제 수동 빈 등록을 사용하는 것이 좋을까?

 애플리케이션은 크게 업무 로직과 기술 지원 로직으로 나눌 수 있다.

  • 업무 로직 빈 : 웹을 지원하는 컨트롤러, 비즈니스 로직이 있는 서비스, 데이터 계층의 로직을 처리하는 리포지토리 등이 모두 업무 로직이다. 이 로직들은 숫자도 매우 많고 대부분 유사한 패턴으로 개발된다. 따라서 자동 기능을 적극 사용하는 것이 좋다.
  • 기술 지원 빈 : 기술적인 문제나 공통 관심사(AOP)를 처리할 때 주로 사용된다. 데이터베이스 연결이나, 공통 로그 처리와 같이 업무 로직을 지원하기 위한 하부 기술이나 공통 기술들에 대한 것이다. 이들은 업무 로직에 비해 그 수가 매우 적고, 애플리케이션 전반에 걸쳐 광범위하게 영향을 미치는 경우가 많다. 따라서 이런 기술 지원 로직 빈들의 경우, 가급적 수동으로 빈을 등록해서 명확하게 드러내는 것이 좋다.

 업무 로직 빈이라고 하더라도, 다형성을 적극 활용하는 경우 수동으로 빈을 등록하고 관리하는 것이 더 유리할 수도 있다. 아래와 같이 관리하면, 한 눈에 관련 빈들을 확인할 수 있기 때문이다.

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

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

 

1. 컴포넌트 스캔

 이전에 스프링 빈을 등록할 때는, 자바 코드의 @Bean이나 XML의 <bean> 등을 통해 설정 정보에 등록할 스프링 빈들을 나열해야했다. 만약 이렇게 등록해야할 스프링 빈의 갯수가 많아진다면 매우 번거로운 작업이 될 것이며, 누락되는 일도 생길 것이다. 그래서 스프링은 별도의 설정 정보 없이 자동으로 스프링 빈을 등록해주는 컴포넌트 스캔이라는 기능을 제공한다. 컴포넌트 스캔은 @Component 어노테이션이 붙은 클래스를 찾아 스프링 컨테이너에 빈으로 등록해주는 기능을 말한다.

 이때, 설정 정보에서 일일히 해주던 의존 관계 주입은, @Autowired라는 어노테이션을 통해 스프링에 위임하게 된다. 스프링 컨테이너에 해당 타입의 빈이 존재한다면, 이를 주입해주는 것이다.

@Component
public class MemberServiceImpl implements MemberService {

    private final MemberRepository memberRepository;

    @Autowired
    public MemberServiceImpl(final MemberRepository memberRepository) {
        this.memberRepository = memberRepository;
    }

    @Override
    public void join(final Member member) {
        memberRepository.save(member);
    }
}
@Configuration
@ComponentScan
public class AutoAppConfig {
}
class AutoAppConfigTest {
    @Test
    void basicScan() {
        final AnnotationConfigApplicationContext ac = new AnnotationConfigApplicationContext(AutoAppConfig.class);

        final MemberService memberService = ac.getBean(MemberService.class);
        assertThat(memberService).isInstanceOf(MemberService.class);
    }
}

 

2. 컴포넌트 스캔 탐색 위치

 컴포넌트 스캔을 할 패키지를 아래와 같이 지정할 수 있다. 특별히 지정하지 않는다면, 디폴트는 @ComponentScan 어노테이션이 붙은 설정 정보 클래스가 있는 패키지가 탐색할 패키지의 시작 위치가 된다.

 일반적으로는 특별히 위치를 지정하지 않고, 설정 정보 클래스의 위치를 프로젝트 최상단에 두는 것이 좋다. 프로젝트의 메인 설정 정보는 프로젝트를 대표하는 정보이기 때문이다. 참고로 스프링 이니셜라이저를 통해 스프링 부트 프로젝트를 생성하면, @SpringBootApplication이 붙은 클래스가 프로젝트 최상단에 생성되는데, 이 어노테이션 내에는 @ComponentScan이 포함되어 있다.

@Configuration
@ComponentScan(basePackages = "hello.core")
public class AutoAppConfig {
}

 

3. 컴포넌트 스캔 기본 대상

  • @Component
  • @Controller : 스프링 MVC 컨트롤러에서 사용
  • @Service : 스프링 서비스 레이어에서 사용
  • @Repository : 스프링 데이터 접근 계층에서 사용
  • @Configuration : 스프링 설정 정보에서 사용

@Component를 제외한 나머지 어노테이션들의 소스 코드를 보면, @Component 어노테이션을 포함하고 있는 것을 확인할 수 있다.

 

* 참고 : 사실 어노테이션에 상속 관계라는 것은 없다. 따라서 어떤 어노테이션이 다른 어노테이션을 포함한다고 해서 그 어노테이션을 인식할 수 있는 것은, 자바 언어가 지원하는 기능은 아니다. 이것은 스프링이 지원하는 기능이다.

 

 위에서 컴포넌트 스캔의 대상이 되었던 어노테이션들은 이 용도 외에도 다른 스프링의 부가 기능을 수행한다.

  • @Controller : 스프링 MVC 컨트롤러로 인식
  • @Repository : 스프링 데이터 접근 계층으로 인식. 데이터 계층에서 발생하는 예외를 스프링 예외로 변환해준다. 왜냐하면, 만약 사용하는 데이터 구현체가 변경되었을 때 예외 종류까지 변경된다면, 이전에 처리해놓았던 예외 처리 또한 모두 변경되어야하기 때문이다. 이를 스프링 예외로 한 번 감싸줌으로써, 구현체에 종속적이지 않을 수 있도록 해준다. 
  • @Configuration : 스프링 설정 정보로 인식. 스프링 빈이 싱글톤으로 유지될 수 있도록 추가적인 처리를 한다.
  • @Service : 사실 특별한 처리를 하는 것은 없다. 다만 개발자들이 여기가 비즈니스 계층이라는 것을 인지하는데 도움을 준다.

 

4. 중복 등록과 충돌

 이름이 같은 빈들이 등록되면 어떻게 될까? 다음 두 가지 상황이 있을 수 있다.

1) 컴포넌트 스캔에 의한 등록 vs 컴포넌트 스캔에 의한 등록 : ConflictingBeanDefinitionException 예외가 발생한다.

@Component("discountPolicy")
public class RateDiscountPolicy implements DiscountPolicy {
}

@Component("discountPolicy")
public class FixDiscountPolicy implements DiscountPolicy {
}

 

2) @Bean에 의한 등록 vs 컴포넌트 스캔에 의한 등록 : 이 경우에는 @Bean에 의한 등록이 우선권을 가진다. 즉, @Bean에 의한 빈이 컴포넌트 스캔에 의한 빈을 오버라이딩한다. 물론 개발자가 이를 의도적으로 의도했다면 상관이 없지만, 현실은 여러 설정들이 꼬여서 이런 결과가 만들어지는 경우가 대부분이다. 이럴 경우 정말 잡기 어려운 버그가 만들어진다. 그래서 최근 스프링 부트에서는 이 경우에도 빈 등록에 충돌이 일어나면 오류가 발생하도록 기본 값을 변경하였다. 만약 오버라이딩을 하도록 변경하고 싶다면, spring.main.allow-bean-definition-overriding=true 로 설정하면 된다.

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

class AutoAppConfigTest {
    @Test
    void basicScan() {
        final AnnotationConfigApplicationContext ac = new AnnotationConfigApplicationContext(AutoAppConfig.class);

        final MemberService memberService = ac.getBean(MemberService.class);
        assertThat(memberService).isInstanceOf(MemberService.class);
    }
}

 

1. 웹 애플리케이션과 싱글톤

 웹 애플리케이션은 보통 여러 고객이 동시에 요청을 한다. 이전에 만들었던 스프링을 사용하지 않는 순수한 DI 컨테이너인 AppConfig은 요청을 할 때마다 객체를 새로 생성한다.

@Test
void pureContainer() {
    AppConfig appConfig = new AppConfig();

    final MemberService memberService1 = appConfig.memberService();
    final MemberService memberService2 = appConfig.memberService();

    assertThat(memberService1).isNotSameAs(memberService2);
}
public class AppConfig {
    public MemberRepository memberRepository() {
        return new MemoryMemberRepository();
    }
    
    public MemberService memberService() {
        return new MemberServiceImpl(memberRepository());
    }
}

 

 해결 방안은, 해당 객체가 딱 1개만 생성되고 이를 공유하도록 설계하면 된다. 이것이 싱글톤 패턴이다.

 

2. 싱글톤 패턴

  싱글톤 패턴이란, 하나의 JVM 안에서 한 클래스의 인스턴스가 딱 1개만 생성되는 것을 보장하는 디자인 패턴이다. 인스턴스가 2개 이상 생성되지 못하도록 막는 방법은 다음과 같다.

 1) static 영역에 객체를 미리 하나 생성해 놓는다.

 2) 이 객체를 사용하려면 getInstance() 메서드를 통해서만 조회할 수 있다. 이 메서드는 항상 같은 객체를 반환한다.

 3) 생성자를 private으로 만들어서 추가적인 객체가 생성되는 것을 막는다. 

public class SingletonService {

    private static final SingletonService instance = new SingletonService();

    private SingletonService() {
    }

    public static SingletonService getInstance() {
        return instance;
    }
}

 

 싱글톤 패턴을 적용하면, 요청이 올 때마다 객체를 생성하는 것이 아니라, 이미 만들어진 객체를 공유해서 효율적으로 사용할 수 있다. 하지만 싱글톤 패턴은 다음과 같은 문제점들을 가지고 있다.

 1) 싱글톤 패턴을 구현하는 코드 자체가 많이 들어간다.

 2) 클라이언트가 구체 클래스에 의존한다. -> DIP 위반

 3) 클라이언트가 구체 클래스에 의존하기 때문에 OCP도 위반할 가능성이 높다.

 4) 테스트하기 어렵다. 

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

 6) private 생성자로 인해 자식 클래스를 만들기 어렵다.

 7) 결론적으로 유연성이 떨어진다.

 8) 때문에 안티 패턴으로 불리기도 한다.

 

3. 싱글톤 컨테이너

 스프링 컨테이너는 싱글톤 컨테이너의 역할을 한다. 스프링의 빈(Bean)들이 모두 싱글톤으로 관리된다. 이렇게 싱글톤 객체를 생성하고 관리하는 기능을 싱글톤 레지스트리라고 한다. 스프링 컨테이너의 이러한 기능 덕분에 싱글톤 패턴의 모든 단점을 해결하면서 객체를 싱글톤으로 유지할 수 있다. 아래의 테스트 코드를 보면, ApplicationContext에서 빈을 여러번 호출하더라도 동일한 객체가 호출됨을 알 수 있다.

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

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

    assertThat(memberService1).isSameAs(memberService2);
}

 

* 참고 : 스프링의 기본 빈 등록 방식은 싱글톤이지만, 싱글톤 방식만 지원하는 것은 아니다. 요청할 때마다 새로운 객체를 생성해서 반환하는 기능도 제공한다.(Bean Scope)

 

4. 싱글톤 방식의 주의점

 객체를 하나만 생성해서 공유하는 싱글톤 방식은, 여러 클라이언트가 하나의 객체를 공유하기 때문에, 객체 상태를 유지(stateful)하게 설계하면 안된다. 즉, 무상태(stateless)로 설계해야 한다. 무상태로 설계하는 방법은 다음과 같다.

 1) 특정 클라이언트에 의존적인 필드가 있어선 안된다.

 2) 특정 클라이언트가 값을 변경할 수 있는 필드가 있어선 안된다.

 3) 가급적 읽기만 가능해야 한다.

 4) 필드를 사용하는 대신, 자바에서 공유되지 않는 지역변수, 파라미터, ThreadLocal 등을 사용해야 한다.

 

5. @Configuration과 싱글톤

 아래의 코드를 보면, MemberService 빈이 생성될 때 memberRepository() 메서드가 호출되어 new MemoryMemberRepository()가 호출된다. 마찬가지로 OrderService 빈이 생성될 때도 memberRepository() 메서드가 호출되어 new MemoryMemberRepository()가 호출된다. 결과적으로 MemoryMemberRepository 객체가 2개 생성되어 싱글톤이 깨진 것처럼 보인다. 

@Configuration
public class AppConfig {
    @Bean
    public MemberRepository memberRepository() {
        System.out.println("AppConfig.memberRepository");
        return new MemoryMemberRepository();
    }
    
    @Bean
    public DiscountPolicy discountPolicy() {
        System.out.println("AppConfig.discountPolicy");
        return new RateDiscountPolicy();
    }

    @Bean
    public MemberService memberService() {
        System.out.println("AppConfig.memberService");
        return new MemberServiceImpl(memberRepository());
    }

    @Bean
    public OrderService orderService() {
        System.out.println("AppConfig.orderService");
        return new OrderServiceImpl(memberRepository(), discountPolicy());
    }
}

 

 하지만 막상 아래의 테스트 코드를 돌려 보면, 싱글톤이 보장된다는 것을 알 수 있다. 그리고 콘솔창을 확인하면, memberRepository() 메서드는 한 번만 호출되었다는 것을 확인할 수 있다.

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

        final MemberServiceImpl memberService = ac.getBean("memberService", MemberServiceImpl.class);
        final OrderServiceImpl orderService = ac.getBean("orderService", OrderServiceImpl.class);
        final MemberRepository memberRepository = ac.getBean("memberRepository", MemberRepository.class);

        assertThat(memberRepository).isSameAs(orderService.getMemberRepository());
        assertThat(memberRepository).isSameAs(memberService.getMemberRepository());
    }
}

 

 

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

 AppConfig의 자바 코드를 보면, memberRepository() 메서드는 분명 3번 호출되는 것이 맞다. 하지만 스프링은 싱글톤을 보장해야하기 때문에, 바이트코드를 조작하는 라이브러리를 사용한다. 아래의 테스트 코드를 실행해보자. 만약 AppConfig이 순수한 클래스라면 AppConfig의 클래스명만 나와야 한다. 하지만 결과를 보면 그렇지 않고 xxxCGLIB가 붙은 것을 볼 수 있다. 이것은 내가 만든 클래스가 아니라 스프링이 CGLIB라는 바이트코드 조작 라이브러리를 사용해서, AppConfig 클래스를 상속받은 임의의 다른 클래스를 만들고, 그 다른 클래스의 객체를 빈으로 등록한 것이다.

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

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

 

 AppConfig@CGLIB의 예상코드는 아래와 같다. @Bean이 붙은 메서드마다 이미 스프링 빈이 존재하면, 존재하는 빈을 반환하고, 없으면 기존 로직을 호출해서 스프링 컨테이너에 등록 후, 반환하는 코드가 동적으로 만들어진 것이다. 이를 통해 스프링은 싱글톤을 보장할 수 있다.

@Bean
public MemberRepository memberRepository() {
	if (memberRepository가 이미 스프링 컨테이너에 등록되어 있으면?) {
    	return 스프링 컨테이너에서 찾아서 반환;
    } else {
    	기존 로직을 호출해서 MemoryMemberRepository객체를 생성하고 스프링 컨테이너에 등록
        return 반환;
    }
}

 

7. @Configuration을 빼면 어떻게 될까?

 만약 AppConfig의 @Configuration을 빼면 어떻게 될까? AppConfig의 @Configuration을 주석 처리하고 아래 테스트 코드를 다시 실행해보면, Bean 등록은 이전과 동일하게 되지만 싱글톤이 보장이 되지 않는다는 것을 확인할 수 있다.(memberRepository 메서드가 3번 호출되고 있음)

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

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

 

 따라서 스프링 설정 정보에 대한 클래스에는 @Configuration를 사용하도록 한다.

1. 스프링 컨테이너

1) 스프링 컨테이너 생성

 아래와 같이 스프링 컨테이너를 생성할 수 있다. ApplicationContext를 스프링 컨테이너라고 한다. 이는 인터페이스인데, 이는 다양한 구현 클래스가 있다는 것을 의미한다. 아래에는 어노테이션 기반인 AnnotationConfigApplicationContext 클래스를 사용하였으나 XML 기반의 컨테이너를 사용할 수도 있다.

ApplicationContext applicationContext = new AnnotationConfigApplicationContext(AppConfig.class);
@Configuration
public class AppConfig {

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

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

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

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

 

2) 스프링 컨테이너 생성 과정

 1) 스프링 컨테이너 생성 

 스프링 컨테이너 내에는 빈(Bean) 저장소가 있다. 빈 저장소는 빈 이름을 Key로 가지고 빈 객체를 값으로 가지는 저장소이다.

 2) 스프링 빈 등록

 스프링 컨테이너는 AppConfig과 같은 구성 정보를 바탕으로 빈을 생성하여 빈 저장소를 구성한다.

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

 스프링 컨테이너는 구성 정보를 참고해서 의존 관계를 주입(DI)한다.

 

2. 스프링 빈 조회

 스프링 컨테이너에서 부모 타입으로 빈을 조회하면, 자식 타입도 함께 조회된다. 따라서 만약 모든 자바 객체의 부모인 Object 타입으로 조회를 한다면, 모든 스프링 빈이 조회될 것이다.

class ApplicationContextExtendsFindTest {
    ApplicationContext ac = new AnnotationConfigApplicationContext(TestConfig.class);

    @Test
    void findAllBeanByObjectType() {
        final Map<String, Object> beansOfType = ac.getBeansOfType(Object.class);
        for (String key : beansOfType.keySet()) {
            System.out.println("key = " + key + " value = " + beansOfType.get(key));
        }
    }

    @Configuration
    static class TestConfig {
    }
}

 

3. BeanFactory와 ApplicationContext

1) BeanFactory

 BeanFactory는 스프링 컨테이너의 최상위 인터페이스이다. 스프링 빈을 관리하고 조회하는 역할을 담당한다.

2) ApplicationContext

 BeanFactory의 기능을 모두 상속받아서 제공하는 인터페이스이다. ApplicationContext는 빈 관련 기능 이 외에도 다양한 부가 기능을 제공한다. 

 1) 메시지 소스를 활용한 국제화 기능(MessageSource)

 2) 환경 변수(EnvironmentCapable) : 로컬, 개발, 운영 등을 구분해서 처리

 3) 애플리케이션 이벤트(ApplicationEventPublisher) : 이벤트를 발행하고 구독하는 모델을 지원

 4) 편리한 리소스 조회(ResourceLoader) : 파일, 클래스패스, 외부 등에서 리소스를 편리하게 조회

 

BeanFactory를 직접 사용할 일은 거의 없다. 부가 기능이 포함된 ApplicationContext를 사용한다.

 

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

 앞서 말했듯이 ApplicationContext는 인터페이스이고 이를 구현한 다양한 클래스가 있다. 예를 들어 어노테이션 기반으로 구성 정보를 제공하는 AnnotationConfigApplicationContext가 있고, xml로 구성 정보를 제공하는 GenericXmlApplicationContext가 있다. 이렇듯 스프링이 다양한 설정 형식을 지원하는 것을 가능하게 해주는데에는 BeanDefinition이라는 추상화가 있다. 스프링 컨테이너는 자바 코드인지 xml인지 몰라도 된다. 오직 BeanDefinition만 알면 된다. 스프링 컨테이너는 BeanDefinition 정보를 기반으로 빈을 생성한다. 이는 역할과 구현을 분리해야한다는 객체 지향적 설계 원칙을 따른 것이다.

1) BeanDefinition 정보

  • BeanClassName : 생성할 빈의 클래스 명(자바 설정처럼 팩토리 역할의 빈을 사용하면 없음)
  • factoryBeanName : 팩토리 역할의 빈을 사용할 경우 이름, 예) appConfig
  • factoryMethodName : 빈을 생성할 때 팩토리 메서드 이름, 예) memberService
  • Scope : 싱글톤이 기본값
  • lazyInit : 스프링 컨테이너를 생성할 때 빈을 생성하는 것이 아니라, 실제 빈을 사용할 떄까지 최대한 생성을 지연 처리하는지의 여부
  • InitMethodName : 빈을 생성하고, 의존 관계를 적용한 뒤에 호출되는 초기화 메서드 명
  • DestroyMethodName : 빈의 생명 주기가 끝나고 제거되기 직전에 호출되는 메서드 명
  • Constructor arguments, Properties : 의존 관계 주입에서 사용된다. (자바 설정처럼 팩토리 역할의 빈을 사용하면 없음)

 

1. 스프링의 핵심 개념은 무엇인가?

 스프링은 자바 언어 기반의 프레임워크이다. 자바의 가장 큰 특징은, 객체 지향 언어라는 것이다. 스프링은 객체 지향 언어가 가진 강력한 특징을 살려내는, 좋은 객체 지향 애플리케이션을 개발할 수 있게 도와주는 프레임워크다. 그렇다면 좋은 객체 지향 프로그래밍이란 무엇일까?

 

2. 좋은 객체 지향 프로그래밍이란?

 좋은 객체 지향 프로그래밍이란, 객체들을 레고 블럭 조립하듯 유연하고 쉽게 변경할 수 있게 개발하는 것을 말한다. 이를 위해서는 역할과 구현을 명확히 분리해야한다. 자바의 다형성을 활용하면 역할은 인터페이스가 되고, 구현은 인터페이스를 구현한 클래스, 구현 객체가 된다. 이렇게 역할과 구현을 분리하면 다음과 같은 장점이 생긴다.

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

 

 프로그램에서 혼자 있는 객체는 없다. 모든 객체는 서로 협력관계에 있으며, 각각의 객체가 클라이언트가 될 수도 있고 서버가 될 수도 있다. 이러한 상황에서 역할과 구현을 분리한다면, 클라이언트를 변경하지 않고 서버의 구현을 유연하게 변경할 수 있게된다. 단 여기에도 한계가 있는데, 만약 역할(인터페이스)가 변한다면 클라이언트, 서버 모두에 큰 변경이 발생한다. 따라서 초기에 인터페이스를 안정적으로 설계하는 것이 중요하다.

 

3. SOLID

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

 한 클래스는 하나의 책임만 가져야 한다는 원칙이다. 이때 하나의 책임이라는 것이 모호할 수 있는데, 변경을 기준으로 하면 된다. 즉, 어떤 클래스를 변경할 때의 이유가 하나뿐이면 된다. SRP는 클래스 레벨에서만이 아니라 메서드 레벨에서도 존재한다. 하나의 메서드는 하나의 역할만 해야 한다.

2. OCP (Open Closed Principle) : 개방 폐쇄 원칙

 소프트웨어의 요소는 확장에는 열려있으나 변경에는 닫혀있어야한다는 원칙이다. 이는 다형성을 통해 가능해지는데, 인터페이스와 구현 클래스를 분리하여 개발한다면, 구현 클래스의 내부 구조가 변경되거나 구현 클래스 자체가 변경되는 경우에도 소스 코드 변경을 하지 않을 수 있다. 즉 인터페이스 확장에는 열려있지만, 코드 변경에는 닫혀있어야 한다.

3. LSP(Liskov Substitution Principle) : 리스코프 치환 원칙

 프로그램의 객체는 프로그램의 정합성을 깨뜨리지 않으면서 하위 타입의 인스턴스로 바꿀 수 있어야 한다는 원칙이다. 예를 들면 자동차라는 인터페이스가 있고 거기에 액셀()이라는 메서드가 있다면, 그 메서드는 자동차가 앞으로 가도록 구현되어야 한다. 만약 새로운 구현 클래스에서 액셀() 메서드가 자동차가 뒤로 가도록 구현이 된다면, 컴파일에는 문제가 없겠지만 새로운 구현 클래스로 치환 시, 프로그램에 문제가 생길 것이다.

4. ISP(Interface Segregation Principle) : 인터페이스 분리 원칙

 특정 클라이언트를 위한 인터페이스 여러개가 범용 인터페이스 하나보다 낫다는 원칙이다. 예를 들면 자동차라는 하나의 인터페이스보단, 그 인터페이스를 운전자와 정비라는 2개의 인터페이스로 나누는 것이 더 낫다는 것이다. 그렇게되면 향후 정비 인터페이스가 변경된다고 하더라도 운전자 인터페이스에는 영향을 주지 않게되고, 각 인터페이스의 역할이 좀 더 명확해지며 재사용성도 높아진다. 이 원칙은 SRP의 구체화된 원칙이라고 봐도 될 것 같다.

5. DIP(Dependency Inversion Principle) : 의존 역전 원칙

 프로그래머는 추상화에 의존해야지, 구체화에 의존하면 안된다는 원칙이다. 즉 인터페이스에 의존해야지, 구현 클래스에 의존해선 안된다.

 

4. DIP, OCP

 객체 지향의 핵심은 결국 다형성이다. 하지만 다형성만으로는 DIP, OCP를 지킬 수 없다. 아래 예시를 보자. MemberRepository와 MemberService라는 인터페이스를 통해 역할을 나타내고, MemoryMemberRepository와 MemberServiceImpl을 통해 구현을 나타냈다. 역할과 구현이 적절히 분리되어 객체 지향적으로 설계가 된 것 같지만, 앞서 말한 것과 같이 이 코드는 DIP와 OCP에 위배되는 코드이다. 

public interface MemberRepository {

    void save(Member member);

    Member findById(Long memberId);
}

public class MemoryMemberRepository implements MemberRepository {

    private static Map<Long, Member> store = new HashMap<>();

    @Override
    public void save(final Member member) {
        store.put(member.getId(), member);
    }

    @Override
    public Member findById(final Long memberId) {
        return store.get(memberId);
    }
}

public interface MemberService {

    void join(Member member);

    Member findMember(Long memberId);
}

public class MemberServiceImpl implements MemberService {

    private final MemberRepository memberRepository = new MemoryMemberRepository();

    @Override
    public void join(final Member member) {
        memberRepository.save(member);
    }

    @Override
    public Member findMember(final Long memberId) {
        return memberRepository.findById(memberId);
    }
}

 

 아래에서 MemberServiceImpl만 따로 다시 보도록 하자. MemberRepository라는 인터페이스를 필드로 가지고 있어 추상화만 의존한다고 생각할 수도 있지만, 구현 객체 생성 또한 이 클래스에서 하고 있기 때문에 구체 클래스에도 의존한다고 볼 수 있다. 이는 DIP 위반이다. 또한 만약 MemoryMemberRepository를 DbMemberRepository라는 다른 구체 클래스로 변경해야하는 상황이 생겼다고 가정해보자. 그렇다면 코드를 변경해야하는데, 이는 확장에는 열려있고 변경에는 닫혀있어야 한다는 OCP에 위배되는 것이다. 

public class MemberServiceImpl implements MemberService {

    private final MemberRepository memberRepository = new MemoryMemberRepository();

    @Override
    public void join(final Member member) {
        memberRepository.save(member);
    }

    @Override
    public Member findMember(final Long memberId) {
        return memberRepository.findById(memberId);
    }
}
public class MemberServiceImpl implements MemberService {

    // private final MemberRepository memberRepository = new MemoryMemberRepository();
    private final MemberRepository memberRepository = new DbMemberRepository();
}

 

5. 관심사의 분리

 위의 문제는 '사용 영역'과 '구성 영역'을 분리함으로써 해결이 가능하다. 먼저 MemberServiceImpl에서 MemberRepository의 객체를 생성하지 않고 생성자를 통해 주입받도록 아래와 같이 수정하도록 한다.  이 코드는 구체 클래스에는 의존하지 않기 때문에 DIP를 만족한다. 또한 다른 객체로 MemberRepository가 변경된다고 하더라도 변경이 일어나지 않으므로 OCP도 만족한다. 

public class MemberServiceImpl implements MemberService {

    private final MemberRepository memberRepository;

    public MemberServiceImpl(final MemberRepository memberRepository) {
        this.memberRepository = memberRepository;
    }

    @Override
    public void join(final Member member) {
        memberRepository.save(member);
    }

    @Override
    public Member findMember(final Long memberId) {
        return memberRepository.findById(memberId);
    }
}

 

 대신 AppConfig이라는 별도의 클래스를 생성하여 어떤 클래스의 객체를 사용할지를 결정하는 '구성 영역'을 아래와 같이 만든다. 이로써 구현 클래스는 자신의 로직에만 집중하면 되고, 객체의 구성은 '구성 영역'을 담당하는 AppConfig이 담당하게 된다. 이는 SRP를 지켰다고 볼 수 있다.

public class AppConfig {

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

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

 

 MemberService를 사용하는 클라이언트에서는 아래와 같이 사용하면 된다. 이 코드 역시 DIP와 OCP를 만족한다.

public class MemberApp {
    public static void main(String[] args) {
        AppConfig appConfig = new AppConfig();
        MemberService memberService = appConfig.memberService();
        final Member member = new Member(1L, "memberA", Grade.VIP);
        memberService.join(member);
    }
}

 

6. IoC, DI, 그리고 컨테이너

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

 위에서 살펴본 코드에서 AppConfig은 프로그램에 대한 제어 흐름에 대한 모든 권한을 가지게 된다. 즉, 어떤 구현 객체를 생성하고 연결할지는 AppConfig이 담당하게 되고, MemberServiceImpl과 같은 구현 객체는 자신의 로직을 실행하는 역할만 하게 된다. MemberServiceImpl은 MemberRepository라는 인터페이스를 호출하지만, 실제로 어떤 구현 객체들이 실행될지는 AppConfig이 결정하게 된다. 심지어 MemberServiceImpl 객체 생성도 AppConfig이 담당한다. MemberService 인터페이스를 호출하는 MemberApp의 경우 어떤 구현 객체가 실행될지 알 수 없다. 이처럼 프로그램의 제어의 흐름을 구현 객체가 직접 담당하는 것이 아니라 외부에서 관리하는 것을 제어의 역전(IoC)라고 한다.

 

* 프레임워크 vs 라이브러리

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

 

2) 의존 관계 주입 DI(Dependency Injection)

 의존 관계는 '정적인 클래스 의존 관계'와 '실행 시점에서 결정되는 동적인 객체 의존 관계' 둘로 분리해서 생각해야 한다.

  • 정적인 클래스 의존 관계 : 클래스가 사용하는 import 코드만 보고 쉽게 의존 관계를 판단할 수 있다. 
  • 동적인 객체 의존 관계 : 애플리케이션 실행 시점에 실제 생성된 객체 인스턴스의 참조가 연결된 의존 관계다.

 의존 관계 주입(Dependency Injection)이란, 애플리케이션 실행시점(런타임)에 외부에서 실제 구현 객체를 생성하고 클라이언트에 전달하여 의존 관계를 연결해주는 것을 말한다. 앞서 살펴본 코드에선 AppConfig이 이 역할을 하였다. 이처럼 의존 관계 주입을 사용하면, 클라이언트 코드를 변경하지 않고, 클라이언트의 객체 의존 관계를 쉽게 변경할 수 있다.

 

3) IoC 컨테이너, DI 컨테이너

 AppConfig 처럼 객체를 생성하고 관리하면서 의존 관계를 연결해주는 것을 IoC 컨테이너 혹은 DI 컨테이너라고 한다. 스프링은 AppConfig이 하는 역할을 좀 더 편리하게 해주는 프레임워크라고 할 수 있다. 

 IoC의 경우 제어의 역전에만 초점이 맞춰져 있는 용어이기 때문에 의존 관계를 연결해주는 스프링 컨테이너를 명확히 설명해주지 못하는 경향이 있다고 판단되어 최근에는 DI 컨테이너라는 용어를 더 많이 쓴다고 한다.

 필자는 약 4년 동안 실무에서 스프링 프레임워크를 이용하여 개발을 해왔다. 스프링을 잘 모르던 시절 '코드로 배우는 스프링 웹 프로젝트'(스프링으로 당장 개발을 시작해야하는 입문자들에겐 정말 좋은 책)라는 책을 통해 스프링을 공부했고, 이후에 부족한 부분들은 매번 구글링을 통해 조금씩 알아가곤 했다. 개발을 하는데 딱히 문제될 건 없었으나, 늘 깊이있는 이해에 대한 갈증이 있었다. '토비의 스프링'이라는 책이 유명하다는 얘기를 듣긴했지만 3.1 기준으로 쓰여진 책이었고 너무 방대해서 시작하기가 망설여졌다.

 그러다 인프런의 '스프링 완전 정복' 로드맵을 알게되었다. 이 포스팅은 이 강의를 수강하면서 배운 내용들을 정리하는 글들로 이루어질 것이다. 사실 너무 기초적인 것부터 시작하는 것 같다는 생각도 들지만, 기초부터 다시 차곡 차곡 쌓아올린다는 마음 가짐으로 들어보려고 한다. 부디 모든 강의를 들은 이후, 스프링에 대한 깊은 이해와 함께 이 포스팅을 마칠 수 있으면 좋겠다.

+ Recent posts