개발일지

[개발일지_safeHome] 4. sample 도메인 추가(로컬 DB 세팅, write/read 세팅, sample 테이블 추가)

woopii 2025. 12. 10. 00:13

 

로컬 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() // 항상 해제
        }
    }
}