[스프링 부트 정리] 독립 실행형 Spring Application

스프링 부트 정리 관련글
스프링 부트 살펴보기
독립 실행형 Servlet Application
- 독립 실행형 Spring Application (현재 게시글)
- DI와 테스트, 디자인 패턴
- 자동 구성 기반 Application
- 조건부 자동 구성
- 외부 설정을 이용한 자동 구성
- Spring JDBC 자동 구성 개발
- 스프링 부트 자세히 살펴보기

🌿 스프링 컨테이너로 옮겨가기

앞에서는 서블릿 컨테이너를 직접 띄우고, 프론트 컨트롤러를 만들어 모든 요청을 한 곳에서 받아 처리했다.

그리고 요청을 적절히 분기해 HelloController라는 오브젝트로 작업을 위임하는 구조까지 완성했다.

 

이제는 한 단계 더 들어가서, HelloController를 스프링 컨테이너에 등록해보자.

직접 new로 객체를 만드는 대신, 스프링 컨테이너가 오브젝트를 생성하고 관리하도록 넘기는 것이다.

 

스프링이 자동으로 해주는 일들의 내부 구조를, 코드 레벨에서 하나씩 확인해보면 "컨테이너가 객체를 관리한다"는 말이 구체적으로 어떤 의미인지 감이 잡힐 것이다.

 

컨테이너가 두 개?

서블릿 컨테이너와 스프링 컨테이너는 이름은 비슷하지만 역할이 완전히 다르다.

  • 서블릿 컨테이너는 HTTP 요청을 받아 서블릿의 생명주기를 관리한다.
    지금까지는 여기에 프론트 컨트롤러 서블릿을 등록해 직접 동작시켰다.
  • 스프링 컨테이너는 애플리케이션 내부의 오브젝트들을 관리한다.
    빈(Bean)으로 등록된 객체의 생성, 주입, 수명까지 모두 맡는다.

결국 하나는 "웹 요청의 입구"를, 다른 하나는 "애플리케이션 내부의 오브젝트 관계"를 담당한다.

우리는 서블릿 컨테이너에서 요청을 받고, 실제 로직은 스프링 컨테이너가 관리하는 객체를 통해 처리하게 된다.

 

스프링 컨테이너가 다루는 두 가지 요소

https://docs.spring.io/spring-framework/reference/core/beans/basics.html

스프링 공식 문서에서는 컨테이너가 다루는 핵심 요소를 두 가지로 정리한다.

  1. POJO : 비즈니스 로직을 담고 있는 평범한 자바 객체(결국 그냥 우리가 만든 애플리케이션 코드를 말하는 것이다)
  2. Configuration Metadata : 애플리케이션을 어떤 구조로 구성할지에 대한 설정 정보

스프링 컨테이너는 이 두 가지를 조합해 바로 사용 가능한 완전한 시스템을 내부에 구성한다.
사진상으로는 컨테이너가 무언가를 새로 만들어내는 것처럼 보이지만, 실제로는 컨테이너 내부에 조합된 상태의 빈(Bean) 그래프, 즉 Fully Configured System이 존재한다.

결국 스프링 컨테이너는 이 두 정보를 기반으로 우리가 작성한 코드 조각들을 하나의 동작 가능한 서버 애플리케이션으로 조립하는 것이다.

 

기존 코드에 스프링 컨테이너 연결하기

기존에는 프론트 컨트롤러가 직접 new 키워드로 HelloController를 생성했다.

WebServer webServer = factory.getWebServer(servletContext -> {
    HelloController helloController = new HelloController();

    servletContext.addServlet("frontcontroller", new HttpServlet() {
        @Override
        protected void service(HttpServletRequest req, HttpServletResponse resp) throws IOException {
            if (req.getRequestURI().equals("/hello") && req.getMethod().equals("GET")) {
                String name = req.getParameter("name");
                String result = helloController.hello(name);
                resp.setContentType("text/plain");
                resp.getWriter().println(result);
            } else {
                resp.setStatus(404);
            }
        }
    }).addMapping("/*");
});

이제는 이 오브젝트를 직접 만들지 않고, 스프링 컨테이너로부터 꺼내서 사용할 것이다.

1. ApplicationContext 생성

스프링 컨테이너를 대표하는 인터페이스는 ApplicationContext이다.
이 컨테이너는 애플리케이션을 구성하기 위한 다양한 정보를 관리한다.
어떤 빈이 등록되어 있는지, 어떤 의존관계를 가지는지, 그리고 리소스 접근이나 이벤트 전달 같은 부가 기능도 모두 담당한다.

 

스프링에는 여러 종류의 ApplicationContext가 있지만 지금처럼 코드 레벨에서 직접 제어할 때는 GenericApplicationContext를 사용하는 것이 가장 간단하다.

GenericApplicationContext applicationContext = new GenericApplicationContext();

2. 빈(Bean) 등록

