웹 애플리케이션 서버와 서블릿 컨테이너

과거 웹 애플리케이션 개발은 WAS(웹 애플리케이션 서버)를 직접 설치하고 WAR 파일을 배포하는 방식이었지만, 스프링 부트의 등장으로 WAS가 애플리케이션 내부에 포함(내장 톰캣)되어 JAR 파일 하나로 쉽게 실행하는 방식으로 발전했다. 이러한 변화는 개발 편의성을 극대화하며, 서블릿 컨테이너의 초기화 과정을 자바 코드로 직접 다루는 이해는 스프링 부트의 동작 원리를 파악하는데 중요하다고 생각한다.

전통적인 웹 애플리케이션 개발과 배포 (외장 서버 방식)

  • 방식: 과거 자바 웹 애플리케이션은 톰캣과 같은 독립적인 WAS를 서버에 설치한 후, 서블릿 스펙에 맞춰 개발된 코드 .war (Web Application Archive)파일로 빌드하여 WAS에 배포했다.
  • 단점: WAS 설치 및 설정, war 파일 배포 등 복잡한 인프라 설정이 필요했으며, 개발 환경(IDE)에서도 WAS와의 연동을 위한 추가 설정이 요구되어 개발 및 운영 과정이 번거로웠다.
  • .war 파일의 특징: 웹 애플리케이션 배포를 위해 고안된 압축 파일로 .jar 파일보다 복잡한 구조를 가진다. WEB-INF 디렉토리 (클래스, 라이브러리, 설정파일)와 정적 리소스(HTML, CSS)를 포함하여 웹 서버 위에 실행된다. 이는 일반 jar 파일이 JVM 위에서 직접 실행되는 것과 대조된다. 단, 스프링 부트의 실행 가능한 JAR는 특별한 중첩 구조(nested JAR)를 통해 내장 서블릿 컨테이너와 애플리케이션 코드를 함께 포함한다

현대적인 웹 애플리케이션 개발과 배포 (내장 서버 방식)

  • 방식: 스프링 부트는 톰캣과 같은 서블릿 컨테이너(또는 웹 컨테이너)를 애플리케이션 코드 내부에 라이브러리 형태로 내장한다. 개발자는 코드를 작성하여 .jar(Java Archive)파일로 빌드한 후, 이 jar 파일만 실행하만 내장된 WAS가 함께 구동된다
  • 장점: WAS 설치나 복잡한 설정 없이 main() 메서드 실행만으로 애플리케이션을 구동할 수 있어 개발 및 배포 과정이 매우 단순화된다. “하나의 실행 가능한 JAR”라는 개념으로 배포 편의성이 대폭 향상되었다.
  • .jar 파일의 특징: 여러 자바 클래스와 리소스를 묶은 표준 압축 파일이다. main() 메서드가 포함된 경우 JVM 위에어 직접 실행될 수 있으며, 다른 프로젝트의 라이브러리로 사용된다.

서블릿 컨테이너 초기화의 이해 – Java 코드

스프링 부트가 WAS와 연동되는 방식을 깊이 이해하려면, 서블릿 컨테이너의 초기화 과정을 수동으로 경험하는 것이 중요하다. 이는 web.xml 대신 자바 코드를 사용하여 서블릿 및 애플리케이션을 초기화하는 방식을 통해 학습할 수 있다

  • ServletContainerInitializer 인터페이스: WAS가 시작될 때 자동으로 호출되는 onStartup() 메서드를 제공하여 서블릿, 필터, 리스너 등을 프로그래밍 방식으로 등록하고 스프링 컨테이너와 같은 애플리케이션 관련 초기화 작업을 수행할 수 있게 한다. 이 인터페이스의 구현체는 resources/META-INF/services/jakarta.servlet.ServletContainerInitializer 파일에 등록하여 WAS에 알린다. (참고: Servlet 3.0 이전의 레거시 프로젝트에서는 javax.servlet.ServletContainerInitializer 사용)
jakarta.servlet.ServletContainerInitializer

