DB 모듈 사용 가이드
개요
스프링 프레임워크를 사용하여 웹 개발을 진행함에 있어서 매 프로젝트마다 새롭게 데이터베이스 관련 설정을 진행 하는 것은 복잡한 일이다. 캐모마일 데이터베이스 모듈은 데이터베이스 관련 작업을 진행함에 있어서 유용한 모듈을 제공하여 쉽고 빠르게 프로젝트 개발을 시작 할 수 있도록 지원한다.
캐모마일 데이터베이스 모듈은 JPA + Mybatis 환경을 기본적으로 제공하여 쉽고 빠르게 개발을 진행할 수 있도록 돕는다.
캐모마일의 대부분의 기본 샘플코드로 chamomile-database 모듈이 포함되어있기 때문에 하나씩 설정하는 것보다는 메이븐 아키타입을 통해서 캐모마일 프로젝트를 생성하여 사용하는것을 추천한다.
데이터베이스 설정
아키타입으로 프로젝트를 생성하면 모든 설정이 자동으로 작성되어있지만 그렇지 않는 경우에는 캐모마일의 데이터베이스 설정은 다음과 같이 진행할 수 있다. 캐모마일의 DBCP는 스프링부트 기본 옵션인 Hikari를 사용한다. 스프링을 사용하는 것과 비슷하게 다음과 같이 작성하면 캐모마일 데이터베이스 설정이 끝난다.
application.yml
resources/sql/config/sqlMapConfig.xml
resources/sql/service 디렉토리 생성 기본 설정으로는 해당 위치에 디렉토리를 생성해주어야한다. 생성하지 않으면 설정 오류가 발생한다.
마이바티스 배치
일반적으로 마이바티스에서 배치 쿼리를 작성할 때 다음과 같은 방법으로 해결한다.
그러나 다음과 같은 방법은 아래와 같은 방법으로 쿼리가 동작하기 때문에 대량의 insert가 발생할 경우 성능 저하 현상이 발생할 수 있다.
따라서 캐모마일 에서는 마이바티스 환경에서 배치 쿼리 작성을 손쉽게 진행할 수 있도록 마이바티스 플러그인을 제공한다.
사용 방법은 간단하다.
다음과 같이 mapper에서 인터페이스를 작성해주고,
서비스에서 다음과 같이 list 형태의 객체와 배치 사이즈를 설정해주면 해당 사이즈대로 배치 쿼리가 동작하게 된다.
다음과 같이 배치 삭제도 가능하다.
마이바티스 Pageable
마이바티스를 사용하여 쿼리를 작성하다보면 다음과 같이 sort와 페이징을 위해 동적으로 쿼리를 작성한다.
다음과 같이 코드를 작성하면 SQL이 비즈니스의 핵심 로직과는 동떨어진다. 또한 핵심 SQL 호출보다 페이징과 정렬이 더 긴 쿼리를 작성하게 되는 일이 발생한다.
그래서 캐모마일은 Spring Data의 Pageable과 PageableInterceptor
를 사용해서 쿼리 작성을 돕는다.
사용 방법은 다음과 같다.
컨트롤러 영역에서 Spring Pageable로 파라미터를 받는다.
화면에서는 다음과 같이 입력하면 Pageable에 인자로 값이 넘어가게 된다.
{ "page": "0", "size": "10", "sort": "groupId,desc" }다음과 같이 컨트롤러도 작성해준다.
@GetMapping(path = "/list") public ChamomileResponse<Page<ResourceVO>> getResourceList(ResourceQuery request, Pageable pageable) { Page<ResourceVO> results = resourceService.getResourceList(request, pageable); return new ChamomileResponse<>(results); }Mapper에서 인터페이스로 Pageable을 파라미터로 전달한다.
Page<ResourceVO> findResourceListData(ResourceQuery query, Pageable pageable);쿼리를 작성한다.
<!-- 리소스 목록 조회 --> <select id="findResourceListData" parameterType="net.lotte.chamomile.admin.resource.api.dto.ResourceQuery" resultType="net.lotte.chamomile.admin.resource.domain.ResourceVO"> SELECT RESOURCE_ID resourceId, /* 리소스 아이디 */ RESOURCE_URI resourceUri, /* 리소스 경로 */ RESOURCE_NAME resourceName, /* 리소스 명 */ RESOURCE_HTTPMETHOD resourceHttpMethod, /* http method */ RESOURCE_DESC resourceDesc, /* 리소스_설명 */ SECURITY_ORDER securityOrder, /* 리소스 순서 */ USE_YN useYn, /* 사용 여부 */ SYS_INSERT_DTM sysInsertDtm, /* 시스템_입력_일시 */ SYS_INSERT_USER_ID sysInsertUserId, /* 시스템_입력_사용자_아이디 */ SYS_UPDATE_DTM sysUpdateDtm, /* 시스템_수정_일시 */ SYS_UPDATE_USER_ID sysUpdateUserId /* 시스템_수정_사용자_아이디 */ FROM CHMM_RESOURCE_INFO A <!-- 검색조건 --> <where> <if test="searchResourceId != null and searchResourceId != ''">AND RESOURCE_ID LIKE CONCAT(CONCAT('%',#{searchResourceId}),'%')</if> <if test="searchResourceName != null and searchResourceName != ''">AND RESOURCE_NAME LIKE CONCAT(CONCAT('%',#{searchResourceName}),'%')</if> <if test="searchUseYn != null and searchUseYn != ''">AND USE_YN = #{searchUseYn}</if> </where> </select>이렇게 캐모마일에서 제공하는 PageableInterceptor를 잘 사용한다면 비즈니스 로직에 집중하는 쿼리를 작성할 수 있다.
반환되는 타입은 다음과 같이 세 가지로 분류된다.
1. Page - 일반적인 Pagenation 을 적용할 때 사용하며, 페이지를 구분하기 위해 totalcount를 호출한다. 2. Slice - 스크롤 페이징을 진행할 때 사용하며, totalCount를 호출하지 않아 Page 보다 성능이 개선된다. 3. List - 단순 List를 조회할 때 사용하며, 다음 페이지에 데이터가 있는지 검색하지 않아 Slice보다 약간의 성능 이점이 있다.필요에 맞게 다음 세가지의 반환형을 잘 사용하여 복잡한 페이징 쿼리를 단순화 할 수 있다.
마이바티스 변경 감지
서비스를 운영하는 도중에 서버를 내리지 않고 마이바티스의 쿼리를 변경하고 싶을 때가 있다. 해당 상황에서 유용하게 사용하기 위해 캐모마일에서는 RefreshableSqlSessionFactoryBean을 제공한다.
application.yml
트랜잭션 관리
데이터베이스를 사용 할 때 JPA 와 MyBatis를 동시에 사용한다면 두 개의 데이터베이스 호출 방식의 차이 때문에 두 개의 트랜잭션을 하나로 통합해주는 과정이 필요하다. 캐모마일을 사용한다면 해당 설정을 자동으로 구성해준다. 또한 pointcut을 사용하여 전역으로 트랜잭션을 관리할 수 있다.
application.yml
위와 같이 전역으로 트랜잭션을 묶게 된다면 클래스에 @Transactional을 사용하지 않고, 개발자 각자의 로직에 따른 트랜잭션 관리가 아닌, 공통 개발자 한 명에 의해서 전체 프로젝트의 트랜잭션을 관리할 수 있다.
데이터베이스 Audit
데이터베이스 테이블을 구성할 때 생성자, 생성일시, 수정자, 수정일시 등의 데이터가 필요한 경우가 많다. 캐모마일에서는 해당 모듈에서 필요한 객체를 상속 받아 쉽게 create/update 관리를 진행할 수 있다.
TimeAuthorLog.java
ResourceVO.java - 예시
ResourceServiceImpl.java - 예시
Mybatis환경에서는 위와 같이 메소드를 직접 호출해주면 된다.
JPA환경에서는 EntityManager에서 commit이 발생 한 경우에 onCreate/onUpdate가 자동으로 호출 된다.
다중 데이터베이스 설정
개요
가볍게 운영하는 서비스라면 모르겠지만 사용자가 다량인 경우 부하를 분산하기 위해서, 데이터의 Read & Write를 분리하여 사용하게 된다.
해당 상황에서 하나의 datasource를 사용할 수 없고 각 각 다른 datasource를 사용해야 하는데 이 글은 그와 같은 상황에서의 예제를 다룬다.
예제
데이터소스 추가 설정
@Configuration @EnableConfigurationProperties(ChamomileJdbcProperties.class) @MapperScan(basePackages = "demo.board.domain" ,sqlSessionFactoryRef="customSqlSessionFactory") @RequiredArgsConstructor public class CustomDatabaseConfiguration { private final ChamomileJdbcProperties jdbcProperties; private final ChamomileMapperProperties chamomileMapperProperties; @Bean @Primary public DataSource customDataSource() { HikariConfig hikariConfig = new HikariConfig(); hikariConfig.setDriverClassName(jdbcProperties.getDriverClassName()); hikariConfig.setJdbcUrl(jdbcProperties.getJdbcUrl()); hikariConfig.setUsername(jdbcProperties.getUsername()); hikariConfig.setPassword(jdbcProperties.getPassword()); hikariConfig.setMaximumPoolSize(jdbcProperties.getMaximumPoolSize()); hikariConfig.setConnectionTimeout(jdbcProperties.getConnectionTimeout()); hikariConfig.setMaxLifetime(jdbcProperties.getMaxLifeTime()); return new HikariDataSource(hikariConfig); } @Bean @Primary public RefreshableSqlSessionFactoryBean customSqlSessionFactory(@Qualifier(value = "customDataSource") final DataSource dataSource, final VendorDatabaseIdProvider databaseIdProvider) { RefreshableSqlSessionFactoryBean refreshableSqlSessionFactoryBean = new RefreshableSqlSessionFactoryBean() {{ setDataSource(dataSource); setConfigLocation(chamomileMapperProperties.getConfigLocation()); setRefreshable(chamomileMapperProperties.isRefreshable()); setRefreshableMapperLocations(chamomileMapperProperties.getRefreshableMapperLocations()); setDatabaseIdProvider(databaseIdProvider); setInterval(chamomileMapperProperties.getInterval()); }}; chamomileMapperProperties.setMapperLocations(new String[]{"classpath:/sql/service/*.xml"}); refreshableSqlSessionFactoryBean.setMapperLocations(chamomileMapperProperties.resolveMapperLocations()); return refreshableSqlSessionFactoryBean; } @Bean public SqlSessionTemplate customSqlSession(@Qualifier(value = "customSqlSessionFactory")SqlSessionFactory sqlSessionFactory) { return new SqlSessionTemplate(sqlSessionFactory); } @Bean public PlatformTransactionManager customTxManager1(@Qualifier(value = "customDataSource")final DataSource dataSource) { return new DataSourceTransactionManager() {{ setDataSource(dataSource); }}; } } @Configuration @EnableConfigurationProperties(ChamomileJdbc2Properties.class) @MapperScan(basePackages = "demo.board.domain2" ,sqlSessionFactoryRef="customSqlSessionFactory2") @RequiredArgsConstructor public class CustomDatabaseConfiguration2 { private final ChamomileJdbc2Properties jdbc2Properties; private final ChamomileMapperProperties chamomileMapperProperties; @Bean public DataSource customDataSource2() { HikariConfig hikariConfig = new HikariConfig(); hikariConfig.setDriverClassName(jdbc2Properties.getDriverClassName()); hikariConfig.setJdbcUrl(jdbc2Properties.getJdbcUrl()); hikariConfig.setUsername(jdbc2Properties.getUsername()); hikariConfig.setPassword(jdbc2Properties.getPassword()); hikariConfig.setMaximumPoolSize(jdbc2Properties.getMaximumPoolSize()); hikariConfig.setConnectionTimeout(jdbc2Properties.getConnectionTimeout()); hikariConfig.setMaxLifetime(jdbc2Properties.getMaxLifeTime()); return new HikariDataSource(hikariConfig); } @Bean public RefreshableSqlSessionFactoryBean customSqlSessionFactory2(@Qualifier(value = "customDataSource2") final DataSource dataSource, final VendorDatabaseIdProvider databaseIdProvider) { RefreshableSqlSessionFactoryBean refreshableSqlSessionFactoryBean = new RefreshableSqlSessionFactoryBean() {{ setDataSource(dataSource); setConfigLocation(chamomileMapperProperties.getConfigLocation()); setRefreshable(chamomileMapperProperties.isRefreshable()); setRefreshableMapperLocations(chamomileMapperProperties.getRefreshableMapperLocations()); setDatabaseIdProvider(databaseIdProvider); setInterval(chamomileMapperProperties.getInterval()); }}; chamomileMapperProperties.setMapperLocations(new String[]{"classpath:/sql/service2/*.xml"}); refreshableSqlSessionFactoryBean.setMapperLocations(chamomileMapperProperties.resolveMapperLocations()); return refreshableSqlSessionFactoryBean; } @Bean public SqlSessionTemplate customSqlSession2(@Qualifier(value = "customSqlSessionFactory2")SqlSessionFactory sqlSessionFactory) { return new SqlSessionTemplate(sqlSessionFactory); } @Bean public PlatformTransactionManager customTxManager2(@Qualifier(value = "customDataSource2")final DataSource dataSource) { return new DataSourceTransactionManager() {{ setDataSource(dataSource); }}; } } @Getter @Setter @ConfigurationProperties(prefix = "chmm.jdbc") public class ChamomileJdbcProperties { /** * jdbc driver class name 입력값. */ private String driverClassName; /** * 캐모마일 jdbc-url */ private String jdbcUrl; /** * 데이터베이스 계정 이름 */ private String username; /** * 데이터베이스 계정 비밀번호 */ private String password; /** * Hikari Pool size */ private int maximumPoolSize; /** * 애플리케이션에서 새로운 데이터베이스 연결을 얻으려고 시도할 때 대기 할 최대 시간. */ private int connectionTimeout; /** * 데이터베이스 연결이 유지될 수 있는 최대 시간 */ private int maxLifeTime; } @Getter @Setter @ConfigurationProperties(prefix = "chmm.jdbc2") public class ChamomileJdbc2Properties { /** * jdbc driver class name 입력값. */ private String driverClassName; /** * 캐모마일 jdbc-url */ private String jdbcUrl; /** * 데이터베이스 계정 이름 */ private String username; /** * 데이터베이스 계정 비밀번호 */ private String password; /** * Hikari Pool size */ private int maximumPoolSize; /** * 애플리케이션에서 새로운 데이터베이스 연결을 얻으려고 시도할 때 대기 할 최대 시간. */ private int connectionTimeout; /** * 데이터베이스 연결이 유지될 수 있는 최대 시간 */ private int maxLifeTime; }다음과 같이 설정하면
첫 번째 datasource는 demo.board.domain 패키지의 매퍼를 인식하고, 두 번째 datasource는 demo.board.domain2 패키지의 매퍼를 인식한다.각각 설정한 xml location에 쿼리를 작성하고 해당 패키지에 mapper를 구현하면 된다.
yaml 설정
chmm: jdbc: driverClassName: net.sf.log4jdbc.sql.jdbcapi.DriverSpy jdbcUrl: jdbc:log4jdbc:mysql://localhost:3306/chamomile?autoReconnect=true&serverTimezone=UTC&allowMultiQueries=true&zeroDateTimeBehavior=convertToNull username: root password: 12345678 maximumPoolSize: 10 connectionTimeout: 30000 maxLifetime: 1800000 jdbc2: driverClassName: net.sf.log4jdbc.sql.jdbcapi.DriverSpy jdbcUrl: jdbc:log4jdbc:mysql://localhost:3306/chamomile2?autoReconnect=true&serverTimezone=UTC&allowMultiQueries=true&zeroDateTimeBehavior=convertToNull username: root password: 12345678 maximumPoolSize: 10 connectionTimeout: 30000 maxLifetime: 1800000다음과 같이 세팅하면 두 개의 datasource를 하나의 was에서 동시에 사용할 수 있다.
Multi Transaction 설정
위와같이 두 개의 datasource를 설정하면 각각의 datasource가 각각 다른 PlatformTransactionManager에 의하여 트랜잭션이 분리된다.
예를 들어 두개의 트랜잭션이 하나로 묶여야 하는데 앞쪽의 비즈니스 로직이 성공했고 뒤쪽의 비즈니스 로직이 실패했다면 앞쪽의 비즈니스 로직이 롤백되지 않는다.
때문에 이 두개의 트랜잭션을 하나로 묶어주어야한다.@Configuration @EnableTransactionManagement public class GlobalTransaction { @Bean @Primary @Autowired public PlatformTransactionManager transactionManager( @Qualifier("customTxManager1") PlatformTransactionManager txManager1, @Qualifier("customTxManager2") PlatformTransactionManager txManager2) { return new ChainedTransactionManager(txManager1, txManager2); } }다음과 같이 두 개의 트랜잭션을 하나로 묶어주면 쉽게 트랜잭션을 하나로 묶어 관리할 수 있다.
예제 코드
캐모마일에서는 샘플코드로 해당 상황에 따른 코드샘플을 제공한다. chamomile-sample-boot-security-multi-datasource 아키타입을 메이븐 프로젝트로 실행하면 예제 코드를 실행해볼 수 있다.
테이블 히스토리 추적
캐모마일에서는 특정 데이터베이스의 변경을 감지하고 추적하는 기능을 제공한다. 프로젝트의 요건에 따라 개인정보 수정 및 관리를 위해 변경 감지를 해야할 일이 있을 경우 사용하면 유용하다.
의존성 주입
<dependency> <groupId>net.lotte.chamomile.module</groupId> <artifactId>chamomile-database-history</artifactId> </dependency>REVINFO 테이블 생성
create table revinfo( rev bigint identity primary key, revtstmp bigint, USER_ID varchar(20) )옵션 설정
추적할 테이블명을 설정한다. 여러개라면 여러개를 작성한다. 여러개를 작성할 땐 쉼표를 통해 구분한다.
데이터베이스 종류 설정
대용량 작업 시 최대 chunk 제한 설정
테이블 suffix 설정
application.yml
chmm: history: table-whitelist: BOARD database-dialect: mysql query-max-row: 1000 table-suffix: _history추적을 위한 테이블 생성.
위의 suffix에서
_history
를 설정했으므로 해당 이름을 붙여 변경기록을 적재할 테이블을 만들어준다.--기존 추적할 테이블-- create table CHMM_USER_INFO ( USER_ID varchar(255) not null primary key, USER_EMAIL varchar(255) not null, USER_MOBILE varchar(14) default NULL, USER_NAME varchar(255) not null, USER_NICK varchar(255) default NULL, USER_PWD varchar(255) not null, USER_IMG varchar(4000) default NULL, USER_MSG varchar(4000) default NULL, USER_DESC varchar(1000) default NULL, USER_STAT_CD varchar(16) default NULL, USER_SNS_ID varchar(255) default NULL, ACCOUNT_NON_LOCK char default '1' not null, ACCOUNT_START_DT varchar(8) default '00000101' not null, ACCOUNT_END_DT varchar(8) default '99991231' not null, PASSWORD_EXPIRE_DT varchar(8) default '99991231' not null, USE_YN char default '1' not null, SYS_INSERT_DTM datetime default NULL, SYS_INSERT_USER_ID varchar(255) default NULL, SYS_UPDATE_DTM datetime default NULL, SYS_UPDATE_USER_ID varchar(255) default NULL, PASSWORD_LOCK_CNT int default 0 not null, EXCEPTION_SEND_YN char default NULL, LOG_SEND_YN char default NULL ); --변경내역을 저장 할 테이블(각종 제한 사항 및 관계를 모두 제거 + column 2개(REV_ID,REVTYPE) 추가) create table CHMM_USER_INFO_HISTORY ( USER_ID varchar(255) ,USER_EMAIL varchar(255) ,USER_MOBILE varchar(14) ,USER_NAME varchar(255) ,USER_NICK varchar(255) ,USER_PWD varchar(255) ,USER_IMG varchar(4000) ,USER_MSG varchar(4000) ,USER_DESC varchar(1000) ,USER_STAT_CD varchar(16) ,USER_SNS_ID varchar(255) ,ACCOUNT_NON_LOCK char ,ACCOUNT_START_DT varchar(8) ,ACCOUNT_END_DT varchar(8) ,PASSWORD_EXPIRE_DT varchar(8) ,USE_YN char ,SYS_INSERT_DTM datetime ,SYS_INSERT_USER_ID varchar(255) ,SYS_UPDATE_DTM datetime ,SYS_UPDATE_USER_ID varchar(255) ,PASSWORD_LOCK_CNT int ,EXCEPTION_SEND_YN char ,LOG_SEND_YN char ,REV_ID bigint ,REVTYPE int );VO 구성
기본적으로 사용자가 직접 입력한 변경 값만을 추적 반영하고있기 때문에 Application에서 의도적으로 변경하지 않은 항목들은 추적하지 않는다. 따라서 변경을 원하는 값을 정확하게 VO에 입력하여 Mapper로 넘겨주는것이 좋다.
특히 PK값은 @PrimaryKey 애노테이션을 활용하여 명시적으로 코드를 작성해주어야한다.PK가 복합키라면 해당 값 모두에 애노테이션을 선언하면 된다.
시퀀스값으로 자동생성되는 경우 mapper에서 GeneratedKey 설정을 해주면 자동생성하는 키를 인식할 수 있다.public class UserVO extends CmmDefaultVO { @NotNull @NotEmpty @Size(min = 5, max = 50) @PrimaryKey(column = "USER_ID") private String userId = ""; /* 사용자아이디 */ @NotNull @NotEmpty @Email @Size(max = 255) private String userEmail = ""; /* 사용자이메일 */ @Size(max = 14) private String userMobile = ""; /* 사용자모바일 */ @NotNull @NotEmpty @Size(max = 100) private String userName = ""; /* 사용자명 */ @Size(max = 255) private String userNick = ""; /* 사용자닉네임 */ @Size(max = 255) private String userPwd = ""; /* 사용자암호 */ private String userImg = ""; /* 사용자이미지 */ private String userMsg = ""; /* 사용자메시지 */ @Size(max = 200) private String userDesc = ""; /* 사용자설명 */ @Size(max = 16) private String userStatCd = ""; /* 사용자상태코드 */ @Size(max = 50) private String userSnsId = ""; /* 사용자 SNS ID */ @Size(max = 1) private String accountNonLock = ""; /* 사용자계정잠김여부 */ private String pwChange = ""; /* 비밀번호변경여부 */ @NotNull @NotEmpty @Size(max = 8) private String accountStartDt = ""; /* 계정시작일자 */ @NotNull @NotEmpty @Size(max = 8) private String accountEndDt = ""; /* 계정종료일자 */ @NotNull @NotEmpty @Size(max = 8) private String passwordExpireDt = ""; /* 패스워드만료일자 */ /** 검색조건 */ private String searchUserId; private String searchUserName; private String searchUseYn; . . .수집정보 추가 커스터마이징
아래와 같이 해당 빈을 등록하여 추가적으로 수집 할 데이터를 설정할 수 있다.
@Primary @Bean public PreparedStatementCreator revisionPreparedStatement() { return con -> { PreparedStatement pstmt = con.prepareStatement( "INSERT INTO REVINFO (REVTSTMP, USER_ID, USER_IP) VALUES (?, ?, ?)", new String[] {"REV"}); pstmt.setLong(1, System.currentTimeMillis()); pstmt.setString(2, SecurityContextHolder.getContext().getAuthentication().getName()); pstmt.setString(3, "127.0.0.1"); return pstmt; }; }마찬가지로, REVINFO 테이블에 추가적으로 수집할 데이터 column을 추가하면 정상 동작한다.
asdf
주의해야할 부분은Mapper 클래스에서 반드시 VO의 형태로 첫 번째 인자를 넘겨야 한다는 것이다. 해당 VO를 만들지 않는다면 히스토리 기능은 동작하지 않는다.
@Mapper public interface UserMapper { int insertUser(UserVO userVO); int insertUser(List<UserVO> userVO, BatchRequest batchRequest); int updateUser(UserVO userVO); void deleteUser(List<UserVO> userVOList, BatchRequest batchRequest); }
예제코드
chamomile-sample-boot-security-history
프로젝트를 메이븐 아키타입으로 생성한다면 동작하는 코드를 빠르게 확인해 볼 수 있다.