스프링 부트에서 초기화 코드를 넣는 3가지 방법
- 이 포스트는 토비님의 유튜브 강의를 보고 내용을 정리한 포스트입니다. (출처 : https://www.youtube.com/watch?v=f017PD5BIEc)
- 위의 영상을 보면서 같이 공부하기 어려운 경우(시간이 없거나 집중하기 어려운 경우..?)에 제 글을 가볍게 참고하시면 좋을 것 같습니다.
배경
스프링부트 애플리케이션이 시작할 때 백그라운드에서 굉장히 많은 스프링 빈(Bean)들이 만들어지고 그 외에 스프링 컨테이너가 초기화하는 과정이 진행된다.
여기서 하고자 하는 것은 앞서 언급한 모든 작업을 마치고 나서 "초기화 코드"를 넣어야 하는 경우에 어떤 방법이 있을까에 대한 것이다.
내가 만든 스프링 부트 애플리케이션이 정상적으로 실행이 완료되면, 모니터링 애플리케이션에게 정상적으로 실행됐다고 메세지를 보내는 작업이 하나의 예다.
스프링 부트에서는 위와 같은 문제를 해결하기 위해 제공하는 기능? 방법이 3가지가 있다.
초기화 코드를 넣는 3가지 방법
아주 간단하게는 다음과 같이 코드를 넣을 수도 있다.
@SpringBootApplication
public class SpringinitApplication {
public static void main(String[] args) {
SpringApplication.run(SpringinitApplication.class, args);
System.out.println("Hello Spring Boot Application");
}
}
우리가 스프링부트 애플리케이션을 만들었을 때 나오는 그 코드에 앞 또는 뒤에 어떤 작업을 넣는 것이다. (위 코드에서는 단순히 문자열을 출력하는 것으로 했다.)
그런데 "이것이 틀린 방법이다"라고 하기에는 좀 그렇지만 약간의 문제가 있다고 볼 수 있다.
일반적인 경우의 스프링 부트 애플리케이션에서는 이미 생성한 빈들(ex. repository, service, ...)을 사용하고자 하는데, 저 위치에서 주입 받기가 상당히 불편한 점이 있다.
그래서 좀 더 간편하게 애플리케이션에서 생성한 빈들을 주입받아서 사용하는 방법을 소개한다.
1. CommandLineRunner
@FunctionalInterface
public interface CommandLineRunner {
/**
* Callback used to run the bean.
* @param args incoming main method arguments
* @throws Exception on error
*/
void run(String... args) throws Exception;
}
CommandLineRunner 인터페이스를 구현한 클래스를 스프링 빈으로 등록하게되면, 스프링이 초기화 작업을 마치고 나서 해당 클래스의 run(String... args) 메서드를 실행시켜주는 방법이다.
@SpringBootApplication
public class SpringinitApplication {
public static void main(String[] args) {
SpringApplication.run(SpringinitApplication.class, args);
}
}
@Component //component scanning에 의한 방식으로 빈을 등록하는 방법
class MyCLRunner implements CommandLineRunner{
@Override
public void run(String... args) throws Exception {
System.out.println("Hello CommandLineRunner");
}
}
CommandLineRunner 인터페이스를 구현한 MyCLRunner라는 클래스를 만들었다.
그 후 @Component 즉, scanning 방식에 의해서 빈을 등록해주는 방식으로 등록하고 실행하면 결과는 다음과 같다.
만약 @Component 스캔에 의해서 빈을 등록하고 싶지 않고 다른 방법을 활용한다면 @Bean annotation을 활용하는 방법도 있다.
@SpringBootApplication
public class SpringinitApplication {
public static void main(String[] args) {
SpringApplication.run(SpringinitApplication.class, args);
}
@Bean
public CommandLineRunner myCLRunner(){
return new MyCLRunner();
}
}
class MyCLRunner implements CommandLineRunner{
@Override
public void run(String... args) throws Exception {
System.out.println("Hello CommandLineRunner");
}
}
@Bean annotation의 경우 원래 @Configuration annotation이 적용되어 있는 클래스에서 메서드에 @Bean을 사용하여 객체을 생성해서 리턴해주면 리턴되는 객체가 빈으로 등록되는 방식인데, 최신 버전에서는 @Configuration annotation 없이 일반적인 클래스에서 가능하다고 한다. (정확하게는 알아보고 사용할 것.)
그리고 또 다른 방법으로 MyCLRunner 클래스는 여기서 딱 한 번만 사용될 클래스기 때문에 그냥 "익명 클래스"를 사용하는 방법도 있다.
@SpringBootApplication
public class SpringinitApplication {
public static void main(String[] args) {
SpringApplication.run(SpringinitApplication.class, args);
}
@Bean
public CommandLineRunner myCLRunner() {
return new CommandLineRunner() {
@Override
public void run(String... args) throws Exception {
System.out.println("Hello Anonymous CommandLineRunner!!!");
}
};
}
}
위의 방법이 클래스를 미리 정의하는 방법 대신 익명 클래스를 이용하여 CommandLineRunner 구현체를 생성하고 빈으로 등록하는 방법이다.
→ 여기서 또 쉽게 변경하면 CommandLineRunner가 @FunctionalInterface(추상 메서드가 하나인 인터페이스)이기 때문에 람다(lambda)로 변경할 수 있다.
@SpringBootApplication
public class SpringinitApplication {
public static void main(String[] args) {
SpringApplication.run(SpringinitApplication.class, args);
}
@Bean
public CommandLineRunner myCLRunner() {
return args -> System.out.println("Hello Lambda CommandLineRunner!!");
}
}
이제 CommandLineRunner 사용 방법은 대충 알았다.
그런데 배경에서 언급한 기능(요구사항)을 처리하려면 CommandLineRunner에서 다른 빈(Bean)을 주입받는 방법도 알아야 한다.
@Bean
public CommandLineRunner myCLRunner(MyController myController) {
return args -> myController.hello();
}
그 방법은 위 코드처럼 메서드의 파라미터로 주입받으면 된다. (MyController는 별도로 만든 임의의 Bean이고 코드는 공간상 생략했다.)
2. ApplicationRunner
@FunctionalInterface
public interface ApplicationRunner {
/**
* Callback used to run the bean.
* @param args incoming application arguments
* @throws Exception on error
*/
void run(ApplicationArguments args) throws Exception;
}
ApplicationRunner의 사용법은 앞서 배운 CommandLineRunner의 사용법과 다르지 않다.
CommandLineRunner와 ApplicationRunner의 차이점은 받는 arguments가 다르다는 것 뿐이다.
굳이 둘 중에 어떤 걸 써야할까 싶으면 ApplicationRunner를 쓰도록 하자(이유는 CommandLineRunner보다는 최신에 만들어 졌기 때문이다)
@SpringBootApplication
public class SpringinitApplication {
public static void main(String[] args) {
SpringApplication.run(SpringinitApplication.class, args);
}
@Bean
public ApplicationRunner myApplicationRunner() {
return args -> System.out.println("Hello ApplicationRunner");
}
}
3. @EventListener
@SpringBootApplication
public class SpringinitApplication {
public static void main(String[] args) {
SpringApplication.run(SpringinitApplication.class, args);
}
@EventListener(ApplicationReadyEvent.class)
public void init(){
System.out.println("Hello EventListener!! ApplicationReadyEvent");
}
}
훨씬 더 간단하고 최신에 나온 방법이다.
@EventListener annotation을 달고 어떤 이벤트가 발생하면 이 메서드를 실행시켜줄지만 적어주면 된다.
여기서는 애플리케이션이 정상적으로 온전히 실행되었을 때 즉, 준비 되었을 때를 나타내는 "ApplicationReadyEvent"를 EventListener에 등록하는 방법이다.
되도록이면 최근에 나오고 깔끔한 코딩을 할 수 있는 @EventListener를 사용하도록 해보자.
EventListener 심화 학습
어떤 매커니즘으로 진행되는지를 조금 더 학습해본다.
@SpringBootApplication
public class SpringinitApplication {
public static void main(String[] args) {
//ConfigurableApplicationContext : ApplicationContext를 코드에 의해서 재구성할 수 있도록 기능을 집어넣은 ApplicationContext.
ConfigurableApplicationContext ac = SpringApplication.run(SpringinitApplication.class, args);
ac.addApplicationListener(new ApplicationListener<MyEvent>() {
@Override
public void onApplicationEvent(MyEvent event) {
System.out.println("Hello applicationEvent : " + event.getMessage());
}
});
ac.publishEvent(new MyEvent(ac,"My Spring Event"));
}
static class MyEvent extends ApplicationEvent {
private final String message;
//원래 String message는 없는 건데 추가한 것임.
public MyEvent(Object source, String message) {
super(source);
this.message = message;
}
public String getMessage(){
return message;
}
}
}
ApplicationListener 직접 사용해보기
원래 ApplicationContext에 void addApplicationListener(ApplicationListener<?> listener); 메서드가 있다.
ApplicationListener 인터페이스를 구현한 리스너를 다 등록할 수가 있는 것이다.
예제를 간략히 설명해보면, ConfigurableApplicationContext는 스프링의 빈을 다 가지고 있는 메인 컨테이너인데 이 applicationContext에 publishEvent(ApplicationEvent) 메서드를 사용해서 이벤트를 발생시키면, 해당 이벤트 타입을 Listening하고 있는 Listener들 모두에게 전달해준다.
그래서 MyEvent라는 커스텀 이벤트를 만들었고 해당 이벤트를 발생시켜 리스너가 이벤트를 감지하는 것까지 구현한 것이다.
익명의 ApplicationListener를 생성할 때 또 @FunctionalInterface인 onApplicationEvent 메서드만 있으니까 이것 또한 다음 코드와 같이 람다로 변경할 수 있다.
@SpringBootApplication
public class SpringinitApplication {
public static void main(String[] args) {
//ConfigurableApplicationContext : ApplicationContext를 코드에 의해서 재구성할 수 있도록 기능을 집어넣은 ApplicationContext.
ConfigurableApplicationContext ac = SpringApplication.run(SpringinitApplication.class, args);
ac.addApplicationListener((ApplicationListener<MyEvent>)event-> System.out.println("Hello ApplicationListener : " + event.getMessage()));
ac.publishEvent(new MyEvent(ac,"My Spring Event"));
}
static class MyEvent extends ApplicationEvent {
private final String message;
//원래 String message는 없는 건데 추가한 것임.
public MyEvent(Object source, String message) {
super(source);
this.message = message;
}
public String getMessage(){
return message;
}
}
}
이벤트 처리 방식의 장점
이런 이벤트 처리 방식의 이점은 빈(Bean)들간의 결합을 끊어버릴 수 있다는 것이다!
만약 회원 가입을 했을 때, 다른 빈에서 어떤 처리를 하고 싶다고 가정해보자.
그럴 때 회원 가입을 담당하는 모듈(또는 Bean)은 회원 가입을 시키고 나서 알림을 줘야하는 다른 A, B, C등의 빈을 주입받아 메서드를 호출할 수도 있지만, 그렇게 하지 않고 회원가입 모듈은 회원 가입이 완료됐다는 이벤트를 발생시키만 하고, 다른 A, B, C의 빈은 리스너를 구현하는 방식으로 처리하면 빈들간의 결합을 낮출 수 있고 더 깔끔할 수도 있다는 장점이 있다. (먼 훗날 서버를 분리한다고 해도 깔끔하게 분리할 수 있다.)
위 코드는 applicationListener를 만들어서 하는 방법이고 스프링 4.2버전 이상이라면, 그냥 아까 배운 @EventListener를 사용하는게 더 깔끔하다.
@SpringBootApplication
public class SpringinitApplication {
public static void main(String[] args) {
//ConfigurableApplicationContext : ApplicationContext를 코드에 의해서 재구성할 수 있도록 기능을 집어넣은 ApplicationContext.
ConfigurableApplicationContext ac = SpringApplication.run(SpringinitApplication.class, args);
ac.publishEvent(new MyEvent(ac,"My Spring Event"));
}
@EventListener(MyEvent.class)
public void onMyEvent(MyEvent myEvent){
System.out.println("Hello EventListener : " + myEvent.getMessage());
}
static class MyEvent extends ApplicationEvent {
private final String message;
//원래 String message는 없는 건데 추가한 것임.
public MyEvent(Object source, String message) {
super(source);
this.message = message;
}
public String getMessage(){
return message;
}
}
}
만약 @EventListener가 여러 개 있으면 어떨까? 기본적으로 멀티캐스트 즉, listen하고 있는 모든 listener에게 다 전달되기 때문에 여러 번 실행될 것이다.
그러면 여러 개의 이벤트가 와도 같은 메서드가 실행되야 할 때는 어떻게 할까?
@EventListener는 기본적으로 이벤트 클래스를 여러개 받게 되어있다. 그렇기 때문에 아래처럼 하면 된다.
@EvnetListener({MyEvent.class, ApplicationReadyEvent.class})
어떤 ApplicationEvent들이 있을까?
앞에서는 간단하게 EventListener의 매커니즘에 대해서 살짝 공부를 한 것이고, 원래 의도대로 스프링의 어떤 이벤트가 발생했을 때(ex. ApplicationReadyEvent)의 동작을 위한 공부를 더 해보자.
고로 어떤 ApplicationEvent들이 존재하는지 알아본다.
- ApplicationContextInitializedEvent
- ApplicationEnvironmentPreparedEvent
- ApplicationPreparedEvent
- ApplicationReadyEvent
- ApplicationStartedEvent
- ApplicationFailedEvent
- ApplicationStartingEvent
어떤 이벤트가 언제 발생하는지는 코드에서 설명을 읽어보면 좋을 것 같다.
조금 더 공부하고 싶다면...
스프링부트가 언제 어떻게 이벤트가 발생하는지가 궁금하면 코드를 좀 분석을 해봐야한다.
public ConfigurableApplicationContext run(String... args) {
StopWatch stopWatch = new StopWatch();
stopWatch.start();
ConfigurableApplicationContext context = null;
Collection<SpringBootExceptionReporter> exceptionReporters = new ArrayList<>();
configureHeadlessProperty();
SpringApplicationRunListeners listeners = getRunListeners(args);//스프링부트가 기본적으로 사용하는 리스너들 등록
listeners.starting();//starting과 관련된 이벤트를 호출(ApplicationStartingEvent)
try {
ApplicationArguments applicationArguments = new DefaultApplicationArguments(args);//arguments 가져오고
ConfigurableEnvironment environment = prepareEnvironment(listeners, applicationArguments);//환경정보 가져오고
configureIgnoreBeanInfo(environment);
Banner printedBanner = printBanner(environment);//배너 찍고
context = createApplicationContext();//context 생성하고
exceptionReporters = getSpringFactoriesInstances(SpringBootExceptionReporter.class,
new Class[] { ConfigurableApplicationContext.class }, context);//스프링 내부에서 사용하는 구성정보를 들고오고
prepareContext(context, environment, listeners, applicationArguments, printedBanner);//가져온 것들로 컨텍스트 준비하고
refreshContext(context);//준비가 끝나면 리프레시
afterRefresh(context, applicationArguments);//리프레시 후에 뭔가 해야할 것들 호출하고
stopWatch.stop();
if (this.logStartupInfo) {
new StartupInfoLogger(this.mainApplicationClass).logStarted(getApplicationLog(), stopWatch);
}
listeners.started(context);//ApplicationStartedEvent 발생하는 곳
callRunners(context, applicationArguments);//ApplicationRunner, CommandLineRunner 실행해주는 곳
}
catch (Throwable ex) {
handleRunFailure(context, ex, exceptionReporters, listeners);//ApplicationFailedEvent
throw new IllegalStateException(ex);
}
try {
listeners.running(context);//running과 관련된 이벤트
}
catch (Throwable ex) {
handleRunFailure(context, ex, exceptionReporters, null);
throw new IllegalStateException(ex);
}
return context;
}
위 코드는 SpringApplication.run()하면 수행하면 핵심이자 뼈대같은 코드다. 주석으로 대략적인 설명을 한다.
ApplicationRunner와 CommandLineRunner 실행 순서
아래 코드는 위에 callRunners(context, applicationArguments);부분이다.
private void callRunners(ApplicationContext context, ApplicationArguments args) {
List<Object> runners = new ArrayList<>();//순서가 있는 List를 사용한다.
runners.addAll(context.getBeansOfType(ApplicationRunner.class).values());//ApplicationRunner먼저
runners.addAll(context.getBeansOfType(CommandLineRunner.class).values());//CommandLineRunner를 나중에
AnnotationAwareOrderComparator.sort(runners);//@Order를 기준으로 정렬 한 번 하고
for (Object runner : new LinkedHashSet<>(runners)) {
if (runner instanceof ApplicationRunner) {
callRunner((ApplicationRunner) runner, args);
}
if (runner instanceof CommandLineRunner) {
callRunner((CommandLineRunner) runner, args);
}
}
}
여기서 보면 ApplicationRunner를 먼저 넣고 후에 CommandLineRunner를 넣기 때문에 아마 ApplicationRunner가 먼저 실행되지 않을까 할 수 있다.
그런데 그 다음에 바로 @Order annotation에 의한 정렬을 한 번 하기 때문에 꼭 ApplicationRunner 다음에 CommandLineRunner가 실행된다고 할 순 없다.
대신 Order로 따로 순서를 안 줬다면 ApplicationRunner가 CommandLineRunner보다 일찍 실행될 것이라고 예측해볼 수 있다.