본문 바로가기
study/JPA

[Spring Data JPA] JPA Auditing을 이용한 생성/수정 이력 추적

by eo_neunal 2024. 7. 3.

0. 들어가며

서비스 운영 시 데이터가 생성되고 수정한 이력을 기록하고 트래킹하는 것은 중요하다. Spring Data JPA에서는 Auditing이라는 기능을 제공한다. 이를 통해 엔티티가 생성되고, 변경되는 시점을 감지해 생성시각, 수정시각, 생성자, 수정자를 기록할 수 있다.

1. Spring Data JPA Auditing 적용

Spring Data JPA Auditing을 사용하기 위해서는 @EnableJpaAuditing 어노테이션을 사용해 Auditing을 활성화해야 한다.

Application 클래스에 직접 붙이거나, @Configuration 어노테이션이 사용된 클래스에 붙이면 된다.

필자는 후자의 방법으로 Auditing을 활성화했다.

 

| JpaAuditingConfig.java

@EnableJpaAuditing
@Configuration
public class JpaAuditingConfig {
    
}

 

Auditing을 활성화한 후 엔티티를 작성한다.

추적용 필드들은 재사용되는 경우가 많기 때문에 아래와 같이 BaseEntity로 선언하고, 이를 상속받아 생성 및 수정 이력을 추적할 수 있다.

 

| BaseEntity.java

@Getter
@AllArgsConstructor(access = AccessLevel.PROTECTED)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@EntityListeners(AuditingEntityListener.class)  // (1)
@MappedSuperclass
public abstract class BaseEntity {

  @Column(updatable = false)
  @CreatedDate  // (2)
  private LocalDateTime createdAt;

  @Column(updatable = false)
  @CreatedBy  // (3)
  private Long createdBy;

  @LastModifiedDate  // (4)
  private LocalDateTime lastModifiedAt;

  @LastModifiedBy  // (5)
  private Long lastModifiedBy;

  private boolean deleted;
}

(1) @EntityListeners: 엔티티의 변화를 감지하여 엔티티와 매핑된 테이블의 데이터를 조작
     파라미터로 이벤트 리스너를 전달해야 하는데, Spring Data JPA에서 제공하는 엔티티의 영속, 수정 감지 이벤트 리스너인 AuditingEntityListener를 넣어준다.

(2) @CreatedDate: 엔티티가 생성됨을 감지하고 생성일시를 createdAt 필드에 기록

(3) @CreatedBy: 엔티티가 생성됨을 감지하고 생성자를 createdBy 필드에 기록

(4) @LastModifiedDate: 엔티티가 수정됨을 감지하고 수정일시를 lastModifiedAt 필드에 기록

(5) @LastModifiedBy: 엔티티가 수정됨을 감지하고 수정자를 lastModifiedBy 필드에 기록

 

하지만, 실제로는 생성일시와 수정일시만 추적이 되고 생성자와 수정자는 추적되지 않는다.

생성시각과 수정시각은 어노테이션 지정만으로 추적이 가능하지만, 생성자와 수정자는 JPA가 알 수 없기 때문에 별도로 AuditorAware<T>를 Bean으로 등록해줘야 한다.

 

이때 제네릭 타입 T@CreatedBy@LastModifiedBy 어노테이션이 선언된 필드의 타입이다.

2. 생성자 및 수정자 추적 설정

2.1. SecurityContext에서 사용자 정보 추출

Spring Security가 적용된 프로젝트이므로, SecurityContextHolder에 저장된 Authentication 객체에서 사용자 정보를 가져온다.

 

| SecurityAuditorAware.java

public class SecurityAuditorAware implements AuditorAware<Long> {

  @Override
  public Optional<Long> getCurrentAuditor() {
    return Optional.ofNullable(SecurityContextHolder.getContext().getAuthentication())
        .map(authentication -> {
          validateAuthentication(authentication);  // (1)
          final Principal loginUser = (Principal) authentication.getPrincipal();  // (2)
          return loginUser.getId();
        });
  }

  private void validateAuthentication(Authentication authentication) {
    if (authentication == null || !authentication.isAuthenticated()) {
      throw new ApiException(HttpStatus.UNAUTHORIZED, ApiStatus.UNAUTHORIZED);
    }
  }
}

(1) Authentication 객체가 null이거나 인증되지 않은 객체인지 검증한다.

