스프링 부트 - 핵심 원리와 활용 : spring boot, embedded tomcat

jkky98·2024년 10월 30일
0

Spring

목록 보기
64/77

WAR 배포 방식의 단점

톰캣을 직접 깔고 애플리케이션을 WAR로 빌드하여 톰캣에 이를 전달하는 등 과거에는 당연했지만 이는 현재의 부트 환경에서는 매우 불편한 방식이다. 단순한 자바 어플리케이션이라면 main() 하나만으로 어플리케이션이 올라간 웹 서버 실행할 수 있기에 현재의 부트환경에서의 스프링 개발자들은 매우 편리하게 개발을 하고 있다.

결국 톰캣또한 자바로 만들어진 웹 서버이므로 이를 라이브러리로 내장하여 main()만 실행하면 웹 서버까지 같이 실행되도록 할 수 있다.

내장 톰켓

부트 환경에서 자연스럽게 사용했던 내장 톰켓 기능을 간단히 구현하자면 아래와 같다.

// dependencies
dependencies {
    //스프링 MVC 추가
    implementation 'org.springframework:spring-webmvc:6.0.4'

    //내장 톰켓 추가
    implementation 'org.apache.tomcat.embed:tomcat-embed-core:10.1.5'
}
public class EmbedTomcatSpringMain {

    public static void main(String[] args) throws LifecycleException {

        System.out.println("EmbbedTomcatSpringMain.main");

        // 톰켓 설정
        Tomcat tomcat = new Tomcat();
        Connector connector = new Connector();
        connector.setPort(8080);
        tomcat.setConnector(connector);

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

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

        //디스패처 서블릿 등록
        Context context = tomcat.addContext("", "/");
        tomcat.addServlet("", "dispatcher", dispatcher);
        context.addServletMappingDecoded("/", "dispatcher");

        tomcat.start();
    }
}

이 main 메서드를 실행할 경우 웹 서버가 뜨면서 그동안 매우 복잡하게 밑바닥에서 구현했던 초기화 과정 또한 위에서 보이는 코드로 더욱 간단하게 구현할 수 있다.

빌드 및 배포

빌드 프로세스

전체적인 빌드 프로세스는 다음과 같다.

  1. 의존성 관리
    build.gradle 파일에 정의된 의존성(예: Spring, 데이터베이스 드라이버 등)을 프로젝트로 가져오는 과정이다. 이 과정에서 Gradle은 필요한 라이브러리를 로컬 또는 원격 저장소(예: Maven Central)에서 다운로드하여 프로젝트에 포함시킨다.
  2. 컴파일
    프로젝트의 소스 코드와 가져온 의존성을 바탕으로 코드를 컴파일한다.컴파일된 바이트코드는 JAR 파일에 포함될 준비를 마친다.
  3. 패키징
    ./gradlew build 명령어는 최종적으로 프로젝트를 JAR 파일 또는 WAR 파일로 패키징하여 출력한다. Spring Boot 프로젝트의 경우 실행 가능한 JAR 파일이 생성되며, 종속된 라이브러리와 함께 모든 코드가 포함된 형태로 존재한다.

이전에는 외부 톰캣에 배포를 위해 WAR로 빌드하여 톰캣에 이를 전달하는 방식이었는데 이제는 자바 프로젝트 내에 라이브러리로 톰캣을 두어 활용하는 방법이기에 JAR로 빌드하여 해당 JAR파일을 원하는 공간에서 실행만 하면 어플리케이션이 올라간 웹 서버를 실행할 수 있다.

결국은 JAR지만 3가지 방법을 소개하며 최종적으로 우리는 어떤 형태를 이용하는지 알아볼 것이다.

일반 JAR 방식

//build.gradle
//일반 Jar 생성
task buildJar(type: Jar) {
    manifest {
        attributes 'Main-Class': 'hello.embed.EmbedTomcatSpringMain'
    }
    with jar
}

./gradlew buildJar로 하여금 jar파일을 생성(빌드)할 수 있고 jar파일을 실행만 하면되지만, 이 방식에는 문제가 존재한다. 우리 프로젝트 내에 존재하는 라이브러리들에 대해서도 jar로 압축해야 하지만 jar안에 jar를 보관하는 것은 자바 스펙상으로 불가능하다. 그러므로 라이브러리를 제외한 순수한 프로젝트 코드만 JAR로 빌드되기에 일반JAR 생성 방법은 한계가 존재한다.

