문제 상황
어떤 것을 하려다가 문제가 발생했는가?
dev에서 pull 받아서 실행해보려고 하는데 기존에 사용하고 있던 브랜치에서 설정한 Test DB 설정이 동작하지 않는다.
발생한 환경, 프로그램
dev 브랜치, TicketServiceConcurrencyTest.java, application-test.propeties
발생한 문제(에러)
SQLSyntaxErrorException: Table 'test.account' doesn't exist
java.sql.SQLSyntaxErrorException: Table 'test.actor' doesn't exist
Duplicate entry '1' for key 'ticket.UK_3yhl9h2vv803mhf1jpk8puq4a'
등등 여러 에러가 발생함. 롤백이 안되거나, BeforeEach에서 duplicate 같은 이상하고 잡다한 오류가 발생함.
주요 에러는
Cannot create Launcher without at least one TestEngine; consider adding an engine implementation JAR to the classpath
원인
추정되는 원인
dev 브랜치로 변경 후 test DB를 바라보지 않아, test DB를 바라볼 수 있게 DaataSourceConfig 설정을 변경했지만, create-drop이 적용되지 않음
실제 원인
JUnit5 실행 엔진에 대한 build.gradle 추가
testImplementation 'org.junit.jupiter:junit-jupiter-engine:5.8.2'
테스트 환경에서 올바른 DB를 참조하지 않음 Junut 4- 테스크 실행 플랫폼/작성 및 실행 API 제공/이전 버전 지원 하는 3 가지 모듈 존재 테스트코드를 작성하려고 각자 라이브러리를 추가했는데 JUnit4와 5버전을 사용하는 팀원들끼리 꼬였던 것, Database 환경별 설정이 생각보다 여러가지가 있었고 Spring Context에 대해 깊이 이해하는 계기가 됨
최종 해결
package com.kb.wallet.global.config;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;
import java.util.Properties;
import javax.sql.DataSource;
import lombok.RequiredArgsConstructor;
import org.apache.ibatis.session.SqlSessionFactory;
import org.mybatis.spring.SqlSessionFactoryBean;
import org.mybatis.spring.SqlSessionTemplate;
import org.mybatis.spring.annotation.MapperScan;
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.ComponentScan;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
import org.springframework.context.annotation.PropertySource;
import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
import org.springframework.data.transaction.ChainedTransactionManager;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.orm.jpa.JpaTransactionManager;
import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean;
import org.springframework.orm.jpa.vendor.HibernateJpaVendorAdapter;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.annotation.EnableTransactionManagement;
@Configuration
@Import(DataSourceConfig.class)
@ComponentScan(basePackages = {
"com.kb.wallet"
})
@PropertySource("classpath:application.properties")
@MapperScan(
basePackages = {
"com.kb.wallet.member.repository",
"com.kb.wallet.ticket.repository",
"com.kb.wallet.seat.repository",
"com.kb.wallet.musical.repository"
},
annotationClass = org.apache.ibatis.annotations.Mapper.class //해당패키지에서 @Mapper어노테이션이 선언된 인터페이스 찾기
)
@EnableJpaRepositories(basePackages = {
"com.kb.wallet.member.repository",
"com.kb.wallet.ticket.repository",
"com.kb.wallet.seat.repository",
"com.kb.wallet.musical.repository",
"com.kb.wallet.account.repository"
})
@EnableJpaAuditing
@EnableTransactionManagement
@RequiredArgsConstructor
public class AppConfig {
private final DataSource dataSource;
@Bean
public ObjectMapper objectMapper() {
ObjectMapper objectMapper = new ObjectMapper();
objectMapper.registerModule(new JavaTimeModule()); // LocalDate와 LocalDateTime을 지원
objectMapper.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS,
false); // 날짜를 타임스탬프가 아닌 ISO 8601 형식으로 출력
return objectMapper;
}
// JPA 설정
@Bean
public LocalContainerEntityManagerFactoryBean entityManagerFactory(DataSource dataSource) {
LocalContainerEntityManagerFactoryBean emf = new LocalContainerEntityManagerFactoryBean();
emf.setDataSource(dataSource);
emf.setPackagesToScan("com.kb.wallet.member.domain", "com.kb.wallet.ticket.domain",
"com.kb.wallet.seat.domain",
"com.kb.wallet.musical.domain", "com.kb.wallet.account.domain");
emf.setJpaVendorAdapter(new HibernateJpaVendorAdapter());
// JPA Properties 설정
Properties jpaProperties = new Properties();
jpaProperties.put("hibernate.hbm2ddl.auto", "update"); // 테이블 자동 생성
jpaProperties.put("hibernate.show_sql", "true"); // SQL 쿼리 로그 출력
jpaProperties.put("hibernate.format_sql", "true"); // SQL 쿼리 포매팅 출력
jpaProperties.put("hibernate.physical_naming_strategy",
"org.hibernate.boot.model.naming.CamelCaseToUnderscoresNamingStrategy");
emf.setJpaProperties(jpaProperties);
return emf;
}
@Bean
public PlatformTransactionManager jpaTransactionManager(
LocalContainerEntityManagerFactoryBean entityManagerFactory) {
JpaTransactionManager jpaTransactionManager = new JpaTransactionManager();
jpaTransactionManager.setEntityManagerFactory(entityManagerFactory.getObject());
return jpaTransactionManager;
}
// MyBatis 설정
@Bean
public SqlSessionFactory sqlSessionFactory(DataSource dataSource) throws Exception {
SqlSessionFactoryBean sessionFactory = new SqlSessionFactoryBean();
sessionFactory.setDataSource(dataSource);
sessionFactory.setTypeAliasesPackage("com.kb.wallet.member.domain,"
+ "com.kb.wallet.ticket.domain,"
+ "com.kb.wallet.seat.domain,"
+ "com.kb.wallet.musical.domain");
sessionFactory.setMapperLocations(new PathMatchingResourcePatternResolver().getResources(
"classpath*:mapper/**/*.xml")); // MyBatis 매퍼 설정
org.apache.ibatis.session.Configuration configuration = new org.apache.ibatis.session.Configuration();
configuration.setAutoMappingBehavior(
org.apache.ibatis.session.AutoMappingBehavior.PARTIAL); // Set AUTO_MAPPING_BEHAVIOR to PARTIAL
configuration.setMapUnderscoreToCamelCase(true);
sessionFactory.setConfiguration(configuration);
return sessionFactory.getObject();
}
@Bean
public SqlSessionTemplate sqlSessionTemplate(SqlSessionFactory sqlSessionFactory) {
return new SqlSessionTemplate(sqlSessionFactory);
}
// MyBatis 트랜잭션 매니저
@Bean
public PlatformTransactionManager myBatisTransactionManager(DataSource dataSource) {
return new DataSourceTransactionManager(dataSource);
}
// 두 트랜잭션 매니저를 ChainedTransactionManager로 묶음
@Bean
public PlatformTransactionManager transactionManager(
@Qualifier("jpaTransactionManager") PlatformTransactionManager jpaTransactionManager,
@Qualifier("myBatisTransactionManager") PlatformTransactionManager myBatisTransactionManager) {
return new ChainedTransactionManager(jpaTransactionManager, myBatisTransactionManager);
}
}
package com.kb.wallet.global.config;
import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;
import javax.sql.DataSource;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.dbcp2.BasicDataSource;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.context.annotation.PropertySource;
@Configuration
@Slf4j
public class DataSourceConfig {
/**
* XXX:
* @Configuration의 중복 처리: DataSourceConfig가 @Configuration을 사용하고 있으므로 Spring은 이 설정 파일들을 스캔할 때
* @Profile에 따라 적절한 DataSource 설정을 자동으로 선택한다.
*
* 프로파일이 "test"일 때 DataSourceConfig.TestConfig가 활성화되고, application-test.properties 파일에서 값을 가져와
* TestConfig의 dataSource() 메서드가 DataSource 빈으로 등록된다.
*/
@Value("${spring.datasource.url}")
private String dbUrl;
@Value("${spring.datasource.username}")
private String dbUsername;
@Value("${spring.datasource.password}")
private String dbPassword;
@Value("${spring.datasource.hikari.minimum-idle}")
private int minimumIdle;
@Value("${spring.datasource.hikari.maximum-pool-size}")
private int maximumPoolSize;
@Value("${spring.datasource.hikari.connection-timeout}")
private long connectionTimeout;
@Value("${spring.datasource.hikari.idle-timeout}")
private long idleTimeout;
@Value("${spring.datasource.hikari.max-lifetime}")
private long maxLifetime;
@Value("${spring.datasource.driver-class-name}")
private String driverClassName;
@Configuration
@Profile("dev")
@PropertySource("classpath:application-dev.properties")
class DevConfig {
@Value("${spring.datasource.url}")
private String datasourceUrl;
@Value("${spring.datasource.username}")
private String datasourceUsername;
@Value("${spring.datasource.password}")
private String datasourcePassword;
@Bean
public DataSource dataSource() {
log.info("getConnection(): {}", datasourceUrl);
return createHikariDataSource(datasourceUrl, datasourceUsername, datasourcePassword);
}
}
@Configuration
@Profile("prod")
@PropertySource("classpath:application-prod.properties")
class ProdConfig {
@Value("${spring.datasource.url}")
private String datasourceUrl;
@Value("${spring.datasource.username}")
private String datasourceUsername;
@Value("${spring.datasource.password}")
private String datasourcePassword;
@Bean
public DataSource dataSource() {
log.info("getConnection()2: {}", datasourceUrl);
return createHikariDataSource(datasourceUrl, datasourceUsername, datasourcePassword);
}
}
@Configuration
@Profile("test")
@PropertySource("classpath:application-test.properties")
class TestConfig {
@Value("${spring.datasource.url}")
private String datasourceUrl;
@Value("${spring.datasource.username}")
private String datasourceUsername;
@Value("${spring.datasource.password}")
private String datasourcePassword;
@Bean
public DataSource dataSource() {
log.info("getConnection()3: {}", datasourceUrl);
return createHikariDataSource(datasourceUrl, datasourceUsername, datasourcePassword);
}
}
private DataSource createHikariDataSource(String dbUrl,
String dbUsername, String dbPassword) {
HikariConfig config = new HikariConfig();
config.setDriverClassName(driverClassName);
config.setJdbcUrl(dbUrl);
config.setUsername(dbUsername);
config.setPassword(dbPassword);
config.setConnectionTimeout(connectionTimeout);
config.setMinimumIdle(minimumIdle);
config.setMaximumPoolSize(maximumPoolSize);
config.setIdleTimeout(idleTimeout);
config.setMaxLifetime(maxLifetime);
config.setAutoCommit(true);
return new HikariDataSource(config);
}
}
package com.kb.wallet.global.config;
import javax.servlet.ServletContextEvent;
import javax.servlet.ServletContextListener;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.env.ConfigurableEnvironment;
import org.springframework.web.context.WebApplicationContext;
import org.springframework.web.context.support.WebApplicationContextUtils;
@Slf4j
public class ProfileInitializer implements ServletContextListener {
@Override
public void contextInitialized(ServletContextEvent event) {
WebApplicationContext ctx = WebApplicationContextUtils
.getWebApplicationContext(event.getServletContext());
if (ctx != null) {
ConfigurableEnvironment env = (ConfigurableEnvironment) ctx.getEnvironment();
String activeProfile = System.getenv("SPRING_PROFILES_ACTIVE");
log.info("activeProfile: {}", activeProfile);
if (activeProfile != null && !activeProfile.isEmpty()) {
env.setActiveProfiles(activeProfile);
} else {
env.setActiveProfiles("test");
}
} else {
// System.out.println("Spring Context 초기화 실패");
}
}
@Override
public void contextDestroyed(ServletContextEvent sce) {
}
}
참고자료