Back-End/Spring Advance & Boot

스프링 부트 - 웹 서버와 서블릿 컨테이너

Meluu_ 2024. 11. 15. 22:19

✔️ 스프링 부트


스프링 부트는 개발자가 스프링 초기 설정,라이브러리관리 등 복잡한 과정을 자동으로 처리해주어 쉽고 빠르게 스프링을 시작하게 해주는 것이다.  (내가 이해한 스프링 부트의 의미)

 

핵심 기능

  • WAS (Web Application Server) : Tomcat 같은 웹 서버를 내장해서 별도의 웹 서버를 설치하지 않아도 됨  
  • 라이브러리 관리 : 손쉬운 빌드 구성을 위한 스타터 종속성 제공, 버전을 자동 관리 (외부 포함)
  • 자동 구성(Auto Configuration) : 프로젝트 시작에 필요한 스프링과 외부 라이브러리의 빈을 자동 등록
  • 외부 설정 : 환경에 따라 달라져야 하는 외부 설정 공통화
  • 프로덕션 준비 : 모니터링을 위한 메트릭, 상태 확인 기능 제공

 

참고

스프링 프레임워크 ≠ 스프링 부트

스프링 부트는 스프링 프레임워크를 편리하게 사용하게 해주는 도구일 뿐이다. 

 

 

 

✔️ 웹 서버와 스프링 부트 소개 


외장 서버, 내장서버에 대해서 알아보자

 

전통적인 방식은 Tomcat같은 WAS 서버를 설치 → WAS에서 동작하도록 서블릿 스펙에 맞춰 코드 작성 → WAR형식으로 빌드 → war파일을 WAS 서버에 전달해서 배포하는 방식

 

최근 방식 은 스프링 부트가 내장 톰캣을 포함하기에 애플리케이션 코드 안에 WAS 서버가 라이브러리로 내장되어 있음

개발자는 코드 작성 → JAR로 빌드 → JAR를 원하는 위치에서 실행(WAS도 함께 실행됨)

 

쉽게 말해서

WAS 안에 애플리케이션 코드 (외장 서버) vs 애플리케이션 안에 WAS (내장 서버)이다.

 

톰캣 서버를 직접 실행해 보자

 

  1. 톰캣 설치
  2. zip 압축 해제 후 bin 폴더의 startup.bat 실행
    • 종료 : shutdown.bat
  3. http://localhost:8080 접속하여 서버 실행 확인

build.gradle

plugins {
    id 'java' 
    id 'war'  // war 파일 생성 
}

//...
dependencies {

    //서블릿
    implementation 'jakarta.servlet:jakarta.servlet-api:6.0.0'
}

 

간단한 HTML을 등록하여 웹 서버가 정적 리소스를 잘 전달하는지 확인하자

/src/main/webapp 폴더 생성 

index.html 생성 (내용은 아무거나 간단하게)

 

서블릿 등록

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

    }
}

 

 

✔️ WAR 빌드와 배포


프로젝트 빌드

  1. cmd를 실행하여 프로젝트 폴더 이동
  2. gradlew build 명령어 입력
  3. WAR 파일 생성 확인
    • build/libs/server-0.0.1-SNAPSHOT.war
  4. WAR 압축 풀기
    • jar -xvf server-0.0.1-SNAPSHOT.war
  5. WAR를 푼 결과 (구조)
    • WEB-INF
      • classes : 실행 클래스 모음
        • hello/servlet/TestServlet.class
      • lib : 라이브러리 모음 
        • jakarta.servlet-api-6.0.0.jar
      • web.xml : 웹 서버 배치 설정 파일 (생략 가능)
    • index.html : 정적 리소스

WEB-INF 폴더 하위는 자바 클래스와 라이브러리, 설정 정보

WEB-INF를 제외한 나머지 영역은 정적 리소스 영역

 

JAR, WAR 간단 소개

JAR(Java Archive)는 여러 클래스와 리소스의 묶음이다.

이 파일은 JVM 위에서 직접 실행되거나 다른 곳에서 라이브러리제공된다.

직접 실행 : main() 메서드 필요, MANIFEST.MF 파일에 실행할 메인 메서드가 있는 클래스를 지정해둬야 함

 

WAR(Web Application Archive)는 WAS에 배포할 때 사용하는 파일

WAR는 웹 애플리케이션 서버 위에서 실행된다.

