본문 바로가기
Spring&SpringBoot

Containerless Spring Boot

by 민휘 2023. 5. 12.

들어가기에 앞서

 

이 문서는 토비 님의 스프링 부트 강의 독립 실행형 애플리케이션 파트의 큰 흐름을 요약합니다. 개선 과정 전체를 따라해보고싶다면 강의를 구입해서 들어보세요. 정말 좋은 강의입니닷

 

이러한 내용을 다룹니다.

  • 스프링 애플리케이션이 무엇에 독립적으로 실행된다는 의미인가?
  • 스프링 애플리케이션이 뜨기 위해서 서블릿 컨테이너가 왜 필요한가?
  • 요청과 응답을 처리하기 위해 필요한 작업은 무엇인가?
  • 서블릿 등록 코드를 어떻게 개선하기 위해 도입한 패턴은 무엇인가?
  • 스프링은 어떻게 매핑과 바인딩 정보를 등록하는가?
  • 스프링부트는 어떻게 서블릿 컨테이너와 스프링 컨테이너 초기화를 모듈화하는가?

 

Containerless

 

컨테이너리스를 지향한다는 것은 서블릿 컨테이너와 관련된 복잡한 설정과 지식을 몰라도 배포를 할 수 있다는 말이다. 개발자는 스프링 컨테이너에 올라가는 빈의 개발만 신경쓰면 된다. Standalone으로 main 메소드를 동작하는 방식으로 스프링 애플리케이션을 동작시킬 수 있다.

 

이 main 메소드를 실행하는 것만으로 컨테이너와 관련된 작업, 스프링이 구동되는데 필요한 모든 작업이 수행되었다. 우리가 작성한 컨트롤러가 컨테이너에 빈으로 등록되는 것까지 완료되었다. 개발자는 정말 컨테이너에 올라가는 애플리케이션 빈만 신경써주면 되는 것이다.

이렇게 컨테이너가 없는 것처럼 개발하는 방법을 Containerless라고 한다. (실제로는 컨테이너가 존재하는데 스프링 부트가 관련 작업을 다 해줘서 개발자가 신경쓸 필요가 없는 것이다) 스프링 애플리케이션이 실행되기 위해서 어떤 작업이 필요한지 알고 있다면, main으로만 애플리케이션이 뜨는 것이 자연스럽지 않게 느껴진다.

 

스프링 애플리케이션이 뜨기 위해서는 애플리케이션 서버가 필요하다. 애플리케이션 서버는 웹 요청을 받아들이고, 이를 처리할 웹 애플리케이션을 실행시키고, 여러 개의 클라이언트 요청을 처리하기 위해 멀티 스레드나 멀티 프로세싱을 지원한다. 스프링은 경량급 애플리케이션 서버인 서블릿 컨테이너를 기반으로 동작한다. 보통은 애플리케이션을 패키징해서 애플리케이션 서버에 배포하고 실행한다.

서블릿 컨테이너 안에서 요청을 누가 처리하고(매핑), 요청이나 응답 정보를 자바 시스템과 외부 시스템에서 이해할 수 있도록 변환(바인딩)하는 작업이 필요하다. 그리고 적절한 핸들러를 찾아 바인딩한 정보를 가지고 비즈니스 로직을 실행해야 한다. 모든 요청이나 응답에서 공통으로 적용되어야 하는 작업(인증, 보안, 다국어)도 필요하다.

 

 

위의 작업은 모두 서블릿을 사용하는 자바 코드로 작성할 수 있다. 하지만 중복되는 코드가 많고 재사용이 어렵기 때문에, 여러 패턴과 객체지향적인 방법으로 코드를 작성할 수 있도록 스프링과 스프링부트가 이러한 기능을 추상화하여 클래스로 제공한다.

이 문서에서는 서블릿이 매핑과 바인딩, 비즈니스 로직을 요청하는 일련의 작업을 단계적으로 추상화하는 과정을 다룬다. 다만 각 단계의 문제와 해결을 위주로 다루기 때문에 포인트가 되는 소스 코드의 일부만을 싣는다. 전체 과정을 따라해보고 싶다면 토비 님의 스프링 부트 강의를 꼭 들으시길! (정말 좋은 강의입니다)

 

 

