인프런/스프링 부트 - 핵심 원리와 활용

4) 스프링 부트와 내장 톰캣 (2)

backend dev 2024. 11. 5.

편리한 부트 클래스 만들기

 

 

지금까지 진행한 내장 톰캣 실행, 스프링 컨테이너 생성, 디스패처 서블릿 등록의 모든 과정을 편리하게 처리해주는

나만의 부트 클래스를 만들어보자.

부트는 이름 그대로 시작을 편하게 처리해주는 것을 뜻한다.

MySpringApplication

public class MySpringApplication {

    public static void run(Class configClass, String[] args){
        System.out.println("MySpringApplication.run 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 dispatcherServlet = new DispatcherServlet(appContext);

        // 디스패처 서블릿 등록
        Context context = tomcat.addContext("", "/");
        tomcat.addServlet("", "dispatcher", dispatcherServlet);
        context.addServletMappingDecoded("/", "dispatcher");
        
        try {
            tomcat.start();
        } catch (LifecycleException e) {
            throw new RuntimeException(e);
        }
    }
}

기존 코드를 모아서 편리하게 사용할 수 있는 클래스를 만들었다.

MySpringApplication.run()

을 실행하면 바로 작동한다

configClass : 스프링 설정을 파라미터로 전달받는다.
args : main(args) 를 전달 받아서 사용한다. 참고로 예제에서는 단순히 해당 값을 출력한다.
tomcat.start() 에서 발생하는 예외는 잡아서 런타임 예외로 변경했다

 

@MySpringBootApplication

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

 

컴포넌트 스캔 기능이 추가된 단순한 애노테이션이다.

시작할 때 이 애노테이션을 붙여서 사용하면 된다.

 

 

MySpringBootMain

@MySpringBootApplication
public class MySpringBootMain {

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

}

 

해당 파일의 위치는 프로젝트 코드가 존재하는 최상위 위치에 존재해야한다.

여기에 위치한 이유는 @MySpringBootApplication컴포넌트 스캔이 추가되어 있는데,

컴포넌트 스캔의 기본 동작은 해당 애노테이션이 붙은 클래스의 현재 패키지 부터

그 하위 패키지를 컴포넌트 스캔의 대상 으로 사용하기 때문이다

 

애노테이션이 붙은 hello.MySpringBootMain 클래스의 패키지 위치는 hello이므로

그 하위의 hello.spring.HelloController를 컴포넌트 스캔한다.

 

MySpringApplication.run(설정 정보, args) 이렇게 한줄로 실행하면 된다.

이 기능을 사용하는 개발자는 @MySpringBootApplication 애노테이션과 MySpringApplication.run() 메서드만

기억하면 된다.

 

이렇게 하면 내장 톰캣 실행, 스프링 컨테이너 생성, 디스패처 서블릿, 컴포넌트 스캔까지 모든 기능이

한번에 편리하게 동작한다.

 

 

더보기

 

  • MySpringBootMain은 설정 클래스로 등록됨
    [ @Component 어노테이션이 없으므로 MySpringBootMain 자체는 빈이 아님]

  • 스프링이 이 클래스의 어노테이션을 분석해서 @ComponentScan 발견

  • MySpringBootMain 클래스가 있는 패키지부터 컴포넌트 스캔 시작

  • 발견된 컴포넌트들이 빈으로 등록됨

 

 

 

스프링 부트

지금까지 만든 것을 라이브러리로 만들어서 배포한다면? 그것이 바로 스프링 부트이다.

 

[ 톰캣을 내장하고 내장하니까 main 메소드 하나로 코드 및 톰캣실행이 쉽게 가능하고,
톰캣 내장 및 필요한 라이브러리를 하나의 Jar로 넣으려면 Fat Jar가 되는데 그것도 해결하고,
어노테이션을 이용하여 컴포넌트스캔으로 인해 자동 빈 등록도 되고,
톰캣 설정 및 스프링 컨테이너를 생성하고 스프링 MVC 디스패처 서블릿 생성 및 연결도 하고 
디스패처 서블릿을 스프링 컨테이너에 등록도 하면서 스프링과 서블릿을 연결도 하는 작업도 한다. ]

 

더보기

스프링 부트(Spring Boot)는 스프링 프레임워크를 기반으로 하여 빠르고 쉽게 애플리케이션을 개발하고 배포할 수 있게 해주는 도구입니다. 이를 통해 복잡한 설정을 최소화하고 생산성을 극대화할 수 있습니다. 주요 특징들을 좀 더 구체적으로 정리하면 다음과 같습니다:


1. 내장 서버 (Embedded Server)

