해당 게시글은 내용 정리 목적으로 작성되었습니다. 틀린 내용이 있다면 언제든지 말씀해 주세요.

서비스의 데이터 구조 설계 시 JPA 엔티티 상속 전략을 활용해서 확장성과 다형성 등 객체지향의 이점을 활용할 수 있다.
예를 들어서 상위 타입인 쿠폰 엔티티가 있고, 하위 타입인 퍼센트 할인 엔티티, 고정 할인 엔티티가 있다고 가정했을 때, 쿠폰 적용 로직에 대해서 다형성 전략을 활용해 구현할 수 있다.

이 글에 정리된 예시는 JPA 엔티티 상속 전략 활용 방법 및 추상 클래스를 통한 다형성 활용 방법에 대한 내용이다.
우선 JPA 상속 전략의 종류에 대해서 알아보자

1. JPA 상속 전략 종류

JPA는 JOINED, SINGLE_TABLE, TABLE_PER_CLASS의 세 가지 상속 전략을 지원하며, 각각의 전략은 데이터베이스에 서로 다른 방식으로 테이블을 구성한다.

JOINED

  • 상속 구조를 구성하기 위해 공통 속성을 저장하는 상위 테이블과 개별 속성을 저장하는 하위 테이블로 구성된다.
  • 장점
    • 데이터 정규화, 필요한 데이터만 조회 가능, 확장 용이
  • 단점
    • 데이터 저장 시, 상위 테이블과 하위 테이블에 데이터를 저장하며, 데이터 조회 시 하위 테이블의 개수 만큼 left join을 해서 쿼리 성능이 저하될 수 있다. 

SINGLE_TABLE

  • 모든 데이터가 하나의 테이블에 저장된다.
  • 장점
    • 쿼리가 단순하고 성능이 좋다
  • 단점
    • 테이블에 많은 열이 생기고, 불필요한 컬럼이 다수 존재할 수 있다. 
    • 데이터 응집도가 떨어짐 

TABLE_PER_CLASS

  • 각 엔티티 클래스가 독립적인 테이블로 생성되며, 상속 구조를 반영하지 않는다.
  • 장점
    • 간단한 쿼리, 테이블 간의 의존성 없음
  • 단점
    • 동일한 컬럼을 여러 테이블에 중복 저장, 다형성 쿼리에서 성능 저하.

상속 전략에 따라서 테이블의 구조가 변경되며, 실제 비즈니스 로직에 영향은 없다. 
상황에 맞는 전략을 활용해보면 좋을 것 같다. 예제는 JOINED 전략을 활용해보도록 하자
개인적으로 실무에서는  JOINED, SINGLE_TABLE 두 전략을 활용할 수 있을 것 같다. 

JOINED를 활용하면 정규화 측면에서 필요한 데이터만 테이블에 저장되기 때문에 테이블 가독성이 및 데이터 응집도가 올라가지만 테이블 종류가 많아지면 조회 성능이 낮아진다는 단점이 있다. SINGLE_TABLE은 한개 테이블만 활용하기 때문에 조회 속도 및 테이블 관리에 용이하지만 서로 관련 없는 데이터들이 다수 존재하기 때문에 가독성과 응집도가 낮아지며, 컬럼이 많아질수록 데이터간 관련성을 확인하기 여러울 수 있다.    

2. JPA 상속 전략 예제

1) 테이블 구조

  • coupon
    • 모든 쿠폰의 상위 테이블이며 쿠폰 공통 정보를 저장한다
    • 모든 하위 테이블은 쿠폰 테이블의 기본키를 PK로 가지는 식별관계이다.
    • coupon_type으로 쿠폰 정보를 구별한다
  • fixed_discount_coupon
    • 고정 할인 쿠폰이며, 할인 금액 컬럼을 갖고 있다. 
  • percent_discount_coupon
    • 퍼센트 할인 쿠폰이며 할인 퍼센트 컬럼을 갖고 있다.

2) JPA 쿠폰 엔티티 

Money

public record Money(double amount) {
    public static final Money ZERO = Money.of(0);

    public static Money of(double amount) {
        return new Money(amount);
    }