이제 HelloController를 스프링 컨테이너에 등록하자.
오브젝트 인스턴스를 직접 넘기는 것이 아니라 클래스 정보를 전달하면 스프링이 나중에 객체를 만들어준다.

applicationContext.registerBean(HelloController.class);

3. 컨테이너 초기화

등록된 클래스 정보를 바탕으로 스프링이 실제 빈 객체를 생성하도록 컨테이너를 초기화한다.

이 과정을 담당하는 메서드가 refresh() 이다.

applicationContext.refresh();

이 시점에 컨테이너 내부에는 HelloController 객체가 생성되어 보관된다.
이제 우리는 필요할 때마다 컨테이너에 객체를 요청해 사용할 수 있다.

4. getBean()으로 꺼내쓰기

프론트 컨트롤러에서 HelloController를 직접 생성하던 부분을 스프링 컨테이너로부터 가져오는 코드로 바꾸면 다음과 같다.

public static void main(String[] args) {
    GenericApplicationContext applicationContext = new GenericApplicationContext();
    applicationContext.registerBean(HelloController.class);
    applicationContext.refresh();

    TomcatServletWebServerFactory factory = new TomcatServletWebServerFactory();
    WebServer webServer = factory.getWebServer(servletContext -> {
        servletContext.addServlet("frontcontroller", new HttpServlet() {
            @Override
            protected void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
                if(req.getRequestURI().equals("/hello") && req.getMethod().equals(HttpMethod.GET.name())) {
                    String name = req.getParameter("name");
                    // 스프링 컨테이너에서 HelloController Bean 갖고오기
                    HelloController helloController = applicationContext.getBean(HelloController.class);
                    String returnValue = helloController.hello(name);

                    resp.setContentType(MediaType.TEXT_PLAIN_VALUE);
                    resp.getWriter().println(returnValue);
                } else {
                    resp.setStatus(HttpStatus.NOT_FOUND.value());
                }
            }
        }).addMapping("/*");
    });
    webServer.start();
}

 

5. 구조적 변화

이제 서블릿 컨테이너는 HelloController가 어떻게 만들어졌는지를 전혀 몰라도 된다.
단순히 "이 타입의 객체가 필요하다"고 스프링 컨테이너에 요청하면 된다!

 

객체 생성, 초기화, 관리 같은 책임은 모두 ApplicationContext, 즉 스프링 컨테이너 내부로 이동했다.

결과적으로 서블릿은 요청과 응답 처리에만 집중할 수 있고 객체의 생명주기와 관리 책임은 스프링이 전담하게 된다.

 

스프링 컨테이너의 역할

이렇게 구성하면 겉보기에는 단순히 스프링 컨테이너가 하나 더 붙은 것처럼 보인다.

하지만 이 구조가 중요한 이유는 객체의 생성과 관리 책임을 컨테이너로 완전히 분리했기 때문이다.

 

이제 프론트 컨트롤러나 서블릿이 new 키워드로 직접 객체를 만들지 않아도 된다. 컨테이너가 모든 빈의 생명주기를 관리하고, 필요할 때마다 같은 객체를 반환한다.

스프링 컨테이너는 기본적으로 같은 타입의 객체를 한 번만 생성한다.

 

예를 들어, 프론트 컨트롤러 외에 다른 서블릿에서도 HelloController 객체가 필요할 수 있다. 이때 컨테이너는 새 인스턴스를 만들지 않고, 이미 생성되어 있는 동일한 객체를 그대로 반환한다.

즉, 여러 요청이나 컴포넌트가 같은 인스턴스를 공유하게 된다.

이런 방식을 싱글톤(Singleton) 패턴이라고 한다.

스프링 컨테이너는 이런 동작을 패턴 수준에서 구현하지 않고도 제공하기 때문에, 싱글톤 레지스트리(Singleton Registry)라고 부른다.

매번 객체를 새로 생성하지 않고, 최초 한 번 생성된 객체를 재사용함으로써 효율성과 일관성을 모두 확보한다.

역할 분리 : Controller와 Service

지금까지는 HelloController가 직접 모든 로직을 처리했다.

하지만 실제 애플리케이션에서는 컨트롤러가 모든 일을 맡는 구조는 좋지 않다. 요청 검증, 비즈니스 로직, 응답 처리의 책임이 한곳에 몰리면 유지보수가 어려워진다.
역할을 분리하기 위해 인사말을 생성하는 로직을 별도의 서비스 객체로 분리해보자

package com.java.spring.service;

public class SimpleHelloService {
    public String sayHello(String name) {
        return "Hello " + name;
    }
}

이제 HelloController는 단순히 요청을 검증하고, 실제 로직 처리는 SimpleHelloService에게 위임한다.

package com.java.spring.controller;

import com.java.spring.service.SimpleHelloService;
import java.util.Objects;

public class HelloController {
    public String hello(String name) {
        SimpleHelloService helloService = new SimpleHelloService();
        return helloService.sayHello(Objects.requireNonNull(name));
    }
}

