Spring Entity 상속 전략 회고

서론

기존 우리 서비스는 기업과 B2B 계약을 한 후에, 회사에서 지원해주는 지원금을 가지고 사원들이미리 식사를 구매한 후, 날짜에 맞추어 배달해주는 서비스였다. 내부 팀에서는 정기식사 서비스라고 부르는 이 서비스가 안정화가 된 후, 사업 폭을 넓혀 배달 이외에 매장 식사 서비스 또 마켓까지 추가가 되면서 점점더 DB 테이블이 복잡해지기 시작했다.

왜 테이블이 이렇게 복잡해졌을까? 를 고민해보다 생각해냈던 하나의 원인은 내가 짠 Entity의 상속 관계였다. 확장성을 생각해서 초기에 만들었던 것인데, 복잡성 때문에 구현 비용이 올라가고, 쿼리 JOIN의 수가 점점 늘어나면서 속도 저하 문제도 발생하는 것처럼 보였다.

이 글은 실제로 e-commerce를 구현하면서 겪게된 문제점과 개선방향을 Entity 상속 전략 관점에서 보고자 한다.

Entity 상속 매핑 전략

Spring Boot JPA에서 Entity 상속 매핑 전략은 다양한 방식으로 구현할 수 있다.

  1. 단일 테이블 전략 (Single Table Strategy): 단일 테이블 전략은 모든 하위 엔티티들이 하나의 테이블에 저장되는 전략이다. 각 하위 엔티티는 구분 컬럼을 사용하여 자신의 유형을 식별한다. 이 방법은 간단하고 직관적이지만, 테이블의 크기가 커지고 사용되지 않는 속성들이 많아질 수 있어 많은 필드들이 NULL 값을 가지게 된다.

    예를 들어, BaseEntity를 슈퍼 클래스로 하는 SubEntity라는 하위 엔티티가 있다고 가정하자.

    @Entity
    @Inheritance(strategy = InheritanceType.SINGLE_TABLE)
    @DiscriminatorColumn(name = "entity_type")
    public abstract class BaseEntity {
        // 공통 속성 및 메서드들
    }
    
    @Entity
    @DiscriminatorValue("sub_entity")
    public class SubEntity extends BaseEntity {
        // 추가적인 속성 및 메서드들
    }
    
    

    위의 예시에서 BaseEntity는 추상 클래스로 지정되고, SubEntity"sub_entity"라는 값을 가지는 entity_type이라는 구분 컬럼을 사용하여 식별된다.

  2. 조인 테이블 전략 (Joined Table Strategy): 각 하위 엔티티마다 별도의 테이블을 생성하고, 공통 속성은 슈퍼 클래스의 테이블에 저장하는 전략이다. 이 방법은 데이터베이스 정규화 원칙을 잘 따르지만, 쿼리의 JOIN 연산이 필요하므로 성능에 영향을 줄 수 있다.

    예를 들어, BaseEntity를 슈퍼 클래스로 하는 SubEntity라는 하위 엔티티가 있다고 가정해보자.

    @Entity
    @Inheritance(strategy = InheritanceType.JOINED)
    public abstract class BaseEntity {
        // 공통 속성 및 메서드들
    }
    
    @Entity
    public class SubEntity extends BaseEntity {
        // 추가적인 속성 및 메서드들
    }
    
    

    위의 예시에서는 BaseEntity는 추상 클래스로 지정되고, SubEntity는 별도의 테이블을 가지게 된다. 공통 속성은 BaseEntity의 테이블에 저장되고, SubEntity의 추가적인 속성은 SubEntity의 테이블에 저장된다.

    이렇게 조인 테이블 전략을 사용하면 각 엔티티마다 별도의 테이블을 가지므로 JOIN 연산이 필요하지만, 데이터베이스 정규화를 잘 따르는 구조를 가질 수 있다는 장점이 있다.

  3. 구체 클래스 매핑 전략 (Concrete Class Mapping Strategy): 각 하위 엔티티마다 별도의 테이블을 생성하고, 슈퍼 클래스는 추상 클래스로 지정되는 전략이다. 이 방법은 각 엔티티에 대한 테이블을 가지므로 쿼리의 JOIN 연산이 필요하지 않지만, 쿼리의 결과를 통합하기 위해 UNION 연산을 사용해야 할 수도 있다.

    예를 들어, BaseEntity를 슈퍼 클래스로 하는 SubEntity라는 하위 엔티티가 있다고 가정해보자.

    @Entity
    public abstract class BaseEntity {
        // 공통 속성 및 메서드들
    }
    
    @Entity
    public class SubEntity extends BaseEntity {
        // 추가적인 속성 및 메서드들
    }
    
    

    위의 예시에서는 BaseEntity는 추상 클래스로 지정되고, SubEntity는 별도의 테이블을 가지게 된다. 구체 클래스 매핑 전략은 @Entity 애노테이션을 사용하여 각 엔티티 클래스에 적용할 수 다. 각 엔티티는 고유한 테이블을 가지므로 쿼리의 JOIN 연산이 필요하지 않다. 이렇게 구현하면 각 엔티티가 독립적인 테이블을 가지므로 성능에 일부 이점이 있을 수 있다.