    public Money calculatePercent(double percent) {
        return Money.of(this.amount * percent);
    }

    public Money minus(Money money) {
        return Money.of(this.amount - money.amount);
    }
}
  • 금액 관련 Value Object이다. 불변 객체이며, 계산 관련 메소드 실행 시 계산 결과가 반영된 새로운 Money 인스턴스를 반환한다

Coupon

@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
@Entity
@Inheritance(strategy = InheritanceType.JOINED)
@DiscriminatorColumn(name = "coupon_type")
public abstract class Coupon {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(name = "name", nullable = false)
    private String name;

    @Column(name = "coupon_type", insertable = false, updatable = false)
    @Enumerated(EnumType.STRING)
    private CouponType type;

    protected Coupon(String name, CouponType type) {
        this.name = name;
        this.type = type;
    }

    public abstract Money use(Money price);
}
  • 쿠폰의 공통 정보를 갖는 클래스이다. 추상클래스이며, 쿠폰의 공통 기능을 추상메소드로 정의했다.
  • @Inheritance(strategy = InheritanceType.JOINED)는 부모 엔티티로 선언한다는 것을 의미하며 상속 전략으로 JOINED 속성을 적용했다.
  • @DiscriminatorColumn(name = "coupon_type")은 하위 타입을 구분하는데 필요한 컬럼의 이름을 명시하는 어노테이션이다.
  • 쿠폰 사용 메소드를 추상메소드로 정의했다. Money 타입의 price 변수를 매개변수로 받아서 쿠폰이 적용되었을 때 결과 값을 반환한다
  • CouponType 변수 선언은 선택사항이다. 별도로 선언하지 않아도  @DiscriminatorColumn(name = "coupon_type") 와 같이 어노테이션을 선언했기 때문에 자동적으로 생성된다.
  • couponType 변수는 단순 조회용 변수이므로 insertable,updatable 속성을 false로 설정했다.

FixedDiscountCoupon

@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
@Entity
@DiscriminatorValue("FIXED_DISCOUNT")
public class FixedDiscountCoupon extends Coupon {

    @AttributeOverride(name = "amount", column = @Column(name = "discount_amount"))
    @Embedded
    private Money discountAmount;

    private FixedDiscountCoupon(String name, Money discountAmount) {
        super(name, CouponType.FIXED_DISCOUNT);
        this.discountAmount = discountAmount;
    }

    public static FixedDiscountCoupon create(String name, Money discountAmount) {
        return new FixedDiscountCoupon(name, discountAmount);
    }

    @Override
    public Money use(Money price) {
        return price.minus(discountAmount);
    }
}
  • 고정 할인 쿠폰 엔티티이다. Coupon 엔티티를 상속받으며 @DiscriminatorValue("FIXED_DISCOUNT") 어노테이션의 값을 통해서 couponType이 저장된다.
  • 여기서 핵심은 추상메소드를 오버라이딩 한 use 메소드이다.
  • 추상 클래스에 대한 다형성을 활용하기 위해 use 메소드를 재정의했다. 
  • 각 쿠폰마다 할인 방식이 다르기 때문에 각 쿠폰마다 추상메소드를 재정의한다.
  • 고정할인 쿠폰은 정해진 값 만큼 할인되기 때문에 매개변수로 전달된 price에 discountAmount 값을 차감한 결과를 반환한다.

PercentDiscountCoupon

@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
@Entity
@DiscriminatorValue("PERCENT_DISCOUNT")
public class PercentDiscountCoupon extends Coupon {


    @Column(name = "discount_percent")
    private double discountPercent;

    private PercentDiscountCoupon(String name, double discountPercent) {
        super(name, CouponType.PERCENT_DISCOUNT);
        this.discountPercent = discountPercent;
    }

    public static PercentDiscountCoupon create(String name, double discountPercent) {
        return new PercentDiscountCoupon(name, discountPercent);
    }

    @Override
    public Money use(Money price) {
        return price.minus(price.calculatePercent(discountPercent));
    }
}
  • 퍼센트 할인 쿠폰이며, 저장된 퍼센트 만큼 금액을 할인한다.
  • use 추상 메소드를 재정의해서 다형성 패턴을 활용할 수 있게 했다. 