원시적인 서블릿 등록

 

스프링 없이 자바 main 메소드에서 서블릿 컨테이너를 우선 띄운다. 톰캣을 사용하는데, 실제 내장 톰캣 클래스를 사용하려면 설정이 번거로우므로 스프링 부트가 제공하는 내장 톰캣 서버 클래스를 사용했다.

 

 

서블릿 컨테이너 안에 들어가는 웹 컴포넌트인 hello 서블릿을 만들어 등록한다. GET /hello로 요청이 들어오면 hello 이름을 가진 서블릿이 매핑되어 처리한다. HTTP 요청에서 plain text로 들어온 이름은 자바의 String으로 바인딩된다. hello 서블릿에서는 응답코드를 200, 헤더 컨텐트 타입은 plain text, 메시지 바디에는 “Hello Servlet”을 작성하여 응답을 만들도록 정의한다. 실제로 서블릿에서 정의한대로 http 응답을 만드는 작업은 서블릿 컨텍스트가 한다.

public class HellobootApplication {

	public static void main(String[] args) {
		ServletWebServerFactory serverFactory = new TomcatServletWebServerFactory();
		WebServer webServer = serverFactory.getWebServer(servletContext -> {
			servletContext.addServlet("hello", new HttpServlet() {

				@Override
				protected void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {

					// 바인딩
					String name = req.getParameter("name");

					// 응답 생성
					resp.setStatus(HttpStatus.OK.value());
					resp.setHeader(HttpHeaders.CONTENT_TYPE, MediaType.TEXT_PLAIN_VALUE);
					resp.getWriter().println("Hello " + name); // 응답 생성에 필요한 로직
				}
			}).addMapping("/hello"); // 매핑
		});

		webServer.start();
	}

}

 

이 코드의 문제를 찾아보자. 우선 조건문으로 모든 요청을 매핑하는 부분이 마음에 들지 않는다. 매핑되어 실행되는 로직에서도 바인딩, 응답 생성, 비즈니스 로직 실행이 섞여서 이해하기 어렵다.

 

 

더 큰 문제는 확장하기가 어렵다는 점이다. 서블릿의 개수가 백개로 늘어난다고 생각해보자. 서블릿이 응답을 생성하기 위해 실행하는 로직은 조금씩 달라지지만 요청을 받거나 응답을 만드는 부분은 비슷한 코드들이 반복될 것이다. 그리고 모든 서블릿이 공통으로 처리해야하는 인증, 보안, 다국어 처리 등의 동일한 로직이 반복될 것이다.

 

 

Front Controller 도입

 

서블릿이 중복되는 코드들을 가지지 않도록, 중복된 코드(매핑, 응답 생성, 공통 처리)를 Front Controller라는 이름을 가진 서블릿에게 할당하고 이 서블릿에서 url을 매핑하여 서블릿에게 개별 로직을 처리하도록 요청한다. 이때 서블릿은 중복되는 코드를 가지지 않고 응답에 필요한 비즈니스 로직만 가진다. 이 실행 로직은 Controller 이름의 POJO로 분리한다.

 

 

public class HelloController {
    public String hello(String name) {
        return "Hello " + name;
    }
}

public class HellobootApplication {

	public static void main(String[] args) {
		ServletWebServerFactory serverFactory = new TomcatServletWebServerFactory();
		WebServer webServer = serverFactory.getWebServer(servletContext -> {

			HelloController helloController = new HelloController();

			servletContext.addServlet("frontcontroller", new HttpServlet() {

				@Override
				protected void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {

					// 서블릿의 공통 기능 처리...

					// mapping
					if (req.getRequestURI().equals("/hello") && req.getMethod().equals(HttpMethod.GET.name())) {

						// binding
						String name = req.getParameter("name");

						// 응답 생성에 필요한 작업 실행
						String ret = helloController.hello(name);

						// 응답 생성
						resp.setStatus(HttpStatus.OK.value());
						resp.setHeader(HttpHeaders.CONTENT_TYPE, MediaType.TEXT_PLAIN_VALUE);
						resp.getWriter().println(ret);
					}
					else if (req.getRequestURI().equals("/user")) {
						//
					}
					else {
						resp.setStatus(HttpStatus.NOT_FOUND.value());
					}

				}
			}).addMapping("/*"); // 중앙 처리
		});

		webServer.start();
	}

}