  • 내장 서버 지원: 스프링 부트는 톰캣(Tomcat), 제티(Jetty), 언더토우(Undertow)와 같은 웹 서버를 내장하고 있어 별도로 서버를 설치하거나 설정할 필요가 없습니다.
  • 간편한 실행: 내장 서버를 통해 main() 메서드로 애플리케이션을 Java 프로그램처럼 쉽게 실행할 수 있습니다. 이를 통해 로컬 개발 환경에서 손쉽게 서버를 실행하고 테스트할 수 있습니다.
  • 다양한 서버 선택 가능: 기본적으로 톰캣을 사용하지만, 다른 서버를 필요에 따라 선택할 수도 있습니다.

2. 자동 구성 (Auto Configuration)

  • 자동 빈 등록 및 구성: @SpringBootApplication 어노테이션을 통해 컴포넌트 스캔과 빈 등록을 자동화합니다. 애플리케이션에 필요한 빈들이 자동으로 구성되므로, 개발자가 수동으로 설정할 필요가 줄어듭니다.
  • 조건부 구성: 스프링 부트는 필요에 따라 빈을 생성하거나 구성 요소를 활성화하는 조건부 구성을 제공합니다. 이는 상황에 맞는 자동 구성을 가능하게 하여 유연성을 높입니다.
  • 서블릿 컨테이너 설정 자동화: 스프링 부트는 서블릿 컨테이너 설정, 스프링 MVC 구성, 디스패처 서블릿 설정 등을 자동으로 설정해줘 개발자가 이러한 설정을 직접 하지 않아도 되도록 합니다.

3. 의존성 관리 (Dependency Management)

  • 의존성 버전 관리: 스프링 부트는 스프링과 주요 외부 라이브러리의 버전을 자동으로 관리해줍니다. 이를 통해 호환성 문제를 줄이고 검증된 라이브러리 버전 조합을 제공하여 안정적인 개발 환경을 제공합니다.
  • 스타터 패키지: 스프링 부트는 애플리케이션에 필요한 의존성을 손쉽게 추가할 수 있는 **스타터 패키지(Starter Packages)**를 제공합니다. 예를 들어, spring-boot-starter-web을 사용하면 웹 애플리케이션에 필요한 모든 기본 의존성이 자동으로 추가됩니다.

4. Fat JAR 생성

  • 단일 JAR로 패키징: 스프링 부트는 애플리케이션과 모든 의존성[=dependency,라이브러리]을 하나의 실행 가능한 JAR 파일(Fat JAR)에 포함시킵니다. 이로써 자바 런타임만 설치된 환경에서 JAR 파일만으로 애플리케이션을 실행할 수 있습니다.
  • 배포 및 실행의 간편성: 별도의 설치 없이 JAR 파일을 통해 서버 배포가 가능하며, 이로 인해 실행 및 배포가 매우 간편해집니다. 이를 통해 환경에 관계없이 일관된 애플리케이션 구동을 할 수 있습니다.

5. 추가적인 기능들