컨트롤러는 요청 파라미터를 검증한 후, 서비스 객체를 통해 결과를 생성하고 반환한다.

(Objects.requireNonNull()을 사용하면 name이 null일 경우 예외를 던져 클라이언트에게 잘못된 요청임을 명확히 전달할 수 있다.)

결과적으로 HelloController는 웹 요청의 흐름과 응답 포맷에 집중하고 SimpleHelloService는 실제 비즈니스 로직에 집중하게 된다.
이렇게 역할을 분리하면 각 객체의 책임이 명확해지고,
스프링 컨테이너는 이 구조를 기반으로 각 빈 간의 의존 관계를 관리하며 하나의 완성된 애플리케이션을 구성하게 된다.


Dependency Injection(DI)

스프링 컨테이너를 얘기할 때 우리는 Spring IoC/DI Container 또는 Spring DI Container라고도 부른다.

지금 구조에서 HelloController는 SimpleHelloService에 직접 의존한다.

오른쪽 서비스의 기능이 바뀌거나 메서드 이름이 변경되면 컨트롤러 코드도 고쳐야 한다.

아예 다른 종류의 HelloService(예: 복잡한 알고리즘으로 인삿말 생성)를 적용하고 싶어도 new로 직접 생성하고 재컴파일/배포를 해야 한다.

이렇게 특정 클래스의 기능을 사용하면 HelloController는 그 클래스에 의존하게 된다.

인터페이스 도입으로 의존성 약화

해결은 간단하다!

HelloController가 구현 클래스가 아니라 HelloService라는 인터페이스에만 의존하도록 만든다.

그리고 SimpleHelloService, ComplexHelloService 같은 구현체를 그 인터페이스를 기준으로 마음껏 바꿔 끼울 수 있게 한다.

이렇게 만들어 두면 구현 클래스를 아무리 많이 만들어도 특정 클래스에 의존하지 않기 때문에 HelloController 코드는 고칠 필요가 없다.

// com.java.spring.service.HelloService
package com.java.spring.service;

public interface HelloService {
    String sayHello(String name);
}
// com.java.spring.service.SimpleHelloService
package com.java.spring.service;

public class SimpleHelloService implements HelloService {
    @Override
    public String sayHello(String name) {
        return "Hello " + name;
    }
}
// com.java.spring.controller.HelloController
package com.java.spring.controller;

import com.java.spring.service.HelloService;
import java.util.Objects;

public class HelloController {
    private final HelloService helloService;

    public HelloController(HelloService helloService) {
        this.helloService = helloService;
    }

    public String hello(String name) {
        return helloService.sayHello(Objects.requireNonNull(name));
    }
}

여기까지 하면 소스 코드 레벨에서는 인터페이스에만 의존하지만, 런타임에는 실제 객체가 필요하다. 이번 요청은 SimpleHelloService를 쓰기로 했는지, 아니면 다른 구현을 쓸 건지 누군가 결정하고 연결해줘야 한다.

이 연결을 외부에서 만들어 주입하는 과정을 의존성 주입(DI)이라고 하고, 이 작업을 담당하는 제3의 존재를 어셈블러(Assembler)라고 부른다.

Spring Container = Assembler

어셈블러 역할을 스프링 컨테이너가 맡는다. 스프링 컨테이너는 우리가 메타 정보를 주면 그걸 바탕으로 클래스의 싱글톤 오브젝트를 만들고, 그 오브젝트가 사용해야 할 다른 의존 오브젝트가 있다면 주입까지 수행한다.

앞에서 서블릿 컨테이너는 서블릿 오브젝트를 우리가 직접 만들어 넣었지만, 스프링 컨테이너는 메타 정보만 받아서 직접 객체를 생성하고 연결까지 해준다.

 

왜 그럴까? 바로 이 주입 작업을 컨테이너가 책임지기 때문이다.

  • 가장 단순한 구성은 생성자 주입이다.
    → HelloController를 만들 때 생성자 파라미터로 HelloService 타입의 오브젝트를 넣어준다(실제 전달되는 것은 SimpleHelloService 인스턴스이다).
  • 팩토리 메서드로 빈을 만들면서 파라미터로 넘기는 방법, 프로퍼티를 정의하고 세터 메서드로 주입하는 방법도 있다.

어떤 방식을 쓰든 오브젝트를 만들고 주입해주는 모든 작업을 컨테이너가 수행한다.

 

컨테이너 설정과 주입 동작

이 구조를 코드로 보면 다음과 같다.

// Application
public class Application {
    public static void main(String[] args) {
        GenericApplicationContext applicationContext = new GenericApplicationContext();
        applicationContext.registerBean(HelloController.class);        // 컨트롤러 빈 등록
        applicationContext.registerBean(SimpleHelloService.class);     // 서비스 빈 등록
        applicationContext.refresh();                                  // 빈 생성 및 초기화
        // ... (이후 서블릿에서 getBean으로 HelloController 사용)
    }
}

