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
'study > JPA' 카테고리의 다른 글
[Hibernate] Soft Delete in Hibernate (2) | 2024.02.06 |
---|---|
[JPA] Join vs Fetch Join (0) | 2024.01.21 |
[Hibernate] 하이버네이트 명명 전략(Hibernate Naming Strategy) (0) | 2022.09.22 |