본문 바로가기
Java & Kotlin & Spring

반복되는 필드를 모아 Entity를 만드는 @MappedSuperClass

by LeeJ1Hyun 2023. 8. 16.

반복되는 필드 재사용

Entity를 만들다 보면 createdAt, createdBy, updatedAt 그리고 updatedBy 등 반복되는 필드가 존재한다. 앞서 나열한 요소뿐만 아니라 서비스에 따라 Entity마다 공통되는 필드가 생기는 경우가 많다. 주소와 같이 우편번호, 지번 등이 포함되는 서비스도 해당될 수 있다.

 

@Entity
@Getter
@Setter
public class Member {

    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "member_seq")
    @SequenceGenerator(name = "member_seq", sequenceName = "member_sequence", allocationSize = 1)
    private Long id;
    private String name;
    private String email;
    private String password;
    private LocalDateTime createdAt;
}

 

@Entity
@Getter
@Setter
public class Admin {

    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "admin_seq")
    @SequenceGenerator(name = "admin_seq", sequenceName = "admin_sequence", allocationSize = 1)
    private Long id;
    @Enumerated(EnumType.STRING)
    private Grade grade;
    private String email;
    private LocalDateTime createdAt;
}

 

몇 가지 공통된 필드를 가지는 Member, Admin Entity가 있다. (Setter를 열어두면 데이터에 여러 곳에서 접근할 수 있기 때문에 변화를 추적하기 어려워 닫아 두는 것이 좋지만, 편의를 위해 예시에서는 열어두었다.) 생성된 DDL은 다음과 같다.

 

 

public class ExampleApplication {

    public static void main(String[] args) {
        EntityManagerFactory emf = Persistence.createEntityManagerFactory("test");
        EntityManager em = emf.createEntityManager();
        EntityTransaction transaction = em.getTransaction();
        transaction.begin();

        try {

            Member member = new Member();
            member.setName("louis");
            member.setEmail("louis@gmail.com");
            member.setPassword("password");
            member.setCreatedAt(LocalDateTime.now());

            Admin admin = new Admin();
            admin.setEmail("admin@gmail.com");
            admin.setGrade(Grade.GENERAL);
            admin.setCreatedAt(LocalDateTime.now());

            em.persist(member);
            em.persist(admin);

            transaction.commit();
        } catch (Exception e) {
            e.printStackTrace();
            transaction.rollback();
        } finally {
            em.close();
        }

        emf.close();
    }
}

 

Entity에 값이 제대로 들어가는지 확인하기 위해 Member와 Admin을 각각 한 명씩 생성했다. 저장 시에 간혹 java.lang.IllegalArgumentException: Unknown entity: 에러를 뿜을 때가 있다. 빌드 환경에 따라 클래스 인식이 안 되는 경우가 있기 때문이다. 이때 persistence.xml 파일에 클래스 경로를 지정해 주면 된다.

 

<?xml version="1.0" encoding="UTF-8"?>
<persistence>
    <persistence-unit name="test">
        <class>example.domain.Member</class>
        <properties>
        	... 생략
        </properties>
    </persistence-unit>
</persistence>

 

순수 JPA를 학습할 때만 이렇게 클래스를 추가하는 게 빌드 환경에 따라 필요할 수 있다. 실무에서는 보통 스프링과 함께 JPA를 사용한다. 스프링과 함께 사용하면 자동으로 엔티티를 스캔하는 기능이 내장되어 있어서 이런 추가 설정 없이 잘 동작한다. (라고 JPA의 신께서 말씀하셨다.)

 

 

 

 

insert 쿼리가 잘 날아간 것을 로그를 통해 알 수 있다. 실제 H2 디비에도 잘 들어갔다. 이제 겹치는 필드들을 BaseEntity로 분리하여 똑같이 동작하는지 확인하면 된다.

 

@MappedSuperClass

두 Entity는 id, email 그리고 createdAt 세 가지 필드를 공통으로 가지고 있다. 현재는 두 클래스지만 만약 이를 공통으로 사용하는 Entity가 늘어난다면 해당 필드를 매번 작성해줘야 한다. 반복되는 작성 과정을 줄이기 위해서 JPA가 제공하는 @MappedSuperClass 어노테이션을 이용하면 공통된 필드를 모아 BaseEntity를 만들 수 있다. 하지만 실제 데이터베이스 테이블과 매핑되는 것이 아니기 때문에 @Entity 어노테이션과 함께 사용할 수 없다.

 

