TIL_20250214_Data JPA Auditing 활용

Kim jisu·2025년 2월 14일

TIL

목록 보기
7/43

Spring Data JPA Auditing과 소프트 삭제(Soft Delete) 기능 구현 TIL

기능개발요구서에 stamp 관련 요구가 있어서 구현했습니다. 효율성을 위해 Spring Data JPA Auditing 기능을 도입하였고, 소프트 삭제(Soft Delete) 기능도 함께 구현해보았습니다.


1. 요구사항 및 고민 배경

  • Stamp 요구사항:
    엔티티 생성 및 수정 시점에 자동으로 생성자, 수정자, 생성/수정 일시를 기록해야 합니다.
    또한 삭제 시 실제 삭제가 아닌 소프트 삭제로 기록하기 위해 삭제 일시(deletedAt)와 삭제자(deletedBy)도 기록해야 합니다.

  • 효율성 고려:

    • Spring Data JPA Auditing을 활용하면 @CreatedDate, @LastModifiedDate, @CreatedBy, @LastModifiedBy 어노테이션을 통해 반복되는 코드를 줄이고, 코드의 일관성을 유지할 수 있습니다.
    • 삭제 시 실제 데이터를 제거하지 않고 상태 플래그(status)를 "DELETED"로 변경하여 감사(audit)와 복구가 가능하도록 구현하는 소프트 삭제 방식을 도입합니다.
  • 고민 포인트:

    • 인증된 사용자가 존재할 경우 AuditorAware를 통해 자동으로 생성자 및 수정자 정보를 채워줄 수 있으나, 회원가입과 같이 최초 등록 시에는 컨텍스트에 인증된 사용자가 없으므로 별도 처리(수동 setter)가 필요합니다.
    • 소프트 삭제 기능은 엔티티 리스너를 활용하여 엔티티가 업데이트되기 전 삭제 상태를 감지하고 삭제 관련 필드를 설정할 수 있도록 구현해야 합니다.

2. Spring Data JPA Auditing을 통한 자동 할당

2.1. AuditorAware 구현

Spring Security를 사용하여 현재 인증된 사용자의 username을 반환하는 AuditorAware 구현체를 작성합니다.

package com.sprta.samsike.config;

import org.springframework.data.domain.AuditorAware;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;

import java.util.Optional;

@Component
public class AuditorAwareImpl implements AuditorAware<String> {

    @Override
    public Optional<String> getCurrentAuditor() {
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        if (authentication == null || !authentication.isAuthenticated()) {
            return Optional.empty();
        }
        return Optional.of(authentication.getName());
    }
}

2.2. JPA Auditing 활성화

AuditorAware 구현체를 등록하고, JPA Auditing을 활성화하는 설정 클래스입니다.

package com.sprta.samsike.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;

@Configuration
@EnableJpaAuditing(auditorAwareRef = "auditorAwareImpl")
public class JpaConfig {
    // 추가 설정이 필요하면 작성
}

2.3. Stamp 엔티티 (상위 클래스) 구현

Stamp 관련 필드를 포함하는 상위 엔티티에 @CreatedDate, @CreatedBy, @LastModifiedDate, @LastModifiedBy 어노테이션을 추가합니다.

package com.sprta.samsike.domain;

import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
import org.springframework.data.annotation.CreatedBy;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedBy;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;

import java.time.LocalDateTime;

@Getter
@Setter  // 리스너나 서비스에서 값을 수정할 수 있도록 setter가 필요합니다.
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class Stamped {

    @CreatedDate
    @Column(nullable = false, updatable = false)
    @Temporal(TemporalType.TIMESTAMP)
    private LocalDateTime createdAt;

    @CreatedBy  // AuditorAware를 통해 자동 할당됨
    @Column(updatable = false)
    private String createdBy;

    @LastModifiedDate
    @Column
    @Temporal(TemporalType.TIMESTAMP)
    private LocalDateTime updatedAt;

    @LastModifiedBy  // 수정자도 자동 할당을 원한다면 추가합니다.
    private String updatedBy;

    // 소프트 삭제 관련 필드
    private LocalDateTime deletedAt;
    private String deletedBy;
}