CouponRepository

public interface CouponRepository extends JpaRepository<Coupon, Long> {
}
  • 쿠폰을 저장하거나 조회할 때 사용할 수 있는 CouponRepository를 정의했다.

CouponTest

class CouponTest {
    @Test
    public void 고정할인_쿠폰_사용_테스트() {
        //given
        Coupon 고정_할인_쿠폰 = FixedDiscountCoupon.create("고정 할인 쿠폰", Money.of(1000));

        //when
        Money discountPrice = 고정_할인_쿠폰.use(Money.of(2000));

        //then
        Assertions.assertThat(discountPrice).isEqualTo(Money.of(1000));
    }

    @Test
    public void 퍼센트할인_쿠폰_사용_테스트() {
        //given
        Coupon 퍼센트_할인_쿠폰 = PercentDiscountCoupon.create("퍼센트_할인_쿠폰", 0.1);

        //when
        Money discountPrice = 퍼센트_할인_쿠폰.use(Money.of(2000));

        //then
        Assertions.assertThat(discountPrice).isEqualTo(Money.of(1800));
    }
}
  • 고정 할인 쿠폰, 퍼센트 할인 쿠폰을 생성 후 Coupon 클래스에 저장했다.
  • 추상 클래스는 하위 클래스의 인스턴스를 저장할 수 있고 추상메소드 호출 시 각 하위클래스에서 재정의한 메소드의 구현이 동작하기 때문에 캡슐화 및 다형성을 활용할 수 있다.

CouponRepositoryTest

@DataJpaTest
class CouponRepositoryTest {

    @Autowired
    public CouponRepository couponRepository;

    @Autowired
    public TestEntityManager testEntityManager;


    @Test
    public void 고정할인_쿠폰_저장() {
        Coupon coupon = couponRepository.save(FixedDiscountCoupon.create("고정 할인 쿠폰", Money.of(1000)));

        Assertions.assertThat(coupon.getId()).isNotNull();
        Assertions.assertThat(coupon.getType()).isEqualTo(CouponType.FIXED_DISCOUNT);
        Assertions.assertThat(((FixedDiscountCoupon) coupon).getDiscountAmount()).isEqualTo(Money.of(1000));
    }

    @Test
    public void 퍼센트할인_쿠폰_저장() {
        Coupon coupon = couponRepository.save(PercentDiscountCoupon.create("퍼센트 할인 쿠폰", 0.5));

        Assertions.assertThat(coupon.getId()).isNotNull();
        Assertions.assertThat(coupon.getType()).isEqualTo(CouponType.PERCENT_DISCOUNT);
        Assertions.assertThat(((PercentDiscountCoupon) coupon).getDiscountPercent()).isEqualTo(0.5);
    }

    @Test
    public void 고정할인_쿠폰_조회() {
        Coupon coupon = couponRepository.save(FixedDiscountCoupon.create("고정 할인 쿠폰", Money.of(1000)));
        testEntityManager.flush();
        testEntityManager.clear();

        Coupon findCoupon = couponRepository.findById(coupon.getId()).get();

        Assertions.assertThat(findCoupon.getId()).isNotNull();
        Assertions.assertThat(findCoupon.getType()).isEqualTo(CouponType.FIXED_DISCOUNT);
        Assertions.assertThat(((FixedDiscountCoupon) findCoupon).getDiscountAmount()).isEqualTo(Money.of(1000));
    }