  • 프로파일 관리: 스프링 부트는 다양한 환경 설정을 쉽게 관리할 수 있도록 프로파일 기능을 제공합니다. 이를 통해 개발, 테스트, 운영 환경별로 설정을 유연하게 적용할 수 있습니다.
  • 외부 설정: YAML, 프로퍼티 파일, 시스템 환경 변수 등 다양한 외부 설정 파일을 지원하여 환경별로 유연한 설정 관리가 가능합니다.
  • 모니터링 및 관리 기능: 스프링 부트는 애플리케이션 상태를 모니터링하고 관리할 수 있는 스프링 부트 액추에이터(Spring Boot Actuator) 기능을 제공합니다. 이를 통해 애플리케이션의 상태와 성능을 실시간으로 모니터링할 수 있습니다.
  • 보안: 스프링 부트는 보안 설정을 위한 다양한 기능을 제공하며, 스프링 시큐리티(Spring Security)와의 통합을 통해 인증과 권한 관리를 쉽게 구성할 수 있습니다.

 

Fat JAR의 클래스 충돌 문제를 스프링 부트는 'Spring Boot Loader'를 통해 해결

 

 

일반적인 스프링 부트 사용법

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

 

 

스프링 부트는 보통 예제와 같이 SpringApplication.run() 한줄로 시작한다.

 

이제 본격적으로 스프링 부트를 사용해보자

 

 

 

스프링 부트와 웹 서버 - 프로젝트 생성

 

스프링 부트는 지금까지 고민한 문제를 깔끔하게 해결해준다.

 

- 내장 톰캣을 사용해서 빌드와 배포를 편리하게 한다.

 

- 빌드시 하나의 Jar를 사용하면서, 동시에 Fat Jar 문제도 해결한다.

 

- 지금까지 진행한 내장 톰캣 서버를 실행하기 위한 복잡한 과정을 모두 자동으로 처리한다.

 

 

스프링 부트로 프로젝트를 만들어보고 스프링 부트가 어떤 식으로 편리한 기능을 제공하는지 하나씩 알아보자.

 

 

 

 

내장 톰캣 의존관계 확인

spring-boot-starter-web 를 사용하면 내부에서 내장 톰캣을 사용한다.

 

build.gradle

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'

라이브러리 의존관계를 따라가보면 내장 톰캣( tomcat-embed-core )이 포함된 것을 확인할 수 있다

 

 

라이브러리 버전

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
}

스프링 부트를 사용하면 라이브러리 뒤에 버전 정보가 없는 것을 확인할 수 있다.

스프링 부트는 현재 부트 버전에 가장 적절한 외부 라이브러리 버전을 자동으로 선택해준다.

(이 부분에 대 한 자세한 내용은 뒤에서 다룬다.)

 

 

 

 

스프링 부트와 웹 서버 - 실행 과정

@SpringBootApplication
public class BootApplication {

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

}

 

스프링 부트를 실행할 때는 자바 main() 메서드에서 SpringApplication.run() 을 호출해주면 된다.

 

여기에 메인 설정 정보를 넘겨주는데 보통 @SpringBootApplication 애노테이션이 있는 현재 클래스를 지정해주면 된다.

 

참고로 현재 클래스에는 @SpringBootApplication 애노테이션이 있는데,

이 애노테이션 안에는 컴포넌트 스캔을 포함한 여러 기능이 설정되어 있다.

기본 설정은 현재 패키지와 그 하위 패키지 모두를 컴포넌트 스캔 한다.

 

 

이 단순해 보이는 코드 한줄 안에서는 수 많은 일들이 발생하지만 핵심은 2가지다.

 

1. 스프링 컨테이너를 생성한다.

 

2. WAS(내장 톰캣)를 생성한다.

 

이전시간에 했던 다음과 같은 코드를 스프링부트가 대신해주고 있는것이다.

public class EmbedTomcatSpringMain {
    public static void main(String[] args) throws LifecycleException {
        System.out.println("EmbedTomcatSpringMain.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 dispatcherServlet = new DispatcherServlet(appContext);

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


    }
}

 

스프링 부트 내부에서 스프링 컨테이너를 생성하는 코드

 

 

org.springframework.boot.web.servlet.context.ServletWebServerApplicationContextFactory

 

class ServletWebServerApplicationContextFactory implements ApplicationContextFactory {

