Spring/JPA

JPA심화트랙(4) - JPA 유용한 기능들 소개

nsean 2024. 4. 3. 09:51

@Where(Hibernate 6.3 이상은 @SQLRestriction)

  • 특정 Entity를 조회하는 모든 쿼리에 Where 조건을 추가해 주는 어노테이션
  • Soft Delete로 db에서 지우는 게 아닌 is_deleted 컬럼의 값을 True로 변경하는 경우, Entity를 조회해야 하는 모든 SQL 쿼리문에 WHERE is_deleted = false 구문을 추가해줘야 한다.
  • 이 구문을 @Where(clause = "is_deleted = false")를 이용하면 Member를 조회하는 모든 SQL 쿼리문에 해당 Where 조건이 추가된다.
    • findById(PK)부터 직접 구현한 JPQL, QueryDSL 에도 전부 반영된다.
    • Lazy Loading으로 조회되는 경우에도 반영된다.
    • @Query(value = "SELECT * FROM member", nativeQuery = true)를 통하여 @Where 조건을 무시할 수 있다.
      • 하지만 이러한 경우 JPQL의 지원을 받을 수가 없어 피하는 것이 좋고, 예외가 반복되면 @Where 사용을 다시 고려해 보는 것이 좋다.
    • Hibernate 6.3에 @Where어노테이션이 Deprecated 됐고, 대신 @SQLRestriction어노테이션을 활용할 수 있다

@SQLDelete

  • 특정 Entity에 대한 삭제를 Soft Delete로 완전히 바꾸고 싶을 때 사용
  • 다음과 같이 설정하며, memberRepository.delete(PK)가 다음 update 쿼리문으로 대체된다.
@Entity
@Where(clause = “is_deleted = false”)
@SQLDelete(sql = "UPDATE member SET is_deleted = true WHERE id = ?")
class Member(

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "member_id")
    var id: Long? = null,

    val email: String,
    val password: String,
    var nickname: String,

    var isDeleted: Boolean = false
)

 

@DynamicInsert

  • INSERT 시 데이터베이스 기본값 활용을 위해서 null을 세팅한 후 INSERT 쿼리를 날리게 된다.
// 내가 예상한 쿼리!
INSERT INTO member_id, email, password, nickname VALUES (default, 'slolee@naver.com', '1234', 'ch4njun');

// 실제 발생한 쿼리!
INSERT INTO member_id, email, password, nickname, is_deleted VALUES (default, 'slolee@naver.com', '1234', 'ch4njun', null);
  • 데이터베이스 컬럼상의 Default Value를 사용하고 싶었지만 JPA는 null을 넣어서 보내버리기에, default value를 활용할 수 없다고 한다.
  • @DynamicInsert를 사용하면 null이 세팅된 필드를 제외하고 INSERT쿼리를 생성할 수 있게 된다.
  • Kotlin에서는 크게 쓸 일이 없다고 한다.

 

@DynamicUpdate

@Transactional
fun withdraw() {
    val member = memberRepository.findByEmail("slolee@naver.com")
        ?: throw RuntimeException("Not Found Member!")
    member.isDeleted = true
    memberRepository.save(member)
}

 

  • 여기서 발생하는 UPDATE 쿼리문을 보면 다음과 같다.
// 내가 예상한 쿼리!
UPDATE member SET is_deleted = true WHERE member_id = 1;

// 실제 발생한 쿼리!
UPDATE member SET email='slolee@naver.com',is_deleted=true,nickname='박찬준',password='1234' WHERE member_id=1;
  • 이런 방식으로 모든 요소에 대하여 UPDATE를 실행해 주는데, 그 이유는 다음과 같다.
    • 모든 UPDATE 쿼리가 동일하게 생겨 Spring Boot 애플리케이션 실행시점에 SQL 쿼리를 생성해 놓고 재사용 가능
    • 데이터베이스 입장에서도 동일한 UPDATE 쿼리를 받기 때문에 이전에 파싱 된 쿼리를 재사용 가능
  • JPA에서는 UPDATE 쿼리 재사용 관점에서의 이득이 더 크다고 판단했기 때문에 모든 컬럼에 대한 UPDATE 쿼리를 만드는 걸 기본으로 사용하는 것
  • 이것이 싫다면 다음과 같이 @DynamicUpdate를 사용할 수 있지만, 위에서 이야기한 2가지 장점이 사라져 매번 UPDATE 쿼리문을 생성해야 하기 때문에 성능이 안 좋아지는 경우도 있다고 한다.
@Entity
@DynamicUpdate
class Member(

    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "member_id")
    var id: Long? = null,

    val email: String,
    val password: String,
    var nickname: String,

    var isDeleted: Boolean = false
)

 

Spring Data JPA Auditing

다음 내역 추적 가능

  1. Entity 객체 생성 시점 (@CreatedDate)
  2. Entity 객체 생성자 (@CreatedBy)
  3. Entity 객체 수정 시점 (@LastModifiedDate)
  4. Entity 객체 수정자 (@LastModifiedBy)

JPA 자체적으로도 @PrePersist, @PostPersist, @PreUpdate, @PostUpdate를 통해 Auditing을 구현할 수 있긴 하다.

Spring Data JPA로 구현돼 있는 Auditing 기능을 활용하는 것이 편리하여 사용.

 

@EnableJpaAuditing로 추적 가능

@EntityListeners(AuditingEntityListener::class)
@MappedSuperclass
class BaseEntity(

    @CreatedDate
    @Column(updatable = false)
    var createdAt: LocalDateTime = LocalDateTime.now(),

    @CreatedBy
    @Column(updatable = false)
    var createdBy: String = "system",

    @LastModifiedDate
    var updatedAt: LocalDateTime = LocalDateTime.now(),

    @LastModifiedBy
    var updatedBy: String = "system"
)

createdBy, updatedBy의 경우 default를 "system"으로 하긴 했지만, 실제 시스템의 경우 누가 처리해 줬는지 정보를 남길 필요가 있다.

시스템마다 다르지만 예시 중 한 가지 방법은 accessToken의 memberId로 볼 수 있다.

@Component
class CustomAuditorAware : AuditorAware<Long> {

    override fun getCurrentAuditor(): Optional<Long> {
        return Optional.ofNullable(SecurityContextHolder.getContext())
            .map { it.authentication }
            .map { it.principal as MemberPrincipal }
            .map { it.memberId }
    }
}

JwtAuthenticationFilter에서 SecurityContext에 인증이 완료된 사용자 정보를 넣어두었기 때문에 해당 정보를 꺼내 사용할 수 있다.