💡 발단
기능 소개
사이드 프로젝트에서 퀴즈 기능 구현을 맡게 되었습니다.
환경을 지키자는 의미에서 환경에 관련된 퀴즈들을 조회하고 맞춰보는 기능입니다.
퀴즈에는 OX 퀴즈와 객관식 퀴즈 두 가지가 있습니다.
OX 퀴즈와 객관식 퀴즈의 차이는 선택지 필드의 유무입니다.
OX 퀴즈는 문항이 O,X 두 개로 고정되지만 객관식 퀴즈는 여러 개의 선택지를 가지는 형태입니다.
🤔 엔티티 설계 과정
제가 1차적으로 어려움을 겪은 부분은 엔티티 객체 설계 과정입니다.
처음에는 Quiz 엔티티 하나만 만들 생각이었습니다.
퀴즈에 대한 타입을 두고 nullable한 필드들을 두어서 여러 종류의 퀴즈 정보를 담을 수 있도록 한 것입니다.
@Getter
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Quiz extends BaseEntity {
// 모든 퀴즈에 대한 공통 필드 : id, question, explanation
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "quiz_id")
private Long id;
@Column(nullable = false)
private String question;
@Column(nullable = false, length = 1000)
private String explanation;
// 퀴즈 타입 분류용 필드
@Column(nullable = false)
@Enumerated(EnumType.STRING)
private QuizType quizType;
// OX 퀴즈일 경우에 필요한 필드 (nullable)
@Enumerated(EnumType.STRING)
private OXChoice oxAnswer;
// 객관식 퀴즈일 경우에 필요한 필드 (nullable)
@OneToMany(mappedBy = "multipleChoiceQuiz", cascade = CascadeType.ALL, orphanRemoval = true)
private List<MultipleChoice> multipleChoiceList = new ArrayList<>();
// ...
}
어디선가 많이 본 패턴입니다.
다형성을 설명할 때 안 좋은 예시로 봐오던 그 패턴입니다.
그리고 자연스럽게 떠오른 생각은 낮은 응집도 문제와 SRP 원칙을 위반한다는 문제였습니다.
위의 Quiz 객체는 3가지의 변경 이유를 가집니다.
퀴즈 자체의 기획이 변경될 때, OX 퀴즈의 기획이 변경될 때, 객관식 퀴즈의 기획이 변경될 때입니다.
또한 만약 퀴즈의 종류가 추가된다면 그 종류의 수만큼 변경 이유가 늘어납니다.
이에 대한 해결법은 추상화(추상 클래스 혹은 인터페이스)입니다.
이를 엔티티에 적용하기 위해 '단일 테이블 전략'을 사용하기로 했습니다. (팀원분의 조언이 계기가 되었습니다. 감사합니다ㅎㅎ)
잠깐, 단일 테이블 전략이란 ?
단일 테이블 전략(Single-Table Strategy)은 테이블을 하나만 사용하고, 구분 컬럼(DiscriminatorColumn)으로 어떤 자식 데이터가 저장되었는지 구분합니다. DB상 하나의 테이블로 저장되기 때문에 조회할 때 조인을 사용하지 않습니다.
🚶 엔티티 설계
코드는 간단하게만 봐주세요! 세부 사항은 주석 처리했습니다.
@Entity
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
@DiscriminatorColumn(name = "DTYPE")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public abstract class Quiz extends BaseEntity {
// 퀴즈 공통 필드들
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "quiz_id")
private Long id;
@Column(nullable = false)
private String question;
@Column(nullable = false)
private String explanation;
// 퀴즈 공통 메서드들...
}
@Entity
@DiscriminatorValue("M")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class MultipleChoiceQuiz extends Quiz {
@OneToMany(mappedBy = "multipleChoiceQuiz", cascade = CascadeType.ALL, orphanRemoval = true)
private List<MultipleChoice> multipleChoiceList = new ArrayList<>(); // 일대다 객관식 문항
// ... 메서드들
}
@Entity
@DiscriminatorValue("OX")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class OXQuiz extends Quiz {
@Enumerated(EnumType.STRING)
private OXChoice answer; // 정답
//...메서드들
}
이렇게 함으로써 객체들의 응집도를 높이고 SRP를 지킬 수 있었습니다.
그러나 큰 문제가 생겼습니다.
⚠ 문제 : Quiz 조회시 서브 클래스에 대한 타입 캐스팅 필요
기획상 전체 퀴즈 중에서 하나를 조회하는 기능이 있었습니다.
즉 OX 퀴즈와 객관식 퀴즈 등 퀴즈의 구분 없이 조회해야 했습니다.
여기서 생겨난 문제는, Quiz의 세부 사항을 결국 DTO에서는 알아야 한다는 것입니다.
각 서브 클래스의 필드들을 슈퍼 클래스인 Quiz에서 접근할 수 없습니다.
이는 결국 타입 캐스팅으로 이어지게 되었습니다.
DTO의 타입 캐스팅
첫번째는 DTO에서 타입 캐스팅하도록 구현했습니다.
// 전체 퀴즈 조회 서비스
public QuizCardResponse getDailyQuiz(...) {
// DB에서 Quiz 조회
Quiz quiz = quizRepository.findRandom(...)
.orElseThrow(() -> ...;
// DTO 정적 팩토리 메서드 호출
return QuizCardResponse.from(quiz);
}
// 전체 퀴즈 조회 DTO
@Builder
public record QuizCardResponse(
// ...
) {
public static QuizCardResponse from(Quiz quiz) {
return QuizCardResponse.builder()
// ...
.build();
}
// 타입에 따라 DTO 필드를 분기 처리하여 타입 캐스팅하는 메서드
private static List<MultipleChoiceResponse> resolveChoicesFromDynamicQuizType(Quiz quiz) {
if (quiz.getClass().equals(MultipleChoiceQuiz.class)) {
// 다운 캐스팅
return ((MultipleChoiceQuiz) quiz).getMultipleChoiceList().stream()
.map(MultipleChoiceResponse::from)
.toList();
}
return null;
}
}
지금까지 말씀드린 방식의 장점은 엔티티 객체의 SRP 준수입니다.
또한 중요도가 더 높은 모듈인 엔티티에서 DTO를 몰라도 된다는 장점이 있습니다. (엔티티->DTO 의존 관계 X)
이 방식의 단점은 DTO의 SRP 위반입니다.
그러나 해당 기획상, DTO의 SRP 위반은 필연적이라고 생각했습니다.
🤔🤔 정말 다른 방법은 없는걸까?
지금까지의 상황을 정리해보겠습니다.
1. 기획상 퀴즈는 여러 타입으로 분류될 수 있다.
2. 퀴즈에 추상화를 적용해 슈퍼 클래스 Quiz와 세부 구현체로 나눠지도록 구현했다. (SRP 위반 때문)
3. 전체 퀴즈 조회 DTO에서 결국 세부 필드들을 알아야 한다.
4. DTO에서 서브 클래스들에 대한 타입 캐스팅이 필요하다. (DTO의 SRP 위반)
SRP 위반 때문에 추상화를 하였는데 필연적인 DTO-엔티티 결합도로 인해 결국 SRP가 위반되었습니다.
마음의 위안이 되는 점은 SRP 위반의 발생 장소를 엔티티에서 DTO로 옮겼다는 점입니다.
그러나 퀴즈 종류가 추가되거나 퀴즈 구현체 필드가 변경될 때마다 DTO는 결국 변경되어야 합니다.
지금은 괜찮지만, 만약 퀴즈의 종류가 100개라고 상상해보면 DTO는 하나의 거대한 공장이 될 것입니다.
그렇다고 해서 엔티티가 DTO를 의존한다면, DTO가 변경될 때 엔티티 객체들이 연쇄적으로 변경되는 위험한 일이 발생할 수 있습니다.
그래서 저는 위의 해결 방법이 최선이라고 생각했습니다.
그러나 처음으로 돌아가서 가정을 바꿔보고 싶습니다.
만약 단일 테이블 전략을 사용하지 않았다면 어땠을까요?
가정 1. 별개의 클래스로 분리 (TABLE_PER_CLASS 전략)
아예 Quiz 객체를 별개의 클래스들로 분리한다면 어떨까요?
이는 최악의 가정이라고 할 수 있습니다.
장점은 각 엔티티 객체들의 SRP 준수입니다.
그 외에는 모두 단점입니다.
우선 퀴즈 종류가 늘어날 때마다 새로운 테이블을 생성하고 레포지토리를 생성해야 합니다.
DB 정규화의 관점에서는 데이터 중복이 발생하게 되는 문제가 있습니다.
또한 쿼리상의 문제가 있습니다.
만약 전체 퀴즈를 조회해야 한다면... 각 종류마다 쿼리를 하나씩 실행해야 합니다.
그리고 DTO는 그런 종류들을 모두 알고 있어야 합니다. (해당 프로젝트 가정)
가정 2. 하나의 엔티티
이 가정은 간단합니다.
SRP 위반을 허용하여 모든 필드와 책임을 Quiz 객체에 할당하는 것입니다.
퀴즈의 타입을 명시하는 필드를 만들고, 서브 클래스였던 클래스들의 필드들을 모두 포함시킵니다.
이 방법의 장점은 우선 DTO 변환 과정에서 일어나는 타입 캐스팅 과정이 필요 없어진다는 것입니다.
또한 DB 스키마 구조가 기존과 같기 때문에 쿼리도 그대로입니다. (경우에 따라 타입에 대한 WHERE 절이 추가됩니다)
단점은 낮은 응집도입니다.
엔티티는 각 종류의 변경 이유를 모두 부담하고, DTO는 그러한 변경에 영향을 받습니다.
가정 3. 조인 전략
슈퍼 클래스와 서브 클래스들을 만들고, 각각을 모두 테이블로 두는 전략입니다.
결론
이 문제는 엔티티와 DTO를 바라보는 관점으로 좁혀질 수 있습니다.
저의 경우엔 엔티티의 높은 응집도를 지키는 것(단일 테이블 전략)이 최선이라고 생각했습니다.
무엇이 더 중요한 객체인가? 그리고 무엇이 덜 자주 변경되는가?를 생각해봤을 때 둘 모두 엔티티 객체의 승리였기 때문입니다.
만약 다시 퀴즈 엔티티를 만든다고 한다면 조인 전략을 쓸 것 같습니다.
또한 이 문제는 제게 엔티티의 존재에 대해 생각해보게 한 계기입니다.
스프링 애플리케이션의 주요 목적중 하나는 API 를 통한 데이터 제공입니다.
어딘가에서는 필연적으로 데이터를 바라봐야 하고, 엔티티 객체가 그 역할을 맡게 됩니다.
즉 DTO-엔티티의 결합도는 필연적이고, 이를 엔티티 설계 과정에서 명심하려고 합니다.
참고
'프로젝트' 카테고리의 다른 글
[복쟉복쟉] 비정규화로 6초 쿼리를 105ms로 줄이기 (0) | 2023.10.01 |
---|