토비의 스프링부트 정리 (2) - 독립 실행형 스프링 애플리케이션 만들기
1. 개요
앞 글에서는 내장 Tomcat과 서블릿을 직접 만들고 연결했다. 이 방식은 웹 요청이 처리되는 원리를 이해하기에 좋지만, 애플리케이션이 커질수록 객체를 만들고 연결하고 서버를 시작하는 코드가 main()에 계속 쌓인다.
이 글에서는 이 작업을 스프링 컨테이너에 맡겨, main() 메서드 하나로 실행되는 독립 실행형(standalone) 스프링 애플리케이션을 만든다. 여기서 독립 실행형이란 외부 Tomcat에 WAR 파일을 배포하지 않고, 애플리케이션이 내장 웹 서버와 함께 직접 실행되는 형태를 말한다.
2. 객체 생성과 연결을 컨테이너에 맡기기
처음에는 HelloController가 SimpleHelloService를 직접 생성했다. 컨트롤러가 어떤 서비스 구현체를 쓸지까지 알아야 하므로, 다른 구현으로 바꾸거나 테스트 대상을 교체하기 어렵다.
HelloService 인터페이스를 만들고 생성자로 주입받으면 컨트롤러는 필요한 기능만 선언한다.
public interface HelloService {
String sayHello(String name);
}
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));
}
}
이처럼 필요한 객체를 생성자 인자로 받는 방식을 의존성 주입(DI, Dependency Injection) 이라고 한다. 객체 생성과 연결은 스프링 컨테이너가 담당하고, HelloController는 HelloService라는 추상화에만 의존한다.
| 구분 | 직접 생성 | 생성자 주입 |
|---|---|---|
| 컨트롤러의 역할 | 서비스 생성과 사용 | 서비스 사용 |
| 의존 대상 | 구체 클래스 | 인터페이스 |
| 구현 교체 | 컨트롤러 수정이 필요하다 | 컨테이너 구성만 바꾸면 된다 |
3. DispatcherServlet에 요청 처리를 맡기기
이전 글의 프런트 컨트롤러는 HttpServlet 안에서 URI와 HTTP 메서드를 직접 비교했다. Spring MVC에서는 DispatcherServlet이 이 프런트 컨트롤러 역할을 수행한다.
먼저 웹 환경을 지원하는 GenericWebApplicationContext에 빈을 등록하고, 그 컨텍스트를 DispatcherServlet에 전달한다.
GenericWebApplicationContext applicationContext =
new GenericWebApplicationContext();
applicationContext.registerBean(HelloController.class);
applicationContext.registerBean(SimpleHelloService.class);
applicationContext.refresh();
WebServer webServer = serverFactory.getWebServer(servletContext -> {
servletContext.addServlet("dispatcherServlet",
new DispatcherServlet(applicationContext))
.addMapping("/*");
});
webServer.start();
refresh()는 등록된 빈 정의를 바탕으로 컨테이너를 초기화하는 시점이다. 초기화가 끝난 컨텍스트를 DispatcherServlet이 사용하므로, 요청을 받을 때 컨트롤러와 관련 빈을 찾아 실행할 수 있다.
컨트롤러에는 요청 매핑 정보를 애너테이션으로 선언한다.
@Controller
public class HelloController {
@GetMapping("hello")
@ResponseBody
public String hello(String name) {
return helloService.sayHello(Objects.requireNonNull(name));
}
}
이제 GET /hello?name=etakyung 요청을 DispatcherServlet이 받고, @GetMapping("hello")이 붙은 메서드를 찾아 호출한다. @ResponseBody는 반환한 문자열을 뷰 이름으로 해석하지 않고 HTTP 응답 본문에 쓴다는 뜻이다.
4. 컨테이너 초기화와 웹 서버 시작을 한곳에 모으기
컨테이너가 준비된 뒤에 웹 서버를 시작해야 한다. ApplicationContext의 onRefresh()는 컨텍스트 초기화 과정에서 호출되는 확장 지점이므로, 이곳에 서버 시작 코드를 넣으면 두 작업의 순서를 연결할 수 있다.
AnnotationConfigWebApplicationContext applicationContext =
new AnnotationConfigWebApplicationContext() {
@Override
protected void onRefresh() {
super.onRefresh();
ServletWebServerFactory serverFactory =
this.getBean(ServletWebServerFactory.class);
DispatcherServlet dispatcherServlet =
this.getBean(DispatcherServlet.class);
WebServer webServer = serverFactory.getWebServer(servletContext ->
servletContext.addServlet(
"dispatcherServlet", dispatcherServlet)
.addMapping("/*"));
webServer.start();
}
};
AnnotationConfigWebApplicationContext는 자바 설정 클래스를 읽을 수 있는 컨테이너다. 웹 서버 팩토리와 DispatcherServlet도 컨테이너에서 꺼내므로, main()은 객체들을 직접 조립하지 않는다.
전체 동작 과정
독립 실행형 스프링 애플리케이션의 동작은 애플리케이션을 시작하는 과정과 들어온 요청을 처리하는 과정으로 나누어 보면 이해하기 쉽다. @GetMapping 메서드는 서버를 시작할 때 자동으로 실행되지 않고, 서버가 시작된 뒤 해당 경로로 요청이 들어올 때 실행된다.
먼저 애플리케이션을 시작할 때는 다음 순서로 진행된다.
main()
→ ApplicationContext 생성
→ 설정 클래스와 빈 정의 등록
→ ApplicationContext.refresh()
→ 컨테이너가 HelloController, HelloService 등의 빈 생성
→ onRefresh()
→ 내장 Tomcat 생성·초기화
→ DispatcherServlet을 Tomcat에 등록
→ Tomcat 시작, HTTP 요청 대기
refresh()는 컨테이너가 빈을 만들고 사용할 준비를 마치는 과정이다. 그 과정에서 호출되는 onRefresh()는 DispatcherServlet을 Tomcat에 등록하고, 모든 HTTP 요청을 DispatcherServlet에 전달하도록 매핑한다. 등록과 초기화가 끝난 Tomcat이 시작되면, 애플리케이션은 포트에서 HTTP 요청을 기다린다.
이후 클라이언트가 GET /hello?name=etakyung을 보내면 요청마다 다음 흐름이 반복된다.
클라이언트의 GET /hello?name=etakyung 요청
→ Tomcat이 요청 수신
→ DispatcherServlet이 요청을 받음
→ @GetMapping("hello")에 해당하는 HelloController.hello() 탐색
→ HelloService.sayHello("etakyung") 호출
→ 반환 문자열을 HTTP 응답 본문에 작성
→ 클라이언트에 HTTP 200 응답
| 구성 요소 | 이 과정에서 하는 일 |
|---|---|
ApplicationContext |
애플리케이션 객체(빈)를 만들고 연결한다. |
| Tomcat | HTTP 요청을 받고 DispatcherServlet을 실행한다. |
DispatcherServlet |
요청 경로에 맞는 컨트롤러 메서드를 찾아 호출한다. |
HelloController |
요청에서 전달된 값을 받아 서비스에 작업을 요청한다. |
HelloService |
인사말을 만드는 실제 비즈니스 로직을 수행한다. |
5. 자바 설정과 컴포넌트 스캔
빈을 하나씩 registerBean()으로 등록하는 대신, 설정 클래스에 @Bean 메서드를 작성할 수 있다.
@Configuration
public class Springboot1Application {
@Bean
public ServletWebServerFactory servletWebServerFactory() {
return new TomcatServletWebServerFactory();
}
@Bean
public DispatcherServlet dispatcherServlet() {
return new DispatcherServlet();
}
}
@Configuration은 이 클래스가 빈 구성 정보를 제공한다는 표시다. 컨테이너는 @Bean 메서드의 반환 객체를 관리한다. 따라서 내장 Tomcat을 만드는 객체와 요청을 처리하는 DispatcherServlet도 일반 애플리케이션 객체와 같은 방식으로 관리할 수 있다.
애플리케이션 클래스를 컨텍스트에 등록하고 초기화한다.
applicationContext.register(Springboot1Application.class);
applicationContext.refresh();
반복적인 @Bean 선언은 컴포넌트 스캔으로 줄일 수 있다.
@Configuration
@ComponentScan
public class Springboot1Application {
// 웹 서버와 DispatcherServlet처럼 명시적으로 구성할 빈만 @Bean으로 등록한다.
}
@RestController
public class HelloController { ... }
@Service
public class SimpleHelloService implements HelloService { ... }
@ComponentScan은 설정 클래스와 같은 패키지 및 하위 패키지를 탐색해 컴포넌트를 빈으로 등록한다. @RestController는 @Controller와 @ResponseBody를 함께 적용한 편의 애너테이션이다. 따라서 문자열을 반환하는 REST API 컨트롤러에 알맞다.
6. 직접 만든 실행기에서 SpringApplication.run()까지
앞 절의 컨텍스트 생성, 설정 클래스 등록, refresh() 호출을 MySpringApplication으로 묶을 수 있다.
public class MySpringApplication {
public static void run(Class<?> applicationClass, String... args) {
AnnotationConfigWebApplicationContext applicationContext =
new AnnotationConfigWebApplicationContext() {
@Override
protected void onRefresh() {
super.onRefresh();
// 컨테이너의 웹 서버 팩토리와 DispatcherServlet로 서버를 시작한다.
}
};
applicationContext.register(applicationClass);
applicationContext.refresh();
}
}
이 클래스는 스프링 부트의 실행 과정을 작게 재현한 것이다. 실행 클래스는 어떤 설정을 사용할지만 전달한다.
public static void main(String[] args) {
MySpringApplication.run(Springboot1Application.class, args);
}
실제 스프링 부트에서는 이미 준비된 SpringApplication을 사용한다.
public static void main(String[] args) {
SpringApplication.run(Springboot1Application.class, args);
}
SpringApplication.run()은 애플리케이션 유형에 맞는 컨텍스트를 준비하고, 설정을 등록하고, 컨텍스트를 초기화한다. 서블릿 웹 애플리케이션에서는 초기화 과정에서 ServletWebServerFactory를 이용해 내장 웹 서버를 시작한다. 그래서 개발자는 외부 서버 설치·배포 대신 자바 애플리케이션을 실행하듯 서버를 시작할 수 있다.
7. 실행하고 확인하기
애플리케이션을 실행한 뒤 다음 명령으로 컨트롤러가 DispatcherServlet을 통해 호출되는지 확인한다.
curl -i "http://localhost:8080/hello?name=etakyung"
다음과 같이 응답 본문을 확인할 수 있다.
HTTP/1.1 200
Content-Type: text/plain;charset=UTF-8
Hello etakyung
예제는 학습을 위해 모든 요청을 /*에 매핑하고 최소한의 빈만 등록했다. 실제 서비스에서는 Spring Boot의 자동 구성, 예외 처리, 입력 검증, 보안 설정 등을 애플리케이션 요구 사항에 맞게 추가해야 한다.
8. 정리
독립 실행형 스프링 애플리케이션의 핵심은 내장 웹 서버, DispatcherServlet, 컨트롤러와 서비스 객체를 하나의 스프링 컨테이너가 관리하도록 만드는 데 있다. 이 구조에서는 애플리케이션이 실행될 때 컨테이너 초기화와 웹 서버 시작이 자연스럽게 이어진다.