HTML 같은 정적 리소스와 클래스 파일을 모두 함께 포함하기에 JAR보다 구조가 더 복잡함

 

 

 

WAR 배포

톰캣 서버를 모두 종료한 상태에서 실행

 

  • 톰캣폴더/webapps 하위 모두를 삭제
  • 빌드된 war  파일을 복사해서 넣기 
  • 이름을 ROOT.war로 변경 (반드시 대문자)
  • 톰캣 서버 실행

 

 

그런데 이과정을 IDE에서 편리하게 자동화해준다. 

필자는 IntelliJ Ultimate를 사용하므로 IntelliJ를 기준으로 정리하겠다.

 

IntelliJ에서 메뉴 → Run Edit Configurations 플러스 버튼 Tomcat Server(TomEE  ❌)  Local 클릭 Server 탭에서 Application server : 행에서 Configure... 클릭 설치했던 Tomcat 폴더를 선택  

 

Deployment 탭 플러스 버튼 (exploded)로 끝나는 war 파일 선택   밑에 Application context 박스 안 내용을 모두 삭제   ok 버튼  설정한 톰캣으로 run 

 

 

톰캣 서버가 정상적으로 실행될 것이다. 



✔️ 서블릿 컨테이너 초기화 


WAR를 실행하는 시점에 필요한 초기화 작업들

: 서비스에 필요한 필터, 서블릿 등록, 스프링 컨테이너 생성, 서블릿과 스프링 연결하는 디스패처 서블릿 등록 이 있다. 

WAS가 제공하는 초기화 기능을 사용하면 WAS 실행 시점에 이러한 초기화 과정을 진행 가능

과거에는 web.xml을 사용해서 초기화했다고 한다. 지금은 서블릿 스펙에서 자바 코드를 사용한 초기화도 지원한다. 

 

 

서블릿 컨테이너 초기화 개발

서블릿은 ServletContainerInitializer라는 서블릿 컨테이너 초기화 인터페이스 제공

서블릿 컨테이너는 실행 시점에 초기화 메서드인 onStartup()을 호출해 준다.

여기에 애플리케이션에 필요한 기능들을 초기화하거나 등록 가능 (스프링 컨테이너 등)

 

public interface ServletContainerInitializer {
    public void onStartup(Set<Class<?>> set, ServletContext servletContext) throws ServletException;
}

 

set : @HandlesTypes 애노테이션과 함께 사용

servletContext : 서블릿 컨테이너 자체의 기능을 제공, 해당 객체를 통해 필터나 서블릿등을 등록

 

public class MyContainerInitV1 implements ServletContainerInitializer {
    @Override
    public void onStartup(Set<Class<?>> set, ServletContext servletContext) throws ServletException {
      // 초기화할 코드 작성
    }
}

 

그리고 WAS에게 실행할 초기화 클래스를 알려줘야 한다.

resources/META-INF/services/jakarta.servlet.ServletContainerInitializer

hello.container.MyContainerInitV1

 

해당 경로가 없다면 만들자 

 

 

 

서블릿을 등록하는 2가지 방법

@WebServlet 애노테이션

프로그래밍 방식

 

 

프로그래밍 방식을 살펴보자

public class HelloServlet extends HttpServlet {

    @Override
    protected void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
        System.out.println("HelloServlet.service");
        resp.getWriter().println("hello servlet!");
    }
}

HttpServlet을 상속받아서 service를 오버라이딩한다. 

응답으로 hello servlet! 을 출력한다.

 

 

 

애플리케이션 초기화

서블릿 컨테이너는 조금 더 유연한 초기화 기능을 지원 (애플리케이션 초기화)

 

AppInit

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

 애플리케이션 초기화를 진행하려면 꼭 인터페이스가 필요

 

 

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


        // 순수 서블릿 코드 등록
        // 서블릿 컨테이너에 HellloServlet 객체 넣기
        ServletRegistration.Dynamic helloServlet =
                servletContext.addServlet("helloServlet", new HelloServlet());

        // 해당 경로로 매핑되면 HelloServlet 호출
        helloServlet.addMapping("/hello-servlet");
    }
}

애플리케이션 초기화 인터페이스를 구현하여 onStartup 메서드를 오버라이딩한다. 

매개변수로 받은 서블릿 컨테이너를 통해 HelloServlet 객체를 넣고 경로를 매핑해 준다.

 

이제 톰캣 서버를 실행해서 /hello-servlet로 접속해 보면 정상 접속이 된다.   

 

