본문 바로가기
프로젝트/팀프로젝트 qp편

관계형 데이터베이스에서의 컬렉션 처리 : Room 엔티티의 구조 개선

by Thumper 2024. 3. 18.

이번 시간엔 프로젝트를 진행하면서 만난 RDB에서의 컬렉션 이슈를 정리해볼 예정이다.
모든 코드는 Github에 있기 때문에 함께 보시면 더 이해하기 쉬우실 것 같아요.

목차

목차는 다음과 같다.

  • Room에 대한 요구사항 : 여러 개의 태그를 가진다.
  • 스터디방 태그 관리: RDB와 컬렉션 구조의 한계
    • RDB에는 내부적으로 컬렉션을 담을 수 있는 구조가 없다.
    • RDB에서 컬렉션 타입의 처리: Entity로 정의하여 일대다(1:N) 관계를 정의한다.
  • Room에 대한 연관관계 매핑정의
  • 회고

Room에 대한 요구사항 : 여러 개의 태그를 가진다.

프로젝트 개발하기 이전에 Room에 대한 요구사항을 세웠었는데, 그 중 하나다.

Room(스터디방)에 여러 개의 방 태그를 붙일 수 있다.

요구사항을 분석해서 만든 스터디 방(그림)은 다음과 같다.

tag tag

프론트분들이 만드신 피그마 파일의 일부를 첨부했다.


요구사항을 분석하여 엔티티 설계

태그로 원하는 스터디방을 검색할 수 있는 요구사항을 만들었다.
사용자가 여러 개의 태그를 선택할 수 있도록 하기 위해 List 필드로 값 타입 컬렉션을 사용하였다.
이를 통해 사용자는 원하는 여러 개의 태그를 선택하여 검색할 수 있을 것이다.

그래서 다음과 같이 Room Entity를 설계했었다.

@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
@Table(name = "room")
public class Room {


    @Id
    @GeneratedValue(strategy = IDENTITY)
    @Column(name = "room_id")
    private Long id;

    ...  

    @Enumerated(EnumType.STRING)
    private List<TagOption> roomHashTags = new ArrayList<>(); // 방에 여러 태그를 붙일 수 있다.

스터디방 태그 관리: RDB와 컬렉션 구조의 한계

Room 엔티티에서 hashtags의 tagOption으로 stream 작업에서 오류가 발생했다.
그리고 MySQL에 List 값이 null로 입력되는 이슈가 있었다.

QueryDSL로 특정 태그가 있는 Room을 모두 조회하는 쿼리를 작성했었는데 예외가 발생했다.

이때 오류 메시지는 다음과 같다.

Caused by: org.hibernate.query.sqm.UnknownEntityException: Could not resolve root entity 'room.roomHashTags'
    at org.hibernate.query.hql.internal.SemanticQueryBuilder.resolveRootEntity(SemanticQueryBuilder.java:2013)
    at org.hibernate.query.hql.internal.SemanticQueryBuilder.visitRootEntity(SemanticQueryBuilder.java:1944)
    at org.hibernate.query.hql.internal.SemanticQueryBuilder.visitRootEntity(SemanticQueryBuilder.java:269)
    at org.hibernate.grammars.hql.HqlParser$RootEntityContext.accept(HqlParser.java:2549)
    at org.hibernate.query.hql.internal.SemanticQueryBuilder.visitEntityWithJoins(SemanticQueryBuilder.java:1914)
    at org.hibernate.query.hql.internal.SemanticQueryBuilder.visitFromClause(SemanticQueryBuilder.java:1901)
    at org.hibernate.query.hql.internal.SemanticQueryBuilder.visitQuery(SemanticQueryBuilder.java:1148)
    at org.hibernate.query.hql.internal.SemanticQueryBuilder.visitQuerySpecExpression(SemanticQueryBuilder.java:941)
    at org.hibernate.query.hql.internal.SemanticQueryBuilder.visitQuerySpecExpression(SemanticQueryBuilder.java:269)
    at org.hibernate.grammars.hql.HqlParser$QuerySpecExpressionContext.accept(HqlParser.java:1869)
    at org.hibernate.query.hql.internal.SemanticQueryBuilder.visitSimpleQueryGroup(SemanticQueryBuilder.java:926)
    at org.hibernate.query.hql.internal.SemanticQueryBuilder.visitSimpleQueryGroup(SemanticQueryBuilder.java:269)
    at org.hibernate.grammars.hql.HqlParser$SimpleQueryGroupContext.accept(HqlParser.java:1740)
    at org.hibernate.query.hql.internal.SemanticQueryBuilder.visitSelectStatement(SemanticQueryBuilder.java:443)
    at org.hibernate.query.hql.internal.SemanticQueryBuilder.visitStatement(SemanticQueryBuilder.java:402)
    at org.hibernate.query.hql.internal.SemanticQueryBuilder.buildSemanticModel(SemanticQueryBuilder.java:311)
    at org.hibernate.query.hql.internal.StandardHqlTranslator.translate(StandardHqlTranslator.java:71)
    at org.hibernate.query.internal.QueryInterpretationCacheStandardImpl.createHqlInterpretation(QueryInterpretationCacheStandardImpl.java:165)
    at org.hibernate.query.internal.QueryInterpretationCacheStandardImpl.resolveHqlInterpretation(QueryInterpretationCacheStandardImpl.java:147)
    at org.hibernate.internal.AbstractSharedSessionContract.interpretHql(AbstractSharedSessionContract.java:769)
    at org.hibernate.internal.AbstractSharedSessionContract.createQuery(AbstractSharedSessionContract.java:819)
    ... 15 more

이 에러는 Hibernate에서 발생한 것으로 보인다. 주어진 오류 메시지에 따르면 'room.roomHashTags' 엔티티를 해석할 수 없다는 것으로 보인다.

이러한 오류는 일반적으로 QueryDSL에서 사용하는 엔티티의 경로가 잘못되었을 때 발생한다고 한다.'room.roomHashTags'가 올바른 경로인지 확인하고 그에 맞게 코드를 수정해야 할 것 같다.

RDB에는 내부적으로 컬렉션을 담을 수 있는 구조가 없다.

해당 오류에 대해 구글 검색을 한 결과로, 이는 RDB (관계형 데이터베이스) 오류인 것 같다.
컬렉션은 1:N 관계를 가지기 때문에 데이터베이스는 컬렉션을 단일 테이블에 저장할 수 없다고 한다. 이러한 유형의 관계를 저장하려면 JOIN이 가능하도록 별도의 테이블이 필요하다.

RDB에서 컬렉션 타입의 처리: Entity로 정의하여 일대다(1:N) 관계를 정의한다.

MySQL과 같은 관계형 데이터베이스는 컬렉션 타입을 지원하지 않는다고 한다.
따라서 새로운 엔티티인 RoomHashTag를 생성하여 그 안에 식별자(id)와 TagOption을 저장하기로 결정했다.

Room에 대한 연관관계 매핑정의

새로운 엔티티 RoomHashTag를 추가했을 때, 도메인 설계를 정리해보자.

테이블 설계



변경된 매핑관계로 설계한 테이블 ERD는 위와 같다.

RoomHashTag (방 태그)

그림 ERD를 분석하면 다음과 같다.

