Spring 기초/Spring 기초 지식

[Spring] ApplicationRunner 활용하기

Stark97 2024. 9. 28. 22:42
반응형
 
 
 

스프링의 ApplicationRunner를 활용해 보자.

📌 서론

스프링 부트(Spring Boot) 애플리케이션에서 ApplicationRunner를 사용하면 애플리케이션이 시작될 때 특정 로직을 실행할 수 있다. 특히 @Configuration 클래스 내에서 @Bean으로 등록하면 필요한 빈들을 주입받아 유연하게 사용할 수 있다.

이번 포스팅에서는 실무에서 바로 적용할 수 있는 몇 가지 예제와 함께 ApplicationRunner의 활용 방법을 자세히 알아보도록 하자.

 

1. ApplicationRunner란 무엇인가?

ApplicationRunner는 스프링 부트 애플리케이션(서버)이 완전히 초기화된 후 실행되는 콜백 인터페이스다. 

이를 구현하면 애플리케이션 시작 시점에 필요한 작업을 수행할 수 있다.

 

ApplicationRunner 인터페이스

  • CommandLineRunner와 유사하지만, ApplicationArguments를 통해 명령줄 인자를 좀 더 편리하게 처리할 수 있다.
package org.springframework.boot;

import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;

/**
 * Interface used to indicate that a bean should <em>run</em> when it is contained within
 * a {@link SpringApplication}. Multiple {@link ApplicationRunner} beans can be defined
 * within the same application context and can be ordered using the {@link Ordered}
 * interface or {@link Order @Order} annotation.
 *
 * @author Phillip Webb
 * @since 1.3.0
 * @see CommandLineRunner
 */
@FunctionalInterface
public interface ApplicationRunner extends Runner {

	/**
	 * Callback used to run the bean.
	 * @param args incoming application arguments
	 * @throws Exception on error
	 */
	void run(ApplicationArguments args) throws Exception;

}

CommandLineRunner 인터페이스

package org.springframework.boot;

import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;

/**
 * Interface used to indicate that a bean should <em>run</em> when it is contained within
 * a {@link SpringApplication}. Multiple {@link CommandLineRunner} beans can be defined
 * within the same application context and can be ordered using the {@link Ordered}
 * interface or {@link Order @Order} annotation.
 * <p>
 * If you need access to {@link ApplicationArguments} instead of the raw String array
 * consider using {@link ApplicationRunner}.
 *
 * @author Dave Syer
 * @since 1.0.0
 * @see ApplicationRunner
 */
@FunctionalInterface
public interface CommandLineRunner extends Runner {

	/**
	 * Callback used to run the bean.
	 * @param args incoming main method arguments
	 * @throws Exception on error
	 */
	void run(String... args) throws Exception;

}

 

 

ApplicationRunner를 사용해 보기 전에
CommandLineRunner와 ApplicationRunner는 어떤 차이점이 있는지 알아보자.


2. CommandLineRunner와 ApplicationRunner의 차이점

ApplicationRunner와 CommandLineRunner는 스프링 부트 애플리케이션에서 애플리케이션이 시작될 때 실행되는 로직을 정의하기 위한 인터페이스로, 매우 유사한 역할을 한다. 하지만 두 인터페이스의 주요 차이점은 '메서드의 매개변수 타입'과 '명령줄 인자' 처리 방식에 있다.

 

CommandLineRunner

void run(String... args) throws Exception;
  • String[] 형태의 명령줄 인자를 그대로 전달받는다.
  • 인자를 직접 파싱해야 하며, 옵션과 비옵션 인자를 구분하려면 추가 로직이 필요하다.

 

ApplicationRunner

void run(ApplicationArguments args) throws Exception;
  • ApplicationArguments 객체를 매개변수로 전달받아 명령줄 인자를 더 구조화되고 편리하게 처리할 수 있다.
  • 옵션 인자와 비옵션 인자를 쉽게 구분하고, 특정 옵션의 존재 여부나 값을 손쉽게 확인할 수 있다.

 