여기서 SimpleHelloServiceHelloService 인터페이스 타입이고, HelloController의 생성자 파라미터 타입도 HelloService다.

스프링 컨테이너는 HelloController 빈을 만들기 위해 생성자를 호출해야 하는데, 그 파라미터 타입이 HelloService 인터페이스라는 걸 확인한다. 그러면 컨테이너에 등록된 정보들 가운데 해당 인터페이스를 구현한 클래스를 찾는다.

SimpleHelloService가 빈으로 등록되어 있으니 이 인스턴스를 생성해서 생성자 파라미터로 전달하고, 그 결과로 HelloController 빈까지 완성한다.

즉, "어느 구현체를 주입할 것인가"는 컨테이너가 등록 정보에서 찾아 결정한다.

 

🔍정리

DI의 요점은 인터페이스를 중간에 두어 코드 레벨의 직접 의존을 제거하고, 스프링 컨테이너(Assembler)가 런타임에 두 객체의 연관관계를 '주입'으로 지정하게 하는 것이다.

이렇게만 설계해두면 구현 교체나 확장이 필요할 때 컨트롤러 코드는 그대로 두고, 컨테이너 설정(어떤 클래스를 빈으로 등록할지)만 바꿔도 된다!


🍋‍🟩DispatcherServlet으로 전환

Servlet Container-less 환경에서의 한계

우리는 FrontController라는 서블릿을 직접 만들었다.

하지만 Servlet Container를 직접 다루지 않는 Container-less 개발 환경이라고 가정해보자.

문제는 애플리케이션의 핵심 로직이 서블릿 코드 안에 너무 깊게 엮여 있다는 점이다.

 

대표적으로 다음과 같은 두 가지가 있다.

  1. 요청 맵핑이 하드코딩되어 있다.
  2. 요청 파라미터를 메소드 파라미터로 직접 바인딩해주는 로직이 필요하다.

예를 들어 아래처럼 작성하면, /hello 요청이 들어왔을 때 name이라는 쿼리 파라미터를 추출하고 그 값을 HelloControllerhello() 메소드에 직접 넘겨줘야 한다.

new HttpServlet() {
    @Override
    protected void service(HttpServletRequest req, HttpServletResponse resp) throws IOException {
        if (req.getRequestURI().equals("/hello") && req.getMethod().equals(HttpMethod.GET.name())) {
            String name = req.getParameter("name");

            HelloController helloController = applicationContext.getBean(HelloController.class);
            String returnValue = helloController.hello(name);

            resp.setContentType(MediaType.TEXT_PLAIN_VALUE);
            resp.getWriter().println(returnValue);
        } else {
            resp.setStatus(HttpStatus.NOT_FOUND.value());
        }
    }
};

이 구조에서는 매번 URI를 비교하고, 파라미터를 꺼내서 직접 전달해야 한다.
요청이 늘어날수록 조건문이 폭발적으로 증가한다.

 

이런 비효율적인 구조를 개선하기 위해 등장한 것이 바로 DispatcherServlet이다.

 

DispatcherServlet의 도입

DispatcherServletFrontController 패턴의 발전된 형태다.

스프링 MVC의 중심이 되는 서블릿으로, 모든 요청을 한 곳에서 받아서 적절한 컨트롤러로 분배(dispatch)한다.

 

아래처럼 등록해보자.

public static void main(String[] args) {
    GenericWebApplicationContext applicationContext = new GenericWebApplicationContext();
    applicationContext.registerBean(HelloController.class);
    applicationContext.registerBean(SimpleHelloService.class);
    applicationContext.refresh();

    ServletWebServerFactory factory = new TomcatServletWebServerFactory();
    WebServer webServer = factory.getWebServer(servletContext -> {
        servletContext.addServlet("dispatcherServlet",
                new DispatcherServlet(applicationContext)
        ).addMapping("/*");
    });
    webServer.start();
}

여기서 중요한 부분은
DispatcherServletApplicationContext(정확히는 WebApplicationContext)를 생성자로 받는다는 점이다.

 

즉, 스프링 컨테이너가 가진 빈(Bean) 들을 모두 활용할 수 있다.
이제 더 이상 직접 컨트롤러를 new하거나 매핑을 하드코딩할 필요가 없다.

 

@GetMapping@RequestMapping의 등장

이제 서블릿 코드 대신 어노테이션 기반의 매핑 정보를 컨트롤러 안에 선언할 수 있다.

public class HelloController {
    private final HelloService helloService;

    public HelloController(HelloService helloService) {
        this.helloService = helloService;
    }

    @GetMapping("/hello")
    public String hello(String name) {
        return helloService.sayHello(Objects.requireNonNull(name));
    }
}

@GetMapping("/hello")

→ GET 메소드 중 /hello 요청을 이 메소드가 처리하겠다는 의미다.

