코드스쿼드 마스터프로젝트 중 이슈트래커 작업 중 테이블 설계와 JPA로 구현하면서 관계맵핑 관련한 부분들을 정리 해봤습니다.
이번에는 설계가 한번에 바로 나오지 않았습니다.
JDBC 라면 크케 고민 하지 않았을 부분이었지만, JPA 로 관계맵핑 하기 좋은 테이블 구조는 무엇일지 고민하는 시간을 밤새 가져 보았습니다.
이전 작업에서 팀원이 단순하게 설계해놓은 ERD를 그냥 작업했지만, 이번에 혼자 진행하는 만큼 쓰여야 할 부분들을 제외시키고 싶진 않았고, 3주 기간 동안 할 수 있는 정도의 복잡성에 대해 고민이 있었습니다.
처음에는 단순하게 이슈 목록들을 나열하는 걸로 생각하고 시작하였는데요, 사용 용도를 생각하면서 이슈목록에서 보여져야 하는 관계되는 테이블들을 생각안 할 수 없었습니다.
여러개가 있는 라벨, 하나만 있는 마일스톤
초반에 살짝 캘린더 구현을 시도해 봤습니다. 일정관리와 차이가 무엇일지 좀더 구체적으로 보고서 이슈트래커의 비즈니스 로직을 들여다 봐야겠다 싶었습니다.
이슈 트래커는 작업단위 중심으로 팀별, 기간별 구분 하여 이용하고, 처리되는 이슈는 open 상태에서 close 하여 처리할 작업에 집중도를 높일 수 있다고 생각했습니다. (🤡..이런게 새로운 서비스 구현 재미)
그래서, 라벨, 마일스톤이 쓰이는 상황이라면...
어려웠던게 팀이란게 보통 생각하는 개념과 달랐던 점이예요. 라벨 하나로 팀을 구별할 수 있는 점이죠.
이때는 로그인을 나중으로 미루면서 세션로그인을 생각한 user 테이블만있다.
테이블에서 n:m의 관계를 풀기 위해
label_has_issue
라는 테이블을 추가했다. 여기서는 부모키를 참조한 외래키를 기본키로 하지 않고, 기본키를 추가하면서 비식별자 관계로 맵핑 했다.
issue_tracker_label
테이블에는 필수관계인 project만을 FK로 참조하고 있따.label_has_issue
를 통해 라벨을 가져온다.Label
은 Issue
와 연관관계 갖지 않는다. @OneToMany(mappedBy = "issue")
private List<IssueLabel> issueLabels = new ArrayList<>();
마일스톤은 이슈와는 1:n의 관계로
issue_tracker_issue
테이블이 mileston를 FK로 참조하고 있다.
Issue
에서 다대일 단방향 연관관계 매핑 했다.Issue
를 갖게 오고자 한다면 다대일 양방향도 가능하다. @ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "milestone_id")
private Milestone milestone;
이슈 중심으로 관계맵핑들을 정리하고 그외는 분리되도록 했습니다. 마일스톤 조회시 이슈 상태별 마일스톤 progress bar 위한 이슈 상태 개수 조회는 어떻게 할지 고민이었습니다.
select
issue_status, count(issue_status) as status_count
from
(
select issue_status, milestone_id from issue_tracker_issue a where milestone_id in (3,4)
) b
group by b.issue_status, milestone_id;
JPA의 최대 걸림돌이 from절의 서브쿼리인 인라인뷰(inlineview)
select
issue_status, count(issue_status) as status_count, milestone_id
from
issue_tracker_issue
where milestone_id in (3,4)
group by issue_status, milestone_id;
public interface NumberOfIssueStatus {
String getIssueStatus();
Long getStatusCount();
Long getMilestoneId();
}
@Query(value =
"select issue_status as issueStatus, count(issue_status) as statusCount, milestone_id as milestoneId"
+ " from issue_tracker_issue"
+ " where milestone_id in :milestoneIds"
+ " group by issue_status, milestone_id", nativeQuery = true)
List<NumberOfIssueStatus> findGroupBy(@Param("milestoneIds") List<Long> milestoneId);
마일스톤 목록에 필요한 IssueStatus 정보는 milestonId별 OPEN 상태의 IssusStatus 개수와 CLOSE 상태의 IssueStatus 개수
NumberOfIssueStatusDto
NumberOfIssueStatusAndMilestoneDto
public NumberOfIssueStatusAndMilestoneDto readByMilestones(List<Long> milestoneIds) {
List<NumberOfIssueStatus> numberOfIssueStatuses = issueRepository.findGroupBy(milestoneIds);
return NumberOfIssueStatusAndMilestoneDto.from(numberOfIssueStatuses);
}
@Transactional(readOnly = true)
public List<MilestoneResponse> readAll(AuthUser authUser) {
List<Milestone> milestoneInfo = milestoneRepository.findAllByProjectId(authUser.getProjectId());
Milestones milestones = Milestones.from(milestoneInfo);
NumberOfIssueStatusAndMilestoneDto numberOfIssueStatusMap = issueService.readByMilestones(
milestones.getMilestoneIds());
return milestones.toResponses(numberOfIssueStatusMap);
}
사실 검색부터 구현 했었는데, 이때부터 제가 outer join을 하지 않는 방향에서의 구현을 시도해보게 됐습니다. 🙃
검색기능은 이슈목록에서 각각 필터 버튼을 누르면 검색어 입력란에 추가되어지면서 검색 조회 요청합니다. ref
ex.is:open+author:sally+label:"feature"
Issue
: label
= 1:n 으로 null 이거나 여러개 가져올 수 있다. + none
이라는 검색이 가능하다.none
? label
의 개수가 각각 다를 때, 이슈의 중복 개수가 늘어날 수 있다. (거기에 마일스톤 등)Issue
의 id 목록들을 통해 관련된 label
을 조회 해오게 하는데,label_has_issue
를 이용해 조회해오게 했습니다.none
이거나 없다면 null, label 검색어가 있다면 검색어를 넣어 조회 public List<IssueResponse.Row> search(AuthUser authUser, IssueSearchRequest request) {
IssueSearchParam searchParam = IssueSearchParam.from(request);
List<IssueLabelDto> issueLabels = issueSearchRepository.findIssueLabels(searchParam.labelName());
List<IssueSearchDto> resultOfSearch = issueSearchRepository.search(
authUser,
searchParam,
toIssueIds(issueLabels));
IssueLabelMapper issueLabelMapper = IssueLabelMapper.from(issueLabels);
return resultOfSearch.stream()
.parallel()
.map(issue -> IssueResponse.Row.from(issue, issueLabelMapper.getValue(issue.getIssueId())))
.collect(Collectors.toList());
}
none
이면 차집합으로, 검색어가 없다면 null, 있다면 해당 검색어가 포함된 label과 관련된 issueId만 조회하게 한다. private BooleanExpression labelsFrom(String labelName, List<Long> issueIds) {
if (SearchKeyType.isNone(labelName)) {
return issue.id.notIn(issueIds);
}
return Strings.isBlank(labelName) ? null : issue.id.in(issueIds);
}
결론은, 구현과정은 재미있었지만, 검색어에 따른 조회로직을 간단히 하고자 querydsl을 썼는데, 복잡해져서 그 장점이 사라진 것도 같다.
1. 코드를 개선 😐
2. outer join도... 😑