1. Tave 지원 페이지, 지원 관리자 페이지
Tave 홈페이지 프로젝트를 완료하고 Tave 지원 페이지, 지원 관리자 페이지를 만들고 있다.
내 개발 파트인 지원서 질문 파트의 간단한 요구사항이다.
- 지원 페이지에서 일반 회원들은 지원서를 작성한다.
- 특정 분야, 공통 분야 질문이 따로 존재한다.
- 질문에 답변할 수 있어야한다.
- 면접 가능 시간대를 물어본다.
- 임시저장이 가능해야한다.
- 과거 지원한 이력서들을 볼 수 있다.
- 지원 관리자 페이지가 필요하다
- 회원에게 보여줄 질문은 관리자페이지에서 생성, 조회, 수정, 삭제가 가능하다.
- 질문의 개수는 유동적으로 변할 수 있다.
2. 설계
위 요구사항들을 생각했을 때 다음과 같은 ERD를 생각했다. (컬럼 생략)
Resume - ResumeQuestion - Question
지원서는 여러 질문을 가진다. 또한 질문은 여러 지원서에서 사용된다.
이는 다대다 매핑이고 실제 ManyToMany는 지양하므로 중간관계 테이블을 사용해서 이를 풀어내고자한다. (물론 ManyToMany를 사용시 알아서 중간관계 테이블이 생기지만, 생겨난 중간 테이블에 유동적으로 필드를 추가할 수 없다.)
다음은 ERD회의 후 만들어진 소스코드이다.
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Resume {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "resume_id")
private Long id;
@Size(min = 1, max = 20)
@Column(length = 20)
private String school;
@Size(max = 20)
@Column(length = 20)
private String major;
@Size(max = 20)
@Column(length = 20)
private String minor;
@Size(min = 1, max = 15)
@Column(length = 8)
private String field;
@Min(1)
@Max(5)
private Integer resumeGeneration;
@Size(max = 255)
@Column(length = 50)
private String blogUrl;
@Size(max = 255)
@Column(length = 50)
private String githubUrl;
@Size(max = 255)
@Column(length = 50)
private String portfolioUrl;
@Size(min = 1, max = 10)
@Column(length = 10)
private String state;
@ManyToOne
@JoinColumn(name = "memberId", nullable = false)
@JsonIgnore
private Member member;
@OneToMany(mappedBy = "resume", cascade = CascadeType.ALL)
private List<ResumeQuestion> specificResumeQuestion = new ArrayList<>();
@OneToMany(mappedBy = "resume", cascade = CascadeType.ALL)
private List<ResumeQuestion> commonResumeQuestion = new ArrayList<>();
@OneToMany(mappedBy = "resume", cascade = CascadeType.ALL)
private List<TimeSlot> timeSlots = new ArrayList<>();
@OneToMany(mappedBy = "resume", cascade = CascadeType.ALL)
private List<LanguageLevel> languageLevels = new ArrayList<>();
}
@Entity
@Getter
@SuperBuilder
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class ResumeQuestion extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@NotNull
@Size(min = 1, max = 300)
@Column(length = 300, nullable = false)
private String question;
@Size(min = 1, max = 1000)
@Column(length = 1000)
private String answer;
@Column(length = 2)
private Integer ordered;
@ManyToOne
@JoinColumn(name = "resume_id", nullable = false)
@JsonIgnore
private Resume resume;
@Enumerated(EnumType.STRING)
private FieldType fieldType;
@Entity
@Getter
@SuperBuilder
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Question extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@NotNull
@Size(max = 300)
@Column(length = 300, nullable = false)
private String content;
@NotNull
@Column(nullable = false)
@Enumerated(EnumType.STRING)
private FieldType fieldType;
@NotNull
@Column(nullable = false)
private Integer ordered;
private Integer textLength;
}
- 그냥 Resume 객체 생성 시 Question 질문 내용을 초기화해주면 되는거 아니야?
-> Resume안에 question1 question2 이렇게 필드를 하드코딩으로 박아두면 질문의 개수가 달라질 수 있다는 요구사항을 충족하지 못한다.
- 추가로 나는 ResumeQuestion을 생성할 때 QuesitonId를 저장하지 않을 것이다.
즉, ResumeQuestion 객체를 생성할 때는 Question의 content(질문 내용)으로 초기화하고 질문 내용에 대한 수정은 고려하지 않을 것이다.
(이 말의 뜻은 Question의 content가 수정될 때 ResumeQuesiton의 String question이 변경되지 않는다는 뜻)
(질문에 대한 답변 수정은 당연히 고려한다.)
왜 ResumeQuestion의 질문 내용 수정은 고려하지 않는가?
-> 모집 시작부터 모집 종료까지는 지원서의 답변내용만 달라진다.
Resume에 대한 질문이 생성되는 비지니스 로직은 다음과 같다.
1. 회원이 지원서를 생성함.
2. 관리자가 설정한 Quesiton을 토대로 ResumeQuestion을 생성함
3. ResumeQusetion에는 Resume의 PK를 가지고 있어 지원서에 대한 답변 수정이 가능
4. 단 ResumeQuestion에는 Question의 PK를 가지고 있지 않아 모집 기간 중 질문 내용이 바뀔 경우 반영되지 않음 -> 이런 엣지케이스는 고려하지 않음.
모집 시작부터 모집 종료까지는 지원서의 답변내용만 달라진다.
모집이 아닌 기간에는 회원이 ResumeQuestion이 생성할 일 없다. 따라서 ResumeQesution의 질문 내용(Question.content)만 객체 생성 시 초기화한다.
3. 문제
요구사항에 맞도록 엔티티를 구상했다고 생각했지만, 예상치 못한 곳에서 문제가 발생했다.
문제가 발생하는 필드는 Resume 엔티티가 가지고 있는
List<ResumeQuestion> specificResumeQuestion과 List commonResumeQuestion 이다.
문제 상황은 commonResumeQuestion에 ResumeQuestion을 add하더라도 specificResumeQuestion에서 조회가 된다는 것이다.
따로 둔 의도는 공통 질문과 분야별 질문을 나누고 객체 그래프 탐색으로 편하게 조회하기 위함이다.
하지만 세상은 그리 쉽게 우리의 의도대로 흘러가지 않는다.
4. Why?
class Resume {
@OneToMany(mappedBy = "resume")
private List<ResumeQuestion> specificResumeQuestion;
@OneToMany(mappedBy = "resume")
private List<ResumeQuestion> commonResumeQuestion;
}
class ResumeQuestion {
@ManyToOne
private Resume resume;
private FieldType fieldType;
}
문제가 발생하는 이유는 다음과 같다.
ResumeQuestion 객체를 생성할 때 Resume 연관관계를 맺어준다.
물론 Resume.getCommonResumeQuestion().add(this)로 반대 연관관계도 맺어준다.
그러나 결국 연관관계의 주인은 ResumeQuestion이다. 실제 테이블에는 ResumeQuestion측에 Resume의 PK가 FK로 저장된다.
이때 commonResumQuestion을 조회하면 당연히 ResumeQuestion의 ResumId 컬럼으로 조회하기 때문에 ResumeId 만으로 specific인지 common인지 구별할 수 없다.
5. 해결
내가 생각한 해결 방법이다.
1. List를 반환할 때 FieldType 별로 필터링
2. @OneToMany를 시원하게 제거하고 @ManyToOne 단방향을 사용 후 DB조회에서 ID와 FieldType으로 같이 조회(Jpa메서드로 직접 List 조회)
나는 2번 방식을 택할 예정이다.
이유는 내가 @OneToMany를 좋아하지 않기 때문이다.
@OneToMany를 사용했을 때 가장 큰 장점은 객체 그래프 탐색이 가능하다는 점이 있지만, 그 이외에는 불편하다고 생각한다.
문제를 겪기전까지 ResumeQuestion 객체를 생성하고 Resume과 연관관계를 맺어주는 과정이 복잡하게 느껴진다.
나는 ResumeQuestion을 DB에 저장하려고 할 때 Resume까지 같이 신경써야하는 게 복잡하다고 느껴진다. (부모를 set, 자식 add)
그동안 개발을 하면서 느낀건 객체 그래프 탐색이 편리하긴 하지만 Repository에서 조회해오는 로직 없이 단순히 (객체.리스트) 모양으로 엔티티를 가져오는 과정이 통일성이 없다고 느껴진다.
예를 들어 사용자에게 Resume 목록을 보여주기 위해서는 Repository에서 List<Resume> findByAll()과 같은 Jpa 메서드를 사용하는데 Resume에 있는 ResumeQuestion은 그냥 resuem.getCommonResumeQuestion()으로 가져오는 게 어색하게 느껴진다는 말이다.
추가로 양방향 관계 시 엔티티를 그대로 반환해 JSON 형식으로 변환 될 경우 순환참조 문제도 있다. (물론 Dto로 변환하는 과정을 거치면 해결.)
'프로젝트에서 일어난 일' 카테고리의 다른 글
라즈베리파이 + MSA(Spring Cloud) + CI/CD 배포 (1) | 2025.04.17 |
---|---|
다수 데이터 Insert 시 성능 개선하기(37.06% 개선) (1) | 2025.04.16 |
졸프: 라즈베리파이와 MSA를 곁들인 (0) | 2025.04.10 |
Jwt토큰 인가 검증에서 일어난 간단한 사건! (0) | 2025.02.04 |
N+1 싹둑(Slice)해버리기 (0) | 2025.02.01 |