본문 바로가기
실습/리눅스 서버 + 스프링 부트

@DependsOn을 사용한 Bean 생성 순서 제어

by 이민우 2024. 4. 10.
728x90
반응형

너무너무너무 당연한 말이지만 빈 생성 순서는 중요하다.

 

A빈이 B빈을 멤버 변수로 갖는다면 B빈이 먼저 선언되고, A빈이 후에 선언되어야 하는 건 당연한 일이다.

 

그런데 이번에 프로젝트를 진행하며 예상치 못한 상황을 맞닥뜨렸다.

 

우선 해당 프로젝트에서는 테이블 생성 등 DB 초기화를 굳이 별도의 쿼리로 입력하고 싶지 않아 schema.sql 파일을 통해 스키마를 사전에 정의해놓도록 설정했다.

  • schema.sql : DB의 스키마를 정의하는 SQL로, 최초 테이블 생성 시 사용됨
  • data.sql : 초기 데이터 삽입 SQL로, 테이블 생성 후 필요한 초기 데이터를 삽입하는 데 사용됨

그리고 기능 중에는 아래와 같은 기능이 있었다.

간단하게 설명하면 사용자에게 요청을 받으면 해당 요청을 "대기" 상태로 저장하고, 내부적으로 작업 후 "성공" 혹은 "실패" 상태로 요청을 갱신하는 프로세스이다.

 

순차적으로 나열하면 아래와 같이 구성된다.

  1. 사용자에게 작업 요청을 전달받는다.
  2. 해당 작업을 "대기" 상태로 DB에 저장하고 사용자에게는 Response를 반환한다.
  3. 이후 내부적으로 요청에 대한 작업을 수행한다.
  4. 작업 완료 후 "성공" 혹은 "실패" 로 "대기" 상태로 저장된 요청을 갱신한다.

그런데 만약 3번 단계에서 어떠한 이유로 프로그램이 꺼지는 현상이 발생하면 어떻게 될까?

당연히 "실패" 상태로 갱신이 되어야할 것이다.

 

이 부분은 프로그램 강제 종료 등 상황 발생시 "대기" 상태의 요청을 전부 "실패" 상태로 변경하도록 코드를 짤 수도 있겠지만, 나는 이 방법을 채택하지 않고, 프로그램 실행 시 @PostConstuct를 통해 "대기" 상태를 전부 "실패" 상태로 변경하도록 코드를 짜놓았다.

 

이 부분은 개발 서버에서는 문제가 되지 않았다. 애초에 개발 직전에 설계된 DB에 따라 테이블을 전부 만들어놓고 코드를 하나씩 추가했기 때문이다.

 

하지만 문제는 운영 서버에 실제로 배포를 하려는 순간에 터졌다. 어떤 문제가 발생했는지는 실제 코드를 통해 알아보자. (물론 실제 프로젝트 코드는 아니고, 비슷하게 만든 더미 코드이다.)

 

 

코드

우선 아래 조건으로 스프링 부트 어플리케이션을 생성했다.

  • JAVA 17
  • Spring Boot 3.2.4

dependency는 간단하게 아래와 같이 두고,


pom.xml에 dbcp2 정도만 추가해두었다.

<!-- BasicDatasource 사용 -->
<dependency>
	<groupId>org.apache.commons</groupId>
	<artifactId>commons-dbcp2</artifactId>
</dependency>


그리고 application.properties에 DB 커넥션 설정을 넣어두었다.

spring.application.name=bean-test

rdb.driver.classname=org.postgresql.Driver
rdb.connection.url=jdbc:postgresql://{IP}:5432/test
rdb.connection.username=postgres
rdb.connection.password=password

logging.level.root=DEBUG

 

이러면 세팅은 끝난 것이다.

그러면 우선 위의 예시를 바탕으로 schema.sql을 생성해보자.

schema.sql은 src/main/resource 바로 밑에 두었다.

 

schema.sql

CREATE TABLE IF NOT EXISTS TEST_TBL (
	SERIAL_NO	SERIAL 		NOT NULL,
	-- 0 : 대기 / 1 : 성공 / 2 : 실패
	STATE_INFO	INT 		NOT NULL DEFAULT 0		CHECK (STATE_INFO IN (0, 1, 2)),
	MDFCN_DT 	TIMESTAMP 	NOT NULL DEFAULT NOW(),
	PRIMARY KEY(SERIAL_NO)
);


COMMENT ON TABLE TEST_TBL IS '테스트 테이블';
COMMENT ON COLUMN TEST_TBL.SERIAL_NO IS '시리얼 넘버';
COMMENT ON COLUMN TEST_TBL.STATE_INFO IS '상태 정보';
COMMENT ON COLUMN TEST_TBL.MDFCN_DT IS '수정일자';

 

