-
Kotlin + Spring Boot 3 Spring Data envers 적용Spring Framework 2023. 7. 18. 00:01728x90
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
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 테이블
REV(이력 ID)와 REVSTMP가 기록되어 있습니다.
UNIXTIMESTAMP로 시간값이 저장됩니다.
컨트롤러 한번 더 호출했을 때
연관관계가 설정되어 있으면 어떻게 될까?
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
이를 막고자 dirty-check를 통해 변경을 반영하고자 합니다.
여러 옵션들을 사용하여 테이블 이름등을 수정하고 싶은 경우
문서의 여러 가지 옵션들이 있으면 다음과 같은 설정을 해보고자 합니다.
- 테이블 이름 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
'Spring Framework' 카테고리의 다른 글
Spring RequestContextHolder으로 Client IP 주소 가져오기 (0) 2023.07.22 Spring Local Cache란? (0) 2023.07.19 log4j, logback, log4j2 비교 (0) 2023.07.16 FeignClient vs WebClient vs RestTemplate (2) 2023.07.13 FeignClient 헤더 가져오기 (0) 2023.07.12