    @Test
    public void 퍼센트할인_쿠폰_조회() {
        Coupon coupon = couponRepository.save(PercentDiscountCoupon.create("퍼센트 할인 쿠폰", 0.1));
        testEntityManager.flush();
        testEntityManager.clear();

        Coupon findCoupon = couponRepository.findById(coupon.getId()).get();

        Assertions.assertThat(findCoupon.getId()).isNotNull();
        Assertions.assertThat(findCoupon.getType()).isEqualTo(CouponType.PERCENT_DISCOUNT);
        Assertions.assertThat(((PercentDiscountCoupon) findCoupon).getDiscountPercent()).isEqualTo(0.1);
    }
}
  • 쿠폰을 저장하거나 조회에 대한 테스트이다. 단건 쿠폰 조회 시 Coupon 클래스를 반환하기 때문에 하위 클래스의 상세 정보를 알기 위해서 다운 캐스팅을 해야한다. 실제 로직에서는 CouponType값으로 if문 혹은 switch문을 활용해서 쿠폰 상세 정보를 알 수 있다. 
  • 추상화하는 이유는 코드의 복잡성을 낮추고, 다형성을 활용해서 객체지향 관점에서 OCP(개방 폐쇄 원칙)를 준수한 코드를 만들 수 있다.

3. 쿠폰 생성을 위한 추상 팩토리 메소드 패턴 

  • 쿠폰 생성 시 여러가지 방법이 있지만 추상 팩토리 메소드를 활용해서 간편하게 구현할 수 있다.
public record CouponCreateCommand(CouponType couponType, String couponName, Double fixedDiscountAmount,
                                  Double percentDiscountAmount) {
}

public interface CouponFactory {
    Coupon create(CouponCreateCommand command);

    CouponType getType();
}

@Component
public class FixedDiscountCouponFactory implements CouponFactory {

    @Override
    public Coupon create(CouponCreateCommand command) {
        return FixedDiscountCoupon.create(command.couponName(), Money.of(command.fixedDiscountAmount()));
    }

    @Override
    public CouponType getType() {
        return CouponType.FIXED_DISCOUNT;
    }
}

@Component
public class PercentDiscountCouponFactory implements CouponFactory {

    @Override
    public Coupon create(CouponCreateCommand command) {
        return PercentDiscountCoupon.create(command.couponName(), command.percentDiscountAmount());
    }

    @Override
    public CouponType getType() {
        return CouponType.PERCENT_DISCOUNT;
    }
}
  • CouponFactory 인터페이스에 create 메소드를 선언하고, CouponFactory를 구현한 PercentDiscountCouponFactory, FixedDiscountCouponFactory를 선언한다.
  • 여기서 트레이드 오프는 CouponCreateCommand에 모든 쿠폰에 대한 정보가 선언되어 있다는 점이다.
  • 쿠폰 종류가 늘어남에 따라 변수의 종류가 많아진다. 이 부분은 각 쿠폰 생성에 대한 API 엔드포인트를 추가하고 쿠폰 타입 별로 쿠폰 생성 서비스를 추가하면 해결할 수 있다. 
@Service
public class CouponCreateUseCase {
    private final Map<CouponType, CouponFactory> couponFactoryMap;
    private final CouponRepository couponRepository;

    public CouponCreateUseCase(List<CouponFactory> couponFactories, CouponRepository couponRepository) {
        this.couponFactoryMap = couponFactories.stream()
            .collect(Collectors.toMap(CouponFactory::getType, Function.identity()));
        this.couponRepository = couponRepository;
    }


    @Transactional
    public Long create(CouponCreateCommand command) {
        Coupon coupon = Optional.ofNullable(couponFactoryMap.get(command.couponType()))
            .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 쿠폰 생성 팩토리"))
            .create(command);

        return couponRepository.save(coupon).getId();
    }
}

 

  •  CouponFactory를 구현한 Bean을 List로 주입받고 CouponFactory.getType() 의 Enum 값을 Key로 Bean 자체를 Value로 정의한 Map을 생성한다.
  • 그리고 command의 CouponType으로 CouponFactory를 검색하고 Coupon 엔티티를 생성한 다음 Coupon을 저장하는 유스케이스이다. 

결론

JPA 상속 전략을 활용한 예제를 구현해보았다. 
중요한 점은 JPA 상속 전략을 사용 방법을 아는 것 보다 어떻게 엔티티를 설계하고 활용할 것인지 고민하는 것이 더 중요하다.

JPA 상속 전략에 매몰되어서 잘못 사용할 경우 오히려 복잡성이 증가하는 결과를 초례할 수 있다. 
해당 전략이 활용되면 좋은 케이스 인지 잘 판단해서 사용하자

 

+ Recent posts