참고

프로그래밍 방식은 무한한 유연성을 제공하기에 사용

경로를 상황에 따라서 바꾸어 외부 설정을 읽어서 등록 가능

서블릿을 특정 조건에 따라 if문으로 분기해서 등록하거나 빼기 가능

서블릿을 내가 직접 생성하기에 생성자에 필요한 정보를 넘길 수 있음

 

 

AppInit은 어디에서도 호출한 거 같지 않은데 어떻게 정상적으로 등록되어 사용가능한 것일까?

 

AppInit의 동작 원리

@HandlesTypes(AppInit.class)  // 애플리케이션 초기화 인터페이스 지정
public class MyContainerInitV2 implements ServletContainerInitializer {
    @Override
    public void onStartup(Set<Class<?>> set, ServletContext servletContext) throws ServletException {
        System.out.println("MyContainerInitV2.onStartup");
        System.out.println("set = " + set);
        System.out.println("servletContext = " + servletContext);

        for (Class<?> aClass : set) {
            try {
                // HandlesTypes에서 지정한 인터페이스의 구현체인 class 정보가 set에 담겨있다.
                // 따라서 꺼내서 객체를 생성한다. (class 정보만 들어있기에 생성)
                AppInit appInit = (AppInit)aClass.getDeclaredConstructor().newInstance();

                // 생성후 초기화 메서드를 실행한다.
                appInit.onStartup(servletContext);
            } catch (Exception e) {
                throw new RuntimeException(e);
            }
        }
    }
}

 

애플리케이션 초기화 과정

  1. @HandlesTypes 애노테이션에 애플리케이션 초기화 인터페이스를 지정
  2. 서블릿 컨테이너 초기화 (ServletContainerInitializer)는 파라미터로 넘어오는 Set <Class <?>> set에 애플리케이션 초기화 인터페이스의 구현체들을 모두 찾아서 클래스 정보로 전달
  3. appInitClass.getDeclaredConstructor().newInstance() : 리플렉션을 사용해서 객체 생성
  4. appInit.onStartup(servletContext) : 애플리케이션 초기화 코드를 직접 실행하면서 서블릿 컨테이너 정보가 담긴 servletContext 도 함께 전달

 

MyContainerV2를 서블릿 컨테이너가 알게 설정에 추가

resources/META-INF/services/jakarta.servlet.ServletContainerInitializer

 

 

set 안에는 AppInitV1Servlet이 들어있다고 보면 된다. 

 

 

 

애플리케이션 초기화 개념을 만든 이유

  • 편리함
    • 서블릿 컨테이너 초기화는 조금 복잡하다. 인터페이스 구현 코드 만들고 설정 파일에 추가해야 한다
      반면에 애플리케이션 초기화는 특정 인터페이스만 구현하면 된다.

 

  • 의존성
    • 애플리케이션 초기화는 서블릿 컨테이너에 상관없이 원하는 모양으로 인터페이스를 만들 수 있다.
      이를 통해 애플리케이션 초기화 코드가 서블릿 컨테이너에 대한 의존을 줄일 수 있다. 

 

 

✔️ 스프링 컨테이너 등록 


WAS와 스프링을 통합해 보자

 

build.gradle에 spring-webmvc를 추가하자.

 

간단한 컨트롤러를 만든다.

@RestController
public class HelloController {

    @GetMapping("/hello-spring")
    public String hello() {
        System.out.println("HelloController.hello");
        return "hello spring!";
    }
}

 

@Configuration
public class HelloConfig {

    @Bean
    public HelloController helloController() {
        return new HelloController();
    }
}

스프링 빈을 수동 등록한다. 

 

 

AppInitV2Spring을 만들고 스프링 컨테이너를 생성한 다음

디스패처 서블릿을 서블릿 컨테이너에 등록하자

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

        // 스프링 컨테이너 생성
        AnnotationConfigWebApplicationContext appContext = new AnnotationConfigWebApplicationContext();
        appContext.register(HelloConfig.class); // 컨테이너에 스프링 설정 추가

        // 스프링 MVC 디스패처 서블릿 생성, 스프링 컨테이너 연결
        DispatcherServlet dispatcher = new DispatcherServlet(appContext);

        // 디스패처 서블릿을 서블릿 컨테이너에 등록 (이름 주의! dispatcherV2)
        servletContext.addServlet("dispatcherV2", dispatcher)
                .addMapping("/spring/*");  // /spring/* 요청이 디스패처 서블릿을 통하도록 설정

    }
}

 

