본문 바로가기

프로젝트/taka: 대여사업 관리 서비스

[taka] entity 수정 + Envers를 이용한 엔티티 변경 감지

@Data 대신 @Getter의 사용

실무에서는 @Data를 지양하는데, 이는 setter가 남용될 경우 데이터가 쉽게 변화될 수 있기 때문이다. 이론적으로는 getter, setter을 모두 제공하지 않고 별도의 메서드를 두는 것이 가장 이상적이라고 하지만, 실무에서는 엔티티 데이터를 조회할 일이 너무 많아서 getter은 모두 열어두고 setter의 경우 열어두면 데이터가 변하고 어디서 변화했는지 찾기가 힘들어지기 때문에 setter 대신 변경 지점이 명확해지도록 비즈니스 메서드를 제공해야 된다고 한다.

 

유효성 검사는 어디에서 해야하는가?

유효성 검사를 Dto에서 해야할지 엔티티에서 해야될지 찾아보았는데 - 원칙적으로는 두 곳에서 모두 유효성 검사를 하는 것이 좋겠지만 실무에서는 너무 많은 부분에서 중복 체크가 이루어지고, 유효성 검사 로직을 여러곳에서 관리한다면 한 쪽에서 누락될 가능성이 높아진다고 한다. 엔티티의 가독성을 높이며 유효성 검사를 하기 위해 Dto에서 유효성 검사를 시행하고, 컨트롤러에서 @Valid를 이용해 전송된 데이터의 유효성을 검증하기로 했다.

 

 

컬렉션의 초기화

컬렉션은 필드에서 초기화 하는 것이 좋다. 

@Entity
public class OrgEntity extends BaseEntity {
    @OneToMany(mappedBy = "rentalItemEntity", cascade = CascadeType.ALL)
    private List<RentalItemEntity> rentalItemsList = new ArrayList<>();
}

위 방식은 필드 선언과 동시에 ArrayList로 초기화 해주는데, 이렇게 되면 컬렉션을 사용할 때 null 체크를 할 필요가 없어서 편리하다. 하지만 컬렉션을 필요한 시점에만 생성하고 싶어서 getFavorEntityList() 같은 메서드에서 컬렉션을 생성했다고 해보자. 

 

@Entity
public class OrgEntity extends BaseEntity {
    @OneToMany(mappedBy = "rentalItemEntity", cascade = CascadeType.ALL)
    private List<RentalItemEntity> rentalItemsList;
    
    public List<RentalItemEntity> getRentalItemEntity() {
        if (rentalItemsList == null) {
            rentalItemsList = new ArrayList<>();
        }
        return rentalItemsList;
    }
}

 

이렇게 하면 실제로 컬렉션이 필요한 시점에만 생성되므로 메모리를 효율적으로 사용할 수 있다. 하지만 이는 하이버네이트와 함께 사용할 때 내부 메커니즘에 문제를 일으킬 수 있다. 왜냐하면 하이버네이트는 객체를 영속화할 때 getter 메서드를 호출하고, 내부적으로 컬렉션을 변경할 수 있기 때문에 getter 메서드에서만 컬렉션을 생성하는 경우 하이버네이트의 동작과 충돌 수 있기 때문이다. 

 

Cascade 타입의 지정

Cascade는 부모 엔티티가 영속화, 병합, 삭제 등의 변경 작업을 수행할 때, 이 변경이 자식 엔티티에도 영향을 주게 하는 기능이다. 만약 cascade를 하지 않는다면 부모 엔티티를 먼저 persist 한 후 자식 엔티티를 각각 persist 해야하지만 cascade 설정 시 부모 엔티티를 persist 하면 나머지 하위 엔티티도 자동으 persist 되어 db에 한 번에 flush 되므로 db 관리를 더 손쉽게 할 수 있다. 옵션에는 ALL, PERSIST, MERGE, REMOVE, REFRESH, DETACH 가 있다. 

 

 

양방향 매핑의 필요성?