주의:

  • 최초 회원가입 시에는 인증된 사용자가 없으므로, 서비스 레이어에서 createdBy를 수동으로 설정해야 합니다.

3. 소프트 삭제(Soft Delete) 기능 구현

소프트 삭제는 실제 데이터를 삭제하지 않고, 삭제된 것으로 표시하기 위해 엔티티의 상태를 업데이트합니다.

3.1. Member 엔티티에 소프트 삭제 관련 메서드 추가

회원 엔티티(Member)는 status 필드로 상태를 관리하며, "DELETED"로 변경 시 소프트 삭제로 간주합니다.

package com.sprta.samsike.domain.member;

import com.sprta.samsike.domain.Stamped;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.Setter;

@Getter
@Setter
@Entity
@Table(catalog = "samsike", name = "p_user")
public class Member extends Stamped {
    @Id
    @Column(length = 50, nullable = false)
    private String username;

    @Column(nullable = false)
    private String password;

    @Column(nullable = false)
    private String name;

    @Column(nullable = false)
    private String email;

    @Column(nullable = false)
    private String status;

    @Column(nullable = false)
    private String role = "CUSTOMER";

    public Member() {
    }

    public Member(String username, String password, String name, String email) {
        this.username = username;
        this.password = password;
        this.name = name;
        this.email = email;
        this.role = "CUSTOMER";
        this.status = "ACTIVE";
    }
    
    // 소프트 삭제를 수행하는 메서드
    public void softDelete() {
        this.status = "DELETED";
    }
}

3.2. 엔티티 리스너를 활용한 삭제 필드 자동 업데이트

삭제 시 엔티티의 상태가 "DELETED"로 변경되면, 엔티티 리스너를 통해 deletedAt과 deletedBy 필드를 자동으로 설정합니다.

package com.sprta.samsike.domain;

import jakarta.persistence.PreUpdate;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;

import java.time.LocalDateTime;

public class SoftDeleteListener {

    /**
     * 엔티티 업데이트 전에 호출됩니다.
     * 여기서 삭제 상태("DELETED")로 변경된 경우, deletedAt과 deletedBy를 설정합니다.
     */
    @PreUpdate
    public void preUpdate(Stamped entity) {
        // 예시: Member 엔티티에서 status가 "DELETED"로 변경된 경우 처리
        if (entity instanceof com.sprta.samsike.domain.member.Member) {
            com.sprta.samsike.domain.member.Member member = (com.sprta.samsike.domain.member.Member) entity;
            if (member.getDeletedAt() == null && "DELETED".equals(member.getStatus())) {
                member.setDeletedAt(LocalDateTime.now());
                Authentication auth = SecurityContextHolder.getContext().getAuthentication();
                if (auth != null && auth.isAuthenticated()) {
                    member.setDeletedBy(auth.getName());
                } else {
                    member.setDeletedBy("SYSTEM");
                }
            }
        }
    }
}

주의:

  • 엔티티 리스너가 동작하려면 Stamped 클래스의 @EntityListeners에 SoftDeleteListener.class를 추가해야 합니다.
  • 조회 시 소프트 삭제된 데이터(예: status가 "DELETED" 또는 deletedAt이 설정된 데이터)를 제외하는 조건을 추가하는 것도 고려해야 합니다.

4. 회원가입 시 createdBy 수동 할당 및 AuditorAware 동작 문제

회원가입 시에는 아직 인증된 사용자가 없으므로, 서비스 레이어에서 수동으로 createdBy를 설정합니다.
그러나 Spring Data JPA Auditing이 동작하는 과정에서, 엔티티가 저장되기 전에 AuditingEntityListener가 AuditorAware를 호출하여 createdBy 값을 세팅합니다.
따라서, 회원가입 로직에서 수동으로 member.setCreatedBy(username)을 호출하더라도, 엔티티가 persist되면서 AuditorAware에서 반환하는 값(예를 들어, "unanimoususer" 또는 "SYSTEM")으로 덮어쓰게 됩니다.