→  초기에는 @RequestMapping(value = "/hello", method = RequestMethod.GET) 형태로 사용되었다.

 

DispatcherServlet의 동작 과정

DispatcherServlet은 ApplicationContext에 등록된 모든 빈을 확인해서,

웹 요청을 처리할 수 있는 컨트롤러들을 자동으로 탐색한다.

  • 클래스 레벨에서 @RequestMapping이 붙어 있으면 "웹 컨트롤러"로 판단
  • 메소드 레벨의 @GetMapping, @PostMapping 등의 정보를 조합해
    "요청 URL → 처리 메소드"의 매핑 테이블을 만든다.
@RequestMapping
public class HelloController {
    private final HelloService helloService;

    public HelloController(HelloService helloService) {
        this.helloService = helloService;
    }

    @GetMapping("/hello")
    public String hello(String name) {
        return helloService.sayHello(Objects.requireNonNull(name));
    }
}

즉,
클래스 단의 매핑(/hello) + 메소드 단의 매핑(GET)을 합성해 최종 매핑 정보를 만들어두는 것이다.

404 에러가 나는 이유

이제 요청을 보내보면 /hello?name=Spring을 호출했을 때 404 에러가 발생한다.
왜 그럴까?

 

컨트롤러의 hello() 메소드가 String을 반환했기 때문이다.
DispatcherServlet은 기본적으로 이 반환값을 뷰 이름(view name) 으로 인식한다.

 

"hello spring"이라는 문자열을 리턴하면 hello spring이라는 이름의 뷰 파일(JSP, Thymeleaf 등)을 찾으려 한다.
하지만 그런 뷰가 없기 때문에 404가 발생하는 것이다.

 

@ResponseBody의 역할

만약 단순히 문자열을 응답 본문에 그대로 담고 싶다면, @ResponseBody를 붙여줘야 한다.

@GetMapping
@ResponseBody
public String hello(String name) {
    return helloService.sayHello(Objects.requireNonNull(name));
}

이제 반환값은 뷰 이름이 아니라 HTTP 응답의 body 영역(text/plain) 에 직접 기록된다.

🔍정리

Spring MVC의 DispatcherServlet은 하나의 FrontController로서 요청을 받아 어노테이션 기반 매핑을 통해 적절한 컨트롤러로 분배한다.

 

이 과정에서 스프링은 수십 가지의 관례를 활용한다.
즉, 우리가 코드를 생략할 수 있는 이유는 “필요 없어서”가 아니라 스프링이 내부적으로 이미 알고 있기 때문이다.

 

결국 중요한 것은,
이 응답 값 앞에 있는 DispatcherServlet이 어떻게 이 코드를 해석할지 머릿속에 그릴 수 있는가이다.

 


🧪 컨테이너 초기화 통합(스프링 구동 원리)

지금까지의 코드는 Spring Container를 생성하고 Bean을 등록해서 초기화하는 SpringContainer 작업 파트가 있고, 그렇게 만들어진 Spring Container를 활용하면서 Servlet Container를 코드에서 생성하고 필요한 Front Controller 역할을 하는 Dispatcher Servlet을 등록하는 ServletContainer 초기화 코드로 구분된다.

이제는 이걸 하나로 통합한다. 두 번째 작업인 서블릿 컨테이너 생성·초기화가 스프링 컨테이너 초기화 과정 중에 일어나도록 코드를 변경해보자.

스프링 컨테이너의 초기화 작업은 refresh 메소드에서 진행된다.

applicationContext.refresh();

refresh() 내부는 전형적인 템플릿 메서드로 구성되어 있다.

템플릿 메서드 패턴에서는 여러 개의 Hook 메소드가 존재하고, 정해진 순서로 호출되는 과정 중 서브클래스가 특정 지점에서 기능을 확장할 수 있다.

여기서 사용할 훅의 이름이 onRefresh()다.

refresh중(스프링 컨테이너 초기화 중)에 부가 작업을 수행하려면 onRefresh()를 오버라이드한다.

템플릿 메서드 패턴은 상속을 통한 확장을 전제로 하므로, 지금 사용하는 GenericWebApplicationContext를 상속한 클래스를 만든다. (클래스를 따로 정의하는 대신 익명 클래스로 간단히 구현할 것이다!)

GenericWebApplicationContext applicationContext = new GenericWebApplicationContext(){
    @Override
    protected void onRefresh() {
        // GenericWebApplicationContext에서도 onRefresh hook 메소드를 확장해서 추가적인 작업을 함(super 삭제 X)
        super.onRefresh();

        ServletWebServerFactory factory = new TomcatServletWebServerFactory();
        WebServer webServer = factory.getWebServer(servletContext -> {
            servletContext.addServlet("dispatcherServlet",
                            new DispatcherServlet(this))
                    .addMapping("/*");
        });
        webServer.start();
    }
};
applicationContext.registerBean(HelloController.class);
applicationContext.registerBean(SimpleHelloService.class);
applicationContext.refresh();