   .......

    private ConfigurableApplicationContext createContext() {
        return (ConfigurableApplicationContext)(!AotDetector.useGeneratedArtifacts() ? new AnnotationConfigServletWebServerApplicationContext() : new ServletWebServerApplicationContext());
    }
}

 

new AnnotationConfigServletWebServerApplicationContext()

부분이 바로 스프링 부트가 생성하는 스프링 컨테이너이다.

 

이름 그대로 애노테이션 기반 설정이 가능하고, 서블릿 웹 서버를 지원하는 스프링 컨테이너이다.

 

 

 

스프링 부트 내부에서 내장 톰캣을 생성하는 코드

org.springframework.boot.web.embedded.tomcat.TomcatServletWebServerFactory

 

@Override
public WebServer getWebServer(ServletContextInitializer... initializers) {
 ...
Tomcat tomcat = new Tomcat();
 ...
Connector connector = new Connector(this.protocol);
 ...
return getTomcatWebServer(tomcat);
}

Tomcat tomcat = new Tomcat() 으로 내장 톰캣을 생성한다

 

그리고 어디선가 내장 톰캣에 디스패처 서블릿을 등록하고, 스프링 컨테이너와 연결해서 동작할 수 있게 한다.

 

어디서 많이 본 것 같지 않은가?

 

스프링 부트도 우리가 앞서 내장 톰캣에서 진행했던 것과 동일한 방식으로 스프링 컨테이너를 만들고,

내장 톰캣을 생성 하고 그 둘을 연결하는 과정을 진행한다.

 

 

더보기

참고

스프링 부트는 너무 큰 라이브러리이기 때문에 스프링 부트를 이해하기 위해

모든 코드를 하나하나 파보는 것은 추천하지 않는다.

스프링 부트가 어떤 식으로 동작하는지 개념을 이해하고, 꼭 필요한 부분의 코드를 확인하자.

 

 

지금까지 스프링 부트가 어떻게 톰캣 서버를 내장해서 실행하는지 스프링 부트의 비밀 하나를 풀어보았다.

다음에는 스프링 부트의 빌드와 배포 그리고 스프링 부트가 제공하는 jar 의 비밀을 알아보자.

 

 

 

 

스프링 부트와 웹 서버 - 빌드와 배포

내장 톰캣이 포함된 스프링 부트를 직접 빌드해보자.

 

 

jar 빌드
./gradlew clean build

[윈도우OS]
gradlew clean build

다음 위치에 jar 파일이 만들어진다.
build/libs/boot-0.0.1-SNAPSHOT.jar

 

 

jar 파일 실행

 

jar 파일이 있는 폴더로 이동한 후에 다음 명령어로 jar 파일을 실행해보자.

 

java -jar boot-0.0.1-SNAPSHOT.jar

 

스프링 부트 애플리케이션이 실행되고, 내장 톰캣이 8080 포트로 실행된 것을 확인할 수 있다.

 

컨트롤러도 잘 호출된다.

 

 

스프링 부트 jar 분석

 

boot-0.0.1-SNAPSHOT.jar 파일 크기를 보면 대략 18M 정도 된다.

 

참고로 버전에 따라서 용량은 변할 수 있다.

 

아마도 앞서 배운 Fat Jar와 비슷한 방식으로 만들어져 있지 않을까?

생각될 것이다.

비밀을 확인하기 위해 jar 파일의 압축을 풀어보자.

 

jar 압축 풀기
build/libs 폴더로 이동하자.

다음 명령어를 사용해서 압축을 풀자
jar -xvf boot-0.0.1-SNAPSHOT.jar


JAR를 푼 결과

boot-0.0.1-SNAPSHOT.jar
	├── META-INF
	│	└── MANIFEST.MF
	├── org/springframework/boot/loader
	│	└── JarLauncher.class : 스프링 부트 main() 실행 클래스
	├── BOOT-INF
	│	├── classes : 우리가 개발한 class 파일과 리소스 파일
	│	│	└── hello/boot/BootApplication.class
	│	│	└── hello/boot/controller/HelloController.class
	│	│	└── …
	│	├── lib : 외부 라이브러리
	│	│	└── spring-webmvc-6.0.4.jar
	│	│	└── tomcat-embed-core-10.1.5.jar
	│	│	└── ...
	│	├── classpath.idx : 외부 라이브러리 경로
	│	└── layers.idx : 스프링 부트 구조 경로

 

 

JAR를 푼 결과를 보면 Fat Jar가 아니라 처음보는 새로운 구조로 만들어져 있다.

 

심지어 jar 내부에 jar를 담아서 인식 하는 것이 불가능한데, jar가 포함되어 있고, 인식까지 되었다.

 

지금부터 스프링 부트가 제공하는 jar에 대해서 알아보자.

 

 

 

더보기

참고

빌드 결과를 보면 boot-0.0.1-SNAPSHOT-plain.jar 파일도 보이는데,

이것은 우리가 개발한 코드만 순수한 jar로 빌드한 것이다. 무시하면 된다.

 

 

 

스프링 부트 실행 가능 Jar

 

Fat Jar는 하나의 Jar 파일에 라이브러리의 클래스와 리소스를 모두 포함했다.

 

그래서 실행에 필요한 모든 내용을 하나 의 JAR로 만들어서 배포하는 것이 가능했다.

 

하지만 Fat Jar는 다음과 같은 문제를 가지고 있다.

 

 

Fat Jar의 단점

 

어떤 라이브러리가 포함되어 있는지 확인하기 어렵다.

- 모두 class 로 풀려있으니 어떤 라이브러리가 사용되고 있는지 추적하기 어렵다.

 

파일명 중복을 해결할 수 없다

- 클래스나 리소스 명이 같은 경우 하나를 포기해야 한다. 이것은 심각한 문제를 발생한다.

예를 들어서 서블릿 컨테이너 초기화에서 학습한 부분을 떠올려 보자.

META-INF/services/jakarta.servlet.ServletContainerInitializer 이 파일이 여러 라이브러리( jar )에 있을 수 있다.

A 라이브러리와 B 라이브러리 둘다 해당 파일을 사용해서 서블릿 컨테이너 초기화를 시도한다.

둘다 해당 파일을 jar안에 포함한다.

Fat Jar를 만들면 파일명이 같으므로 A , B 둘중 하나의 파일만 선택된다.

결과적으로 나머지는 정상 동작하지 않는다

 

 

 

실행 가능 Jar

 

스프링 부트는 이런 문제를 해결하기 위해 jar 내부에 jar를 포함할 수 있는 특별한 구조의 jar를 만들고

동시에 만든 jar를 내부 jar를 포함해서 실행할 수 있게 했다.

 

이것을 실행 가능 Jar(Executable Jar)라 한다.

 

이 실행 가능 Jar를 사용 하면 다음 문제들을 깔끔하게 해결할 수 있다.

 

문제: 어떤 라이브러리가 포함되어 있는지 확인하기 어렵다.

해결: jar 내부에 jar를 포함하기 때문에 어떤 라이브러리가 포함되어 있는지 쉽게 확인할 수 있다.

 

문제: 파일명 중복을 해결할 수 없다.

해결: jar 내부에 jar를 포함하기 때문에 a.jar , b.jar 내부에 같은 경로의 파일이 있어도 둘다 인식할 수 있다.

 

 

참고로 실행 가능 Jar는 자바 표준은 아니고, 스프링 부트에서 새롭게 정의한 것이다.

 

지금부터 실행 가능 Jar를 자세히 알아보자.

 

 

실행 가능 Jar 내부 구조

 

boot-0.0.1-SNAPSHOT.jar
	├── META-INF
	│	└── MANIFEST.MF
	├── org/springframework/boot/loader
	│	└── JarLauncher.class : 스프링 부트 main() 실행 클래스
	├── BOOT-INF
	│	├── classes : 우리가 개발한 class 파일과 리소스 파일
	│	│	└── hello/boot/BootApplication.class
	│	│	└── hello/boot/controller/HelloController.class
	│	│	└── …
	│	├── lib : 외부 라이브러리
	│	│	└── spring-webmvc-6.0.4.jar
	│	│	└── tomcat-embed-core-10.1.5.jar
	│	│	└── ...
	│	├── classpath.idx : 외부 라이브러리 경로
	│	└── layers.idx : 스프링 부트 구조 경로

 

 

Jar 실행 정보

java -jar xxx.jar 를 실행하게 되면 우선 META-INF/MANIFEST.MF 파일을 찾는다.

그리고 여기에 있는 Main-Class 를 읽어서 main() 메서드를 실행하게 된다.

스프링 부트가 만든 MANIFEST.MF 를 확인해보자.

 

 

- Main-Class
우리가 기대한 main() 이 있는 hello.boot.BootApplication 이 아니라 
JarLauncher 라는 전혀 다른 클래스를 실행하고 있다.
JarLauncher 는 스프링 부트가 빌드시에 넣어준다.
org/springframework/boot/loader/JarLauncher에 실제로 포함되어 있다.
스프링 부트는 jar 내부에 jar를 읽어들이는 기능이 필요하다.
또 특별한 구조에 맞게 클래스 정보도 읽어들여야 한다. 
바로 JarLauncher가 이런 일을 처리해준다. 
이런 작업을 먼저 처리한 다음 StartClass: 에 지정된 main() 을 호출한다.


- Start-Class 
우리가 기대한 main() 이 있는 hello.boot.BootApplication 가 적혀있다.


- 기타
스프링 부트가 내부에서 사용하는 정보들이다.
Spring-Boot-Version : 스프링 부트 버전
Spring-Boot-Classes : 개발한 클래스 경로
Spring-Boot-Lib : 라이브러리 경로
Spring-Boot-Classpath-Index : 외부 라이브러리 모음
Spring-Boot-Layers-Index : 스프링 부트 구조 정보


- 참고
Main-Class를 제외한 나머지는 자바 표준이 아니다. 
스프링 부트가 임의로 사용하는 정보이다.

 

 

스프링 부트 로더 [ Spring Boot Loader ]

 

org/springframework/boot/loader 하위에 있는 클래스들이다.

JarLauncher를 포함한 스프링 부트가 제공하는 실행 가능 Jar를 실제로 구동시키는 클래스들이 포함되어 있다.

스프링 부트는 빌드시에 이 클래스들을 포함해서 만들어준다

 

 

BOOT-INF

 

classes : 우리가 개발한 class 파일과 리소스 파일

 

lib : 외부 라이브러리

 

classpath.idx : 외부 라이브러리 모음

 

layers.idx : 스프링 부트 구조 정보

 

WAR구조는 WEB-INF 라는 내부 폴더에 사용자 클래스와 라이브러리를 포함하고 있는데,

실행 가능 Jar도 그 구조를 본따서 만들었다. 이름도 유사하게 BOOT-INF 이다.

 

JarLauncher 를 통해서 여기에 있는 classes 와 lib 에 있는 jar 파일들을 읽어들인다.

 

 

 

실행 과정 정리

1. java -jar xxx.jar

2. MANIFEST.MF 인식

3. JarLauncher.main() 실행

--> BOOT-INF/classes/ 인식
--> BOOT-INF/lib/ 인식


4. BootApplication.main() 실행

 

 

 

 

더보기

참고

 

실행 가능 Jar가 아니라, IDE에서 직접 실행할 때는 BootApplication.main() 을 바로 실행한다.

 

IDE가 필요한 라이브러리를 모두 인식할 수 있게 도와주기 때문에 JarLauncher가 필요하지 않다.

 

[ IDE를 이용하여 서버 실행시jarlaucnher 필요 x , 명령어를 통해 직접 jar 실행시 jarLauncher 필요 o ]

 

댓글