서버를 실행해 보면 localhost:8080/spring/hello-spring 접속이 되는 것을 확인할 수 있다.

즉, 서블릿 컨테이너(디스페처 서블릿)와 스프링 컨테이너가 연결되었다

 

 

 

✔️ 스프링 MVC 서블릿 컨테이너 초기화 지원


서블릿 컨테이너 초기화를 위해 생각보다 복잡한 과정을 거쳤다.

스프링 MVC는 이러한 초기화 작업을 이미 만들어 뒀다. 

개발자는 애플리케이션 초기화 코드만 작성하면 된다. 

 

WebApplicationInitializer

package org.springframework.web;

public interface WebApplicationInitializer {
    void onStartup(ServletContext servletContext) throws ServletException;
}

해당 인터페이스를 구현하면 애플리케이션 초기화를 사용할 수 있다.

 

 

 

public class AppInitV3SpringMvc implements WebApplicationInitializer {
    @Override
    public void onStartup(ServletContext servletContext) throws ServletException {
        System.out.println("AppInitV3SpringMvc.onStartup");

        // 스프링 컨테이너 생성
        AnnotationConfigWebApplicationContext appContext = new AnnotationConfigWebApplicationContext();
        appContext.register(HelloConfig.class);

        // 스프링 MVC 디스패처 서블릿 생성, 스플링 컨테이너 연결
        DispatcherServlet dispatcher = new DispatcherServlet(appContext);

        // 디스패처 서블릿을 서블릿 컨테이너에 등록 (이름 주의! dispatcherV3)
        servletContext.addServlet("dispatcherV3", dispatcher)
                .addMapping("/");  // 모든 요청이 디스패처 서블릿을 통하도록 설정
    }
}

끝이다. 아까처럼 서블릿 컨테이너 초기화 과정은 스프링이 알아서 처리해 준다. 

 

 

 

스프링 MVC가 제공하는 서블릿 컨테이너 초기화 분석

WebApplicationInitializer 인터페이스 하나로 애플리케이션 초기화가 가능한 이유에 대해서 알아보자

 

spring-web 라이브러리를 열어보면 우리가 서블릿 컨테이너 초기화때 했던 설정 파일이 이미 존재한다.

org.springframework.web.SpringServletContainerInitializer

해당 코드를 확인해 보면

@HandlesTypes({WebApplicationInitializer.class})
public class SpringServletContainerInitializer implements ServletContainerInitializer {
    public SpringServletContainerInitializer() {
    }

    public void onStartup(@Nullable Set<Class<?>> webAppInitializerClasses, ServletContext servletContext) throws ServletException {
        List<WebApplicationInitializer> initializers = Collections.emptyList();
        Iterator var4;
        if (webAppInitializerClasses != null) {
            initializers = new ArrayList(webAppInitializerClasses.size());
            var4 = webAppInitializerClasses.iterator();

            while(var4.hasNext()) {
                Class<?> waiClass = (Class)var4.next();
                if (!waiClass.isInterface() && !Modifier.isAbstract(waiClass.getModifiers()) && WebApplicationInitializer.class.isAssignableFrom(waiClass)) {
                    try {
                        ((List)initializers).add((WebApplicationInitializer)ReflectionUtils.accessibleConstructor(waiClass, new Class[0]).newInstance());
                    } catch (Throwable var7) {
                        throw new ServletException("Failed to instantiate WebApplicationInitializer class", var7);
                    }
                }
            }
        }

        if (((List)initializers).isEmpty()) {
            servletContext.log("No Spring WebApplicationInitializer types detected on classpath");
        } else {
            servletContext.log(((List)initializers).size() + " Spring WebApplicationInitializers detected on classpath");
            AnnotationAwareOrderComparator.sort((List)initializers);
            var4 = ((List)initializers).iterator();

            while(var4.hasNext()) {
                WebApplicationInitializer initializer = (WebApplicationInitializer)var4.next();
                initializer.onStartup(servletContext);
            }

        }
    }
}

 

앞서 만든 서블릿 컨테이너 초기화 코드와 비슷하다.

@HandlesTypes 의 대상이 WebApplicationInitializer 이기에 이 인터페이스만 구현하면 애플리케이션 초기화가 가능했던 것이었다.

 

🔖 학습내용 출처


스프링 부트 - 핵심 원리와 활용 / 김영한