매개변수로 ApplicationArguments를 받는 것의 장점

  • ApplicationArguments는 명령줄 인자를 파싱하고 관리하기 위한 유틸리티 메서드를 제공한다.
    • 옵션 인자: --로 시작하는 인자 (예: --debug, --port=8080)
    • 비옵션 인자: 옵션 인자가 아닌 일반 인자 (예: 파일 이름이나 기타 입력값)

주요 메서드

  • 각 메서드 설명은 코드 위에 주석으로 적어뒀다.
package org.springframework.boot;

import java.util.List;
import java.util.Set;

public interface ApplicationArguments {

    // 원본 인자 배열을 반환한다.
    String[] getSourceArgs();
    
    //모든 옵션 인자의 이름을 반환한다.
    Set<String> getOptionNames();
    
    // 특정 옵션 인자가 존재하는지 확인한다.
    boolean containsOption(String name);

    // 특정 옵션 인자의 값을 반환한다.
    List<String> getOptionValues(String name);

    // 비옵션 인자들을 반환한다.
    List<String> getNonOptionArgs();

}

 

예시를 통한 이해

  • 아래의 명령어를 스프링 프로젝트에서 사용하여 실행해 보자. (인텔리제이 시작버튼을 누르지 말고 명령어로 실행해 보자.)
# 애플리케이션 실행 예시
java -jar myapp.jar --port=8080 --debug filename.txt 

# 실제로 이 명령어를 사용하려면 libs 패키지에 들어가서 명령어를 입력해야 실행 가능하다.
java -jar {스프링 build/libs에서 이름 찾아서 넣기}.jar --port=8080 --debug filename.txt
  • 옵션 인자
    • --port=8080
    • --debug

  • 비옵션 인자
    • filename.txt

 

예시 1. CommandLineRunner를 사용한 경우

  • 개발자가 직접 args를 for문 돌려서 인자를 하나하나 꺼내서 사용해야 한다.
@Component
public class MyCommandLineRunner implements CommandLineRunner {

    @Override
    public void run(String... args) throws Exception {
        // args 배열: ["--port=8080", "--debug", "filename.txt"]
        for (String arg : args) {
            System.out.println(arg);
        }
        // 옵션과 비옵션 인자를 구분하려면 추가 파싱 로직 필요
    }
    
}

결과

[2024-09-28 20:59:13] [INFO ] c.t.l.c.c.DatabaseConnectionChecker - Database connection is OK!
[2024-09-28 20:59:13] [INFO ] c.t.l.c.c.DatabaseConnectionChecker - DataSource class: com.github.gavlyukovskiy.boot.jdbc.decorator.DecoratedDataSource

arg: --port=8080
arg: --debug
arg: filename.txt

[2024-09-28 20:59:13] [DEBUG] o.s.b.a.ApplicationAvailabilityBean - Application availability state ReadinessState changed to ACCEPTING_TRAFFIC

한계점

  • 인자를 직접 파싱해야 하므로 코드가 복잡해질 수 있다.
  • 옵션 인자의 존재 여부나 값을 확인하기 위한 추가 로직이 필요하다.

 

예시 2. ApplicationRunner를 사용한 경우

  • ApplicationArguments에 선언된 메서드를 사용해서 간단하게 인자를 출력할 수 있다.
@Component
public class MyApplicationRunner implements ApplicationRunner {

    @Override
    public void run(ApplicationArguments args) throws Exception {
    
        // 원본 인자
        String[] sourceArgs = args.getSourceArgs();
        System.out.println("Source Args: " + Arrays.toString(sourceArgs));

        // 옵션 인자 이름
        Set<String> optionNames = args.getOptionNames();
        System.out.println("Option Names: " + optionNames);

        // 특정 옵션 인자 값 가져오기
        if (args.containsOption("port")) {
            List<String> portValues = args.getOptionValues("port");
            System.out.println("Port: " + portValues);
        }

        // 비옵션 인자
        List<String> nonOptionArgs = args.getNonOptionArgs();
        System.out.println("Non-Option Args: " + nonOptionArgs);
        
    }
    
}

