정리정리

Spring JPA 데이터베이스 초기화 본문

JPA

Spring JPA 데이터베이스 초기화

wlsh 2023. 3. 27. 23:30

스프링 2.7 기준으로 작성된 글입니다.

스프링 부트 2.5 이후로 데이터베이스를 초기화하는 방법에 몇가지 변화가 있었습니다. 그래서 이번 포스트에서는 스프링 부트 2.5 이후에서 데이터베이스 스키마를 생성하고 초기화 하는 방법들을 알아보겠습니다.

JPA 데이터베이스 초기화

@Entity 가 붙은 클래스들을 스캔해서 자동으로 스키마를 생성해주는 방식입니다. 이 방식에는 JPA에서 제공하는 설정과 Hibernate에서 제공하는 설정이 있습니다. 보통 개발 단계에서 Hibernate가 제공하는 ddl-auto의 create, create-drop, update 기능을 많이 사용하고, 프로덕트에는 none 으로 설정을 합니다.

  • spring.jpa.generate-ddl=(boolean)
    • JPA에서 기본적으로 제공하는 스키마 생성 옵션
    • true 로 설정 시 자동으로 생성
  • spring.jpa.hibernate.ddl-auto=(enum)
    • Hibernate가 제공하는 스키마 생성 옵션
    • JPA의 generate-ddl 보다 더 다양한 옵션 제공
      • create: 기존 스키마 제거 후 다시 생성
      • create-drop: 기존 스키마 제거 후 다시 생성, 종료 시 제거
      • update: DB 스키마와 비교해, 엔티티에 변화가 있으면 DB 칼럼 추가 (제거는 하지 않음)
      • validate: 시작 시 Entity와 DB 스키마 구조를 비교해서 같은지만 확인, 일치하지 않으면 에러 발생
      • none: 아무것도 하지 않음
    • 기본값은 스키마 관련 설정을 하지 않으면 create-drop이지만, 그 외는 전부 none

SQL Script를 이용한 초기화 (Script-based Initialization)

이번에는 SQL 스크립트 파일을 이용해서 스키마를 생성하는 방식을 알아보겠습니다. 스프링 부트는 기본적으로 DataSource 의 스키마를 schema.sql을 통해 생성하고, data.sql을 통해 초기화를 할 수 있습니다. 해당 파일들을 클래스패스의 루트 경로에 두면 DataSource가 생성될 때 스프링 부트가 자동으로 해당 파일들을 읽고 초기화를 합니다.

예제

application.yml

spring:
  datasource:
    driver-class-name: org.h2.Driver
    url: jdbc:h2:mem:db
    username: sa
    password:

  jpa:
    hibernate:
      ddl-auto: none

    properties:
      hibernate:
        format_sql: true
        show_sql: true

schema.sql

drop table if exists member CASCADE;

create table member (
	id bigint generated by default as identity,
	name varchar(255),
	primary key (id)
);

data.sql

insert into member (id, name) values (default, 'data.sql');

테스트 코드

@SpringBootTest
@Transactional
class MemberRepositoryTest {

    @Autowired
    MemberRepository memberRepository;

    @Test
    void findTest() throws Exception {
        //when
        Member member = memberRepository.findById(1L).get();
		
        //then
        assertThat(member.getName()).isEqualTo("data.sql");
    }
}

경로 및 이름 지정

스프링 부트 2.5 Configuration 변경 사항
스프링 부트 2.5 이후로 스키마 경로 지정 설정 방법이 다음처럼 바뀌었습니다.

spring.datasource.data  spring.sql.init.data-locations
docs: data-locations

spring.datasource.schema  spring.sql.init.schema-locations
docs: schema-locations

클래스 패스를 기준으로 경로와 스크립트 이름을 지정해주면 됩니다.

spring:
  sql:
    init:
      data-locations: classpath:db/h2/dml.sql
	  schema-locations: classpath:db/h2/ddl.sql

스크립트 플랫폼 지정

Spring Boot processes the schema-${platform}.sql and data-${platform}.sql files (if present), where platform is the value of spring.sql.init.platform. This allows you to switch to database-specific scripts if necessary. For example, you might choose to set it to the vendor name of the database (hsqldb, h2, oracle, mysql, postgresql, and so on)

