티스토리 뷰

목표

  • Spring Boot 2.1.x 기반 프로젝트에서 복수개의 데이터베이스에 대해 HikariCP 커넥션 풀 기반으로 JPA 개발 환경을 구축한다.
  • Spring Data JPA를 적용하여 RAW 쿼리문 작성을 최소화하고 페이지네이션 기능 등을 간편하게 제공하는 리파지터리를 구현한다.
  • Spring Data REST를 적용하여 개발된 엔티티와 리파지터리에 대해 HAL 형식으로 즉각적으로 확인한다.
  • Flyway를 적용하여 데이터베이스에 대한 형상 관리를 애플리케이션 레벨에서 수행한다.

build.gradle

  • /build.gradle 파일에 아래 내용을 추가한다.
buildscript {
    ext {
        kotlinVersion = '1.3.41'
    }
    dependencies {
        classpath("org.jetbrains.kotlin:kotlin-allopen:${kotlinVersion}")
        classpath("org.jetbrains.kotlin:kotlin-noarg:${kotlinVersion}")
    }
}

apply plugin: 'kotlin-jpa'

allOpen {
    annotation("javax.persistence.Entity")
    annotation("javax.persistence.MappedSuperclass")
    annotation("javax.persistence.Embeddable")
}

dependencies {
    implementation('org.springframework.boot:spring-boot-starter-data-jpa')
    compile group: 'com.zaxxer', name: 'HikariCP', version: '3.3.1'
    // MySQL 일 경우
    compile group: 'mysql', name: 'mysql-connector-java', version: '8.0.13'
    // MariaDB 일 경우
    compile group: 'org.mariadb.jdbc', name: 'mariadb-java-client', version: '2.5.1'
}
  • 위는 Kotlin으로 JPA 관련 클래스를 효율적으로 작성하기 위한 설정이다. 각 설정의 자세한 의미는 이 글을 정독할 것을 추천한다.
  • HikariCP는 Spring Boot 2.x부터 기본 커넥션 풀 라이브러리로 채택되었을 정도로 현존 최고 성능을 자랑한다. (따라서 기본적으로 제공되므로 생략해도 무관하다. 다만, 최신 버전을 사용하기 위해 별도로 명시했다.)
  • MySQL(또는 MariaDB) 데이터베이스 사용을 가정하여 관련 드라이버 아티팩트를 추가하였다.

application.yml

  • /src/main/resources/application.yml 파일에 아래 내용을 추가한다.
spring:
  datasource:
    somedb:
      jdbc-url: jdbc:mysql://localhost:3306/somedb?useUnicode=yes&characterEncoding=UTF-8
      driver-class-name: com.mysql.cj.jdbc.Driver
      username: someuser
      password: somepassword

  jpa:
    database-platform: org.hibernate.dialect.MySQL5InnoDBDialect
    open-in-view: false
    properties:
      hibernate:
        format_sql: true
    generate-ddl: true

logging:
  level:
    org:
      hibernate:
        SQL: DEBUG
        type:
          descriptor:
            sql:
              BasicBinder: TRACE
  • 본래 spring.datasource에 적절히 커넥션 풀 정보를 명시하면 Spring Boot가 자동으로 인식하여 DataSource 빈을 생성해준다. 하지만 프로젝트 내에 2개 이상의 DataSource 빈 설정이 필요할 경우 확장이 불가능하다. 따라서 위와 같이 somedb라는 이름의 데이터베이스 식별자를 추가하였다.
  • spring.jpa.database-platform에는 어떤 데이터베이스에 접속할 것인지에 대한 명시적 설정이 가능하다. MySQL의 경우 위와 같이 org.hibernate.dialect.MySQL5InnoDBDialect로 설정하면 InnoDB 스토리지 엔진의 특성을 활용할 수 있다.
  • spring.jpa.generate-ddl 옵션을 활성화화면 최초 데이터베이스 스키마를 자동으로 생성해준다. 이 옵션은 개발 환경에서만 권장하며 운영 환경에서는 Flyway와 같은 별도의 전문적인 마이그레이션 툴 이용을 권장한다.
  • logging.level.org.hibernate.SQLDEBUG로 설정하면 로그에 JPA가 실행하는 쿼리문을 출력해주는 역할을 한다. 추가적으로 logging.level.org.hibernate.type.descriptor.sql.BasicBinderTRACE로 설정해주면 쿼리문의 파라메터까지 출력해준다. 마지막으로 spring.jpa.properties.hibernate.format_sql 옵션을 활성화하면 쿼리문을 가독성이 좋게 정렬하여 출력해준다.