FatJar 방식

fatJar(뚱뚱한 Jar)는 일반 JAR방식을 보완하여 라이브러리들의 클래스들을 모두 빌드할 프로젝트로 가져와서 다 같이 압축한 jar로 하여금 라이브러리를 포함할 수 있게 된다. 하지만 이 방식또한 한계가 존재하는데, 각각의 라이브러리들마다 존재할 수 있는 클래스명 중복 문제를 피할 수 없다. 각각의 라이브러리들의 클래스를 모두 한 곳으로 가져오는 방식이기 때문에 클래스명 중복 발생시 하나를 무시해버린다.

SpringBoot 빌드 방식

스프링 부트는 이를 말끔히 해결한다. 스프링 부트는 라이브러리들을 jar로 jar안에 보관될 수 있도록 특별한 방식을 사용한다. jar를 실행하면 우선 MANIFEST.MF파일로 하여금 Main-Class를 읽어 main() 메서드를 실행하는데 이를 main메서드가 아닌 Boot가 준비한 JarLauncher를 실행시켜 부트만의 로직을 실행시킨다.

Spring Boot는 Fat JAR 내부에 포함된 의존성 JAR 파일을 로드하기 위해 Spring Boot Launcher와 커스텀 클래스 로더(LaunchedURLClassLoader)를 사용한다.
실행 시, Spring Boot Launcher는 /BOOT-INF/lib/에 위치한 각 라이브러리 JAR 파일을 자체 클래스 로더를 통해 로드하여 애플리케이션이 실행될 수 있도록 한다.
이를 통해 자바 스펙에서 지원하지 않는 "JAR 안의 JAR" 구조를 우회하며, 애플리케이션이 모든 의존성을 포함한 상태로 실행될 수 있게 된다.

Boot

@SpringBootApplication
public class 프로젝트명Application {

    public static void main(String[] args) {
        SpringApplication.run(프로젝트명Application.class, args);
    }

}

spring starter로 프로젝트를 생성하면 이와 같은 main메서드가 담긴 프로젝트명Application 클래스가 존재한다. 특이한 점은 @SpringBootApplication 애노테이션이 붙어있다는 것인데, 이 간단한 애노테이션으로 하여금 내장 톰캣 실행, 스프링 컨테이너 생성등 초기화 과정에 필요한 작업들을 모두 진행해준다.

초기화 기능이 제한적이지만 이를 직접 구현하자면

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@ComponentScan
public @interface MySpringBootApplication {
}

우선 인터페이스를 위와 같이 만들어줄 수 있다. 예제 격의 실습이므로 @ComponentScan으로 하여금 @Repository, @Service와 같은 컴포넌트 대상을 실행시점에 Config작업 없이 자동적으로 빈 등록을 시킬 수 있다.

나머지 @Target, @Retention, @Documented는 적용범위, 적용시기, 문서화 포함기준 등을 나타낸다.

public class MySpringApplication {

    public static void run(Class configClass, String[] args) {

        System.out.println("MySpringApplication.main args=" + List.of(args));

        // 톰켓 설정
        Tomcat tomcat = new Tomcat();
        Connector connector = new Connector();
        connector.setPort(8080);
        tomcat.setConnector(connector);

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

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

        //디스패처 서블릿 등록
        Context context = tomcat.addContext("", "/");
        tomcat.addServlet("", "dispatcher", dispatcher);
        context.addServletMappingDecoded("/", "dispatcher");

        try {
            tomcat.start();
        } catch (LifecycleException e) {
            throw new RuntimeException(e);
        }
    }
}

이후 이전에 main()에 직접 작성했던 초기화 코드를 MySpringApplication 클래스의 run메서드에 옮긴다.

@MySpringBootApplication
public class MySpringBootMain {

    public static void main(String[] args) {
        System.out.println("MySpringBootMain.main");
        MySpringApplication.run(MySpringBootMain.class, args);
    }
}

최종적으로 우리가 경험한 SpringBoot main처럼 간단한 main 메서드가 완성된다.

SpringBoot가 지원하는 애노테이션들을 통해 기존의 설정과 같은 기능들을 애노테이션안에 함축하여 초기화 시점에 처리하고, run()메서드로 초기화 기능들을 수행한다. 물론 실제 Boot의 run()은 더 많은 초기화 코드를 가지며, 애노테이션 또한 더 많은 기능들을 가진다.

profile
자바집사의 거북이 수련법

0개의 댓글