[Spring] 최초 실행 시 동작하는 로직을 정의해보자

도입

서비스를 운영하다보면, 다양한 이유로 애플리케이션의 구동 시점에 단 한 번만 실행되어야 하는 로직을 정의할 필요성을 느끼게 될 수도 있다. 최초 실행 시 긴 시간을 요구하는 로직들을 사용자에게 빠르게 제공하기 위한 Warm up 과정이나, 실행 시점에 입력된 동적 환경변수를 기반으로 제어되는 로직의 관리 등이 이에 해당한다.

그렇다면, 이러한 로직들은 어떻게/어디에 정의해야 할까? 이 글에서는 그 방법을 3가지로 분류하여 소개한다.

  • Bean 생명주기를 이용
  • Runner를 이용
  • Event를 이용

 

 

Bean 생명주기를 이용

생성자

@Slf4j
@Component
public class MyBean {

    private final RequiredBean requiredBean;

    @Value("${hello.world}")
    private String helloWorld;

    @Autowired
    public MyBean(RequiredBean requiredBean) {
        this.requiredBean = requiredBean;
        log.info("[constructor] helloWorld: {}", helloWorld); // null
    }

}

Spring 프로그램이 시작되면, ApplicationContext를 초기화하는 과정에서 필요한 Bean을 생성해 등록하는 과정이 이루어진다. 이 때 Bean 객체의 생성을 위해 호출되는 메서드가 있다. 바로 생성자이다. 이 생성자 안에 특정 로직을 정의함으로써 최초 1회 실행 로직을 정의할 수 있다.

하지만 생성자를 통해 로직을 수행하고자 할 때에는 아래의 사항에 주의해야 한다.

  1. 생성자가 호출되는 시점에는 아직 ApplicationContext의 초기화 과정이 완전히 이루어지지 않았기 때문에, 일부 데이터가 주입되지 않은 상태일 수 있다. 특히 환경변수 등록이 아직 이루어지지 않았기 때문에, 환경변수가 주입되는 필드는 모두 null인 상태임에 주의해야 한다.
  2. Spring Bean Scope를 어떻게 설정하냐에 따라 생성자는 여러 번 호출될 수 있다. Bean의 기본 scope가 singleton일 뿐이지 Bean == Singleton이 아니기 때문이다. 따라서 ‘최초 1회’ 수행되어야 하는 로직이라면 해당 Bean을 Singleton으로 관리하도록 유의하거나 다른 방법을 이용해야 한다.

@PostConstruct

@Slf4j
@Component
public class MyBean {

    private final RequiredBean requiredBean;

    @Value("${hello.world}")
    private String helloWorld;

    @Autowired
    public MyBean(RequiredBean requiredBean) {
        this.requiredBean = requiredBean;
        log.info("[constructor] helloWorld: {}", helloWorld); // null
    }

    @PostConstruct
    void init() {
        log.info("[@PostConstruct] helloWorld: {}", helloWorld); // real value
    }

}

@PostConstruct는 ApplicationContext 초기화가 완전히 이루어진 후 호출되는 로직이다. 따라서, 모든 데이터 주입이 완료된 상태로 최초 로직을 수행할 수 있다.

하지만, 생성자 방식과 마찬가지로 동일 타입 Bean이 여러 개 존재할 수 있는 prototype과 같은 scope가 지정된 Bean이라면 @PostConstruct 메서드도 여러 번 실행될 수 있음에 주의하자.

 

 

Runner를 이용

CommandLineRunner

@Slf4j
@Component
public class MyRunner implements CommandLineRunner {

    @Value("${hello.world}")
    private String helloWorld;

    @Override
    public void run(String... args) throws Exception {
        log.info("[CommandLineRunner] helloWorld: {}", helloWorld); // real value
    }
}

CommandLineRunner를 구현하면 프로그램의 실행 시점에 커맨드라인 인자로 전달한 값이 제공되는 메서드를 오버라이딩하게 된다. 이러한 Runner 또한 최초 실행 시 단 한 번만 호출되며, ApplicationContext가 완전히 초기화된 후 호출되므로 모든 Bean 및 환경변수를 사용할 수 있다.

ApplicationRunner

@Slf4j
@Component
public class MyRunner implements ApplicationRunner {

    @Value("${hello.world}")
    private String helloWorld;

    @Override
    public void run(ApplicationArguments args) throws Exception {
        log.info("[ApplicationRunner] helloWorld: {}", helloWorld);
    }
}

ApplicationRunner 또한 CommandLineRunner와 동일하게 실행 시점에 인자로 전달된 변수들에 접근할 수 있으며, 프로그램 실행 과정에서 단 한 번만 호출된다.

ApplicationRunner는 CommandLineRunner와 달리 인자가 ApplicationArguments라는 클래스로 wrapping된 형태로 제공되며, 이 클래스에서는 인자들을 다루기 위한 메서드가 정의되어 있다.

 

 

Event를 이용