  • 스터디방(ROOM_ID)을 외래 키로 가진다.
  • 선택한 태그 정보(TAG_OPTION)을 가진다.

엔티티 설계와 매핑



ERD(테이블 설계)를 기반으로 실제 엔티티를 설계한 UML이다.

스터디방과 태그는 일대다 관계다. 여기서 RoomHashTag.room가 연관관계의 주인이다. room.roomHashTags 필드에는 mappedBy 속성을 사용해서 주인이 아님을 표시했다.

코드는 다음과 같다.

스터디방(Room) 엔티티


@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
@Table(name = "room")
public class Room {

    @Id
    @GeneratedValue(strategy = IDENTITY)
    @Column(name = "room_id")
    private Long id;

    ...


    @OneToMany(mappedBy = "room", cascade = CascadeType.ALL)
    private Set<RoomHashTag> roomHashTags = new HashSet<>(); // 방에 여러 태그를 붙일 수 있다.

태그(RoomHashTag) 엔티티

@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
public class RoomHashTag {

    @Id
    @GeneratedValue(strategy = IDENTITY)
    @Column(name = "room_hash_tag_id")
    private Long id;

    @ManyToOne(fetch =  FetchType.LAZY)
    @JoinColumn(name = "room_id")
    private Room room;

TagOption (Enum 타입)


@Getter
public enum TagOption {
    AI("인공지능"),
    DEEP_LEARNING("딥러닝"),
    CODING_TEST("코딩테스트"),
    ALGORITHM("알고리즘"),
    DEVELOP("개발"),
    INTERVIEW("면접준비"),
    STARTUP("창업"),
    DATA("데이터과학"),
    FRONTEND("프론트엔드"),
    BACKEND("백엔드"),
    PORTFOLIO("포트폴리오"),
    STUDY("스터디"),
    ...

회고

회고 3

 

스터디 커뮤니티 프로젝트를 만들어가면서 JPA로 도메인 모델을 어떻게 구성하고 객체와 테이블을 어떻게 매핑해야 하는지 고민을 많이 했었다. 팀원들과 세운 요구사항에 맞추어 도메인 모델을 설계하고, 이를 관계형 데이터베이스에 맞게 매핑해야 했었다.

그런데 스터디 태그 List에서 오류가 터졌었다. MySQL과 같은 관계형 데이터베이스는 컬렉션 타입을 지원하지 않는다고 한다. RDB는 컬렉션을 직접적으로 저장하지 못한다는 것이었다. 이는 객체지향 모델에서는 자주 사용되는 List나 Set과 같은 컬렉션 형태를 그대로 저장하기 어렵다는 것을 의미한다.

새로운 엔티티를 추가로 정의하여 일대다(1:N) 관계를 정의하여 이 문제는 해결했다. 새로운 엔티티인 RoomHashTag를 생성하여 그 안에 식별자(id)와 TagOption을 저장하기로 결정했다. 이를 통해 컬렉션 형태의 데이터를 관리할 수 있게 되었고, 객체지향적인 모델을 유지하면서도 데이터베이스에 맞는 구조를 갖출 수 있었다.

다음 시간에는 전체 도메인을 정리하고자 한다.

댓글