JpaConfig 작성

  • /src/main/java/JpaConfig.java 파일에 아래 내용을 작성한다.
package com.jsonobject.jpademo

import com.zaxxer.hikari.HikariDataSource
import org.springframework.beans.factory.annotation.Qualifier
import org.springframework.boot.context.properties.ConfigurationProperties
import org.springframework.boot.jdbc.DataSourceBuilder
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.data.jpa.repository.config.EnableJpaRepositories
import org.springframework.orm.jpa.JpaTransactionManager
import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean
import org.springframework.transaction.PlatformTransactionManager
import org.springframework.transaction.annotation.EnableTransactionManagement
import javax.persistence.EntityManagerFactory
import javax.sql.DataSource

@Configuration
@EnableTransactionManagement
@EnableJpaRepositories(
        entityManagerFactoryRef = "entityManagerFactory",
        transactionManagerRef = "transactionManager",
        basePackages = ["com.jsonobject.jpademo.repository.somedb"]
)
class JpaConfig {

    @Primary
    @Bean
    @ConfigurationProperties("spring.datasource.somedb")
    fun dataSource(): DataSource {

        val dataSource = DataSourceBuilder.create().type(HikariDataSource::class.java).build()

        // UTF-8이 아닌 레거시 데이터베이스에 연결시 한글 문자열을 온전히 처리하기 위해 사용
        dataSource.connectionInitSql = "SET NAMES utf8mb4"

        return dataSource
    }

    @Primary
    @Bean
    fun entityManagerFactory(

            builder: EntityManagerFactoryBuilder,
            @Qualifier("dataSource") dataSource: DataSource): LocalContainerEntityManagerFactoryBean {

        return builder
                .dataSource(dataSource)
                .packages("com.jsonobject.jpademo.entity.somedb")
                .persistenceUnit("somedb")
                .build()
    }

    @Primary
    @Bean
    fun transactionManager(

            @Qualifier("entityManagerFactory") entityManagerFactory: EntityManagerFactory): PlatformTransactionManager {

        val transactionManager = JpaTransactionManager()
        transactionManager.entityManagerFactory = entityManagerFactory

        return transactionManager
    }
}
  • JpaConfg 환경 설정 빈은 연결하고자 하는 물리적인 데이터베이스마다 1개씩 작성해야 한다.(연결하는 데이터베이스가 1개라면 위 예제로 충분하다.) 각 설정마다 대응하는 엔티티와 리파지터리의 패키지 위치를 명시해주어야 정상적으로 작동한다.
  • EntityManagerFactory 빈 생성시 persistenceUnit을 지어줄 수 있다. 복수개의 EntityManagerFactory 빈 생성시 각 빈의 식별자 역할을 하는데 1개의 빈만 생성할 것이라면 생략해도 된다.

User 엔티티 작성

  • 테이블을 대표할 엔티티를 작성할 차례이다. /src/main/java/User.java 파일에 아래 내용을 작성한다.
package com.jsonobject.jpademo;

import javax.persistence.*;
import java.time.LocalDateTime;

@Entity
@Table(name = "some_database.user")
class User {

    @Id
    @Column(name = "id")
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    var id: Long = 0

    @Column(name = "name")
    var name: String = ""

    @Column(name = "email")
    var email: String = ""

    @Column(name = "gender")
    @Enumerated(EnumType.STRING)
    var gender: Gender = Gender.MALE

    @Column(name = "created_at")
    var createdAt: LocalDateTime = LocalDateTime.now()