이 글에서는 Event의 정의와 사용법을 설명하지는 않는다. 만약 Spring Event라는 개념이 생소하다면 아래 포스팅을 먼저 읽을 것을 권장한다.

 

Spring Event 도입기 (1) - 핵심 로직을 변경으로부터 보호하라

핵심 로직 변경에 따른 문제 발생과 해결 문제 - 가독성 저하 현재 사내에서 운영하는 서비스에는 사용자의 요청을 받으면 엔티티를 생성해 저장하고 메시지 큐에 발행한다라는 핵심 로직이 존

devpanpan.tistory.com

Spring 애플리케이션이 실행되는 과정에서, 다양한 Spring Event가 발행된다. 이들을 구독하는 이벤트 리스너를 등록함으로써 최초 실행 로직을 처리할 수도 있다.

정확히 어떤 이벤트들이 발행되는지 알고 싶다면 SpringApplicationEvent 클래스를 상속하는 클래스들을 확인하면 된다. 이 글에서는 내가 주로 사용하는 2가지 이벤트에 대해서만 소개한다.

ApplicationStartedEvent

@Slf4j
@Component
public class MyEventListener {

    @EventListener
    public void listen(ApplicationStartedEvent event) {
        log.info("[ApplicationStartedEvent] call: {}", event);
    }

}

ApplicationStartedEvent는 ApplicationContext의 초기화가 모두 이루어진 후 발행되는 이벤트로, 모든 Bean과 환경변수를 이용할 수 있다.

이 이벤트 객체로부터는 아래와 같은 정보를 획득할 수 있다.

  1. 초기화가 완료된 context
  2. 초기화에 소요된 시간 정보

ApplicationReadyEvent

@Slf4j
@Component
public class MyEventListener {

    @EventListener
    public void listen(ApplicationReadyEvent event) {
        log.info("[ApplicationStartedEvent] call: {}", event);
    }

}

ApplicationStartedEvent의 발행 시점 또한 ApplicationContext가 완전히 초기화된 이후 시점이다. started event와 read event의 차이점은, read event의 경우 Runner가 모두 호출된 후 발행되는 이벤트라는 점이다.

이 이벤트 객체도 마찬가지로 초기화가 완료된 context, 초기화에 소요된 시간 정보를 저장한다.

 

 

위에서 소개한 방법들의 호출 순서

위에서 소개한 방법들을 혼합하여 사용하는 경우, 이들 간 호출 순서가 중요하게 작용할 수도 있다. 따라서, 이번 단락에서는 이들 간의 호출 순서를 알아보고, 동일한 방법으로 구현한 로직 간의 순서를 다루는 방법에 대해 살펴본다.

호출 순서

화살표는 클래스 간 의존 관계를 나타내는 것이 아니라, 호출되는 시간적 순서를 나타내는 것이다.

 

Runner 간 순서 지정

일반적으로 ApplicationRunner가 CoomandLineRunner보다 먼저 실행된다. (라고 알고 있었는데, Spring Boot 3버전 무렵부터 순서가 뒤섞이기 시작했다. 틀린 정보라면 제보 바람.)

Runner 간 순서를 명시적으로 지정하고자 한다면 @Order 어노테이션을 이용하면 된다. @Order 어노테이션에 제공되는 value가 작을 수록 먼저 호출된다.

EventListener 간 순서 지정

Runner와 마찬가지로, 리스너 간 순서를 명시적으로 지정하고자 한다면 @Order 어노테이션을 이용하면 된다.

 

 

유의 사항

예외가 전파되면?

생성자, @PostConstruct, Runner 방식 모두 메인 스레드가 로직을 수행하므로 예외가 전파되면 애플리케이션이 종료된다.

EventListener의 경우 동기 이벤트 리스너로 등록한 경우 메인 스레드가 로직을 수행하므로 애플리케이션이 종료되고, 비동기 리스너로 등록한 경우에는 별도 스레드가 로직을 수행하므로 애플리케이션의 실행 여부에 영향을 미치지 않는다.

언제 무엇을 사용할지?

이 부분은 개인적 의견이 담긴 부분이므로, 가볍게 참고만 하길 권장한다.

  1. 최초 실행 로직이 특정 Bean에 종속되는 부가 로직인 경우, @PostConstruct를 사용한다. 생성자 방식을 이용하면 초기화되지 않은 값에 접근할 위험이 있고, 생성자 내에 너무 복잡한 로직을 정의하거나 다른 메서드를 호출하는 로직을 두는 것은 안티 패턴이라고 생각하기 때문이다.
  2. Bean과 종속 관계를 갖지 않고 최초 실행 로직 자체가 해당 Bean의 단일 책임에 해당하는 로직인 경우, Runner 방식 또는 EventListener 방식 중 편한 쪽을 사용한다. (프로젝트 내에서 Spring Event 사용을 권장하지 않는 룰이 있는 경우 Runner를 이용하면 되고, 반대의 경우도 마찬가지이다.)
  3. 최초 실행 로직을 별도 스레드에서 비동기로 호출하고자 한다면 Runner 또는 EventListener를 @Async와 함께 사용하면 된다.