
서비스를 개발하면서 데이터의 히스토리를 파악할 수 있는 Auditing 기능은 필수라고 생각이 됩니다. JPA를 쓰신다면 JPA에서 지원하는 Auditing 기능을 활용해 다음과 같이 상속을 활용해 많이 사용하실 것입니다.
// auditing data 를 저장하는 BaseEntity 클래스
@NoArgsConstructor
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public class BaseEntity {
@CreatedDate
@Column(updatable = false)
private LocalDateTime createdAt;
@LastModifiedDate
private LocalDateTime modifiedAt;
}
// BaseEntity를 상속받은 Member 클래스
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Table(name = "Member")
public class Member extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String name;
@Column(nullable = false)
private int age;
}
그런데 최근에 스터디에서 상속과 합성에 관련된 주제로 이야기하다가 JPA auditing에서는 왜 상속을 사용할까에 대해 이야기해보았지만 명확한 결론이 나오지 않았습니다. 그래서 이것을 기회삼아 상속과 합성의 기준점을 한번 더 판단해보기 위해 이 글을 작성하게 되었습니다.
상속은 객체지향 프로그래밍의 4가지 특징 중 하나로 기존의 코드를 수정하지 않으면서 클래스의 기능을 확장할 수 있어 코드 재사용성을 높이는데 큰 도움이 되었습니다. 하지만 부모클래스의 속성들이 그대로 자식 속성으로 전달되기 때문에 상속 계층에 포함되어 있는 클래스들간의 결합도가 높아집니다. 그렇기 때문에 상속의 깊이가 깊어질수록 상위클래스들의 변경은 굉장히 어렵게 되고, 새로운 상위클래스를 만들게 되면 기존 상속 계층 구조에 따라 수많은 클래스를 만들어야 하므로 클래스 폭발 문제 또한 발생할 수 있습니다.
단점을 가지고 있지만 써야하는 경우도 물론 있습니다. 하지만 Auditing 기능에 상속을 활용하면 더 이상 해당 클래스는 상속의 활용이 불가능해집니다. 그렇다면 이후에 비슷한 클래스들이 생겨서 상위 부모 클래스로 한번에 다루고 싶어도 그러지 못할 것입니다.
하지만 이미 많은 레퍼런스 코드들에서 JPA Auditing을 상속을 활용해 사용하고 있고 직접 코드를 짜서 활용하기 전까지 모르는 부분이 있기 때문에 간단하게 구현 후 비교를 해보도록 하겠습니다.
BaseEntity@Embeddable 어노테이션을 클래스에 붙여주었습니다.// BaseEntity.java
@Getter
@NoArgsConstructor
@Embeddable
public class Audit {
@CreatedDate
@Column(updatable = false)
private LocalDateTime createdAt;
@LastModifiedDate
private LocalDateTime modifiedAt;
}
Member 클래스Audit 클래스가 부모 클래스기 때문에 @EntityListeners를 Audit 클래스에 붙여주어도 되었지만 합성이기 때문에 엔티티에 붙여주어야 합니다.// Member.java
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Builder
@EntityListeners(AuditingEntityListener.class)
public class Member {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Embedded
Audit audit;
@Setter
@Column(nullable = false)
private String name;
@Column(nullable = false)
private int age;
public static Member getSampleMember() {
return Member.builder().name("tom").age(20).build();
}
}
getSampleMember() 를 호출해 얻은 샘플 엔티티를 저장하는 api 입니다.@PostMapping
public String createSampleData() {
Member member = memberSpringDataRepository.save(Member.getMember());
return "Success!";
}
그럼 이 API를 호출한 결과는 어떻게 되었을까요?