구성 정보(Factory Method) 도입

스프링 컨테이너가 사용하는 구성 정보(우리가 만든 코드를 어떻게 오브젝트로 만들어 컨테이너 내 컴포넌트로 등록할지, Bean이 다른 오브젝트를 사용한다면 그 의존 관계를 어떻게 맺고 어느 시점에 주입할지 등)를 자바 코드의 팩토리 메소드로 제공한다.

이 팩토리 메소드는 스프링 컨테이너가 호출한다. "HelloController를 자바 코드로 만들 텐데, 그때 필요한 의존 오브젝트를 파라미터로 넘겨달라"는 선언이 된다.

 

스프링이 이 메서드들을 빈 팩토리 메소드로 인지하도록 메서드에는 @Bean, 클래스에는 @Configuration을 붙인다.

 

Annotation 기반 컨텍스트로 전환 및 등록

이 구성 정보를 사용하려면 두 가지가 필요하다.

기존에 사용한 GenericApplicationContext는 자바 설정 클래스를 읽을 수 없다.

따라서 AnnotationConfigWebApplicationContext로 변경한다.

이름 그대로 어노테이션이 붙은 자바 코드를 이용해 구성 정보를 읽는 컨텍스트다.

 

또한 앞에서는 registerBean으로 직접 클래스를 등록했지만, 이제는 구성 정보를 담은 자바 클래스(위의 Application)를 등록한다. 이 클래스를 시작점으로 빈들을 생성한다.

@Configuration
public class Application {
    @Bean
    public HelloController helloController(HelloService helloService) {
        return new HelloController(helloService);
    }

    @Bean
    public HelloService helloService() {
        return new SimpleHelloService();
    }
    
    public static void main(String[] args) {
        AnnotationConfigWebApplicationContext applicationContext = new AnnotationConfigWebApplicationContext(){
            @Override
            protected void onRefresh() {
                super.onRefresh();

                ServletWebServerFactory factory = new TomcatServletWebServerFactory();
                WebServer webServer = factory.getWebServer(servletContext -> {
                    servletContext.addServlet("dispatcherServlet",
                                    new DispatcherServlet(this))
                            .addMapping("/*");
                });
                webServer.start();
            }
        };
        applicationContext.register(Application.class);
        applicationContext.refresh();
    }
}

 

여기서 중요한 점은 @Configuration이 붙은 클래스가 AnnotationConfig 컨텍스트에 가장 먼저 등록된다는 사실이다.

이 클래스에는 팩토리 메소드 외에도 애플리케이션 구성에 필요한 다양한 힌트를 담을 수 있다.

 

컴포넌트 스캔

스프링 컨테이너에는 컴포넌트 스캐너가 있다.

@Component가 붙은 모든 클래스를 찾아 빈으로 등록한다. 빈으로 사용할 HelloController에 레이블을 붙여보자

@Component
@RequestMapping("/hello")
public class HelloController {
    private final HelloService helloService;

    public HelloController(HelloService helloService) {
        this.helloService = helloService;
    }

    @GetMapping
    @ResponseBody
    public String hello(String name) {
        return helloService.sayHello(Objects.requireNonNull(name));
    }
}

컨테이너에 등록하는 첫 번째 클래스에 여러 힌트를 넣을 수 있다.

@Configuration
@ComponentScan
public class Application {
    (...)
}

@ComponentScan을 붙이면 이 클래스의 패키지부터 하위를 뒤져 @Component가 붙은 모든 클래스를 빈으로 등록한다.

등록 시 필요하면 의존 오브젝트를 찾아 생성자 파라미터로 넘겨준다.

 

컴포넌트 스캔의 장점은 새로운 빈을 추가할 때 구성 정보를 매번 수정할 필요가 없다는 점이다.

단점은 빈이 매우 많아지면 "무엇이 등록되는가"를 추적하기 번거로울 수 있다는 점이다.

보통 패키지 구조와 모듈 분리를 잘하면 충분히 관리 가능하므로 장점이 더 커 널리 사용되어지는 방법이다.

 

메타 어노테이션과 커스텀 컴포넌트

@Component는 해당 어노테이션을 메타 어노테이션으로 갖는 어노테이션이 붙은 경우에도 동일하게 인식된다.

메타 어노테이션은 "어노테이션에 붙은 어노테이션"을 뜻한다.

어노테이션을 만들 때는 @Retention , @Target을 지정한다.

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

이제 HelloController@Component 대신 @MyComponent를 붙여도 스캐너가 빈으로 등록한다.

@MyComponent
@RequestMapping("/hello")
public class HelloController {
    (...)
}

커스텀 어노테이션을 쓰는 이유는 빈의 역할/계층을 명시하고 싶을 때가 있기 때문이다.

전통적인 계층형 아키텍처(웹/서비스/데이터 액세스 등)에서 역할을 어노테이션으로 표현할 수 있다.

 

여기서 스프링은 이미 몇 가지 스테레오타입을 제공한다!