결과

[2024-09-28 21:03:29] [INFO ] c.t.l.c.c.DatabaseConnectionChecker - Database connection is OK!
[2024-09-28 21:03:29] [INFO ] c.t.l.c.c.DatabaseConnectionChecker - DataSource class: com.github.gavlyukovskiy.boot.jdbc.decorator.DecoratedDataSource

Source Args: [--port=8090, --debug, filename.txt]
Option Names: [debug, port]
Port: [8080]
Non-Option Args: [filename.txt]

[2024-09-28 21:03:29] [DEBUG] o.s.b.a.ApplicationAvailabilityBean - Application availability state ReadinessState changed to ACCEPTING_TRAFFIC

장점

  • CommandLineRunner를 사용했을 때에 비해 옵션 인자와 비옵션 인자를 쉽게 구분할 수 있다.
  • 특정 옵션의 존재 여부 및 값을 손쉽게 확인할 수 있다.
  • 코드가 더 간결하고 이해하기 쉬워진다. (코드를 작성할 때 메서드만 봐도 어떤 것을 사용하면 될지 알 수 있다.)

왜 ApplicationRunner가 더 편리한가?

  • 구조화된 인자 처리: ApplicationArguments는 명령줄 인자를 구조화하여 제공하므로, 직접 문자열 배열을 파싱 할 필요가 없다.
  • 옵션 인자 관리 용이: 옵션 인자의 존재 여부를 확인하고, 값을 가져오는 것이 간단하다.
  • 코드 간결성: 복잡한 파싱 로직 없이 필요한 인자를 바로 사용할 수 있어 코드가 간결해진다.

요약정리

  1. CommandLineRunner는 단순히 String[] args를 받아서 처리해야 하므로, 복잡한 인자 파싱 로직이 필요할 수 있다.
  2. ApplicationRunner는 ApplicationArguments를 통해 명령줄 인자를 구조화된 방식으로 제공하여 더 편리하게 처리할 수 있다.

 

3. 실무에서의 활용 예제

예제 1: 데이터베이스 초기화

  • 애플리케이션 시작 직후 데이터베이스가 연결되었는지 확인하는 방법이 있다.
/**
 * 스프링 부트 애플리케이션이 시작된 후 데이터베이스 연결을 확인하는 클래스
 */
@Slf4j
@Component
public class DatabaseConnectionChecker implements ApplicationRunner {

    private final JdbcTemplate jdbcTemplate;
    private final TransactionTemplate transactionTemplate;

    @PersistenceContext
    private EntityManager entityManager;

    public DatabaseConnectionChecker(JdbcTemplate jdbcTemplate, TransactionTemplate transactionTemplate) {
        this.jdbcTemplate = jdbcTemplate;
        this.transactionTemplate = transactionTemplate;
    }

    @Override
    public void run(ApplicationArguments args) {
        checkDatabaseConnection();
        checkDataSourceStatus();
    }

    /**
     * @apiNote 데이터베이스 연결 확인
     */
    private void checkDatabaseConnection() {
        try {
            transactionTemplate.execute(status -> {
                Object selectCurrentTimestamp = entityManager.createNativeQuery("SELECT CURRENT_TIMESTAMP").getSingleResult();
                log.info("Current timestamp: {}", selectCurrentTimestamp);
                log.info("Database connection is OK!");
                return null;
            });
        } catch (Exception e) {
            log.error("Failed to connect to the database", e);
        }
    }