현재 테이블 상태

상속 매핑 전략을 사용하면서 가장 문제였던 것은 주문쪽이었다. OrderOrderItem 이라는 Super class를 상속하는 OrderMembership OrderDailyFood 더해서, 마지막으로 추가될 OrderProduct 와 또 OrderItem을 상속하는 테이블들. 추가로 상품이 다양해진다면 Product를 상속하게 될 상품 테이블들을 생각하면 테이블이 더 복잡해진다.

사실, 처음에 생각했던 것은 Item 이라는 Super Class를 생성해서 식단, 멤버십, 상품 등이 Item 을 Extend 하는 방식을 생각했었다.

코드를 짜면서 늘어나는 JOIN의 수와 instanceOf 의 지속적인 사용에 현타가 오면서 생각하게 된 것은 “근본적으로 왜 Order와 OrderItem를 만들게 되었나”라는 의문이었다. 서비스에서 판매할 상품의 종류가 늘어날 것을 알고 있어 새로운 형태의 상품을 추가할 수 있다는 이점이 있었고, 주문의 공통 속성 및 동작을 처리하고 싶었다.

하지만 실제로 마켓을 구현해보면서 기존에 존재하던 정기식사 로직과 차이가 컸으며, 그러다보니 오히려 복잡성이 늘어났다. 그래서 다다른 결론은 ”평평하게 만들자! 의존 관계를 끊자!”였다.

결국 핵심은 "평평하게 만들자! 클래스 의존 관계를 끊자!"이다. 초기에는 상속 관계를 이용하여 확장성을 고려하여 Entity를 설계했지만, 실제로 구현하면서 복잡성이 증가하고 성능 저하 문제가 발생했기에 이에 따라 상속 관계를 끊고 테이블을 평평하게 만들기로 결정했다.

사실, 상속 매핑에 회의를 갖게 된 것은 유쾌한 스프링방에서 어느 분이 말씀하신 이야기 때문인데

🗣 JPA로 개발하는 대부분에서 조인은 거의 안씁니다. 단순 쿼리만 하거나 별도로 다른 것으로 조회합니다. DB가 물리적으로 나뉘어져 있어서 데이터 소스가 하나가 아니기 때문입니다. 대규모 쇼핑몰로 치면 상품 재고 팀이 있고 회원 팀이 있고 결제 팀이있다면 결제 시 상품 재고, 회원 팀의 API를 통해서 값을 가져와야하는데 분리된 DB에서 JOIN을 할 수 있을까요?

현재 테이블 상태를 살펴보면, 상속으로 인해 주문과 주문 상품에 대한 테이블 구조가 복잡해진 것을 확인할 수 있다. 이에 따라 JOIN의 수와 instanceOf의 사용이 증가하며 복잡성이 증가했다. 따라서 평평한 구조로 변경하여 의존 관계를 줄이고, 테이블을 단순화하여 미래를 대비할 생각이다.

Spring Entity 상속 전략 회고

댓글 남기기

Scroll to top