Dev_Henry

[Spring JPA] 테이블 공통 속성 분리하여 Entity 설계하기 본문

Web/Spring

[Spring JPA] 테이블 공통 속성 분리하여 Entity 설계하기

데브헨리 2024. 3. 18. 14:56
728x90

최근 진행한 프로젝트는 '개인 경매 플랫폼' 이었다.

해당 서비스에서는 일반적으로 생각할 수 있는 '경매'도 물론 가능하고, '역경매'라는 서비스도 제공했다.

'경매'는 다르게 말하면 '판매' 카테고리로 판매글을 올리고 경매시간이 되면 다른사람들이 경매에 참여할 수 있다.

'역경매'는 반대로 '구매' 카테고리다. 구매하고싶은 물건을 올리면 다른사람들이 본인 물건을 어필하며 역경매가 진행된다.

 

이렇게 이번 서비스에서는 '판매'와 '구매'가 존재했고, 둘 모두 '거래'라는 공통점을 가지며 공통적으로 필요한 필드도 많이 존재했다. 

중복이 많이 존재하면 공간의 낭비, 코드 중복, 개발자의 귀찮음 등 다양한 문제들이 생긴다.

문제를 인지를 했으니 이것을 어떻게 효율적이게 설계할지 고민해보았다.


문제의 테이블 일부

위 사진은 프로젝트 초기 기획단계에서 설계한 ERD이다. 이후 수정된 부분들이 있지만, 공통부분을 정규화시킨다는 이번 주제를 보여주기는 무리가 없어 가져왔다.

이렇게 공통되는 부분을 '거래'테이블로 따로 만들어 두었으니, 이제 Spring + JPA에서 어떤식으로 Entity를 설계할지 생각해보자.

 

위의 ERD처럼 설계하는 방법은 2가지가 있었다.

1번. 거래 엔티티를 판매,구매 엔티티와 각각 @OneToOne 연관관계로 매핑.

2번. @Inheritance를 사용하여 거래 엔티티를 판매,구매 엔티티가 상속받아 구현.

 

(엔티티 상속에서 @MappedSuperclass 어노테이션을 사용하는 방법이 있는데, 이는 부모타입이 엔티티가 되지도 않으며, 관계를 묶는것이 아니라 단순히 같은 종류의 속성을 재사용하기 위한 방법이다.)

 

1번 방식이 일단 생각하기가 쉬웠다. DB에서는 상속이라는 개념이 없는데다, JPA에서 상속관계를 사용해본적이 없어서 각각 엔티티를 분리하고, 연관관계 매핑방식이 설계와 이해가 명확하고 코드를 다루기도 쉬웠다.

 

반면, 2번 방식의 경우. 상속구조를 사용해본적이 없어서 학습이 필요했지만 다형성을 활용하여 공통되는 로직을 코드 중복없이 처리하는게 가능했다. 

 

현재 요구사항의 경우, 단순히 각 테이블에 공통으로 존재하는 필드가 있다는것만 문제는 아니었다.

판매/구매 공통적으로 거래글을 스크랩 하거나, 설정한 시간이 다가오면 알림을 보내주는 등, 공통으로 처리해야하는 로직도 많이 존재했다.

이런 상황을 고려하여 코드 중복을 줄이자는 목적에 맞게 2번 방식을 선택했다.

 


 

위에서 언급했듯이 객체지향 언어인 Java에는 상속이라는 개념이 있지만, DB에서는 상속이라는 개념이 없다.

때문에 @Inheritance를 사용하는 상속관계에서는 3가지 매핑전략을 제공한다.

(해당 어노테이션과 관련된 구체적인 사용법은 이 글에서 다루지 않습니다)

 

  • JOINED: 조인 전략
    • 졍규화된 전략으로, 부모 필드는 부모테이블에, 자식 필드는 자식테이블에만 저장된다.
    • 부모의 pk를 자식이 pk겸 fk로 사용하며, 자식 테이블 구분을 위한 필드하나를 가질수있다.
    • join이 필요해 쿼리가 복잡해질 수 있고, insert 쿼리가 2개 나가는 단점

  • SINGLE_TABLE: 단일 테이블 전략
    • 테이블 정규화를 하지 않고 부모테이블에 자식속성을 모두 함께저장.
    • 자식타입을 구분하기 위한 필드가 필수
    • 조인이 필요없기 때문에 쿼리가 쉽고 빠름.
    • 정규화되지 않아 null이 많아지고 테이블이 커지는 단점

  • TABLE_PER_CLASS: 구현 클래스마다 테이블 전략
    • 부모타입은 테이블로 만들지 않음.
    • 자식타입들 모두 중복으로 속성 가짐.
    • 자식타입간의 명확한 구분이 가능하고 not null 가능
    • 여러 자식 함께 다룰때(다형성) 쿼리가 복잡하고(union) 느린 단점

 

현재 목적을 복기하자면,

1. 정규화를 통해 공간낭비를 줄이고 명확한 구분

2. 다형성을 활용하여 공통되는 로직 코드 중복없이 한번에 처리

위의 2가지를 위한 Entity설계이며 Join 전략이 적합함을 알수있다.

 


 

Join 전략의 적용 예시는 아래와 같다.

@Entity
@Getter
@Inheritance(strategy = InheritanceType.JOINED)
@DiscriminatorColumn
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@EntityListeners(AuditingEntityListener.class)
public abstract class Deal {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private long id;
    @CreatedDate
    private LocalDateTime createTime;
    @LastModifiedDate
    private LocalDateTime updateTime;
    private String title;
    private String content;
..

}

---------------------

@Entity
@Getter
@Setter
@ToString
@NoArgsConstructor
@DiscriminatorValue("sale")
public class Sale extends Deal {

    private int immediatePrice;
    @OneToOne(fetch = FetchType.LAZY)
    private Bid highestBid;
    private int startPrice;
    private LocalDateTime endTime;
..
}

 

모든 거래는 반드시 구매와 판매 둘 중 하나에 속해야하기 때문에, 명확하게 표현하기 위해 부모타입은 거래는 추상클래스로 정의하고, 판매 엔티티와 구매 엔티티가 상속받도록 했다.

 

이렇게 상속관계를 매핑한 덕분에 스크랩이나 알림 등 공통적으로 처리하는 로직을 하나의 코드로 사용할 수 있었다.

@Entity
public class Wish {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private long id;
    @ManyToOne(fetch = FetchType.LAZY)
    private Deal deal;
    
    ..
}

------------------------

    @Override
    public void auctionStartAlarm() {
        List<Wish> wishes = wishRepository.getByIsBeforeAlarm(false);
        for (Wish wish : wishes) {
            Deal deal = wish.getDeal();
            if (deal.getStartTime()
                .isAfter(LocalDateTime.now().minusMinutes(ALARM_TIME))) {
                sseService.send(
                    SseDto.of(wish.getMember().getId(), deal.getId(),
                        deal.getClass().getSimpleName(),
                        SseType.START_AUCTION_BEFORE,
                        LocalDateTime.now()));
                wish.setBeforeAlarm(true);
            }
        }
    }

 

 

 

--

참고

https://ict-nroo.tistory.com/128

https://jaime-note.tistory.com/381

728x90
반응형