    /**
     * @apiNote 데이터 소스 상태 확인
     */
    private void checkDataSourceStatus() {
        try {
            DataSource dataSource = jdbcTemplate.getDataSource();

            if (dataSource != null) {
                log.info("DataSource class: {}", dataSource.getClass().getName());
                
                // 다른 소스가 있다면 추가 가능

                if (dataSource instanceof HikariDataSource hikariDataSource) {
                    log.info("HikariCP connection pool status:");
                    log.info("  Max pool size: {}", hikariDataSource.getMaximumPoolSize());
                    log.info("  Active connections: {}", hikariDataSource.getHikariPoolMXBean().getActiveConnections());
                    log.info("  Idle connections: {}", hikariDataSource.getHikariPoolMXBean().getIdleConnections());
                }
            } else {
                log.warn("DataSource not found!");
            }
        } catch (Exception e) {
            log.error("Error checking DataSource status", e);
        }
    }

}

결과

  • 애플리케이션을 실행하면 아래와 같이 current_time을 select 해서 데이터를 출력한다.
  • 또한 HikariDataSource의 정보가 잘 출력되는 것도 확인할 수 있다.
Started GrpcApplication in 2.057 seconds (process running for 2.341)
Current timestamp: 2024-09-28T21:46:43.563356+09:00
Database connection is OK!
DataSource class: com.zaxxer.hikari.HikariDataSource
HikariCP connection pool status:
Max pool size: 10
Active connections: 0
Idle connections: 10

 

 

 

ApplicationRunner 인터페이스 구현이 아니라 @Bean으로 등록하는 방식도 사용해 보자.
애플리케이션 시작 후 스프링은 컨텍스트 내의 모든 ApplicationRunner 빈의 run 메서드를 호출한다.



빈 등록에 대해 이해하고 넘어갈 부분!

  • 만약 스프링에서 특정 인터페이스의 구현체가 여러 개 있고, 이를 주입받을 때 의존성 주입 대상(bean consuming side)에서 어떤 빈을 주입해야 할지 모호성이 발생하면 애플리케이션이 시작되지 않고 오류가 발생하게 된다. 이는 의존성 주입 시에만 발생하는 문제다.

  • 예를 들면 아래와 같은 상황이라고 생각하면 된다. 이 상태로 서버를 실행하면 "NoUniqueBeanDefinitionException: No qualifying bean of type 'PostUseCase' available: expected single matching bean but found 3" 이런 오류가 발생하게 된다.
@Service
public class PostServiceA implements PostUseCase {
    // 구현 내용
}

@Service
public class PostServiceB implements PostUseCase {
    // 구현 내용
}

@Service
public class PostServiceC implements PostUseCase {
    // 구현 내용
}

@RestController
public class PostController {

    private final PostUseCase postUseCase;

    // 오류 발생: PostUseCase 타입의 빈이 여러 개 있으므로 어떤 것을 주입해야 할지 모호함
    public PostController(PostUseCase postUseCase) {
        this.postUseCase = postUseCase;
    }
    
}
  • 하지만 우리가 사용 중인 ApplicationRunner 인터페이스의 경우에는 의존성 주입 대상이 아니라, 스프링이 자동으로 실행하는 콜백 인터페이스다. 즉, ApplicationRunner를 구현한 빈들이 스프링 컨테이너에 여러 개 등록되어 있어도, 이들을 다른 빈에서 주입받지 않기 때문에 모호성 문제가 발생하지 않는다.
@Component
public class RunnerA implements ApplicationRunner {
    @Override
    public void run(ApplicationArguments args) {
        System.out.println("RunnerA executed");
    }
}

@Component
public class RunnerB implements ApplicationRunner {
    @Override
    public void run(ApplicationArguments args) {
        System.out.println("RunnerB executed");
    }
}

@Component
public class RunnerC implements ApplicationRunner {
    @Override
    public void run(ApplicationArguments args) {
        System.out.println("RunnerC executed");
    }
}

즉, ApplicationRunner는 서로 주입될 일이 없어서 문제가 없는 것이다.

 

 

예제 2: Kafka 연결 설정 (빈 등록 방식)

  • 애플리케이션 시작 시점에 Kafka 브로커와의 연결을 설정하고, 필요한 토픽을 생성하거나 설정을 확인할 수 있다.