schema-${platform}.sql, data-${platform}.sql 과 같이 platform 을 지정하고, properties에 spring.sql.init.platform 을 통해 특정 db의 스크립트를 실행시킬 수 있습니다. 이를 통해 여러 db마다 다른 sql문법을 극복할 수 있을 것 같습니다.

예시

application.yml

spring:
  sql:
    init:
      platform: dev

data-prod.sql

insert into member (id, name) values (default, 'prod');

data-dev.sql

insert into member (id, name) values (default, 'dev');

테스트 코드

    @Test
    void platformTest() throws Exception {
        //when
        Member member = memberRepository.findById(1L).get();

        //then
        assertThat(member.getName()).isEqualTo("dev");
    }

플랫폼을 지정하여 스크립트를 실행할 경우, 만약 schema.sql과 data.sql도 존재한다면 해당 파일도 실행이 되는 점을 조심해야 합니다.
이때 실행되는 순서는 schema-${platform}.sql -> schema.sql 순이며, data.sql도 마찬가지로 플랫폼 파일이 먼저 실행됩니다.

테스트 코드

    @Test
    void platformFindAllTest() throws Exception {
        //when
        List<Member> members = memberRepository.findAll();

        //then
        for (Member member : members) {
            System.out.println(member);
        }
        
        assertThat(members.size()).isEqualTo(2);
    }

만약 schema.sql 에서 drop table 라인을 제거하면 스키마 생성 과정에서 예외가 발생합니다.

SQL 스크립트 초기화 주의점 1 (인메모리 실행)

By default, SQL database initialization is only performed when using an embedded in-memory database. To always initialize an SQL database, irrespective of its type, set spring.sql.init.mode to always. Similarly, to disable initialization, set spring.sql.init.mode to never

기본적으로 스프링 부트에서 SQL 스크립트 초기화 기능은 인메모리 데이터베이스일 경우에만 작동됩니다.
만약 Mysql 등의 서버 상태의 디비를 사용하면서 SQL을 초기화하고 싶다면 spring.sql.init.mode: always 로 설정해야 합니다.
(spring.datasource.initalization-mode 는 스프링 부트 2.5에서 Deprecated 되었습니다. 변경 사항)
spring.sql.init.mode는 다음과 같이 3가지 설정이 가능합니다.

  • always : 어떤 db를 사용해도 항상 sql 실행
  • embedded (기본값): 임베디드 db를 사용할 때만 실행
  • never : 절대 실행하지 않음

SQL 스크립트 초기화 주의점 2 (Hibernate 초기화와 data.sql)

스프링 2.5 이후로 Flyway나 Liquibase와 같은 스크립트 기반 툴의 동작 방식과 일치시키기 위해 data.sql은 JPA의 EntityManagerFctory 가 생성되기 전에 실행되도록 바뀌었습니다. 변경 사항
그렇기 때문에 schema.sql 없이 Hibernate의 스키마 생성 방식인 ddl-auto: create  data.sql 을 이용해 스키마 초기화 작업을 하면 Hibernate를 통해 스키마 생성이 되기 전에 data.sql 을 실행시키기 때문에 테이블이 없다고 오류가 나게 됩니다.

예시

application.yml

spring:	
	jpa:
    hibernate:
      ddl-auto: create-drop

data.sql

insert into member (id, name) values (default, 'test');

테스트 코드

    @Test
    void findTest() throws Exception {
        //when
        Member member = memberRepository.findById(1L).get();

        //then
        assertThat(member.getName()).isEqualTo("data");
    }

org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'dataSourceScriptDatabaseInitializer' defined in class path resource [org/springframework/boot/autoconfigure/sql/init/DataSourceInitializationConfiguration.class]: Invocation of init method failed; nested exception is org.springframework.jdbc.datasource.init.ScriptStatementFailedException: Failed to execute SQL script statement #1 of URL [file:/Users/jinho/Desktop/jinho/study/spring/dbtest/out/production/resources/data.sql]: insert into member (id, name) values (default, 'test'); 
nested exception is org.h2.jdbc.JdbcSQLSyntaxErrorException: Table "MEMBER" not found (this database is empty); 
SQL statement: insert into member (id, name) values (default, 'test') [42104-214]

예외 메시지를 자세히 살펴보면 마지막에 MEMBER 테이블이 존재하지 않기 때문에 예외를 발생한 것을 볼 수 있습니다.