비즈니스 로직을 처리하는 코드를 컨트롤러에게 할당하여, 서블릿마다 달라지는 부분을 제거했다. 하지만 여전히 프론트 컨트롤러 서블릿에 조건문으로 매핑하는 부분과 바인딩, 응답 생성은 중복된다.

 

 

Spring Container와 Dispatcher Servlet 등록

 

중복되는 부분이 매핑과 요청 정보 바인딩, 응답 생성이다. 사실 코드를 엄청나게 개선해서 완전히 분리할 수 있지만, 이걸 굳이 우리가 할 필요는 없다. 스프링이 이러한 기능을 간단한 선언만으로 사용할 수 있도록 추상화된 기능을 제공하기 때문이다. DispatcherServlet이라는 이름을 가진 서블릿, HandlerMethodArgumentResolver, HttpMessageConverter가 있다. 이 객체들은 스프링 컨트롤러에 의해 빈으로 등록되어 싱글톤으로 관리된다.

 

 

  • 매핑 : 개발자가 @RequestMapping 애노테이션을 사용해서 컨트롤러 객체에 어느 요청에 매핑될 것인지 그 정보를 등록해두면, 스프링 컨테이너가 디스패처 서블릿이 이 매핑 애노테이션을 읽어서 매핑 정보 테이블을 만든다. 이 테이블을 참고하여 매핑 작업을 수행한다.
  • 바인딩 : 매핑되어 선택된 컨트롤러가 HTTP 요청에 담긴 파라미터를 자바 오브젝트로 변경하기 위해 HandlerMethodArgumentResolver가 관여한다. 요청 정보를 바인딩하는 작업은 구현체인 RequestParamMethodArgumentResolver가 담당한다.
  • 응답 생성 : 컨트롤러가 반환하는 자바 오브젝트는 클라이언트 시스템이 이해할 수 있는 포맷으로 변환되어야 한다. 이 작업을 HttpMessageConverter가 담당한다. Json으로 변경하는 클래스 JacksonObjectMapper는 Jackson 라이브러리로 제공한다.

 

 

스프링을 사용하여 컨트롤러 코드를 짜보자. 컨트롤러가 매핑 정보 등록, 바인딩된 값의 검사, 응답 로직 실행과 응답 반환의 책임을 가진다. 요청값 검사나 반환 오브젝트와 관련된 책임이 복잡해질 경우, 응답에 필요한 로직을 분리하기 위해 서비스 객체를 사용한다. 서비스는 스프링 컨테이너로 DI 받는다.

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

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

    @GetMapping("/hello")
    public String hello(String name) {
        if (name == null || name.trim().length() == 0) throw new IllegalArgumentException();
        return helloService.sayHello(Objects.requireNonNull(name));
    }

    @GetMapping("/count")
    public String count(String name) {
        return name + helloService.countOf(name);
    }
}

@Service
public class SimpleHelloService implements HelloService {

    private final HelloRepository helloRepository;

    public SimpleHelloService(HelloRepository helloRepository) {
        this.helloRepository = helloRepository;
    }

    @Override
    public String sayHello(String name) {
        this.helloRepository.increaseCount(name);
        return "Hello " + name;
    }

    @Override
    public int countOf(String name) {
        return helloRepository.countOf(name);
    }
}

 

 

그리고 main에서 컨트롤러, 서비스와 함께 디스패처 서블릿을 빈으로 등록하면 된다. 디스패처 서블릿은 모든 요청을 받아들이고 매핑 테이블을 참고해 매핑을 진행한다. 또한 모든 요청에 필요한 공통 작업도 디스패처 서블릿이 해준다.