import org.apache.kafka.clients.admin.NewTopic;
import org.springframework.boot.ApplicationRunner;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.kafka.core.KafkaAdmin;

@Configuration
public class KafkaInitializer {

    @Bean
    public ApplicationRunner kafkaConnectionRunner(KafkaAdmin kafkaAdmin) {
        return args -> {
            // KafkaAdmin을 사용하여 토픽 생성 또는 설정 확인
            NewTopic topic = new NewTopic("my-topic", 1, (short) 1);
            kafkaAdmin.createOrModifyTopics(topic);
            System.out.println("Kafka topics are set up.");
        };
    }
    
}
  • KafkaAdmin: 스프링 카프카에서 제공하는 빈으로, 토픽 생성 및 설정을 관리한다.
  • NewTopic: 새로운 토픽을 정의하는 객체로, 토픽 이름, 파티션 수, 리플리카 수 등을 설정한다.
  • 토픽 생성 및 설정 확인: kafkaAdmin.createOrModifyTopics(topic)을 통해 토픽을 생성하거나 기존 토픽의 설정을 수정한다.

동작 방식

  • 빈 등록: @Bean 어노테이션이 붙은 메서드는 스프링 컨테이너에 빈으로 등록된다.
  • 의존성 주입: KafkaAdmin 빈은 메서드 파라미터로 주입된다.
  • ApplicationRunner 실행: 애플리케이션 시작 후 스프링은 컨텍스트 내의 모든 ApplicationRunner 빈의 run 메서드를 호출한다.

 

예제 3: Redis 캐시 초기 로딩 (빈 등록 방식)

  • 빈번히 사용되는 데이터를 Redis 캐시에 미리 로딩하여 애플리케이션의 응답 속도를 향상시킬 수 있다.
  • 참고로 @Configuration 클래스에서 @Bean 메서드의 매개변수로 의존성을 선언하면, 스프링은 자동으로 해당 빈들을 주입해 준다. 즉, 생성자 주입이나 필드 주입 없이도 @Bean 메서드의 매개변수에 필요한 빈들을 선언하면, 스프링이 알아서 주입한다.
@Configuration
public class RedisCacheInitializer {

    @Bean
    public ApplicationRunner redisCacheLoader(
        RedisTemplate<String, Object> redisTemplate,
        ProductRepository productRepository
    ) {
        return args -> {
            List<Product> products = productRepository.findAll();
            for (Product product : products) {
                redisTemplate.opsForHash().put("PRODUCT", product.getId(), product);
            }
            System.out.println("Product data loaded into Redis cache.");
        };
    }
    
}
  • 캐시 로딩 로직
    • productRepository.findAll()을 통해 모든 상품 데이터를 조회한다.
    • 루프를 돌면서 각 상품 데이터를 Redis의 해시(Hash)에 저장한다.
    • "PRODUCT" 해시에 product.getId()를 키로, product 객체를 값으로 저장한다.

  • 효과
    • 애플리케이션 시작 시 상품 데이터가 Redis에 로딩되어, 이후 요청에서 데이터베이스 조회 없이 빠르게 데이터를 가져올 수 있다.

 

4. @Bean 등록 vs 인터페이스 구현

결론 및 추천

  • 둘 다 기능적으로는 동일하며, 애플리케이션 시작 시 특정 로직을 실행하는 ApplicationRunner 빈을 등록한다.
  • 선택은 주로 코드의 구조와 설계 방식에 따라 달라지게 될 것 같다.

개인적인 생각

  • 단순한 초기화 작업이나 설정 관련 작업은 @Configuration 클래스와 @Bean 메서드를 사용하는 것이 적합할 것 같다. (애플리케이션 실행 시 db연결이 되었는지 확인하는 것 같이 단순한 작업)
  • 복잡한 로직이나 클래스 수준에서 관리해야 할 상태나 메서드가 있는 경우 @Component 클래스에서 ApplicationRunner를 구현하는 것이 좋을 것 같다. (kafka, redis 등)

 

 

 
반응형