해당 게시글은 내용 정리 목적으로 작성되었습니다. 틀린 내용이 있다면 언제든지 말씀해 주세요.
서비스의 데이터 구조 설계 시 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 상속 전략에 매몰되어서 잘못 사용할 경우 오히려 복잡성이 증가하는 결과를 초례할 수 있다.
해당 전략이 활용되면 좋은 케이스 인지 잘 판단해서 사용하자
'IT > 백엔드 필수 교양' 카테고리의 다른 글
AWS EC2 젠킨스 CI/CD를 구축하고 ECR 배포 파이프라인 생성하기 (3) | 2024.09.11 |
---|---|
[JDK] 가비지 컬렉션(garbage collection)에 대한 기초적인 지식 (1) | 2024.08.24 |
[AWS] SQS (Simple Queue Service)와 Spring 연동하기 (0) | 2024.08.14 |
[Java] 스프링에서 자주 사용하는 디자인 패턴 - 전략 패턴 (0) | 2024.08.05 |
[DB] foreign key와 공유 락에 관하여 (0) | 2024.08.01 |