다음으로는 mapper xml을 생성한다. 해당 xml은 src/main/resource/mapper 밑에 두었다.

 

mapper xml 내에는 간단하게 "대기" 상태의 작업을 "실패" 상태로 수정하고, 수정일자를 현재 시간으로 변경하는 쿼리만 넣어두었다.

 

test.xml

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">

<mapper namespace="mapper.rdb.test">

	<update id="cancelWork">
		UPDATE
			TEST_TBL
		SET
			STATE_INFO = 2,
			MDFCN_DT = NOW()
		WHERE
			STATE_INFO = 1
	</update>
	
</mapper>

 

이제 schema.sql과 mapper xml을 사용할 수 있도록 Mybatis 용 Configuration 클래스를 작성한다.

 

BeanTestConfig.java

package com.mwlee.test;

import javax.sql.DataSource;

import org.apache.commons.dbcp2.BasicDataSource;
import org.apache.ibatis.session.AutoMappingBehavior;
import org.apache.ibatis.session.ExecutorType;
import org.apache.ibatis.session.SqlSessionFactory;
import org.mybatis.spring.SqlSessionFactoryBean;
import org.mybatis.spring.SqlSessionTemplate;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.ClassPathResource;
import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.jdbc.datasource.init.DataSourceInitializer;
import org.springframework.jdbc.datasource.init.ResourceDatabasePopulator;
import org.springframework.transaction.annotation.EnableTransactionManagement;

/**
 * Mybatis 설정
 */
@Configuration
@EnableTransactionManagement
public class BeanTestConfig {

	// application.properties 내에 존재하는 DB 연결 변수
	@Value("${rdb.driver.classname}")
	private String rdbDriverClassName ;
	@Value("${rdb.connection.url}")
	private String rdbJdbcUrl;
	@Value("${rdb.connection.username}")
	private String rdbJdbcUsername;
	@Value("${rdb.connection.password}")
	private String rdbJdbcPassword;
	
	/**
	 * DataSource 생성
	 * @return
	 */
	@Bean
	@Qualifier("rdbDataSource")
	public DataSource rdbDataSource() {

		BasicDataSource dataSource = new BasicDataSource();
		
		// 연결 정보 설정
		dataSource.setDriverClassName(rdbDriverClassName);
		dataSource.setUrl(rdbJdbcUrl);
		dataSource.setUsername(rdbJdbcUsername);
		dataSource.setPassword(rdbJdbcPassword);
		// 최소 idle 커넥션 수 설정
		dataSource.setMinIdle(10);

		return dataSource;
	}
	
	/**
	 * 최초 실행 시 schema.sql 스크립트를 실행하여 
	 * 테이블 생성하는 DataSourceInitializer 빈 생성
	 * 
	 * @param dataSource
	 * @return
	 */
	@Bean
	@Qualifier("rdbDataSourceInitializer")
	public DataSourceInitializer rdbDataSourceInitializer(@Qualifier("rdbDataSource") DataSource dataSource) {
	    
		DataSourceInitializer initializer = new DataSourceInitializer();
	    initializer.setDataSource(dataSource);
	    ResourceDatabasePopulator databasePopulator = new ResourceDatabasePopulator();
	    
	    // schema.sql 위치 설정
	    // src/main/resources 바로 밑에 있으므로 파일명만 지정
	    databasePopulator.addScript(new ClassPathResource("schema.sql"));
	    databasePopulator.setSqlScriptEncoding("UTF-8");
	    initializer.setDatabasePopulator(databasePopulator);
	    
	    return initializer;
	}
	
	/**
	 * 트랜잭션 사용을 위해 트랜잭션 매니저 생성
	 * 
	 * @param rdbDataSource
	 * @return
	 */
	@Bean
	@Qualifier("rdbDataSourceTransactionManager")
	public DataSourceTransactionManager rdbDataSourceTransactionManager(@Qualifier("rdbDataSource") DataSource rdbDataSource) {
		return new DataSourceTransactionManager(rdbDataSource);
	}