// 패키지명 + 파일명
hello.container.MyContainerInitV1
hello.container.MyContainerInitV2
  • @HandlesTypes 애노테이션과 애플리케이션 초기화: ServletContainerInitializer와 함께 사용되는 @HandlesTypes 애노테이션은 서블릿 컨테이너가 클래스패스를 스캔하여 특정 인터페이스를 구현한 모든 클래스들을 찾아내고 이를 onStartup() 메서더의 Set<Class<?>> c 파라미터로 전달해준다.
    • 이를 통해 ServletContainerInitializer 구현체는 AppInit와 같은 사용자 정의 애플리케이션 초기화 인터페이스의 구현체들을 동적으로 찾아 객체를 생성해 실행할 수 있다
    • 장점: AppInit와 같은 애플리케이션 초기화 코드는 서블릿 컨테이너(ServletContext ctx)에 대한 직접적인 의존성을 줄이고 더 유연하고 모듈화된 초기화 로직을 구현할 수 있게 한다. 필요에 따라 조건부로 서블릿을 등록하거나 외부 설정을 활용하는 등 web.xml이나 @WebServlet 애노테이션 방식으로는 어려웠던 동적인 제어가 가능해진다.

애노테이션 방법

@WebServlet(urlPatterns = "/test")
public class TestServlet extends HttpServlet {
    @Override
    protected void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        System.out.println("TestServlet.service");
        resp.getWriter().println("test");
    }
}

프로그래밍 방식

public interface AppInit {
    void onStartup(ServletContext servletContext);
}


public class AppInitV1Servlet implements AppInit {
    @Override
    public void onStartup(ServletContext servletContext) {
        System.out.println("AppInitV1Servlet.onStartup");

        ServletRegistration.Dynamic helloServlet =
                servletContext.addServlet("helloServlet", new HelloServlet());
        helloServlet.addMapping("/hello-servlet");

         // 체인 형식으로 사용 가능
        // servletContext.addServlet("helloServlet", new HelloServlet()).addMapping("/hello-servlet");
    }
}


@HandlesTypes({AppInit.class})
public class MyContainerInitV2 implements ServletContainerInitializer {
    @Override
    public void onStartup(Set<Class<?>> c, ServletContext ctx) throws ServletException {
        System.out.println("MyContainerInitV2.onStartup");
        System.out.println("MyContainerInitV2 c = " + c);
        System.out.println("MyContainerInitV2 ctx = " + ctx);

        for (Class<?> appInitClass : c) {
            try {
                AppInit appInit = (AppInit) appInitClass.getDeclaredConstructor().newInstance();
                appInit.onStartup(ctx);
            } catch (Exception e) {
                throw new RuntimeException(e);
            }
        }
    }
}

실행 후 출력

MyContainerInitV1.onStartup
MyContainerInitV1 c = null
MyContainerInitV1 ctx = org.apache.catalina.core.ApplicationContextFacade@540870e9
MyContainerInitV2.onStartup
MyContainerInitV2 c = [class hello.container.AppInitV1Servlet]
MyContainerInitV2 ctx = org.apache.catalina.core.ApplicationContextFacade@540870e9
AppInitV1Servlet.onStartup
  • V1에서는 c가 null이었지만, 이후에는 AppInit 인터페이스를 구현한 AppInitV1Servlet이 전달된다. 즉, @HandlesTypes로 인터페이스를 지정하면 해당 인터페이스의 모든 구현체를 찾아 클래스 정보를 넘겨주며, 개발자는 이 정보를 활용해서 다양한 초기화 작업을 수행할 수 있으며, 구현체는 여러 개일 수도 있다

스프링 부트의 내장 WAS 방식은 개발자의 초기 설정 부담을 크게 줄여주지만, 그 배경에는 서블릿 컨테이너의 초기화 메커니즘과 ServletContainerInitializer, @HandlesTypes와 같은 표준 기능들이 있다. 이러한 전통적인 초기화 과정을 이해하는 것은 스프링 부트가 어떻게 WAS와 유기적으로 결합하여 동작하는지에 대한 깊이 있는 통찰을 제공하며, 현대 웹 개발의 기반 지식을 튼튼히 한다

출처 – 김영한 님의 강의 중 스프링 부트 – 핵심 원리와 활용