로컬 DB는 H2DB를 사용할 예정
(DB를 메모리에 적재하는 방법과, 파일에 적재하는 방법이 있는데, 파일을 생성할려고 함)
schema.sql, data.sql 을 활용해서 로컬데이터 초기화에 사용할거고
그리고 write, read 용 2개의 DB를 가정하여 세팅을 할것임
(로컬은 같은 DB를 가지고 write, read 를 세팅하고 transaction에 따라 routing 사용할 DB를 선택 할 것임)
1. schema.sql, data.sql
우선, sample DB 초기화용 schema.sql, data.sql을 작성
JPA ddl-auto 에 비해 더욱 예측 가능한 형태로 만들수 있고, 의도치 않은 수정이 이루어지지 않는게 장점인거 같음
- schema.sql
-- 셈플 마스터 테이블
CREATE TABLE IF NOT EXISTS samples (
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT 'ID',
name VARCHAR(100) NOT NULL COMMENT '이름',
code VARCHAR(100) NOT NULL COMMENT '코드 (유니크)',
description TEXT NULL COMMENT '설명',
order_no INT NOT NULL DEFAULT 0 COMMENT '정렬 순서',
is_deleted BOOLEAN NOT NULL DEFAULT FALSE COMMENT '삭제 여부',
created_id BIGINT NOT NULL COMMENT '생성자 ID',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성 시간',
updated_id BIGINT NOT NULL COMMENT '수정자 ID',
updated_at TIMESTAMP NULL DEFAULT NULL COMMENT '수정 시간',
UNIQUE (code)
);
CREATE INDEX IF NOT EXISTS idx_sample_name ON sample(name);
CREATE INDEX IF NOT EXISTS idx_sample_order ON sample(order_no);
CREATE INDEX IF NOT EXISTS idx_sample_deleted ON sample(is_deleted);
-- 셈플 상세 테이블
CREATE TABLE IF NOT EXISTS sample_details (
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT 'ID',
sample_id BIGINT NOT NULL COMMENT '샘플 ID',
detail_value VARCHAR(500) NULL COMMENT '상세 값',
is_deleted BOOLEAN NOT NULL DEFAULT FALSE COMMENT '삭제 여부',
created_id BIGINT NOT NULL COMMENT '생성자 ID',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '생성 시간',
updated_id BIGINT NOT NULL COMMENT '수정자 ID',
updated_at TIMESTAMP NULL DEFAULT NULL COMMENT '수정 시간'
);
CREATE INDEX IF NOT EXISTS idx_detail_sample_id ON sample_detail(sample_id);
CREATE INDEX IF NOT EXISTS idx_detail_detail_value ON sample_detail(detail_value);
CREATE INDEX IF NOT EXISTS idx_detail_deleted ON sample_detail(is_deleted);
- data.sql
-- 샘플 마스터 데이터
INSERT IGNORE INTO sample (name, code, description, order_no, is_deleted, created_id, created_at, updated_id, updated_at)
VALUES
('샘플 A', 'SMP-A', '샘플 A 설명입니다.', 1, FALSE, 1, CURRENT_TIMESTAMP, 1, CURRENT_TIMESTAMP),
('샘플 B', 'SMP-B', '샘플 B 설명입니다.', 2, FALSE, 1, CURRENT_TIMESTAMP, 1, CURRENT_TIMESTAMP),
('샘플 C', 'SMP-C', '샘플 C 설명입니다.', 3, FALSE, 1, CURRENT_TIMESTAMP, 1, CURRENT_TIMESTAMP);
-- 샘플 상세 데이터 (A)
INSERT IGNORE INTO sample_detail (sample_id, detail_value, is_deleted, created_id, created_at, updated_id, updated_at)
VALUES
(1, '샘플 A 상세값 1', FALSE, 1, CURRENT_TIMESTAMP, 1, CURRENT_TIMESTAMP),
(1, '샘플 A 상세값 2', FALSE, 1, CURRENT_TIMESTAMP, 1, CURRENT_TIMESTAMP);
-- 샘플 상세 데이터 (B)
INSERT IGNORE INTO sample_detail (sample_id, detail_value, is_deleted, created_id, created_at, updated_id, updated_at)
VALUES
(2, '샘플 B 상세값 1', FALSE, 1, CURRENT_TIMESTAMP, 1, CURRENT_TIMESTAMP),
(2, '샘플 B 상세값 2', FALSE, 1, CURRENT_TIMESTAMP, 1, CURRENT_TIMESTAMP);
-- 샘플 상세 데이터 (C)
INSERT IGNORE INTO sample_detail (sample_id, detail_value, is_deleted, created_id, created_at, updated_id, updated_at)
VALUES
(3, '샘플 C 상세값 1', FALSE, 1, CURRENT_TIMESTAMP, 1, CURRENT_TIMESTAMP),
(3, '샘플 C 상세값 2', FALSE, 1, CURRENT_TIMESTAMP, 1, CURRENT_TIMESTAMP);
2. application-db.yml
application.yml 설정 파일을 여러 여러 설정파일을 import하는 형태로 구성함(이렇게 하는게 관리가 편한거 같음)
db관련 설정 파일은 application-db.yml 로 만들었다.
- application.yml
spring:
application:
name: safehome
config:
import:
- classpath:application-db.yml
- application-db.yml
---
spring:
config:
activate:
on-profile: local
h2:
console:
enabled: true
path: /h2-console
settings:
web-allow-others: true # 외부 접근 허용 (개발 환경에서만)
datasource:
write:
driver-class-name: org.h2.Driver
url: jdbc:h2:file:./h2db/db/application;MODE=MYSQL;DATABASE_TO_UPPER=false
username: sa
password:
read:
driver-class-name: org.h2.Driver
url: jdbc:h2:file:./h2db/db/application;MODE=MYSQL;DATABASE_TO_UPPER=false;ACCESS_MODE_DATA=r
username: sa
password:
hikari:
minimum-idle: 2 # 최소 유지할 커넥션 수
maximum-pool-size: 5 # 최대 커넥션 수
idle-timeout: 600000 # 커넥션이 사용되지 않을 경우 대기 시간 (밀리초)
max-lifetime: 600000 # 커넥션의 최대 수명 (밀리초)
connection-timeout: 30000 # 커넥션 풀에서 커넥션을 얻기까지의 최대 대기 시간 (밀리초)
pool-name: HikariCP # 커넥션 풀의 이름
connection-test-query: SELECT 1 # 커넥션을 확인할 때 사용할 쿼리 (MySQL 기준)
sql:
init:
schema-locations: classpath*:init/h2db/schema.sql
data-locations: classpath*:init/h2db/data.sql
mode: ${SQL_INIT_MODE:always}
jpa:
defer-datasource-initialization: true
hibernate:
ddl-auto: none
show-sql: true
format_sql: true
3. dataSource config
application-db에서 write, read DB를 분리 후 이후 작업을 안하고
어플리케이션 시작 하면, 파일DB 생성되지 않고
메모리에 생성되는 것을 볼 수가 있다.
H2 console available at '/h2-console'. Database available at 'jdbc:h2:mem:b2f32b38-4ad5-4c8e-9a2e-2b783a90b105'
이유는 내가 write, read 을 커스텀하게 설정했는데, dataSource를 지정 안해줘서
spring.datasource.url을 없는것으로 보고 기본 H2 인메모리를 생성한 것임.
그래서 추가로 data source config를 추가하였다.
- write / read DB를 라우팅하는 DataSource config
package com.woopi.safehome.global.config
import com.woopi.safehome.global.config.datasource.RoutingDataSource
import jakarta.persistence.EntityManagerFactory
import org.springframework.beans.factory.annotation.Qualifier
import org.springframework.boot.autoconfigure.jdbc.DataSourceProperties
import org.springframework.boot.context.properties.ConfigurationProperties
import org.springframework.boot.orm.jpa.EntityManagerFactoryBuilder
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.context.annotation.Primary
import org.springframework.orm.jpa.JpaTransactionManager
import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean
import org.springframework.transaction.PlatformTransactionManager
import javax.sql.DataSource
@Configuration
class RoutingDataSourceConfig {
/**
* Write DB의 기본 연결 정보 (url, username, password 등)를 담는 Properties 객체
* application-db.yml의 spring.datasource.write 설정을 바인딩
*/
@Bean
@ConfigurationProperties("spring.datasource.write")
fun writeDataSourceProperties(): DataSourceProperties = DataSourceProperties()
/**
* Read DB의 기본 연결 정보를 담는 Properties 객체
* application-db.yml의 spring.datasource.read 설정을 바인딩
*/
@Bean
@ConfigurationProperties("spring.datasource.read")
fun readDataSourceProperties(): DataSourceProperties = DataSourceProperties()
/**
* Write용 DataSource 생성
* 1. writeDataSourceProperties()로 url, username 등 기본 정보 가져오기
* 2. initializeDataSourceBuilder()로 HikariDataSource 빌더 생성
* 3. @ConfigurationProperties("spring.datasource.hikari")가 자동으로 Hikari 설정 바인딩
*/
@Bean
@ConfigurationProperties("spring.datasource.hikari")
fun writeDataSource(): DataSource =
writeDataSourceProperties().initializeDataSourceBuilder().build()
/**
* Read용 DataSource 생성
* Write와 동일한 방식으로 생성하지만, readDataSourceProperties() 사용
* 주의: write와 동일한 hikari 설정을 공유함
*/
@Bean
@ConfigurationProperties("spring.datasource.hikari")
fun readDataSource(): DataSource =
readDataSourceProperties().initializeDataSourceBuilder().build()
/**
* Write/Read를 동적으로 선택하는 라우팅 DataSource
* @Primary: 기본 DataSource로 지정 (다른 DataSource 빈이 있어도 이게 우선)
* 트랜잭션의 readOnly 속성에 따라 write/read DB를 자동 선택
*/
@Primary
@Bean
fun routingDataSource(): DataSource =
RoutingDataSource(writeDataSource(), readDataSource())
/**
* JPA EntityManager를 생성하는 팩토리
* - dataSource: 위에서 만든 routingDataSource 사용
* - packages: 엔티티 클래스들이 있는 패키지 스캔
* - persistenceUnit: 영속성 유닛 이름 (default)
*/
@Primary
@Bean
fun entityManagerFactory(
builder: EntityManagerFactoryBuilder
): LocalContainerEntityManagerFactoryBean =
builder
.dataSource(routingDataSource())
.packages("com.woopi.safehome.domain")
.persistenceUnit("default")
.build()
/**
* JPA 트랜잭션 관리자
* EntityManagerFactory를 통해 트랜잭션 관리 수행
* @Transactional 어노테이션 동작의 핵심
*/
@Primary
@Bean
fun transactionManager(
@Qualifier("entityManagerFactory") emf: EntityManagerFactory
): PlatformTransactionManager = JpaTransactionManager(emf)
}
- 라우팅 DataSource 객체
package com.woopi.safehome.global.config.datasource
import org.springframework.jdbc.datasource.lookup.AbstractRoutingDataSource
import javax.sql.DataSource
/**
* 트랜잭션의 readOnly 속성에 따라 Write/Read DataSource를 동적으로 선택하는 라우팅 DataSource
* ThreadLocal 기반 DataSourceContextHolder를 통해 현재 스레드의 DataSource 타입을 결정
*/
class RoutingDataSource (
private val writeDataSource: DataSource,
private val readDataSource: DataSource
): AbstractRoutingDataSource() {
init {
// DataSource 타입별 매핑 설정
val targetDataSources = mapOf<Any, Any>(
DataSourceType.WRITE to writeDataSource,
DataSourceType.READ to readDataSource
)
setTargetDataSources(targetDataSources)
// 기본값은 Write DataSource (안전장치)
setDefaultTargetDataSource(writeDataSource)
// 초기화 완료
afterPropertiesSet()
}
/**
* 현재 스레드에서 사용할 DataSource 타입 결정
* DataSourceContextHolder에 값이 없으면 기본값으로 WRITE 사용
*/
override fun determineCurrentLookupKey(): DataSourceType {
val key = DataSourceContextHolder.get() ?: DataSourceType.WRITE
logger.debug("### Using DataSource: $key")
return key
}
}
- DataSourceType
package com.woopi.safehome.global.config.datasource
/** Write/Read DataSource 구분용 타입 */
enum class DataSourceType {
WRITE, READ
}
- ThreadLocal에 DataSource를 담는 contextHolder
package com.woopi.safehome.global.config.datasource
/**
* ThreadLocal을 이용해 현재 스레드의 DataSource 타입을 저장/조회
* 주의: 반드시 사용 후 clear()를 호출해야 메모리 누수 방지 가능
*/
object DataSourceContextHolder {
private val contextHolder = ThreadLocal<DataSourceType>()
/** DataSource 타입 설정 */
fun set(type: DataSourceType) {
contextHolder.set(type)
}
/** 현재 스레드의 DataSource 타입 조회 */
fun get(): DataSourceType? = contextHolder.get()
/** ThreadLocal 값 제거 (메모리 누수 방지, 필수 호출) */
fun clear() {
contextHolder.remove()
}
}
- Transaction에 따라서 DataSource를 선택하기 위한 인터셉터
finally에 clear를 하는 이유?
올바른 DataSource 선택을 위해서(의도치 않게 ThreadLocal이 남아서 잘못된 DB를 선택함을 방지하기 위해)
예시
요청 A (Thread-1):
- set(READ)
- 쿼리 실행
- clear() 안 함
요청 B (Thread-1 재사용):
- set() 호출 안 함
- get() → 여전히 READ 반환
- Write 쿼리인데 READ DB로 감
package com.woopi.safehome.global.aop
import com.woopi.safehome.global.datasource.DataSourceContextHolder
import com.woopi.safehome.global.datasource.DataSourceType
import org.aspectj.lang.ProceedingJoinPoint
import org.aspectj.lang.annotation.Around
import org.aspectj.lang.annotation.Aspect
import org.springframework.stereotype.Component
import org.springframework.transaction.annotation.Transactional
@Aspect
@Component
class DataSourceTransactionInterceptor {
@Around("@annotation(transactional)")
fun setDataSource(joinPoint: ProceedingJoinPoint, transactional: Transactional): Any? {
try {
// readOnly면 READ, 아니면 WRITE
val type = if (transactional.readOnly) DataSourceType.READ else DataSourceType.WRITE
DataSourceContextHolder.set(type)
return joinPoint.proceed()
} finally {
DataSourceContextHolder.clear() // 항상 해제
}
}
}
'개발일지' 카테고리의 다른 글
| [개발일지_safeHome] 6.JPA Audit 설정 추가 (0) | 2025.12.15 |
|---|---|
| [개발일지_safeHome] 5. sample 도메인 개발 (모델, 엔티티) (0) | 2025.12.14 |
| [개발일지_safeHome] 3. sample 도메인 추가(패키지 설계) (0) | 2025.12.07 |
| [개발일지_safeHome] 2. 백엔드 프로젝트 생성 (0) | 2025.12.02 |
| [개발일지_safeHome] 1. safeHome 토이 프로젝트 시작 (0) | 2025.12.02 |