서론
Go + Fiber로 개발했던 Grafi 서비스를 Java + SpringBoot로 마이그레이션하는 작업을 본격적으로 시작했다. 일단 가장 먼저 모든 엔티티에 CreatedAt, DeletedAt 같은 공통 필드를 선언하기 위해 당연히 @MappedSuperclass를 사용하려고 했지만 문득 Embedded Type도 필드들을 묶어서 선언하는 것 아닌가 하는 생각이 들었다. 비슷한 고민의 글들이 많아서 이를 종합하여 정리해 보았다.
@MappedSuperclass
- 엔티티 간의 공통 속성과 메서드를 추출하여 재사용
- 이 애너테이션이 붙은 클래스는 테이블을 생성하지 않고, JPA에서 관리되는 엔티티가 아니다.
- 이 클래스의 속성과 메서드는 이 클래스를 상속받은 실제 엔티티 클래스에서 사용된다.
- 테이블과 관계없고, 단순히 엔티티가 공통으로 사용하는 매핑 정보를 모으는 역할
@Embeddable
- 공통 속성을 묶어 하나의 객체로 만들어 재사용
- 단순히 값들을 하나로 묶은 것이다
- 마찬가지로 테이블을 생성하지는 않는다.
‘상속보다는 컴포지션을 사용하라’
일반적으로 객체지향 관점에서 상속은 최선의 해결 방법이 아니다.
- 메서드 호출과 달리 캡슐화를 깨뜨린다.
- 상위 클래스의 구현 방식에 따라 하위 클래스 동작에 이상이 생길 수 있다. 다시 말해 강하게 결합된다. - 다중 상속이 불가능하다.
- 설계가 복잡하다
간단 요약이지만, 위와 같은 문제들 때문에 상속보다는 객체 간의 캡슐화도 지키고 결합도를 낮출 수 있는 컴포지션을 사용하도록 권장된다.
그렇다면 컴포지션을 사용하는 Embedded Type이 맞지 않을까?
예시 코드 1 - @MappedSuperclass
@Getter
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class) // JPA Entity에서 이벤트가 발생할 때마다 특정 로직을 실행
public abstract class BaseTimeEntity {
@CreatedDate
@Column(columnDefinition = "TIMESTAMP", updatable = false)
private Timestamp createdAt;
}
@Entity
@AllArgsConstructor
@NoArgsConstructor
public class User extends BaseTimeEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;
// 생략
}
- 기존 엔티티 필드들 변경 없이 상속만 받으면 된다.
예시 코드 2 - @Embeddable
@Embeddable
@NoArgsConstructor
public class BaseTimeEntity {
@CreatedDate
@Column(columnDefinition = "TIMESTAMP", updatable = false)
private Timestamp createdAt;
}
@Entity
@AllArgsConstructor
@NoArgsConstructor
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;
// 추가된 부분
@Embedded
private BaseTimeEntity baseTimeEntity;
// 생략
}
- 엔티티의 값으로 표현된다.
두 코드를 비교해 봤을 때 무엇이 좀 더 명확할까? 이 경우에서는 아래와 같은 이유들 때문에 상속이 좀 더 명확하다.
- 단순히 엔티티의 중복되는 필드를 재사용하기 위해 사용하기 때문
- createdAt, deletedAt은 의미적으로도 묶을 이유가 딱히 없음
- 묶는다는 측면에서, Embedded Type은 필드들을 의미상으로 묶는 느낌이 더 강하다면, @MappedSuperclass는 공통적인 부분들을 재사용하는 느낌이 더 강한 것 같다 (개인적인 의견) - 약간의 코드 증가와 엔티티 필드에 값을 추가해야 된다는 점
추가적인 이유(JPQL) - 영한님
Embedded Type을 사용하는 경우 JPQL 쿼리를 하려면 다음과 같이 항상 baseTimeEntity라는 식으로 임베디드 타입을 적어주어야 합니다.
select u from User u where u.baseTimeEntity.createdAt > ?상속을 사용하면 다음과 같이 간단하고 쉽게 풀립니다.
select u from User u where u.createdAt > ?
결국 둘 중 선택이기는 합니다만, 편리함과 직관성 때문에, 저는 이 경우 상속을 사용합니다^^
위처럼 JPQL 쿼리 시에도 baseTimeEntity라는 임베디드 타입을 적어줘야 한다.
마무리
둘의 차이는 결국 상속이냐 컴포지션(위임)이냐의 차이이다. 대부분의 경우에는 상속보다는 컴포지션을 사용하는 게 좋아 보이지만, 이 경우와 같이 의미가 따로 있지는 않고, 단순히 엔티티의 중복되는 필드를 재사용하기 위함이라면 상속을 사용하는 것이 코드적으로도, 쿼리시에도 깔끔하다.
앞서 말한 "묶는다는 측면에서, Embedded Type은 필드들을 의미상으로 묶는 느낌이 더 강하다면, @MappedSuperclass는 공통적인 부분들을 재사용하는 느낌이 더 강한 것 같다 (개인적인 의견)" 이 부분이 포인트인 것 같다.