auditing 컬럼인 created_at과 modified_at 은 만들어졌지만 값이 들어가지 않았습니다.
문제를 해결하기 위해 @Embeddable 과 @Embedded에 관련된 공식문서를 보았지만 해결책을 얻지 못해 간단한 디버깅을 진행해보았습니다.
JPA auditing 은 엔티티가 영속화 되기 직전 실행하는 @PrePersist 어노테이션이 붙은 메서드(EntityListener 내부)를 활용합니다.
// AuditingEntityListener
@PrePersist
public void touchForCreate(Object target) {
Assert.notNull(target, "Entity must not be null");
if (this.handler != null) {
AuditingHandler object = (AuditingHandler)this.handler.getObject();
if (object != null) {
object.markCreated(target);
}
}
}
위의 함수의 실행을 통해 엔티티의 auditing 컬럼에 현재 시간이 매핑되게 되고 그 후 엔티티의 영속화가 이루어지게 됩니다.
아래의 코드는 그 과정 중에서 현재 시간(파라미터의 value)을 auditing 컬럼에 설정하는 부분입니다. auditing 속성은 PersistentPropertyPath 값으로 전달됩니다.
// SimplePersistentPropertyPathAccessor
public void setProperty(PersistentPropertyPath<? extends PersistentProperty<?>> path, @Nullable Object value, AccessOptions.SetOptions options) {
Assert.notNull(path, "PersistentPropertyPath must not be null");
Assert.isTrue(!path.isEmpty(), "PersistentPropertyPath must not be empty");
PersistentPropertyPath<? extends PersistentProperty<?>> parentPath = path.getParentPath();
if (parentPath == null) {
this.setProperty(path.getLeafProperty(), value);
} else {
PersistentProperty<? extends PersistentProperty<?>> leafProperty = path.getLeafProperty();
if (options.propagate(parentPath.getLeafProperty())) {
AccessOptions.GetOptions lookupOptions = options.getNullHandling() != SetNulls.REJECT ? DEFAULT_GET_OPTIONS.withNullValues(GetNulls.EARLY_RETURN) : DEFAULT_GET_OPTIONS;
Object parent = this.getProperty(parentPath, lookupOptions); // 이 부분 주목!
if (parent == null) {
this.handleNull(path, options.getNullHandling());
} else if (parent == this.getBean()) {
this.setProperty(leafProperty, value);
}
// ...
그런데 코드를 보게 되면 먼저 parentPath를 찾고 그 parent를 사용하여 setProperty를 호출하게 됩니다. 그렇다면 코드 중 Object parent = this.getProperty(parentPath, lookupOptions); 이 부분의 실행 결과를 디버깅으로 확인해서 적절한 parent가 불렸는지 확인해보겠습니다.

하지만.. 결과는 null 이었습니다. 그 이유는 parent인 audit 이 null 이었기 때문이었습니다. 즉, 엔티티가 만들어질 때 @Embedded Audit 클래스의 인스턴스가 생성되지 않아 audit 속성에 접근할 수 없는 점이 문제였습니다.
인스턴스 초기화가 되지 않았던 것이 문제였기 때문에 엔티티 생성시 초기화가 되도록 코드를 추가하였습니다. 저는 lombok의 builder를 활용하고 있었기 때문에 Audit 필드에 @Default 어노테이션을 추가해서 빌더에도 초기화가 적용되게끔 하였습니다.
// Member.java
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Builder
@EntityListeners(AuditingEntityListener.class)
public class Member {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Default // 추가
@Embedded
Audit audit = new Audit(); // 초기화 코드 추가
@Setter
@Column(nullable = false)
private String name;
@Column(nullable = false)
private int age;
public static Member getSampleMember() {
return Member.builder().name("tom").age(20).build();
}
}
그 결과..!

auditing 정보들이 잘 저장된 것을 확인하였습니다!
저는 "Yes" 라고 생각합니다.
상속을 꼭 써야하는 상황일까?
만약 상속을 써서 공통적인 로직을 강제해야한다든가, 의미론적으로 is-a 관계일 때 상속의 사용을 고려해볼 수 있습니다. 하지만 단순히 auditing의 경우에는 상속을 사용해서 얻는 이점보다 이후 엔티티들의 상속 가능성을 막는 것으로 인한 단점이 현재 저에게는 조금 더 크게 느껴집니다.
코드 재사용
코드 재사용적인 측면에서는 상속이 조금 더 유리한 것으로 보입니다. 상속을 사용한다면 @EntityListeners(AuditingEntityListener.class) 어노테이션을 Audit 클래스 한군데에만 달아주면 되겠지만 합성을 사용한다면 엔티티마다 달아주어야 하기 때문입니다. 또한 합성의 경우 초기화를 해야하는 코드도 추가로 필요할 것 같네요. 하지만 두 개의 코드 모두 특별한 로직이 들어가는 코드가 아니기 때문에 수정이 거의 일어나지 않아, 엔티티마다 추가되는 것은 trade-off로 감안할 수 있지 않을까 싶습니다.
Audit 클래스의 초기화가 필요하다는 점입니다. 이러한 과정을 겪고보니, 라이브러리나 프레임워크를 사용하는 입장에서 어떤 클래스에 자동으로 매핑을 해줄 때 해당 클래스의 인스턴스가 접근 가능한 상태여야 한다는 것은 어찌보면 당연한 일이었습니다. 그렇기 때문에 접근 불가능할 가능성을 염두에 두고 초기화 코드만 체크를 한다면 무리없이 사용할 수 있을 것입니다.