	/**
	 * sqlSessionFactory 생성
	 * 
	 * @param rdbDataSource
	 * @return
	 * @throws Exception
	 */
	@Bean
	@Qualifier("rdbSqlSessionFactory")
	public SqlSessionFactory rdbSqlSessionFactory(@Qualifier("rdbDataSource") DataSource rdbDataSource) throws Exception {
		SqlSessionFactoryBean bean = new SqlSessionFactoryBean();
		bean.setDataSource(rdbDataSource);
		// xml 파일 위치 설정
		bean.setMapperLocations(new PathMatchingResourcePatternResolver().getResources("classpath:mapper/*.xml"));
		// *Configuration annotation과 겹쳐서 이렇게 작성
		org.apache.ibatis.session.Configuration configuration = new org.apache.ibatis.session.Configuration();
		
		configuration.setCacheEnabled(true);
		configuration.setUseGeneratedKeys(false);
		configuration.setDefaultExecutorType(ExecutorType.SIMPLE);
		configuration.setLazyLoadingEnabled(false);
		configuration.setAggressiveLazyLoading(true);
		configuration.setUseColumnLabel(true);
		configuration.setAutoMappingBehavior(AutoMappingBehavior.PARTIAL);
		configuration.setMultipleResultSetsEnabled(true);
		configuration.setSafeRowBoundsEnabled(true);
		configuration.setMapUnderscoreToCamelCase(false);
		
		bean.setConfiguration(configuration);
		
		return bean.getObject();
	}

	/**
	 * sqlSessionTemplate 생성
	 * @param rdbSqlSessionFactory
	 * @return
	 */
	@Bean
	@Qualifier("rdbSqlSession")
	public SqlSessionTemplate rdbSqlSession(@Qualifier("rdbSqlSessionFactory") SqlSessionFactory rdbSqlSessionFactory) {
		return new SqlSessionTemplate(rdbSqlSessionFactory);
	}
	
}

 

마지막으로 test.xml을 사용해 DB에 작업을 수행할 Mapper 클래스를 작성한다.

 

BeanTestMapper.java

package com.mwlee.test;

import org.mybatis.spring.SqlSessionTemplate;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;

import jakarta.annotation.PostConstruct;
import lombok.extern.slf4j.Slf4j;

@Slf4j
@Repository
public class BeanTestMapper {
	
	@Autowired
	@Qualifier("rdbSqlSession")
	private SqlSessionTemplate sqlSession;
	
	@PostConstruct
	@Transactional(transactionManager="rdbDataSourceTransactionManager")
	public void setFailOnWaitingWork() {
		log.info("@@ Set Fail On Waiting Works...");
		sqlSession.update("mapper.rdb.test.cancelWork");
	}
	
}

 

 

평범하게 생각했을 때, 당연히 schema.sql이 먼저 실행되어 DB에 테이블이 생성되고, 그 후에 setFailOnWaitingWork가 실행될 것이다.

 

하지만 결과는 반대였다.

에러 내용

 

schema.sql이 생성되기도 전에, 즉 rdbDataSourceInitializer 빈이 생성되기 전에  BeanTestMapper 빈이 먼저 생성되어 테이블이 생성되지 않은 상태에서 쿼리를 날려 해당 테이블이 존재하지 않는다는 에러를 발생시키는 것이다.

 

그러면 한 번 어떤 순서로 Bean이 생성되는지 확인해보기 위해 각 Bean 생성 메소드마다 콘솔을 찍어보자.

간단하게 이런식으로

 

역시 DataSourceInitializer보다 다른 빈이 먼저 생성되어 실행되고 있음을 확인할 수 있었다.

 

 

해결책

해결책이라면 다양하겠지만, 오늘은 아래 해결책을 소개하고 싶다.

 

바로 @DependsOn 어노테이션을 사용하는 것이다.

 

@DependsOn 어노테이션은 Spring에서 빈 간의 초기화 순서를 제어하는 데 사용된다.

즉 해당 이노테이션을 사용해 특정 빈이 다른 빈에 의존한다는 것을 명시적으로 지정할 수 있으며,

이를 통해 특정 빈이 다른 빈이 초기화 된 후에만 사용될 수 있음을 의미해 초기화 순서를 제어할 수 있다.

 

위의 경우를 보면 DataSourceInitializer은 DataSource 빈이 생성된 직후에 생성되고 실행되어야 한다. 그렇다면 원래 로직에서 DataSource 직후에 생성되는 SqlSessionFactory에 @DependsOn 어노테이션을 사용해서 DataSourceInitializer을 보다 먼저 생성하도록 명시할 수 있는 것이다.

 

* Mapper 클래스 자체에 @DependsOn을 붙여도 무방하나, 위의 경우 단적인 예시로 하나의 매퍼만이 존재할 뿐 실제 프로그램은 수십 개의 매퍼가 존재하기에 하나하나 일일히 붙이고 싶지 않아 SqlSessionFactory에만 붙여놓았다. 나머지 빈들은 SqlSessionFactory보다 우선순위가 낮아 SqlSessionFactory이후에 생성되므로.

 

코드는 아래와 같이 추가했다.

DependsOn 추가

 

이제 다시 한 번 돌려보자.

 

정상적으로 빈들이 순차적으로 등록되었고, 에러도 발생하지 않았음을 확인할 수 있다.

728x90
반응형