    // JPA로 제어하지 않을 필드
    @Transient
    var notJpaField: String = ""
}
  • @Entity를 클래스 레벨에 명시하여 JPA에 의해 관리되는 엔티티 임을 선언했다. 추가적으로 @Table을 명시하면 실제 맵핑될 테이블의 이름을 명확히 선언할 수 있다.
  • @Id를 필드 레벨에 명시하여 기본키 식별자 역할을 하는 필드 임을 선언했다. 추가적으로 @GeneratedValue(strategy = GenerationType.IDENTITY)을 명시하여 기본키 생성을 데이터베이스가 수행하도록 선언했다.
  • @Column을 필드 레벨에 명시하여 테이블의 어떤 컬럼에 대응하는 필드인지를 선언했다. 데이터베이스 컬럼과 엔티티 필드의 일치하는 경우에는 생략해도 된다.
  • 만약 JPA로 제어하지 않을 필드를 정의해야 한다면 @Transient를 필드 레벨에 선언해야 한다. [관련 링크]
  • Enum 클래스의 경우 @Enumerated를 명시해주지 않으면 예외가 발생한다. 데이터베이스에 어떻게 저장하고 가져올지 방법을 정해주어야 한다. EnumType.STRINGEnum의 이름을 그대로 문자열로 저장한다. 반면에 EnumType.ORDINAL은 숫자로 저장한다. [관련 링크]
  • Enum 클래스와 데이터베이스 컬럼 간의 데이터 변환을 직접 수행하는 방법도 있다. 아래와 같이 @Converter 클래스를 작성하면 된다. autoApply = true 옵션을 주면 엔티티에 별도로 @Converter를 명시하지 않아도 자동으로 컨버터가 적용되어 편리하다. 이 경우, 주의할 점은 @Enumerated를 제거해야 정상적으로 컨버터가 작동한다. [관련 링크]
@Converter(autoApply = true)
class GenderConverter : AttributeConverter<Gender, String> {

    override fun convertToEntityAttribute(dbData: String?): Gender? {

        return when (dbData) {
            null -> null
            else -> Gender.valueOf(dbData.toUpperCase())
        }
    }

    override fun convertToDatabaseColumn(attribute: Gender?): String? {

        return attribute?.name?.toLowerCase()
    }
}
  • 아래는 JSON 문자열이 저장된 데이터베이스의 컬럼을 JavaMap 타입으로 변환하는 컨버터의 작성 예이다.
/**
 * 컨버터를 사용할 엔티티 필드에는 아래와 같이 명시
 *
 * @Convert(converter = JsonConverter::class)
 * var someJsonField: Map<String, String> = emptyMap()
 */
@Converter(autoApply = false)
class JsonConverter : AttributeConverter<Map<String, String>, String> {

    override fun convertToEntityAttribute(dbData: String?): Map<String, String> {

        return try {
            ObjectMapper().readValue<HashMap<String, String>>(dbData ?: "")
        } catch (ex: Exception) {
            return emptyMap()
        }
    }

    override fun convertToDatabaseColumn(attribute: Map<String, String>?): String {

        return try {
            ObjectMapper().writeValueAsString(attribute)
        } catch (ex: Exception) {
            return ""
        }
    }
}

UserRepository 리파지터리 작성

  • User 엔티티에 대한 CRUD를 수행할 리파지터리를 작성할 차례이다. /src/main/java/UserRepository.java 파일에 아래 내용을 작성한다.
package com.jsonobject.jpademo;

import org.springframework.data.jpa.repository.JpaRepository;