(2) Authentication 객체에서 Principal(인증 주체) 정보를 가져온다.

(3) 자체 로그인 및 소셜 로그인에서 인증 객체를 재사용하기 위해 구현한 커스텀 인증 객체 Princial에서 필용한 사용자 아이디 값을 반환한다.

 

| Principal.java

@Getter
@RequiredArgsConstructor
public class Principal implements UserDetails, OAuth2User {

  private final User user;
  private final List<Authority> authorities;

  @Override
  public Map<String, Object> getAttributes() {
    return Map.of();
  }

  @Override
  public Collection<? extends GrantedAuthority> getAuthorities() {
    return authorities.stream()
        .map(authority -> new SimpleGrantedAuthority("ROLE_" + authority.getName()))
        .toList();
  }

  @Override
  public String getPassword() {
    return user.getPassword();
  }

  @Override
  public String getUsername() {
    return user.getUsername();
  }

  @Override
  public String getName() {
    return user.getName();
  }

  @Override
  public boolean isAccountNonExpired() {
    return true;
  }

  @Override
  public boolean isAccountNonLocked() {
    return true;
  }

  @Override
  public boolean isCredentialsNonExpired() {
    return true;
  }

  @Override
  public boolean isEnabled() {
    return true;
  }

  public Long getId() {
    return user.getId();
  }
}

 

이후 AuditorAware 구현체를 Bean으로 등록한다.

 

 

| JpaAuditingConfig.java

@EnableJpaAuditing
@Configuration
public class JpaAuditingConfig {

  @Bean
  public AuditorAware<Long> auditorAware() {
    return new SecurityAuditorAware();
  }
}

2.2. HTTP Request에서 사용자 정보 추출

Spring Security를 사용하지 않은 경우 HttpServletRequest를 주입받아 필요한 정보를 가져올 수 있다.

 

사용자가 로그인 후 발급받은 액세스 토큰을 헤더에 담아 요청을 보내오는 경우 헤더에서 액세스 토큰을 추출해 토큰에 담긴 사용자 아이디를 꺼내오는 경우 아래와 같이 구현할 수 있다.

 

| JpaAuditingConfig.java

@RequiredArgsConstructor
@EnableJpaAuditing
@Configuration
public class JpaAuditingConfig {

  private static final String ACCESS_TOKEN_HEADER_NAME = "Authorization";

  private final HttpServletRequest httpServletRequest;
  private final TokenManager tokenManager;

  @Bean
  public AuditorAware<Long> auditorAware() {
    return () -> {
      final String accessToken = httpServletRequest.getHeader(ACCESS_TOKEN_HEADER_NAME);
      return Optional.of(tokenManager.extractUserId(accessToken));
    };
  }
}

(앞서 구현했던 SecurityAuditorAware처럼 AuditorAware의 구현체 선언하고, 이를 Bean으로 등록할 수도 있지만, 이처럼 익명 클래스를 Bean으로 등록하는 방법도 있다. )

 

| TokenManager.java

@Component
public class TokenManager {

  private static final String ID = "id";
  private static final String BEAR_TOKEN_PREFIX = "Bearer ";
  private static final String REMOVE = "";

  private final Key key;

  public TokenManager(@Value("${jwt.secretKey}") String key) {
    this.key = Keys.hmacShaKeyFor(Decoders.BASE64.decode(key));
  }
  
  ...

  public Long extractUserId(String token) {
    return parseClaims(removePrefix(token)).get(ID, Long.class);
  }
  
  private String removePrefix(String token) {
    validateTokenType(token);
    return token.replace(BEAR_TOKEN_PREFIX, REMOVE);
  }

  private void validateTokenType(String token) {
    if (!token.startsWith(BEAR_TOKEN_PREFIX)) {
      throw new ApiException(HttpStatus.BAD_REQUEST, ApiStatus.UNSUPPORTED_JWT);
    }
  }

  private Claims parseClaims(String token) {
    return Jwts.parser()
        .verifyWith((SecretKey) key)
        .build()
        .parseSignedClaims(token)
        .getPayload();
  }
}

 

References

Auditing :: Spring Data JPA

[JPA] @CreatedBy, @LastModifiedBy 를 위한 AuditorAware 적용

[JPA] JPA Auditing 정리 및 구현 - 오리블로그 - 티스토리