@Setter
@Getter
@MappedSuperclass
public abstract class BaseEntity {

    @Id
    @GeneratedValue
    private Long id;
    private String email;
    private LocalDateTime createdAt;
}

 

공통된 필드를 모아 BaseEntity라는 추상 클래스를 만들었다. 물론 실제 서비스에서는 Member, Admin 클래스의 공통된 기반 필드를 담은 클래스라는 의미를 담아 짓는 것이 좋다. 여기서는 그냥 의미를 기억하기 편하게 BaseEntity라고 하였다. BaseEntity 자체를 인스턴스로 생성하여 사용할 일이 없으므로 추상 클래스로 선언한다.

 

@Entity
@Getter
public class Member extends BaseEntity {

    private String name;
    private String password;
}

 

@Entity
@Getter
public class Admin extends BaseEntity {
	
    @Enumerated(EnumType.STRING)
    private Grade grade;
}

 

BaseEntity를 상속받은 Member, Admin 클래스는 위와 같이 간소화될 수 있다.

 

 

DDL도 동일하게 생성된 것을 확인할 수 있다.

 

똑같이 Member, Admin을 저장하는 코드를 실행시켜 봤다. 다만 정말 그런지 확인을 위해 name, email 그리고 password 같은 문자열 요소들은 변경했다.

 

 

 

역시나 쿼리가 정상적으로 실행되었고, 데이터도 잘 들어간 것을 확인할 수 있다. BaseEntity를 상속함으로써 중복된 코드를 줄이고 재사용성을 높였다. 차이점이 하나 있다면 각각의 생성된 데이터들의 Id 값이 이전에는 분리된 Entity로 구분하여 1, 1이었다면 이제는 하나의 BaseEntity에서 Id라는 필드 하나로 공유하고 있기 때문에 먼저 생성된 Member가 1, Admin이 2가 된다. 이렇게 되면 여러 엔티티들의 Id 필드가 충돌하는 상황이 발생할 수 있습니다.

 

만약 Member와 Admin이 하나의 큰 User라는 도메인 안에 묶인다면 상관이 없을 것이다. 예를 들어 상품을 판매하는 서비스에 Book, Movie Entity가 있다고 하자. 이들은 상품을 의미하는 Item이라는 도메인에 묶여있을 수 있다. 즉 Id가 Book, Movie 별로 1, 2, 3... 순서대로 각각 생성되지 않아도 문제가 되지 않는다. Book Id 1, Book Id 3, Movie Id 2 이래도 서비스적으로 상관없을 가능성이 크다는 말이다.

 

하지만 보통 서비스를 이용하는 일반 유저인 Member와 관리자 화면에 접속하기 위해 생성한 Admin은 따로 구분하는 경향이 있다. Admin을 맨 처음 생성했을 때 Id가 1이지만, 그 사이 많은 Member들이 생성되면 그다음 Admin Id는 1000번이 될 수도 있다. 이는 서비스의 흐름을 추적하기에 불필요한 어려움을 줄 수도 있다.

 

이밖에도 BaseEntity에 Id를 포함하는 것에는 문제가 있다. 위에서 id를 Long타입으로 지정하였는데 만약 다른 자료형을 사용하고 싶어도 변경할 수 없다. 물론 이미 타입까지 점검하여 통합 Entity를 만든 것이겠지만 이를 상속받게 될 많은 Entity에 미칠 영향도 생각해봐야 한다는 의미이다.

 

Id를 BaseEntity에서 제거하면 아래와 같다.

 

@Setter
@Getter
@MappedSuperclass
public abstract class BaseEntity {

    private String email;
    private LocalDateTime createdAt;
}

 

@Entity
@Getter
@Setter
public class Admin extends BaseEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "admin_seq")
    @SequenceGenerator(name = "admin_seq", sequenceName = "admin_sequence", allocationSize = 1)
    private Long id;
    @Enumerated(EnumType.STRING)
    private Grade grade;
}

 

@Entity
@Getter
@Setter
public class Member extends BaseEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "member_seq")
    @SequenceGenerator(name = "member_seq", sequenceName = "member_sequence", allocationSize = 1)
    private Long id;
    private String name;
    private String password;
}

 

 

 

역시나 쿼리는 잘 실행되었고, 이번에는 Id도 각각 1, 1로 들어갔다. 의도대로 BaseEntity를 만들었다.

댓글