interface UserRepository : JpaRepository<User, Long>
  • 소스 코드를 보면 놀라울 정도로 간단하다. 단지 JpaRepository 인터페이스를 상속했을 뿐이다. 전통적으로 추가해야 했던 @Repository 또한 명시할 필요가 없다. 이 것 만으로도 엔티티에 대한 기본적인 CRUD를 위한 메써드를 제공한다.
  • 엔티티가 복잡해질수록 진가는 더욱 발휘된다. Spring Data가 사전 제공하는 네이밍 규칙을 따라 추가적으로 메써드를 선언하면 런타임에서 자동으로 해당 쿼리를 생성해준다. 자세한 네이밍 규칙은 이 글을 참고한다.
  • @Repository 인터페이스의 메써드 레벨에는 @Lock을 명시할 수 있다. 특정 엔티티를 조회하는 순간부터 트랜잭션이 종료되는 시점까지 다른 트랜잭션에서의 해당 엔터티에 대한 접근을 차단하여, 데이터의 무결성을 보장할 수 있다. 대표적으로 2가지 전략이 지정할 수 있는데 @Lock(LockModeType.PESSIMISTIC_READ)는 트랜잭션이 끝날 때까지 쓰기, 삭제만 차단하고, 읽기는 보장한다. @Lock(LockModeType.PESSIMISTIC_WRITE)는 훨씬 엄격하다. 트랜잭션이 끝날 때까지 다른 트랜잭션으로부터의 읽기, 쓰기, 삭제를 차단한다. [관련 링크]
  • 이제 요구사항에 따라 필요한 메써드를 제작하면 된다. Spring Boot Data JPA는 철저히 엔티티의 필드 이름을 기반의 메써드 작성을 요구한다. 작성 예는 아래와 같다.
// User의 address: Address 필드로 조회한다.
fun findByAddress(address: Address): List<User>

// User의 address: Address 필드의 id로 조회한다.
fun findByAddressId(addressId: Long): List<User>

// User의 gender: Gender 필드를 내림차순으로 정렬 후 100개를 조회한다.
fun findTop100ByGenderOrderByIdDesc(gender: Gender): List<User>

트랜잭션 적용

  • @Service 클래스의 클래스 레벨 또는 메써드 레벨에 트랜잭션을 적용할 수 있다. (Querydsl을 사용했을 경우 @Repository 클래스에도 적용이 필요하다.) 아래는 사용 가능한 모든 옵션을 명시한 예이다.
@Transactional(readOnly = false, isolation = Isolation.READ_COMMITTED, propagation = Propagation.REQUIRED, transactionManager = "someTransactionManager", rollbackFor = [Exception::class])
fun doSomething() {
    ...
}

페이지네이션을 적용한 컨트롤러 작성

  • Pageable 객체를 이용하면 컨트롤러 레벨부터 적은 공수로 상당히 편리하게 페이지네이션을 적용할 수 있다. [관련 링크] 작성 예는 아래와 같다.
@RestController
class UserController(
        val userRepository: UserRepository,
) {
    /**
     * /users?page=0&size=5&sort=id,desc
     */
    @GetMapping("/users")
    fun getAllUsers(pageable: Pageable): ResponseEntity<*> {

        val users = userRepository.findAll(pageable)

        return ResponseEntity.ok(users)
    }
}
  • 응답 결과는 다음과 같다.
{
  "content": [
    {
      "id": 2000,
      "name": "20a71f52",
      "email": "1ce508fb@gmail.com",
      "createdAt": "2019-10-05T17:22:13.073858"
    },
    {
      "id": 1999,
      "name": "77e0a0f5",
      "email": "63acf78d@gmail.com",
      "createdAt": "2019-10-05T17:22:13.071905"
    },
    {
      "id": 1998,
      "name": "582e1f69",
      "email": "31ca8f95@gmail.com",
      "createdAt": "2019-10-05T17:22:13.070929"
    },
    {
      "id": 1997,
      "name": "4f2cd4ed",
      "email": "35f00153@gmail.com",
      "createdAt": "2019-10-05T17:22:13.068977"
    },
    {
      "id": 1996,
      "name": "cb2df7d",
      "email": "136bc01e@gmail.com",
      "createdAt": "2019-10-05T17:22:13.067025"
    }
  ],
  "pageable": {
    "sort": {
      "sorted": true,
      "unsorted": false,
      "empty": false
    },
    "pageNumber": 0,
    "pageSize": 5,
    "offset": 0,
    "unpaged": false,
    "paged": true
  },
  "last": false,
  "totalPages": 400,
  "totalElements": 2000,
  "numberOfElements": 5,
  "first": true,
  "size": 5,
  "number": 0,
  "sort": {
    "sorted": true,
    "unsorted": false,
    "empty": false
  },
  "empty": false
}

Application 작성

  • 이제 애플리케이션의 시작점이 되는 Application을 작성할 차례이다.