즉,

  • 수동 설정 → AuditorAware 오버라이드:
    회원가입 시에 직접 setCreatedBy를 호출해도, 엔티티 저장 시점에 AuditingEntityListener가 자동으로 AuditorAware.getCurrentAuditor()를 호출하여 createdBy 필드를 업데이트합니다.

이를 해결하기 위한 두 가지 접근 방법이 있습니다.

접근 방법 : 회원가입 로직에서 SecurityContext를 설정하기

회원가입 시점에 사용자는 아직 인증되지 않은 상태입니다. 그래서 AuditorAware 구현체가 기본값(예: "unanimoususer" 또는 "SYSTEM")을 반환하게 됩니다.
이를 방지하기 위해, 회원가입 로직에서 임시로 인증 객체(Authentication)를 SecurityContext에 설정하는 방법이 있습니다.

구현 예시

import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;

// 회원가입 서비스 내에서
public void signup(@Valid SignupRequestDTO requestDto) {
    String username = requestDto.getUsername();
    String password = passwordEncoder.encode(requestDto.getPassword());

    // 회원 중복 및 이메일 중복 확인 (생략)

    Member member = new Member(username, password, requestDto.getName(), requestDto.getEmail());
    
    // 임시 인증 객체 생성 및 SecurityContext 설정
    UsernamePasswordAuthenticationToken authToken =
            new UsernamePasswordAuthenticationToken(
                    username,
                    null,
                    List.of(new SimpleGrantedAuthority("ROLE_CUSTOMER")));
    SecurityContextHolder.getContext().setAuthentication(authToken);
    
    // AuditorAware가 이 시점의 username을 반환하여 createdBy에 반영함
    memberRepository.save(member);
    
    // 필요에 따라 SecurityContext를 초기화(클리어)할 수 있음
    SecurityContextHolder.clearContext();
}

장점:

  • AuditorAware를 통해 모든 생성/수정 이벤트가 통일된 방식으로 처리되어 일관성이 유지됩니다.

주의:

  • 임시로 SecurityContext에 인증 정보를 설정하는 것이므로, 스레드 안전(Thread-safety)과 후속 처리에 주의해야 합니다.

5. 정리

  1. JPA Auditing 도입:

    • AuditorAware 구현체(AuditorAwareImpl)를 통해 현재 사용자의 username을 반환하도록 설정
    • @CreatedDate, @LastModifiedDate, @CreatedBy, @LastModifiedBy 어노테이션을 사용해 자동으로 감사 정보를 기록
  2. 소프트 삭제 구현:

    • 엔티티의 status 필드를 통해 삭제 여부를 관리하고, 삭제 시 "DELETED"로 변경
    • 엔티티 리스너(SoftDeleteListener)를 활용해 @PreUpdate 시점에 deletedAt과 deletedBy를 자동으로 설정
    • 실제 삭제가 아닌 업데이트로 처리하여 데이터 보존 및 감사가 가능
  3. 특수 상황(회원가입) 처리:

    • 최초 회원가입 시 인증 컨텍스트가 없으므로, 서비스 레이어에서 수동으로 createdBy 필드를 설정
    • 단, AuditingEntityListener가 엔티티 저장 시 자동으로 AuditorAware.getCurrentAuditor()를 호출하여 값을 덮어쓸 수 있으므로,
      이를 방지하기 위해 회원가입 시점에 임시 SecurityContext를 설정하는 방법(또는 커스텀 리스너 구현)을 고려해야 합니다.

이와 같이 구현함으로써 기능 요구사항에 맞추어 엔티티 생성/수정/삭제 시 감사 정보를 효율적으로 관리할 수 있게 되었으며, 소프트 삭제 방식으로 데이터의 무결성과 복구 가능성을 확보할 수 있었습니다.


profile
Dreamer

0개의 댓글