원래는 OrgEntity에서 특정 단체가 가진 대여 물품의 목록 필드인 rentalItemsList와 특정 단체에 해당하는 회원 목록인 membershipList 필드를 OrgEntity에 만들어두었다. 

@OneToMany(mappedBy = "rentalItemEntity", cascade = CascadeType.ALL)
private List<RentalItemEntity> rentalItemsList = new ArrayList<>();

@OneToMany(mappedBy = "org", cascade = CascadeType.ALL, orphanRemoval = true)
private List<MembershipEntity> membershipList = new ArrayList<>();

 

그러나 특정 단체가 가지는 대여 물품의 목록은 이미 RentalItemEntity에 외래키로 지정이 되어있다. 즉 쿼리의 시작은 RentalItemEntity이기 때문에 굳이 대여물품 리스트를 양방향 매핑 할 필요는 없다고 판단하여 위 필드를 삭제하였다. 같은 이유로 특정 단체가 가지는 회원 목록은 이미 MembershipEntity에 외래키로 지정이 있기 때문에 해당 필드 또한 삭제하였다. 

 

조회가 빠르게 필요한 경우에는 리스트를 넣어두는게 좋을 수도 있지만, 사실상 단방향으로 모든 매핑을 해두어도 큰 문제는 없다고 한다 .

 


Spring Data Envers를 이용한 엔티티 Auditing

Envers는 Hibernate의 모듈로, 엔티티의 변경 이력을 자동으로 기록하고 관리하는 기능을 제공한다. JPA와 함께 사용할 수 있으며, 엔티티의 각 수정 사항을 별도의 감사(Audit) 테이블에 저장하여 나중에 조회할 수 있다. 이를 통해 데이터 변경 이력을 투명하게 관리하고 감사할 수 있다.

사용 방법

사용방법은 그냥 엔티티 클래스에 @Audited 어노테이션을 추가하면 된다. 나는 MembershipEntity의 status가 바뀌는 과정을 감지하고 싶어서 멤버십 엔티티에 추가했다. 특정 필드만 감시하고 싶은 경우 해당 필드에만 어노테이션을 붙여도 된다. 클래스에 @Audited 어노테이션을 추가하면, Hibernate Envers가 해당 엔티티의 변경 이력을 자동으로 관리해준다.

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Table(name = "membership_entity")
@Audited
public class MembershipEntity extends BaseEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "user_org_id")
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "user_id", nullable = false)
    private UserEntity user;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "org_id", nullable = false)
    private OrgEntity org;

    @Column(nullable = false)
    private boolean isAdmin;

    @Enumerated(EnumType.STRING)
    @Column(nullable = false)
    @Setter
    private MembershipStatus status;

    public enum MembershipStatus {
        PENDING,
        APPROVED,
        REJECTED
    }
}

 

사용 시 테이블은 어떻게 생성될까?

Envers를 사용하면 기본 테이블 외에 _AUD 접미사가 붙은 감사 테이블이 생성된다. 예를 들어, MembershipEntity 클래스는 아래와 같은 SQL 문 형태로 감사 테이블이 생성된다.

CREATE TABLE membership_entity_AUD (
    user_org_id BIGINT NOT NULL,
    rev INTEGER NOT NULL,
    revtype TINYINT,
    user_id BIGINT,
    org_id BIGINT,
    isAdmin BOOLEAN,
    status VARCHAR(255),
    PRIMARY KEY (user_org_id, rev)
);

 

rev는 개정 번호를 나타내며, revtype은 변경 유형을 나타낸다. revtype의 값은 세 가지가 있다. 

  • 0: 추가 (ADD)
  • 1: 수정 (MOD)
  • 2: 삭제 (DEL)

 

기타 기능

Envers는 기본적인 감사 기능 외에도 다양한 고급 기능을 제공한다.

 

필드 변경 여부 관리

특정 필드가 변경되었는지 여부를 추적할 수 있다. @Audited(withModifiedFlag = true) 어노테이션을 사용하면, 변경 여부를 나타내는 추가 컬럼이 생성된다.