package com.jsonobject.jpademo;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.autoconfigure.domain.EntityScan;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;

@SpringBootApplication
public class JpaDemoApplication {

    public static void main(String[] args) {

        SpringApplication.run(JpaDemoApplication.class, args);
    }
}

테스트 케이스 작성

package com.jsonobject.jpademo;

import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.annotation.Rollback;
import org.springframework.test.context.junit4.SpringRunner;

import javax.transaction.Transactional;
import java.time.LocalDateTime;

@RunWith(SpringRunner.class)
@SpringBootTest
public class JpaDemoApplicationTests {

    @Autowired
    private UserRepository userRepository;

    @Test
    @Transactional
    @Rollback(false)
    public void createUser() {

        User user = new User();
        user.setName("test");
        user.setEmail("test@gmail.com");
        user.setDate(LocalDateTime.now());
        userRepository.save(user);
    }
}
  • 테스트시 기본적으로 케이스를 성공하면 트랜잭션은 롤백된다. @Rollback(false)을 설정하면 트랜잭션 결과를 영구적으로 커밋할 수 있다.

Flyway 적용

  • Flyway는 데이터베이스의 마이그레이션 작업과 형상관리를 자동화해주는 무료 오픈 소스 라이브러리이다. 번거롭고 간과하기 쉬운 데이터베이스 형상관리를 자동으로 작업해주기 때문에 프로젝트 관리가 상당히 편리해진다. 적용 방법은 아래와 같다. 먼저 /build.gradle 파일에 아래 내용을 추가한다.
dependencies {
   compile group: 'org.flywaydb', name: 'flyway-core', version: '5.2.4'
}
  • 다음으로 /src/main/resources/application.yml 파일에 아래 설정을 추가한다.
spring:
  flyway:
    enabled: true
    encoding: UTF-8
  • spring.flyway.enabled 옵션을 true로 설정하면 애플리케이션 시작 시점마다 Flyway가 실행되어 자동으로 데이터베이스 형상관리를 수행한다. (형상관리 이력은 flyway_schema_history 테이블에 기록되어 관리된다. 마이그레이션 중 문제가 생길 경우 해당 테이블을 직접 수정하거나 삭제하면 된다.)

  • 마지막으로 Flyway의 형상관리 대상이 되는 SQL 스크립트를 작성할 차례이다. /src/main/resources/db/migration/V{version}__{description}.sql 파일에 초기 테이블과 인덱스를 생성하는 SQL 스크립트를 작성하면 된다. 형상이 변경되면 기존 스크립트는 유지한 채 위 파일명 컨벤션을 준수하여 새로운 스크립트를 작성하면 Flyway가 자동으로 적용해준다.

  • 애플리케이션 시작 후 형상관리가 성공적으로 완료되면 아래 로그가 출력된다.
2018-12-20 11:37:54.141  INFO 1279 --- [           main] o.f.c.internal.license.VersionPrinter    : Flyway Community Edition 5.2.4 by Boxfuse
2018-12-20 11:37:54.142  INFO 1279 --- [           main] com.zaxxer.hikari.HikariDataSource       : HikariPool-1 - Starting...
2018-12-20 11:37:54.170  INFO 1279 --- [           main] com.zaxxer.hikari.HikariDataSource       : HikariPool-1 - Start completed.
2018-12-20 11:37:54.174  INFO 1279 --- [           main] o.f.c.internal.database.DatabaseFactory  : Database: jdbc:mariadb://localhost:3306/some_db (MySQL 5.6)
2018-12-20 11:37:54.217  INFO 1279 --- [           main] o.f.core.internal.command.DbValidate     : Successfully validated 1 migration (execution time 00:00.018s)
2018-12-20 11:37:54.258  INFO 1279 --- [           main] o.f.c.i.s.JdbcTableSchemaHistory         : Creating Schema History table: `some_db`.`flyway_schema_history`
2018-12-20 11:37:54.328  INFO 1279 --- [           main] o.f.core.internal.command.DbMigrate      : Current version of schema `some_db`: << Empty Schema >>
2018-12-20 11:37:54.329  INFO 1279 --- [           main] o.f.core.internal.command.DbMigrate      : Migrating schema `some_db` to version 20181220 - INIT
2018-12-20 11:37:54.408  INFO 1279 --- [           main] o.f.core.internal.command.DbMigrate      : Successfully applied 1 migration to schema `somedb` (execution time 00:00.150s)