대표적으로 살펴보자면, @Controller@Component를 메타로 포함한다.

추가로 @RestController@ResponseBody를 메타로 포함하므로, 마치 @ResponseBody가 직접 붙은 것처럼 동작한다(정확히는 DispatcherServlet이 인식한다).

클래스 레벨 @RequestMapping 없이 메서드 레벨 매핑만으로도 동작하는 점도 장점이다.

 

서버/디스패처도 빈으로 등록하고 onRefresh에서 연결하기

TomcatServletWebServerFactoryDispatcherServlet도 빈으로 등록한다.

스프링이 관리하면 나중에 더 유연한 구성이 가능하다. 등록은 팩토리 메서드(@Bean)로 하고, 반환 타입은 추상 타입(ServletWebServerFactory)으로 둔다. (나중에 톰캣이 아닌 다른 서버로 교체할 수 있다)

DispatcherServlet은 컨트롤러를 찾기 위해 스프링 컨테이너가 필요하다.

앞서 onRefresh에서 new DispatcherServlet(this)로 넘겼지만, 이제는 컨테이너에서 빈을 꺼내 서블릿으로 등록한다.

그 과정에서 컨테이너를 한 번 주입해준다.

@Override
protected void onRefresh() {
    super.onRefresh();

    ServletWebServerFactory serverFactory = new TomcatServletWebServerFactory();

    WebServer webServer = serverFactory.getWebServer(servletContext -> {
        servletContext.addServlet("dispatcherServlet", new DispatcherServlet(this))
                .addMapping("/*");
    });
    webServer.start();
}

위 로직에서 new TomcatServletWebServerFactory()를 빈으로 등록했으므로, 실제로는 getBean()으로 가져오는 편이 낫다. DispatcherServlet도 마찬가지다.

ServletWebServerFactory serverFactory = this.getBean(ServletWebServerFactory.class);
DispatcherServlet dispatcherServlet = this.getBean(DispatcherServlet.class);

WebServer webServer = serverFactory.getWebServer(servletContext -> {
    servletContext.addServlet("dispatcherServlet", dispatcherServlet)
            .addMapping("/*");
});

// Factory Method로 만들었으므로, 컨텍스트를 명시적으로 주입
dispatcherServlet.setApplicationContext(this);

전체 로직은 아래와 같다.

@Configuration
@ComponentScan
public class Application {
    @Bean
    public ServletWebServerFactory servletwEBserverFactory() {
        return new TomcatServletWebServerFactory();
    }

    @Bean
    public DispatcherServlet dispatcherServlet() {
        return new DispatcherServlet();
    }

    public static void main(String[] args) {
        AnnotationConfigWebApplicationContext applicationContext = new AnnotationConfigWebApplicationContext(){
            @Override
            protected void onRefresh() {
                super.onRefresh();

                ServletWebServerFactory serverFactory = this.getBean(ServletWebServerFactory.class);
                DispatcherServlet dispatcherServlet = this.getBean(DispatcherServlet.class);
                dispatcherServlet.setApplicationContext(this);

                WebServer webServer = serverFactory.getWebServer(servletContext -> {
                    servletContext.addServlet("dispatcherServlet", dispatcherServlet)
                            .addMapping("/*");
                });
                webServer.start();
            }
        };
        applicationContext.register(Application.class);
        applicationContext.refresh();
    }
}

 

ApplicationContextAware와 빈 라이프사이클

만약 위 코드에서 dispatcherServlet.setApplicationContext(this)를 제거해도 실제로는 잘 동작한다. 이유는 스프링이 빈 라이프사이클 콜백으로 컨텍스트를 주입해주기 때문이다.

DispatcherServlet의 type hierarchy를 보면 ApplicationContextAware인터페이스를 구현하고 있다.

Windows 단축키 Ctrl + H


이 인터페이스의 setApplicationContext(ApplicationContext)는 컨테이너가 빈을 등록하고 관리하는 중에 컨테이너가 관리하는 인프라 오브젝트를 빈에 주입할 때 호출되는 라이프사이클 메소드다.

팩토리 메소드, 설정 파일, 컴포넌트 스캔 등 어떤 방식으로 빈이 등록되든 컨테이너는 이 인터페이스를 구현한 빈에 대해 해당 setter를 호출한다.

직접 확인해보자.

@RestController
public class HelloController implements ApplicationContextAware {
    private final HelloService helloService;

    public HelloController(HelloService helloService) {
        this.helloService = helloService;
    }

    @GetMapping("/hello")
    public String hello(String name) {
        return helloService.sayHello(Objects.requireNonNull(name));
    }

    @Override
    public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
        System.out.println(applicationContext);
    }
}


이 코드는 스프링 컨테이너 초기화 시점에 실행된다. 서버를 띄우기만 해도 콘솔에 Root WebApplicationContext started on ... 같은 로그가 보인다. 실제 서비스 코드에서는 주입받은 컨텍스트를 멤버 필드에 저장해 활용한다.