While we do not recommend using multiple data source initialization technologies, if you want script-based DataSource initialization to be able to build upon the schema creation performed by Hibernate, set spring.jpa.defer-datasource-initialization to true. This will defer data source initialization until after any EntityManagerFactory beans have been created and initialized.

spring.jpa.defer-datasource-initialization: true를 통해 해결할 수 있지만, 스프링에서는 Script-base 방식과 Hibernate의 생성 방식과 같이 다양한 데이터 초기화 기술을 함께 쓰는 것을 권장하지 않습니다.

Hibernate의 스크립트 초기화 (import.sql)

Hibernate는 import.sql 이라는 파일을 통해 데이터를 초기화 할 수 있습니다. 이때 import.sql은 클래스패스의 루트 디렉토리에 위치해야 하며, ddl-auto가 create, create-drop 일 경우에만 실행됩니다. 따라서 해당 옵션을 사용하기 힘든 프로덕트 환경이 아니라 테스트나 로컬 개발을 위한 기능으로 사용됩니다.

또한 하나의 쿼리를 한 줄에 작성해야 합니다.

import.sql

-- 예외 발생
insert
into
    member
(id, name)
values
    (default, 'import');
    
-- 정상 작동
insert into member (id, name) values (default, 'import');

스프링에서 spring.datasource.initalization-mode의 기능을 spring.sql.init.mode가 대신 하면서 data.sql이 작동하지 않아, 스프링 부트의 데이터 초기화 파일 이름이 data.sql에서 import.sql 로 바뀌었다고 착각한 분들을 종종 보았습니다. 하지만 import.sql 은 Hibernate가 생성되고 스키마를 만든 후 개인적으로 실행시키는 파일로, 스프링과는 전혀 관련 없는 기능이고 스프링 docs에도 이와 같은 내용이 기재되어 있습니다. docs

@Sql을 통한 초기화

스프링은 4.1 이후부터 테스트를 위한 @Sql 애노테이션을 제공합니다. 해당 애노테이션을 통해 특정 스키마를 생성하고 초기화할 수 있습니다.
이 외에도 트랜잭션 모드, 인코딩, 등을 설정할 수 있는 기능들을 포함하고 있습니다.

package org.springframework.test.context.jdbc;

/**
 * @author Sam Brannen
 * @since 4.1
 * @see SqlConfig
 * @see SqlMergeMode
 * @see SqlGroup
 * @see SqlScriptsTestExecutionListener
 * @see org.springframework.transaction.annotation.Transactional
 * @see org.springframework.test.context.transaction.TransactionalTestExecutionListener
 * @see org.springframework.jdbc.datasource.init.ResourceDatabasePopulator
 * @see org.springframework.jdbc.datasource.init.ScriptUtils
 */
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
@Repeatable(SqlGroup.class)
public @interface Sql {

	@AliasFor("scripts")
	String[] value() default {};

	@AliasFor("value")
	String[] scripts() default {};

	String[] statements() default {};

	ExecutionPhase executionPhase() default ExecutionPhase.BEFORE_TEST_METHOD;

	SqlConfig config() default @SqlConfig;

	enum ExecutionPhase {
		BEFORE_TEST_METHOD,
		AFTER_TEST_METHOD
	}

}

예제

member-data.sql

insert
into
    member
(id, name)
values
    (default, 'sql-annotation');
@SpringBootTest
@Transactional
@Sql(scripts = {"classpath:member-data.sql"})
class MemberRepositoryTest {

    @Autowired
    MemberRepository memberRepository;
    
    @Test
    void findTest() throws Exception {
        //when
        Member member = memberRepository.findById(1L).get();

        //then
        assertThat(member.getName()).isEqualTo("sql-annotation");
    }
}

참고

https://github.com/spring-projects/spring-boot/wiki/Spring-Boot-2.5-Release-Notes
https://github.com/spring-projects/spring-boot/wiki/Spring-Boot-2.5.0-Configuration-Changelog
https://docs.spring.io/spring-boot/docs/2.7.x/reference/html/howto.html#howto.data-initialization
https://docs.spring.io/spring-boot/docs/current/reference/html/application-properties.html
https://www.baeldung.com/spring-boot-data-sql-and-schema-sql

Comments