ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • Kotlin + Spring Boot 3 Spring Data envers 적용
    Spring Framework 2023. 7. 18. 00:01

    Spring Data envers란?

    데이터베이스 테이블의 변경하면 가장 최근 상태만 알 수 있습니다.

    하지만 envers를 사용하면 데이터 변경 이력을 관리하는 기능을 제공합니다.

     

    Gradle

    plugins {
    	id("org.springframework.boot") version "3.1.0"
    	id("io.spring.dependency-management") version "1.1.0"
    	kotlin("jvm") version "1.8.21"
    	kotlin("plugin.spring") version "1.8.21"
    	kotlin("plugin.jpa") version "1.8.21"
    }
    
    dependencies {
    	//Spring Web 의존성
    	implementation("org.springframework.boot:spring-boot-starter-web")
    	implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
    	implementation("org.jetbrains.kotlin:kotlin-reflect")
    	testImplementation("org.springframework.boot:spring-boot-starter-test")
    
    	//로그 의존성
    	implementation("io.github.microutils:kotlin-logging-jvm:2.0.11")
    
    	//Spring Data 의존성
    	implementation("org.springframework.data:spring-data-envers")
    	runtimeOnly ("com.h2database:h2")
    	implementation ("org.springframework.boot:spring-boot-starter-data-jpa")
    }

     

    JPA Entity

    @Entity
    @Table(name = "board")
    @Audited
    class BoardJpaEntity(
        @Id
        @Column(nullable = false)
        val boardId: String = UUID.randomUUID().toString(),
    
        @Column(nullable = false)
        var content: String,
    )

    @Audit 어노테이션 붙여줍니다

    hibernate.hbm2ddl.auto 옵션이 create, create-drop, update 면 audit 테이블이 자동으로 생성됩니다.

     

    content는 변환시키기 위해 var으로 사용합니다.

     

    Jpa Repository

    interface BoardJpaRepository :
        JpaRepository<BoardJpaEntity, String>,
        RevisionRepository<BoardJpaEntity, String, Long> {
    }

    RevisionRepository를 상속받아서 히스토리 테이블을 사용할 수 있도록 합니다.

     

    SpringBoot Application에 @EnablaJpaRepositories 추가

    @EnableJpaRepositories(repositoryFactoryBeanClass = EnversRevisionRepositoryFactoryBean::class)
    @SpringBootApplication
    class Application
    
    fun main(args: Array<String>) {
    	runApplication<Application>(*args)
    }

     

    Database 세팅

    # Database Settings
    spring:
      datasource:
        url: jdbc:h2:mem:testdb;MODE=mysql;
        username: sa
        password:
        driverClassName: org.h2.Driver
    
      h2:
        console:
          enabled: true
          path: /h2-console

    localhost:8080/h2-console으로 접속하여 url, username을 치고 들어가서 편리하게 h2 database를 볼 수 있습니다.

     

    BoardAuditCheckController

    @RestController
    class BoardAuditCheckController(
        private val  boardJpaRepository: BoardJpaRepository,
    ) {
    
        @GetMapping("/audit")
        fun boardAuditView(){
            //INSERT
            val board = BoardJpaEntity(content = "content")
            boardJpaRepository.save(board)
    
            //UPDATE
            board.content = "update_content"
            boardJpaRepository.save(board)
    
            //DELETE
            boardJpaRepository.delete(board)
    
        }
    }

     

    여기서 주의할점은 @Transational으로 묶게 된다면 Spring Data Envers는 트랜잭션 단위로 변경을 감지하기 때문에 아무것도 이력에 남지 않습니다.

     

    호출해 보기

    ### audit INSERT -> UPDATE -> DELETE 주기 돌리기
    GET localhost:8080/audit

     

    호출 후 데이터베이스 테이블 관찰

    Board 테이블은 생성 -> 업데이트 -> 삭제되었기 때문에 아무것도 남아있지 않습니다.

     

    Board_AUD

    BOARD_AUD

    default 옵션의 create에 의해 테이블이 생성된 것 같습니다.

    REV, REVTYPE이 새로 생겼습니다. Member(board_id, content)는 원래 2개의 column으로 구성되어 있었습니다.

     

    REV

    auto_increment와 같은 이력관리 ID값입니다.

     

    REVTYPE

    - 0: 등록

    - 1: 수정

    - 2: 삭제

    enum이 아니라 숫자로 되어있는 부분이 조금 아쉽습니다.

     

    Board가 생성 -> 업데이트 -> 삭제 절차에 따른 REVTYPE이 기록되어 있고, BOARD_ID, CONTENT들도 잘 남아있습니다.

     

    REVINFO 테이블

    REVINFO 테이블

    REV(이력 ID)와 REVSTMP가 기록되어 있습니다.

    UNIXTIMESTAMP로 시간값이 저장됩니다.

     

    컨트롤러 한번 더 호출했을 때

    6건이 생성되었다

     

    연관관계가 설정되어 있으면 어떻게 될까?

    controller

    @GetMapping("/audit")
        fun boardAuditView(){
            //INSERT
            val boardId = UUID.randomUUID().toString()
            val board = BoardJpaEntity(
                boardId = boardId,
                content = "content",
                comments = mutableListOf(
                    CommentJpaEntity(
                        comment = "comment",
                        boardId = boardId,
                    )
                )
            )
            boardJpaRepository.save(board)
    
            //UPDATE
            board.content = "update_content"
            board.comments.first().comment = "update_content"
            boardJpaRepository.save(board)
    
            //DELETE
            boardJpaRepository.delete(board)
        }

     

    BoardJpaEntity 수정

    @OneToMany(mappedBy = "board", cascade = [CascadeType.ALL], fetch = FetchType.LAZY)
    val comments: MutableList<CommentJpaEntity> = mutableListOf(),

     

    CommentJpaEntity

    @Entity
    @Audited
    @Table(name = "comment")
    open class CommentJpaEntity(
        @Id
        @Column(nullable = false)
        val commentId: String = UUID.randomUUID().toString(),
    
        @Column(nullable = false)
        var comment: String,
    
        @Column(nullable = false)
        var boardId: String,
    
        @ManyToOne(fetch = FetchType.LAZY)
        @JoinColumn(name = "boardId", referencedColumnName = "boardId", insertable = false, updatable = false)
        var board: BoardJpaEntity? = null,
    )

    CommentJpaEntity에도 Audit이 설정되어 있는 경우에는 Board, Comment에 이력이 정상적으로 잘 적재됩니다.

     

    만약 CommentJpaEntity에 Audit이 붙어있지 않다면?

    Caused by: org.hibernate.envers.boot.EnversMappingException:
    An audited relation from com.example.study.envers.repository.BoardJpaEntity.comments to a not audited entity com.example.study.envers.repository.CommentJpaEntity!
    : origin(envers)

    컴파일에러로 실행되지 않습니다.

     

    @NotAudited를 사용하여 CommentJpaEntity필드는 추적되지 않도록 하면 해결할 수 있습니다.

    @NotAudited
    @OneToMany(mappedBy = "board", cascade = [CascadeType.ALL], fetch = FetchType.LAZY)
    val comments: List<CommentJpaEntity> = listOf(),

    다만 더 이상 Comment는 이력이 관리되지 않습니다

     

    BaseEntity와 같이 사용하는 경우에는?

    BaseEntity에도 @Audit을 붙여주면 됩니다.

     

     

    기존 테이블을 수정해야 할 일이 있다면 어떻게 될까?

    기존 Entity 수정

     

    @Column(nullable = true)
    val addColumn: String? = null,

     

    ddl-auto option none으로 설정 후 직접 테이블 생성

    create table board (
            created_at timestamp(6) with time zone not null,
            modified_at timestamp(6) with time zone not null,
            add_column varchar(255),
            board_id varchar(255) not null,
            content varchar(255) not null,
            primary key (board_id)
        )
    create table board_history (
            rev_id integer not null,
            revtype tinyint,
            created_at timestamp(6) with time zone,
            modified_at timestamp(6) with time zone,
            board_id varchar(255) not null,
            content varchar(255),
            primary key (rev_id, board_id)
        )

    board_history에서는 add_column을 빼보겠습니다.

     

    create table comment (
            board_id varchar(255),
            comment varchar(255) not null,
            comment_id varchar(255) not null,
            primary key (comment_id)
        )
    create table revinfo (
            rev integer generated by default as identity,
            revtstmp bigint,
            primary key (rev)
        )

     

    add_colummn이 없기 때문에 런타임 에러가 발생합니다.

    org.h2.jdbc.JdbcSQLSyntaxErrorException: Column "ADD_COLUMN" not found; SQL statement:
    insert into board_history (revtype,add_column,content,created_at,modified_at,board_id,rev_id) values (?,?,?,?,?,?,?)

     

    기존 테이블에 alter가 발생하면 history table도 변경사항을 반영해주어야 합니다.

     

    Rev Long으로 변환하기

    rev는 기본적으로 Integer를 사용합니다.

    하지만 rev는 DB 트랜잭션단위로 증가하기 때문에 여러 테이블이 함께 사용하면 금방 소모되어 Integer.MAX에 다다를 수 있습니다.

     

    RevisionEntity를 Custom 하게 만들어서 사용할 수 있습니다. (INT -> LONG)

     

    @RevisionEntity와 @RevisionNumber, @RevisionTimestamp만 잘 설정해 주면 됩니다.

    @RevisionEntity를 만들면 envers가 사용하는 모든 revision 기록은 이 entity를 사용하게 됩니다.

     

    @Entity
    @RevisionEntity
    @Table(name = "REVINFO")
    data class LongRevisionEntity(
    
        @Id
        @GeneratedValue(strategy = GenerationType.IDENTITY)
        @RevisionNumber
        @Column(name = "REV_ID")
        var id: Long? = null,
    
        @RevisionTimestamp
        @Column(name = "REVTSTMP")
        var timestamp: Long? = null
    ) : Serializable {
    
        private fun getRevisionDate() = timestamp?.let { Date(it) }
    
        override fun toString(): String =
            "LongRevisionEntity(id = $id, revisionDate = ${DateFormat.getDateTimeInstance().format(getRevisionDate())}"
    }

     

    Custom RevisionEntity 이후 생성되는 Table의 query

    create table revinfo (
            rev_id bigint generated by default as identity,
            revtstmp bigint,
            primary key (rev_id)
        )
    create table board_history (
            revtype tinyint,
            created_at timestamp(6) with time zone,
            modified_at timestamp(6) with time zone,
            rev_id bigint not null,
            add_column varchar(255),
            board_id varchar(255) not null,
            content varchar(255),
            primary key (rev_id, board_id)
        )

    2개의 table에서 모두 bigint로 잘 생성되는 모습입니다.

     

    JPQL으로 업데이트 하는 경우에도 반영될까?

    @Modifying
    @Query("update BoardJpaEntity u set u.addColumn = :someValue where u.boardId = :boardId")
    fun updateAddColumn(someValue: String, boardId: String): Int

    위의 Update Bulk 연산은 하이버네이트 ORM작업에서 Envers가 변경된 내용을 파악할 수 없기 때문에 Envers는 작업이 수행되었다는 사실을 알지 못하고 이력이 쌓이지 않게 됩니다.

    https://hibernate.atlassian.net/browse/HHH-10318

     

    [HHH-10318] - Hibernate JIRA

    회사에서 관리하는 프로젝트에 참여하고 있습니다

    hibernate.atlassian.net

    이를 막고자 dirty-check를 통해 변경을 반영하고자 합니다.

     

    여러 옵션들을 사용하여 테이블 이름등을 수정하고 싶은 경우

    https://docs.jboss.org/hibernate/orm/current/userguide/html_single/Hibernate_User_Guide.html#envers-configuration

     

    Hibernate ORM 6.2.5.Final User Guide

    Fetching, essentially, is the process of grabbing data from the database and making it available to the application. Tuning how an application does fetching is one of the biggest factors in determining how an application will perform. Fetching too much dat

    docs.jboss.org

    문서의 여러 가지 옵션들이 있으면 다음과 같은 설정을 해보고자 합니다.

    • 테이블 이름 AUD -> HISTORY로 변경
    • 이력관리 ID 이름 REV -> REV_ID로 변경
    • 삭제 시엔 데이터가 null으로 들어가는데 데이터가 들어가도록 세팅
    • JPA @Versioning을 활용할 때 @Version 칼럼도 audit 테이블에 저장하기 위해서 false (기본 true)
    spring:
      jpa:
        properties:
          org.hibernate.envers.audit_table_suffix: _history
          org.hibernate.envers.revision_field_name: rev_id
          org.hibernate.envers.store_data_at_delete: true
          org.hibernate.envers.do_not_audit_optimistic_locking_field: false

     

     

    BOARD_HISTORY 테이블

    • BOARD_AUD -> BOARD_HISTORY
    • REV -> REV_ID
    • REVTYPE이 2인 경우에도 CONTENT가 보임

     

     

     

    참고자료

    https://docs.spring.io/spring-data/envers/docs/current/reference/html/#dependencies

    https://www.youtube.com/watch?v=fGPaj-rlN5w&t=3s 

    https://docs.jboss.org/hibernate/orm/current/userguide/html_single/Hibernate_User_Guide.html#envers

    https://blog.leocat.kr/notes/2019/06/04/hibernate-change-envers-REV-revision-number-to-long

    https://stackoverflow.com/questions/45081782/hibernate-envers-with-querydsl-update

     

    댓글

Designed by Tistory.