@RestController
public class HelloController implements ApplicationContextAware {
    private final HelloService helloService;
    private ApplicationContext applicationContext; // 생성자 이후 주입되므로 final 금지

    public HelloController(HelloService helloService) {
        this.helloService = helloService;
    }

    @GetMapping("/hello")
    public String hello(String name) {
        return helloService.sayHello(Objects.requireNonNull(name));
    }

    @Override
    public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
        System.out.println(applicationContext);
        this.applicationContext = applicationContext;
    }
}

final 필드는 생성자 완료 시점까지 초기화되어야 하는데, 라이프사이클 콜백은 이미 인스턴스가 만들어진 이후에 호출되기 때문에 final로 만들면 안된다!

최신 방식으로는 생성자 주입을 사용하면 final로 둘 수 있다.

private final ApplicationContext applicationContext;

public HelloController(HelloService helloService, ApplicationContext applicationContext) {
    this.helloService = helloService;
    this.applicationContext = applicationContext;
}

 

참고로 컨테이너 관점에서 ApplicationContext 타입의 오브젝트도 자기 자신을 빈처럼 관리하고 주입한다.

 

메인 메소드의 컨텍스트 부팅 로직을 메서드로 추출해보자.

@Configuration
@ComponentScan
public class Application {
    @Bean
    public ServletWebServerFactory servletwEBserverFactory() {
        return new TomcatServletWebServerFactory();
    }

    @Bean
    public DispatcherServlet dispatcherServlet() {
        return new DispatcherServlet();
    }

    public static void main(String[] args) {
        run();
    }

    private static void run() {
        AnnotationConfigWebApplicationContext applicationContext = new AnnotationConfigWebApplicationContext(){
            @Override
            protected void onRefresh() {
                super.onRefresh();

                ServletWebServerFactory serverFactory = this.getBean(ServletWebServerFactory.class);
                DispatcherServlet dispatcherServlet = this.getBean(DispatcherServlet.class);
                dispatcherServlet.setApplicationContext(this);

                WebServer webServer = serverFactory.getWebServer(servletContext -> {
                    servletContext.addServlet("dispatcherServlet", dispatcherServlet)
                            .addMapping("/*");
                });
                webServer.start();
            }
        };
        applicationContext.register(Application.class);
        applicationContext.refresh();
    }
}

이제 재사용을 위해 메인 클래스명을 파라미터로 받는다.

 

실행 파라미터(args)까지 전달하는 버전으로 확장해보자

public static void main(String[] args) {
    run(UserApplication.class, args);
}

private static void run(Class<?> applicationClass, String... args) {
    AnnotationConfigWebApplicationContext applicationContext = new AnnotationConfigWebApplicationContext(){
        @Override
        protected void onRefresh() {
            super.onRefresh();
            // ...
        }
    };
    applicationContext.register(applicationClass);
    applicationContext.refresh();
}

 

이 형태는 다른 메인 클래스에서도 서블릿 컨테이너를 자동으로 띄우고, 스프링 컨테이너 준비 작업을 재사용 가능하게 한다.

그래서 MySpringApplication이라는 유틸 클래스로 추출한다.

public class MySpringApplication {
    public static void run(Class<?> applicationClass, String... args) {
        AnnotationConfigWebApplicationContext applicationContext = new AnnotationConfigWebApplicationContext(){
            @Override
            protected void onRefresh() {
                super.onRefresh();

                ServletWebServerFactory serverFactory = this.getBean(ServletWebServerFactory.class);
                DispatcherServlet dispatcherServlet = this.getBean(DispatcherServlet.class);
                dispatcherServlet.setApplicationContext(this);

                WebServer webServer = serverFactory.getWebServer(servletContext -> {
                    servletContext.addServlet("dispatcherServlet", dispatcherServlet)
                            .addMapping("/*");
                });
                webServer.start();
            }
        };
        applicationContext.register(applicationClass);
        applicationContext.refresh();
    }
}

@Configuration
@ComponentScan
public class Application {
    @Bean
    public ServletWebServerFactory servletwEBserverFactory() {
        return new TomcatServletWebServerFactory();
    }

    @Bean
    public DispatcherServlet dispatcherServlet() {
        return new DispatcherServlet();
    }

    public static void main(String[] args) {
        MySpringApplication.run(Application.class, args);
    }
}

 

메인에서는 정적 메서드 한 줄에 구성 클래스와 실행 파라미터를 넘긴다.

스프링 이니셜라이저가 만들어주는 기본 코드와 동일한 구조다.

결국 다음 한 줄이 내부적으로 지금까지 구현한 모든 과정을 수행한다.

public static void main(String[] args) {
    SpringApplication.run(Application.class, args);
}

스프링 부트는 우리가 만든 MySpringApplication을 훨씬 발전시켜 내장 서버 초기화 + 자동 설정까지 포함한 형태다.