[Spring] ApplicationRunner 활용하기
스프링의 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는 명령줄 인자를 구조화하여 제공하므로, 직접 문자열 배열을 파싱 할 필요가 없다.
- 옵션 인자 관리 용이: 옵션 인자의 존재 여부를 확인하고, 값을 가져오는 것이 간단하다.
- 코드 간결성: 복잡한 파싱 로직 없이 필요한 인자를 바로 사용할 수 있어 코드가 간결해진다.
요약정리
- CommandLineRunner는 단순히 String[] args를 받아서 처리해야 하므로, 복잡한 인자 파싱 로직이 필요할 수 있다.
- 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 등)