Flyway: java.lang.IllegalStateException 트러블슈팅

  • 형상관리 대상 파일을 저장하는 프로젝트의 /src/main/resources/db/migration 디렉토리가 존재하지 않으면 아래 예외가 발생한다.
Caused by: java.lang.IllegalStateException: Cannot find migrations location in: [classpath:db/migration] (please add migrations or check your Flyway configuration)

Flyway: org.flywaydb.core.api.FlywayException 트러블슈팅

  • 형상관리 대상 파일의 파일명 규칙을 지키지 않을 경우 아래 예외가 발생한다. 파일명은 반드시 V{version}__{description}.sql이 되어야 한다. (대소문자도 지켜야 한다.)
Caused by: org.flywaydb.core.api.FlywayException: Wrong migration name format: xxxxx.sql(It should look like this: V1.2__Description.sql)

쿼리 결과를 @Entity가 아닌 POJO에 맵핑하기

  • 만약 Spring Data JPA에서 쿼리 결과를 엔티티가 아닌 일반적인 POJO 클래스에 맵핑하려면 어떻게 해야 할까? @SqlResultSetMapping,@NamedNativeQuery를 활용하여 엔티티 클래스 레벨에 맵핑 정보를 명시하면 된다. 작성 예는 아래와 같다.
@SqlResultSetMapping(
    name = "barMapping",
    classes = {
        @ConstructorResult(
            targetClass = Bar.class,
            columns = {
                @ColumnResult(name = "id", type = Long.class),
                @ColumnResult(name = "bar", type = String.class),
                @ColumnResult(name = "created_at", type = LocalDateTime.class)
            }
        )
    }
)
@NamedNativeQuery(
    name = "findAllByCreatedAtBetween",
    resultSetMapping = "barMapping",
    resultClass = Bar.class,
    query = "SELECT * FROM bar WHERE created_at BETWEEN :start_at AND :end_at ORDER BY id ASC"
)
@Entity
public class Foo {
    ...
}
  • Repository 인터페이스는 아래와 같이 작성한다.
public interface FooRepository extends JpaRepository<Foo, Long> {

    @Query(nativeQuery = true, name = "findAllByCreatedAtBetween")
    List<Bar> findAllByCreatedAtBetween(@Param("startAt") LocalDateTime startAt, @Param("endAt") LocalDateTime endAt);
}

Spring Data REST 적용하기

  • Spring Data REST를 적용하면 모든 리파지터리에 대해 자동으로 HAL(Hypertext Application Language) 형식의 REST API를 생성해준다. 추가적으로 HAL Explorer를 추가하면 미려한 UI로 모든 리파지터리에 대한 CRUD가 가능해진다. 인하우스 시스템의 어드민에 사용하기에는 정말 편리한 기능이다.
  • 먼저 프로젝트 루트의 /build.gradle 파일에 아래 내용을 추가한다.
dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-data-rest'
    compile group: 'org.springframework.data', name: 'spring-data-rest-hal-explorer', version: '3.2.0.RELEASE'
    compile group: 'org.webjars', name: 'hal-explorer', version: '0.11.0'
}
  • 다음으로 브라우저에서 /explorer 주소 요청시 HAL Expolorer이 실행되도록 맵핑해주어야 한다.
@Configuration
class RestConfig : WebMvcConfigurer {

    override fun addResourceHandlers(registry: ResourceHandlerRegistry) {
        if (!registry.hasMappingForPattern("/explorer/**")) {
            registry.addResourceHandler("/explorer/**")
                    .addResourceLocations("classpath:/META-INF/resources/webjars/hal-explorer/0.11.0/")
        }
    }
}

참고 글

댓글
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2024/04   »
1 2 3 4 5 6
7 8 9 10 11 12 13
14 15 16 17 18 19 20
21 22 23 24 25 26 27
28 29 30
글 보관함