이번 포스팅은 올려 올려 라디오 프로젝트의 도메인 모델 개선에 대해 다룹니다.
현재 도메인 모델의 문제점을 짚어보고, 더 나은 도메인 모델을 제시하면서 그 근거를 정리하겠습니다.
시작하기 앞서, 프로젝트 리팩토링 과정을 잘 이해하기 위해 우리 프로젝트가 어떻게 동작하는지 간단하게 짚고 넘어가겠습니다.
프로젝트 동작 구조
답장 서비스 동작 구조
위 그림은 우리 프로젝트의 첫 번째 MVP인 `답장 서비스`가 동작하는 구조입니다.
유저가 `편지`를 써서 전달하면 생성형 AI가 `답장`을 생성해 응답합니다.
우리 프로젝트는 유저가 작성한 `편지`와 `답장`을 각각 별개의 엔티티로 관리합니다.
두 엔티티 간 관계는 1:1 관계를 갖고 있습니다.
데일리 리포트 서비스 동작 구조
우리 프로젝트의 두 번째 MVP인 `데일리 리포트 서비스`가 동작하는 구조입니다.
하루에 작성한 편지들 중 일부(정책상 정한 개수)를 생성형 AI를 통해 통해 각 편지당 `감정 분석`을 하나씩 생성하며, `데일리 리포트`를 함께 생성합니다.
예를 들어, 하루에 편지 10개를 작성했다면 정책상 3개의 편지만 분석 대상이 되며, 편지 3개에 대응하는 각각의 `감정 분석`과 그 감정 분석들을 기반으로 한 `데일리 리포트` 1개가 생성됩니다.
따라서 `편지`와 `감정 분석` 엔티티는 1:1 관계를, `편지`와 `데일리 리포트` 관계를 N:1 관계를 갖습니다.
현재 도메인 모델의 문제점
지금까지 프로젝트가 어떻게 동작하는지 간략하게 살펴보고, 엔티티 간 관계에 대해 간략하게 알아봤습니다.
실제 엔티티 간 연관 관계 매핑도 동작 관계에서 설명한 그대로 설정했는데요, 여기에 문제가 있었습니다.
문제가 되는 엔티티 간 연관 관계 매핑
우리 프로젝트는 JPA를 이용해 엔티티 간 연관 관계를 아래와 같이 매핑했습니다.
먼저 `편지`와 `감정 분석` 엔티티 간 연관 관계입니다.
1) 감정 분석-편지 (OneToOne)
`편지`와 `감정 분석`의 연관 관계 주인은 1:1 관계기 때문에 연관 관계의 주인은 어디에 있더라도 상관없습니다.
비즈니스 로직 상 `편지`가 단독으로 조회되는 경우가 없고, `감정 분석`만 단독으로 조회되기 때문에 `감정 분석`이 연관 관계의 주인이 되도록 설계되었습니다. (`감정 분석` 테이블이 FK를 가짐)
또한 비즈니스 로직 상 `감정 분석`과 `편지`가 함께 조회되는 경우가 없기 때문에 단방향으로만 매핑되도록 설정되었습니다.
아래 코드를 보실 땐 각 엔티티의 연관 관계에만 초점을 맞추기 위해 연관 관계와 관련 없는 필드와 메서드는 생략했다는 점을 알아주세요.
감정 분석 (연관 관계의 주인)
@Getter
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Table(name = "letter_analysis")
public class LetterAnalysis extends BaseTimeEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "letter_analysis_id")
private Long id;
@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "letter_id")
private Letter letter;
}
편지 엔티티
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
@Table(name = "letter")
public class Letter extends BaseTimeEntity {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
@Column(name = "letter_id", columnDefinition = "BINARY(16)")
private UUID id;
}
2) 편지-데일리 리포트 (ManyToOne)
`편지`와 `데일리 리포트`의 연관 관계 주인은 데이터베이스 상 FK를 갖는 `편지`입니다.
비즈니스 로직 상 `데일리 리포트` 조회 시 `편지`가 함께 조회될 일이 없기 때문에 단방향으로만 매핑되도록 설정했습니다.
아래 코드도 마찬가지로 연관 관계와 관련없는 필드와 메서드는 생략했습니다.
편지 엔티티 (연관 관계의 주인)
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
@Table(name = "letter")
public class Letter extends BaseTimeEntity {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
@Column(name = "letter_id", columnDefinition = "BINARY(16)")
private UUID id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "daily_report_id")
private DailyReport dailyReport;
}
데일리 리포트 엔티티
@Getter
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Table(name = "daily_report")
public class DailyReport extends BaseTimeEntity {
@Id
@GeneratedValue(strategy = GenerationType.UUID)
@Column(name = "daily_report_id", columnDefinition = "BINARY(16)")
private UUID id;
}
여기까지만 봤을 때 문제가 없어 보입니다.
하지만 생성된 데이터베이스를 보면 이야기가 달라집니다.
연관 관계 매핑에 의해 생성된 테이블들
연관 관계 주인과 데이터베이스 상 FK가 위치하는 테이블이 일치하도록 설계했기 때문에 `감정 분석` 테이블은 `편지` 테이블을 참조합니다. 마찬가지로 `편지` 테이블은 `데일리 리포트` 테이블을 참조합니다.
애플리케이션이 배포된 이후, 데이터가 비교적 적을 때까지만 해도 무엇이 문제인지 감이 잘 안 잡혔습니다.
진짜 문제는 `편지` 테이블에 레코드가 쌓일 때 문제가 드러나기 시작합니다.
위의 그림은 `편지` 테이블에 분석을 수행하지 않은 레코드가 늘어날 때 문제가 발생함을 보여줍니다.
무엇이 문제가 되는 걸까요?
문제 1. 편지 테이블에 레코드가 쌓일 때마다 데이터 낭비가 발생한다.
첫 번째 문제는 시간이 흐를수록 편지 테이블에 데이터 낭비가 심각해질 수 있습니다.
왜 그럴까요?
저는 엔티티 간 잘못된 연관 관계가 문제라고 진단했습니다.
한 엔티티가 다른 엔티티를 포함할 때 제약의 정도에 따라 연관 관계가 달라져야 한다는 것을 설명하고자 합니다.
예시를 통해 두 엔티티 간 관계가 단순히 N:1이라고 해서 ManyToOne으로 설정한 것은 잘못된 설계가 될 수 있음을 설명하겠습니다.
case 1. 한 엔티티는 다른 엔티티에 "속해야만" 하는 제약이 있다.
`축구 선수(Player)`와 `팀(Team)`의 관계가 있습니다.
상식적으로 생각하면, `축구 선수`는 무소속으로 경기에 출전할 수 없습니다. 반드시 `팀`에 "속해야만" 합니다.
물론, 방출되는 등의 특정 상황에서는 "잠시동안" 선수가 무소속일 수 있습니다. 하지만 "일반적인 상황"에서 `축구 선수`는 `팀`에 "속해야만"하는 제약이 있습니다.
이 경우 축구 선수와 팀의 연관 관계는 ManyToOne이 되는 것이 바람직합니다.
일시적으로 선수가 무소속일 때는 `null`이 될 수 있지만 대다수의 상황에선 레코드가 팀에 해당하는 FK를 갖고 있기 때문에 `축구 선수`와 `팀`의 연관 관계는 명확하게 표현되었다고 말할 수 있습니다.
case 2. 한 엔티티는 다른 엔티티에 속하지 않는 것이 일반적이다.
우리 프로젝트의 `편지`와 `데일리 리포트`의 관계입니다.
위의 case 1과 달리 일반적인 상황에서 모든 `편지`는 `데일리 리포트`에 속하지 않아도 됩니다.
정책상 하루에 `편지`를 많이 작성한다고 해서 모든 `편지`가 `데일리 리포트` 대상이 되는 것이 아니기 때문입니다.
다시 말해, 한 엔티티가 다른 엔티티에 포함되지 않는 것이 더 일반적인 상황입니다.
일반적으로 NULL을 포함한 레코드가 더 많은 상황이라면 비즈니스 로직 상 `편지`와 `데일리 리포트`의 예상된 관계가 깨지는 경우가 많아지게 되며, 테이블 간 관계에서 "선택적인 관계"인지 "필수적인 관계"인지 직관적으로 판단하기 어렵게 된다는 점이 문제가 된다고 생각합니다.
문제 2. 엔티티마다 연관 관계 주인이 달라 관리가 복잡하다.
`감정 분석`과 `편지` 관계에서 연관 관계의 주인은 `감정 분석`입니다. 이 경우, 연관 관계의 주인인 `감정 분석` 쪽에서 `cascade`나 `orphanRemoval` 등으로 관리가 필요합니다.
`편지`와 `데일리 리포트`의 관계에서 연관 관계의 주인은 `편지` 쪽에서 `cascade`나 `orphanRemoval` 같은 관리가 필요합니다.
이렇듯 연관 관계 주인이 두 엔티티(`감정 분석`, `편지`)로 나눠져 있기 때문에 관리가 복잡해진다는 문제가 있습니다.
해결: 개선된 모델
`편지`와 `감정 분석`, `편지`와 `데일리 리포트`의 관계는 단순히 OneToOne, ManyToOne으로 설정했을 때 두 가지 문제가 발생한다는 점을 살펴봤습니다.
따라서 세 엔티티 간의 연관 관계를 명확하게 표현하기 위해서 다대다 관계를 풀어낼 때처럼 중간 테이블로 풀어내는 것이 합리적이라고 할 수 있습니다. `편지`-`중간 테이블`은 OneToOne, `중간 테이블`-`데일리 리포트`는 ManyToOne으로 설정하는 것이 가장 최적이라고 생각합니다. 추가로 중간 테이블을 둘 경우 `감정 분석` 테이블이 중간 테이블 자체가 될 수 있다는 장점도 있습니다.
아래는 최종적으로 개선된 모델입니다.
추가 장점: MySQL 트랜잭션 관점에서 동시성 문제도 해결된다.
중간 테이블을 뒀을 때 동시성 문제(레이스 컨디션)도 어느 정도 해결이 됩니다.
예시를 들어보겠습니다.
가정: InnoDB 엔진 / 트랜잭션 격리 수준은 REPEATABLE READ
1단계
먼저 트랜잭션 없이 `스레드 A`와 `스레드 B`는 `편지 테이블`에서 편지 1, 2, 3을 읽습니다.
두 스레드는 외부 API(생성형 AI API) 호출을 통해 `감정 분석 1, 2, 3`과 `데일리 리포트`를 생성합니다.
2단계
이제 스레드 각각 트랜잭션 A와 트랜잭션 B가 동시에 시작합니다.
편의상 `스레드 A`에서 트랜잭션 A가, `스레드 B`에서 트랜잭션 B가 시작됐다고 하겠습니다.
트랜잭션 A와 B는 `데일리 리포트` 테이블에 INSERT를 수행합니다.
REPEATABLE READ 격리 수준에서 다른 테이블에 대한 참조가 없기 때문에 두 트랜잭션으로부터 두 개의 레코드가 삽입되었습니다.
3단계
트랜잭션 A와 트랜잭션 B는 이제 `감정 분석` 테이블에 INSERT 작업을 수행합니다.
`편지` 테이블에 대한 참조와 `데일리 리포트`에 대한 참조 무결성을 위해 락(Lock)을 획득해야 합니다.
다만, `편지` 테이블에 대한 유니크 인덱스가 있기 때문에 배타 락(X Lock)을 획득하고, `데일리 리포트`에 대해서는 공유 락(S Lock)을 획득해야 한다는 차이점이 있습니다.
따라서, 두 트랜잭션 중 단 하나의 트랜잭션만 `편지` 테이블의 편지 1, 2, 3 레코드에 대한 배타 락을 획득하고, 다른 트랜잭션은 락 대기 상태에 들어갑니다.
4단계
결과적으로 두 트랜잭션 중 단 하나의 트랜잭션만 `감정 분석` 테이블에 INSERT가 성공합니다.
이 과정에서 데드락은 발생하지 않습니다.
그 이유는, 먼저 배타 락을 획득한 트랜잭션이 `감정 분석` 테이블에 편지 1, 2, 3에 대한 레코드를 INSERT 성공하고 커밋을 마치면 배타 락을 대기하고 있던 다른 트랜잭션에서는 이미 편지 1, 2, 3에 대해 물리적으로 INSERT가 수행됐음을 인지하고 유니크 제약 조건에 의해 삽입이 실패하기 때문입니다.
(참고로 유니크 인덱스는 다른 인덱스와는 달리 중복 체크를 위해 체인지 버퍼(디스크 I/O를 줄이기 위한 임시 메모리 공간)를 사용할 수 없습니다.)
그러나
외부 API 호출은 비용이 발생합니다. 따라서 두 트랜잭션이 동시에 실행되지 못하도록 조치가 필요합니다.
제가 사용한 조치는 기존 프로젝트에서 사용했던 간단한 방법인 네임드 락을 이용하는 것입니다.
마치며
JPA는 개발자가 Java 코드에서 엔티티 간의 관계를 쉽게 정의하고 데이터베이스에 반영할 수 있도록 도와주는 강력한 도구입니다.
하지만 그 편리함 뒤에는 신중한 설계가 필수적이라는 것을 이번 경험을 통해 절실히 느꼈습니다.
엔티티 간의 관계를 깊이 고려하지 않은 매핑은 예상치 못한 문제를 야기할 수 있습니다. 특히 "선택적" 관계와 "필수적" 관계를 명확히 구분하지 못하면, 비즈니스 로직의 의미가 모호해지고 예상된 연관 관계가 깨져 코드의 가독성과 유지 보수성을 저해할 수 있습니다.
이번 도메인 모델 설계 문제를 해결하는 과정에서 연관 관계에 대한 깊이 있는 이해를 얻을 수 있었습니다. 단순히 JPA의 기능을 사용하는 것을 넘어, 비즈니스 요구사항을 정확하게 파악하고 적절한 매핑 전략을 선택하는 것이 얼마나 중요한지 깨달았습니다.
'Project' 카테고리의 다른 글
프로젝트 리팩토링 (4) - 책임 재할당과 트랜잭션에서 외부 API 분리하기 (0) | 2025.02.07 |
---|---|
프로젝트 리팩토링 (3) - 책임 재할당과 실행 계획 분석으로 검증하기 (1) | 2025.02.04 |
프로젝트 리팩토링 (1) - 객체 지향 설계 (0) | 2025.01.31 |
모놀리식 아키텍처에서 이벤트 기반으로 비즈니스 로직의 원자성 확보하기 (2) (1) | 2025.01.17 |
모놀리식 아키텍처에서 이벤트 기반으로 비즈니스 로직의 원자성 확보하기 (1) (0) | 2025.01.16 |