public class HellobootApplication {

	public static void main(String[] args) {

		GenericWebApplicationContext applicationContext = new GenericWebApplicationContext(); // 코드로 받은 스프링 컨테이너
		applicationContext.registerBean(HelloController.class);
		applicationContext.registerBean(SimpleHelloService.class);
		applicationContext.refresh(); // 구성 정보를 바탕으로 컨테이너 초기화

		ServletWebServerFactory serverFactory = new TomcatServletWebServerFactory();
		WebServer webServer = serverFactory.getWebServer(servletContext -> {
			servletContext.addServlet("dispatcherServlet",
					new DispatcherServlet(applicationContext)
			).addMapping("/*"); // 중앙 처리
		});

		webServer.start();
	}

}

 

 

 

스프링 컨테이너 초기화 코드를 무상태로 만들어 모듈로 분리

 

윗 부분까지는 스프링이 어떻게 매핑과 바인딩을 재사용 가능하도록 살펴본 것이었고, 이제 스프링부트가 main에 있는 스프링 컨테이너 실행과 디스패처 서블릿 등록을 어떻게 모듈화하는지 알아본다.

 

 

서블릿 컨테이너 초기화 코드를 스프링 컨테이너가 초기화되는 과정에서 일어나도록 바꿔보자. 스프링 부트가 그렇게 동작하기 때문이다.

스프링 컨테이너의 초기화 작업은 refresh 메소드에서 일어난다. refresh는 전형적인 템플릿 메소드인데, onRefresh 콜백을 오버라이딩하면 스프링 컨테이너가 초기화되는 중에 부가적으로 수행할 작업을 전달할 수 있다.

public class HellobootApplication {

	public static void main(String[] args) {

			GenericWebApplicationContext applicationContext = new GenericWebApplicationContext() {
 
           @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) // applicationContext
                            .addMapping("/*");
                });

                webServer.start();
            }

        };

        applicationContext.registerBean(HelloController.class);
				applicationContext.registerBean(SimpleHelloService.class);
				applicationContext.refresh(); // 구성 정보를 바탕으로 컨테이너 초기화
        applicationContext.refresh();
    }

}

 

 

애플리케이션 빈 등록은 컴포넌트 스캔으로 변경하고, 컴포넌트 스캔 시작 위치는 main 메소드가 있는 HellobootApplication으로 한다. 인프라 스트럭처 빈은 자바 코드로 구성한다. 빈 등록을 간결하게 하고 나서 main의 코드를 다른 클래스로 옮겨보자. main에서 달라지는 부분은 컴포넌트 스캔 시작 위치이므로 이 클래스를 매개변수로 받는다.

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);

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

                webServer.start();
            }
        };

        applicationContext.register(applicationClass); // 매번 달라짐
        applicationContext.refresh();
    }
}

@Configuration @ComponentScan
public class HellobootApplication {

	@Bean
	public ServletWebServerFactory servletWebServerFactory() {
		return new TomcatServletWebServerFactory();
	}

	@Bean
	public DispatcherServlet dispatcherServlet() {
		return new DispatcherServlet(); // spring container 필요
	}

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

}

 

 

 

애플리케이션 클래스에 남은 코드가 스프링부트 이니셜라이저로 등록한 부분과 비슷해졌다. 빈을 등록하는 코드가 없어졌는데, 이 부분은 스프링 부트의 자동구성이 해당 타입의 빈을 자동으로 등록해주기 때문이다.

@SpringBootApplication
public class HellobootApplication {

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

}

 

 

아무튼 스프링이 HTTP 요청과 응답을 추상화하기 위해 디스패처 서블릿, 계층형 애노테이션, 매핑 테이블이나 오브젝트 매퍼 등을 사용한다는 점을 알게 되었다. 그리고 스프링부트는 내장 서버를 사용해 서블릿을 등록하고 사용하는 코드를 완전히 분리해서 run 메소드로 제공한다는 것을 알게 되었다.

 

 

 

스프링부트 짱.

토비 선생님.. 짱.