우리 팀에 맞는 도메인 설계는 무엇일까?
프로젝트 구현을 위한 설계 과정에서 발생한 문제와 이를 해결하기 위한 과정을 기록했습니다. 이 글의 예시 코드는 당시 상황을 이해하기 쉽게 간결하게 구성되었습니다.✍️
1. 도메인 설계를 위한 ERD 작성
프로젝트 기획 후, 구현을 위해서 제일 처음 한 것은 ERD 작성이었습니다. JPA를 사용하기 위해서 도메인 설계를 하기 전에 ERD를 먼저 작성했습니다.
JPA를 위한 ERD가 먼저 작성된 후 도메인을 설계하니, 도메인 관점과 맞지 않는 부분이 생겼습니다.
특정 멤버가 자신이 참여한 프로젝트에 대한 리뷰를 받기 위해 프로젝트명(group_name
)을 설정하고, 리뷰 그룹을 생성합니다.
이후 다른 사람들이 해당 리뷰 그룹에 리뷰를 작성하면, 멤버는 자 신의 리뷰 그룹을 통해 받은 리뷰를 확인할 수 있어야 합니다.
논리적으로는 도메인 관점에서 객체의 연관 관계 방향이 ReviewGroup
→ Review
가 되어야 합니다.
하지만, DB 관점에서는 Review
가 ReviewGroup
을 @ManyToOne
으로 참조하고 있기 때문에 실제 구현에서는 Review
가 ReviewGroup
을 참조하는 방향으로 설계됩니다.
- 여기서 리뷰와 리뷰 그룹의 관계뿐만 아니라, 리뷰에 포함되는 리뷰 질문과 답변에 대한 경우도 만찬가지이나, 이 글에서는 리뷰와 리뷰 그룹 간의 관계에 대해서만 설명하겠습니다
2. 도메인 설계 관점과 DB 설계 관점의 비교
도메인 설계 관점에서 Review
와 ReviewGroup
은 스스로 자신의 상태를 책임집니다. Review
는 생성될 때 스스로 리뷰 내용을 검증한 후 생성됩니다.
이후 ReviewGroup
에 포함될 때에는 그 자체로 검증된 객체로 제공되며, ReviewGroup
은 리뷰 작성자가 리뷰 그룹의 소유자와 일치하는지 등 규칙이나 정책에 따라 Review
가 자신에게 속할 수 있는지 만을 확인합니다.
// 리뷰 생성자
public Review(Member reviewer, String content) {
validateContent(content);
this.reviewer = reviewer;
this.content = content;
}
public class ReviewGroup {
private List<Review> reviews;
public void addReview(Review review) {
if (canAddReview(review)) {
// 리뷰 그룹 포함 검증 실패 예외
}
reviews.add(review);
}
private boolean canAddReview(Review review) {
// Review를 그룹에 추가할 수 있는지 검증하는 로직
return true;
}
}
하지만 DB 설계를 따라 Review
가 ReviewGroup
을 참조하는 방향으로 설계되면, 리뷰가 생성되는 순간에 리뷰 그룹이 정해집니다.
따라서 Review
가 생성되는 순간에 스스로 리뷰 내용을 검증할 뿐만 아니라, ReviewGroup
과의 협력을 통해 자신이 해당 그룹에 속할 수 있는지도 함께 검증해야 합니다.
public class Review {
// 다른 필드
private ReviewGroup reviewGroup;
public Review(Member reviewer, ReviewGroup reviewGroup, String content) {
validateContent(content);
validateInclusionInReviewGroup(reviewGroup);
this.reviewer = reviewer;
this.reviewGroup = reviewGroup;
this.content = content;
}
private void validateInclusionInReviewGroup(ReviewGroup reviewGroup) {
if (!reviewGroup.canAddReview(this)) {
// 리뷰 그룹 포함 검증 실패 예외
}
}
}
이렇게 도메인 설계와 DB 설계 간의 연관 관계 방향의 불일치가 있을 때 발생할 수 있는 문제가 있습니다.
3. 도메인과 DB 연관 관계 방향의 불일치로 인한 문 제
ReviewGroup
은 Review
를 그룹으로 관리하기 위해 만들어진 도메인으로, 재사용보단 관리의 목적이 큽니다.
그러나 Review
는 ReviewGroup
없는 상황에서도 독립적으로 재사용이 가능해야 합니다.
하지만 DB 설계를 따라 Review
가 ReviewGroup
을 참조하는 방향으로 설계되면, Review
에는 리뷰 그룹 포함과 관련된 검증이 포함되게 됩니다.
private void validateInclusionInReviewGroup(ReviewGroup reviewGroup) {
if (!reviewGroup.canAddReview(this)) {
// 리뷰 그룹 포함 검증 실패 예외
}
}
이는 Review
가 Review
자체에 대한 책임뿐만 아니라, ReviewGroup
의 책임도 지니게 하면서 단일 책임 원칙을 위반하게 됩니다.
Review
는ReviewGroup
이 없는 상황에서 독립적으로 재사용할 수 없게 됩니다.Review
가ReviewGroup
의 기능을 직접 사용하게 되면서ReviewGroup
이 관리해야 할 책임이Review
로 넘어가게 됩니다.Review
는ReviewGroup
의 변경에 영향을 받게 됩니다.
단일 책임 원칙에 따르면, 모듈은 한 가지 이유로만 변경되어야 하지만, Review
는 자체 변경뿐만 아니라 ReviewGroup
의 변경에도 영향을 받습니다.
이는 Review
와 ReviewGroup
간의 결합도를 높여 변경에 유연하지 못하는 상태를 초래하며, 유지 보수를 어렵게 만듭니다.
4. 양방향 관계가 해결책이 될 수 있을까?
처음에는 도메인 관점과 DB 관점의 연관 관계 방향 모두 충시키기 위해 양방향 관계를 사용했습니다.
public Review(Member reviewer, ReviewGroup reviewGroup, String content) {
validateContent(content);
this.reviewer = reviewer;
this.reviewGroup = reviewGroup;
this.content = content;
reviewGroup.addReview(this);
}
하지만 책임 분리가 명확하지 않고, 초기화되지 않은 상태에서의 참조로 인해 의도치 않은 문제가 발생했습니다. 양방향 관계에서 발생하는 문제를 해결하기 위해 추가적인 장치들을 도입했지만, 이는 오히려 로직을 복잡하게 만들었습니다.
결과적으로 개발자가 관리해야 할 부분을 증가시키기 때문에 문제를 해결하기보단, 새로운 문제를 야기하게 되어 해결책이 되지 못했습니다.
5. 어떤 설계가 좋은 설계일까?
우리는 어떤 설계가 좋은 설계인지 계속해서 고민했습니다.
- 로직을 작성하기 쉬운 설계
- 성 능을 고려한 설계
- 변경에 유연하고 유지 보수에 용이한 설계
다양한 쟁점들이 있었지만, 현재 우리 팀의 상황에 맞는 설계가 무엇인지 고민할 필요가 있었습니다.🤔
💡구현에 앞서 기획 단계에서 우리가 내렸던 결론을 다시 생각해 보았습니다.
‘리뷰미’ 는 팀 프로젝트를 같이 한 팀원들과 상호 리뷰를 통해서 긍정적인 경험을 제공하고자 했습니다. 이 목표를 달성하기 위해, 다양한 기능들을 제공하기보다는 ‘리뷰’ 자체에 중점을 두어 리뷰이와 리뷰어 모두가 만족할 수 있는 품질 좋은 리뷰를 작성하게 하는 것을 목표로 했습니다.
따라서 리뷰 작성 폼을 지속적으로 발전시킬 필요가 있었고, 이는 리뷰 작성 폼이 언제든지 변경 가능하며, 리뷰의 질문 형태도 서술형이나 선택형 등 지속적으로 다양한 형태로 발전할 가능성을 염두에 두고 설계되어야 했습니다.
이러한 이유로 우리는 변경에 유연하게 대처할 수 있는 설계가 현재 팀의 상황에 맞는 가장 좋은 설계라고 결론을 내렸고, 이를 중심으로 다시 설계에 대한 고민을 이어갔습니다.
6. 연관 관계를 다시 생각해보자
객체지향 설계에서 객체들은 서로 연관 관계를 맺음으로써 메시지를 주고받으며 협력합니다. 그러나 불필요한 연관 관계가 생기면 불필요한 책임이 생기고, 이는 변경에 유연하 지 못하게 됩니다.
따라서 우리는 불필요한 연관 관계를 제거하고, 정말 필요한 곳만 연관 관계를 가지도록 했습니다. 이 과정에서 조영호 님의 우아한객체지향을 통해 공부하고 참고했습니다.
객체를 묶는 기준
우리는 의존 받는 객체의 변경이 의존하는 객체에 영향을 미치는 것을 경험했습니다. 모든 의존 관계를 완전히 제거하는 것이 아니라, 같이 변경되어야 하는 객체들만 묶는다면 다른 객체들은 변경에 영향을 미치지 않을 것이라고 생각했습니다.
같이 변경되어야 하는 객체들은 본질적으로 결합도가 높아 같이 변경되어도 무방하여, 도메인적인 관점에서 같이 처리되어야 할 객체들입니다. 우리는 이 기준을 생명 주기를 함께하는 객체로 설정했습니다.
현재 구현의 예시로는 리뷰(Review
)와 리뷰에 작성된 답변(TextAnswer
)이 있습니다.
- 리뷰의 답변은 리뷰 없이 생성될 수 없고, 리뷰가 작성되는 시점에 항상 같이 생성됩니다.
- 리뷰 없이 답변만 있는 것이 불가능하기에, 리뷰가 삭제되면 답변도 항상 같이 생성됩니다.
이러한 특성 때문에 Review
와 TextAnswer
는 생명 주기가 일치하며, 두 객체는 강한 결합 관계를 가집니다.
따라서 두 객체를 하나의 단위로 묶어 관리하는 것이 적절하다고 생각했습니다.
public class Review {
// 다른 필드
@OneToMany(fetch = FetchType.EAGER, cascade = CascadeType.PERSIST)
@JoinColumn(name = "review_id", nullable = false, updatable = false)
private List<TextAnswer> textAnswers;
}
논리적인 연관 관계의 장점
논리적으로 연관 관계를 재설정하면서 느꼈던 장점은
- 객체 관계를 명확하게 정의함으로써, 설계 의도를 더 명확하게 드러낼 수 있었습니다.
- 변경이 발생할 때, 그 영향에 대한 범위가 명확함으로 예상 가능하며 다른 부분에 미치는 영향을 최소화할 수 있습니다. 이를 통해 코드 재사용을 높이고, 일관성을 유지할 수 있었습니다.
- 데이터베이스의 연관 관계에 따라
CascadeType
과FetchType
설정을 고민해야 했지만, 논리적인 기준을 세우고 객체를 묶음으로써 연관 관계 설정의 기준을 명확히 할 수 있었습니다.- 두 객체는 함께 생성되고 관리되므로
CascadeType.PERSIST
설정을 주었습니다. - 리뷰를 조회할 때, 답변도 함께 조회할 수 있도록
FetchType.EAGER
를 사용했습니다.
- 두 객체는 함께 생성되고 관리되므로
간접 참조의 활용
위에서 세운 기준을 통해 강한 결합 관계를 가지는 객체들을 묶어 관리하고 불필요한 연관 관계를 끊었습니다.
여기서 강한 결합 관계를 가지는 객체들이 아닌 객체들의 연관 관계를 모두 완전히 끊을 수는 없기에, 직접적인 객체 참조 대신 id
를 사용하여 객체 간의 결합을 느슨하게 유지할 수 있게 했습니다.
public class Review {
@Column(name = "review_group_id", nullable = false)
private long reviewGroupId;
}
7. 다중성을 가지는 연관 관계의 설정
보통 다중성을 가지는 연관 관계에서 다중성이 적은 방향을 선택하는 것이 좋은 방법이라고 합니다.
(@OneToMany
대신 @ManyToOne
사용) 하지만 Review
와 TextAnswer
관계에서 다중성이 적은 방향을 선택한다면, 이 역시 DB 설계 관점의 방향으로 도메인 설계 방향과 불일치가 발생하게 됩니다.
@OneToMany
단방향을 사용했습니다.
@OneToMany
단방향은 엔티티에서 연관 방향과 반대로 테 이블에서는 Many 쪽에 FK가 관리되므로 작업한 엔티티가 아닌 다른 엔티티에서 쿼리가 발생하기 때문에 헷갈릴 수 있으며 추가 쿼리가 발생한다는 단점이 있습니다.
예를 들어,
Review
를 저장한 후,TextAnswer
가 추가되거나 삭제될 때,TextAnswer
테이블의 FK에 대한update
쿼리가 추가로 발생하게 됩니다.
리뷰미 정책 상 리뷰는 한 번 작성되면 변경될 수 없고, 함께 생성되고 삭제되기에 Review
를 저장한 후, TextAnswer
가 추가되거나 중간에 삭제되는 일이 없습니다.
따라서 CascadeType.PERSIST
를 통해 함께 영속화 시키고, @JoinColumn(nullable = false, updatable = false)
를 통해 TextAnswer
의 FK 가 null 일 수 없으며 이후 변경되지 않도록 하여 @OneToMany
단뱡향의 단점을 상쇄시킬 수 있었습니다.
8. 결론
이번 설계를 통해 객체들 간의 관계를 정의하는 기준을 재정립하고, 연관 관계가 불필요한 객체들은 id
를 통해 간접적으로 관리하는 방식으로 접근했습니다.
이러한 방식은 여러 이점도 있었지만 단점도 존재했습니다.
- 필요할 때만 DB에서 연관된 객체를 조회하기 때문에 불필요한 데이터를 가져오지 않도록 할 수 있지만, 이는 다르게 보면 연관된 객체를 필요로 할 때마다 별도의 쿼리를 통해 조회해야 하기 때문에 성능 저하가 발생할 수 있습니다.
- 간접 참조를 사용함으로써 책임이 명확하게 분리되고 독립적으로 관리되지만, 객체 간의 관계를 수동으로 처리해야 하는 부담이 생깁니다. 또 데이터 정합성을 유지하기 위한 추가적인 로직이 필요할 수 있습니다.
이 외에도 더 많은 구현을 해보면 직접적으로 느끼는 것이 많을 거라 생각됩니다.
하지만 우리가 설계에서 고려했던 핵심 중 하나는 서비스 요구사항이 계속해서 변화할 가능성이 크다는 점이었습니다. 초기 기획이 계속해서 변경되었고, 향후에도 서비스 요구사항은 계속해서 변경할 가능성이 있습니다. 이러한 변화에 대비하기 위해, 우리는 확장성을 예측해서 구현하기보다는 확장성을 열어두는 방향으로 설계를 하는 것이 중요하다 생각했습니다.
우리는 이번 설계 과정을 통해 여러 가지 새로운 시도를 해보았고, 특정 상황에서 이론적으로 좋은 설계라고 여겨지던 부분들이 실제 구현에서는 예상치 못한 문제를 야기할 수 있음을 깨달았습니다. 이와 같은 경험들은 앞으로의 설계와 구현에서 더 나은 결정을 내릴 수 있는 기반이 될 것이라고 생각합니다.