@Audited(withModifiedFlag = true)
public class MembershipEntity extends BaseEntity {
    // ...
}

이렇게 설정하면, name_MOD와 같은 컬럼이 생성되어 해당 필드의 변경 여부를 기록한다.

 

같은 트랜잭션에서 변경된 엔티티 검색

동일한 트랜잭션에서 변경된 엔티티들을 추적할 수 있다. 이를 위해 org.hibernate.envers.track_entities_changed_in_revision 설정을 true로 변경한다.

org.hibernate.envers.track_entities_changed_in_revision = true

이 설정을 통해 REVCHANGES 테이블이 생성되며, 변경된 엔티티들을 기록한다.

 

 

특정 조건에 따른 이력 조회

특정 조건에 따라 엔티티의 이력을 조회할 수 있다. 예를 들어, 이름이 '초키'이고 수정된 이력만 조회하는 코드는 다음과 같다.

이 코드는 name 필드가 '초키'이고, RevisionType이 MOD인 이력만 조회한다. 페이징 조건과 정렬 조건도 추가할 수 있다.

 
public List<MembershipEntity> findRevisionsWithWhere() {
    return auditReader().createQuery()
        .forRevisionsOfEntity(MembershipEntity.class, true, true)
        .add(AuditEntity.property("name").eq("초키"))
        .add(AuditEntity.revisionType().eq(RevisionType.MOD))
        .setFirstResult(0)
        .setMaxResults(2)
        .addOrder(AuditEntity.property("tel").desc())
        .getResultList();
}

 

예시 코드

다음은 엔티티의 수정 이력을 조회하는 예시 코드다.

public List<MembershipEntity> findRevisions(Long id) {
    return auditReader().createQuery()
        .forRevisionsOfEntity(MembershipEntity.class, true, true)
        .add(AuditEntity.id().eq(id))
        .getResultList();
}

이 코드는 MembershipEntity의 변경 이력을 조회하는 쿼리를 생성하고 실행한다. auditReader().createQuery()를 통해 쿼리를 생성하고, forRevisionsOfEntity 메서드를 사용하여 특정 엔티티의 이력을 조회한다.

 

 


참고자료

- 인프런 김영한 강사님: 실전! 스프링 부트와 JPA 활용1

- 우아한 기술 블로그에 있는 모든 파일럿 관련 글들

https://techblog.woowahan.com/8357/

 

우당탕탕 정산어드민 시스템 파일럿 프로젝트 도전기(feat. 정산플랫폼팀) | 우아한형제들 기술블

{{item.name}} 들어가기 전에 안녕하세요🙂 우아한테크캠프pro(3기)를 수료하고 얼마 전 정산플랫폼팀에 합류하게 된 최영진입니다. 우아한형제들 기술블로그를 통해 자주 도움을 얻고 긍정적인

techblog.woowahan.com

 

- 유효성 검사는 어디서 ?
https://stackoverflow.com/questions/42280355/spring-rest-api-validation-should-be-in-dto-or-in-entity

 

Spring Rest API validation should be in DTO or in entity?

In which tier should the validation be in a Spring Boot Rest API. I have some models, endpoints and DTOs. I added some @NotNull and @Size annotations in the DTO. I added the @Valid annotation in the

stackoverflow.com

 

https://www.inflearn.com/questions/548289/%EC%97%94%ED%8B%B0%ED%8B%B0-dto-%EC%9C%A0%ED%9A%A8%EC%84%B1-%EA%B2%80%EC%82%AC%EC%97%90-%EB%8C%80%ED%95%B4-%EC%A7%88%EB%AC%B8-%EB%93%9C%EB%A6%BD%EB%8B%88%EB%8B%A4

 

엔티티, DTO 유효성 검사에 대해 질문 드립니다. - 인프런

엔티티, DTO를 둘 다 유효성 검사를 하나요? 만약 엔티티도 유효성 검사를 할 떄 Bean validation을 사용하시나요? - 질문 & 답변 | 인프런

www.inflearn.com

 

https://www.youtube.com/watch?v=fGPaj-rlN5w