들어가면서
4차 스프린트가 진행중인 현재 2차 스프린트 회고를 진행해보고자 한다. 노션에 개인적으로 2차 스프린트에 대한 회고를 메모형식으로 정리해두기는 했었으나 프로젝트 일정이 더 급하다는 이유로 포스팅을 미뤄뒀었다. 시간이 더 흘러서 적어뒀던 메모를 보고도 기억이 나지 않기 전에 스스로를 위해 포스팅한다.
2차 스프린트는 뒤쳐지지 않기 위해 무던히 애를 썼던 기간이었다. 첫 주부터 예비군 훈련으로 내리 4일을 빠지게 되었기에 그 동안 팀에서 진행한 업무들을 동기화하는데 더 노력을 기울일 수 밖에 없었다. 다행인지 불행인지는 모르겠지만 4일 동안 1차 스프린트 데모데이 피드백을 중심으로 서비스의 방향성을 뒤집는 기획 회의만 이뤄졌었다. 개발적인 부분이 먼저 시작되었으면 더 쫓아가기 힘들었을텐데 딱 캠퍼스에 돌아온 날부터 개발이 시작되어서 다행이라는 생각이 들었다.
정말 오랜만에 개발을 다시 하는 것과 더불어 JPA라는 새로운 기술을 도입하여 개발을 진행하는 이슈가 겹치니 처음엔 머릿속이 새하얘지는 기분도 오랜만에 느껴봤던 것 같다. 이 때는 최대한 팀원들을 활용해서 빠르게 감을 되찾으려고 노력했다. 부끄럽긴 했지만 현재 내 상태를 솔직하게 공유하고 기본적인 부분들도 함께 리마인드하며 기능 개발을 진행해나갔다. 많이 답답할 수도 있었을텐데 친절하게 답해준 테오, 모디, 그리고 영혼의 페어 마코에게 감사하다는 인사를 전한다.
2차 스프린트
서비스 기획변경
1차 데모데이 때 전반적으로 모든 팀들이 서비스 기획에 대한 피드백을 받았을 것이라 생각한다. 하루스터디 팀도 별반 다르지는 않았기에 기획에 대한 회의가 이어질 수 밖에 없었다. 준 코치님이 진행해주신 ux 특강에서 사용자들이 우리 서비스를 계속 사용하게 하기 위해서는 비타민이 아니라 진통제와 같은 역할을 하도록 만들어야 한다는 말씀을 해주신게 가장 인상에 남았다.
기존 하루스터디는 작은 주제를 선정하여 짧은 단위의 스터디를 진행할 수 있도록 도와주는 스터디 모집 플랫폼에 가까웠다. 하지만 스터디를 하고자 하는 사람들이 정말 겪는 불편함이 스터디 모집에 있는가에 대해서는 생각해보지 못한 부분이었다. 팀 내 논의를 거쳐서 나온 결론은 사람들의 불편함이 스터디 모집에 있는 것이 아니라 스터디 진행에 있다는 것이었다. 따라서 다른 스터디 모집 서비스와 차별점을 두고 실제 서비스 사용자들의 불편함을 해소하는 진통제 역할을 수행할 수 있는 서비스로 기획 방향을 틀기로 결정했다.
단순 모집 플랫폼이었을 때 기획을 진행하려고 하면 어떻게 해야 사용자들의 불편함이 해소될 지, 어떻게 해야 사용자들이 계속 하루스터디 서비스를 사용할 지를 생각하는게 막연했었다. 하지만 서비스 제공 기능 가짓수를 축소하고 정말 사용자들이 불편함을 느끼는 부분을 해결하는데 집중하는 진통제와 같은 서비스를 만들자고 생각하니 서비스 기획이 이전에 비해 훨씬 더 뾰족해졌다는 것을 바로 느낄 수 있었다. 여기에서 한층 더 나아가 뽀모도로 스터디 방법론과 스터디 전후로 계획, 회고 템플릿 제공 등을 통해 우리 서비스에서 스터디 진행을 도와주는 기획으로 더 구체화할 수 있었다.
개인적으로 뽀모도로 스터디 방식으로 공부하는 것을 좋아하고 많이 해봤었기에 바뀐 서비스 기획안을 봤을 때 더 만족스러웠고 우리 서비스에 더 애정이 생겼다.
기능 구현
서비스 기획 변경이 완료되고 나서 최소 기능 구현 목록이 도출되었다.
하루스터디 백엔드 팀에서는 동기화가 필요한 DB 설계 및 엔티티 설계는 몹 프로그래밍으로, 서비스 최소 기능 구현은 페어 프로그래밍으로 진행하기로 결정했다. 서비스 기능을 구분해서 페어 별로 구현하는 것으로 결정했기에 스터디 개설, 참여, 조회, 진행 기능으로 API 들을 구분했다. 나와 마코 페어는 스터디 참여와 조회 기능을 담당하는 API를 설계하고 구현하게 되었다. 구현을 진행하면서 마주쳤던 문제상황들이 많았는데 이를 정리하고자 한다.
DB / 엔티티 설계
먼저 페어로 찢어져서 구현에 들어가기 전에 공통적인 부분인 DB 테이블과 엔티티 설계는 팀원 모두가 함께 진행하게 되었다. 이 때 중점적으로 고려되었던 부분은 향후 하루스터디에서 제공하는 스터디 방식이 잦은 수정을 거치거나 다양해지는 방향으로 고도화할 때 더 수월할 수 있도록 설계하는 것이었다. 이를 위해 엔티티 설계에서 상속관계를 통한 여러 스터디 방식 추가를 고려하고자 했고 이는 DB 테이블 설계에도 반영되었다. 이후 나름대로 엔티티 간 상속관계를 DB 모델로 풀어내기 위해서 Join 전략을 사용한 근거에 대해서도 학습하고 정리해보았다.
상속관계 엔티티 조회하기
마코와 페어 프로그래밍으로 조회 기능을 구현하면서 엔티티 상속구조로 인한 불편함을 바로 맞닥뜨리게 되었다. 스터디 구성원의 진행도를 나타내는 memberProgress에 대한 repository 코드가 어떻게 바뀌었는지 따라가며 마주했던 문제들을 정리하고자 한다. 먼저 MemberProgress, PomodoroProgress 엔티티와 가장 처음 작성했던 Repository 코드이다.
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Inheritance(strategy = InheritanceType.JOINED)
@DiscriminatorColumn(name = "progress_type")
@Entity
public abstract class MemberProgress extends BaseTimeEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "study_id")
private Study study;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id")
private Member member;
@OneToMany(mappedBy = "memberProgress")
private List<MemberRecord> memberRecords = new ArrayList<>();
private boolean isDone = false;
}
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Entity
public class PomodoroProgress extends MemberProgress {
@NotNull
private Integer currentCycle;
@Enumerated(value = EnumType.STRING)
private StudyStatus studyStatus;
public PomodoroRecord findPomodoroRecordByCycle(Integer cycle) {
return getMemberRecords().stream()
.filter(pomodoro -> ((PomodoroRecord) pomodoro).getCycle().equals(cycle))
.map(record -> (PomodoroRecord) record)
.findAny()
.orElseThrow(IllegalArgumentException::new);
}
public List<PomodoroRecord> getPomodoroRecords() {
return getMemberRecords().stream()
.map(record -> (PomodoroRecord) record)
.toList();
}
}
public interface MemberProgressRepository<MemberProgress> extends JpaRepository<MemberProgress, Long> {
Optional<MemberProgress> findByStudyIdAndMemberId(Long studyId, Long memberId);
List<MemberProgress> findByStudyId(Long studyId);
}
첫번째 문제
MemberProgress - PomodoroProgress 상속관계 엔티티를 영속화하고 다시 조회해올때 연관관계를 맺는 엔티티의 지연로딩 설정으로 프록시 객체가 주입된다. 이 때 연관관계를 맺는 Study, MemberRecord 엔티티가 프록시 객체라서 부모타입에서 하위타입으로 형변환이 불가능한 문제가 발생했다.
// 프록시 객체로 조회된 Study는 하위 타입인 Pomodoro로 형변환이 불가능했다.
Pomodoro pomodoro = (Pomodoro) pomodoroProgress.getStudy(); //Error!!
지연로딩이 아닌 즉시로딩을 사용할 수도 있었으나 연관관계를 맺는 엔티티에 대해 즉시로딩 전략을 거는 건 위험부담이 크고 관리하기 힘들어질 것 같다고 판단했다. 이를 해결하기 위해 Fetch Join 전략을 사용해서 연관관계를 맺는 엔티티들을 직접 프록시가 아닌 실제 엔티티 타입으로 조회해오도록 구현해서 해결하도록 했다.
public interface MemberProgressRepository<MemberProgress> extends JpaRepository<MemberProgress, Long> {
@Query("""
select mp from MemberProgress mp
join fetch mp.study
where type(mp) = (PomodoroProgress)
and mp.study.id = :studyId
and mp.member.id = :memberId
""")
Optional<MemberProgress> findByStudyAndMember(@Param("studyId") Long studyId, @Param("memberId") Long memberId);
@Query("""
select mp from MemberProgress mp
join fetch mp.study
join fetch mp.member
where type(mp) = (PomodoroProgress)
and mp.study.id = :studyId
""")
List<MemberProgress> findByStudy(@Param("studyId") Long studyId);
}
// 프록시 객체가 아닌 실제 객체로 조회된 Study는 하위 타입인 Pomodoro로 형변환이 가능해졌다.
Pomodoro pomodoro = (Pomodoro) pomodoroProgress.getStudy();
두번째 문제
위 Repository를 사용할 때는 아래와 같이 조회해 온 MemberProgress를 PomodoroProgress로 service 코드에서 강제로 형변환해줘야 하는 불편함이 발생했다. 단순히 귀찮음을 넘어서 memberProgress를 조회해왔을 때 여러 하위 스터디 타입 중 어떤 것으로 변환이 가능한지 개발자가 사용할 때마다 정확하게 체크해줘야만 한다는 문제가 같이 발생했다.
PomodoroProgress pomodoroProgress = (PomodoroProgress) memberProgressRepository.findByStudyIdAndMemberId(study, member)
.orElseThrow(IllegalArgumentException::new);
이러한 불편함과 문제를 해결하기 위해 제네릭 타입과 제한된 타입 파라미터를 Repository 선언 시 사용하도록 수정했다.
public interface MemberProgressRepository<T extends MemberProgress> extends JpaRepository<T, Long> {
@Query("""
select mp from MemberProgress mp
join fetch mp.study
where type(mp) = (PomodoroProgress)
and mp.study.id = :studyId
and mp.member.id = :memberId
""")
Optional<T> findByStudyAndMember(@Param("studyId") Long studyId, @Param("memberId") Long memberId);
@Query("""
select mp from MemberProgress mp
join fetch mp.study
join fetch mp.member
where type(mp) = (PomodoroProgress)
and mp.study.id = :studyId
""")
List<T> findByStudy(@Param("studyId") Long studyId);
}
이를 통해 MemberProgressRepository 구현체 생성 시 PomodoroProgress를 제네릭 타입으로 줘서 PomodoroProgressRepository처럼 바로 엔티티를 조회해올 수 있도록 문제상황을 개선할 수 있었다.
이런 과정을 거치면서 상속관계인 하위 타입 엔티티를 조회해오는 기능을 구현할 수 있었다. 하지만 어떤 방식이 Best Practice인지는 학습해보지 못했기에 이후 심화학습을 하게된다면 추가 포스팅을 해보고 싶다.
Repository에서 Id를 통한 조회를 할 것인지, 엔티티를 통한 조회를 할 것인지?
위에서 언급된 MemberProgressRepository 코드에서 보이는 것처럼 처음엔 id를 기반으로 엔티티를 조회하도록 구현했다. 하지만 팀 코드리뷰 진행 도중 Repository에서 엔티티를 조회할 때 id로 조회할 것인지, 엔티티로 조회할 것인지에 대한 논의가 진행되었다.
// 기존 id를 통한 조회를 사용하는 코드
public interface MemberProgressRepository<T extends MemberProgress> extends JpaRepository<T, Long> {
@Query("""
select mp from MemberProgress mp
join fetch mp.study
where type(mp) = (PomodoroProgress)
and mp.study.id = :studyId
and mp.member.id = :memberId
""")
Optional<T> findByStudyAndMember(@Param("studyId") Long studyId, @Param("memberId") Long memberId);
@Query("""
select mp from MemberProgress mp
join fetch mp.study
join fetch mp.member
where type(mp) = (PomodoroProgress)
and mp.study.id = :studyId
""")
List<T> findByStudy(@Param("studyId") Long studyId);
}
// 엔티티를 사용한 조회로 변경한 코드
public interface MemberProgressRepository<T extends MemberProgress> extends JpaRepository<MemberProgress, Long> {
@Query("""
select mp from MemberProgress mp
join fetch mp.study
where type(mp) = (PomodoroProgress)
and mp.study = :study
and mp.member = :member
""")
Optional<T> findByStudyAndMember(@Param("study") Study study, @Param("member") Member member);
@Query("""
select mp from MemberProgress mp
join fetch mp.study
join fetch mp.member
where type(mp) = (PomodoroProgress)
and mp.study = :study
""")
List<T> findByStudy(@Param("study") Study study);
}
처음 id를 통해 엔티티를 조회하도록 구현했을 때는 클라이언트로부터 id 값이 요청으로 들어오고 이를 기반으로 엔티티를 조회하면 되기 때문에 엔티티로 조회해야한다는 생각을 못했었다. 하지만 다른 크루 페어는 엔티티를 통해 조회하는 방식이 JPA 사상에 더 적합하지 않냐는 입장이었다. 만약 엔티티로 조회한다고 하면 다음과 같은 장단점이 있을 것이라 생각했다.
- 장점
- 엔티티 내부에 존재하는 id 값을 꺼내지 않고 엔티티 자체로 조회할 수 있다. (객체를 기반으로 코드를 다루고자 하는 JPA 사상에 더 가깝다.)
- 조회하기 위한 엔티티가 참조하는 엔티티를 미리 조회하기 때문에 fetch join 사용이 불필요해진다.
- 단점
- id로 조회를 할 때보다 추가적인 select 쿼리문이 실행된다.
- Repository에서 필요한 모든 엔티티들을 참조하고 있는 경우 여러 도메인 패키지 간 의존성 강결합의 문제가 발생할 수 있다. (하지만 우리들의 예상일 뿐 실제로 문제가 될지는 확신할 수 없다.)
위에서 팀원들과 나눴던 코드 리뷰에서의 논의처럼, 어떤 방식을 선택해야만 한다는 뚜렷한 장단점은 찾기 힘들었다. 그래서 일단 문제가 실제로 발생하기 전까지는 스타일을 통일하고 JPA 사상에 더 가까운 엔티티 기반 조회 방식을 채택하기로 결정했다. 이후 문제상황이 실제로 발생하거나 성능 최적화가 필요할 때, 패키지 간의 의존관계를 풀어줘야 할 때 Id 조회를 도입하도록 했다.
이후 repository에서 Id가 아닌 엔티티로 직접 조회하는 것으로 결정하면서 Fetch Join의 필요성이 사라졌다. 이미 Study 엔티티가 프록시가 아닌 실제 엔티티 타입으로 영속성 컨텍스트에 존재했기 때문이다. 따라서 다음과 같은 코드로 최종 수정되었다.
// Service 단 로직
public MemberContentResponses findMemberContent(Long studyId, Long memberId) {
// PomodoroProgress를 조회하기 위해 필요한 Study와 Member 엔티티를 미리 조회한다.
Study study = studyRepository.findById(studyId).orElseThrow(IllegalArgumentException::new);
Member member = memberRepository.findById(memberId)
.orElseThrow(IllegalArgumentException::new);
// PomodoroProgress를 조회하는 시점에는 이미 Study와 Member 엔티티가 로딩되어 있는 상태다.
PomodoroProgress pomodoroProgress = memberProgressRepository.findByStudyIdAndMemberId(study, member)
.orElseThrow(IllegalArgumentException::new);
...
}
public interface MemberProgressRepository<T extends MemberProgress> extends JpaRepository<MemberProgress, Long> {
Optional<T> findByStudyAndMember(@Param("study") Study study, @Param("member") Member member);
List<T> findByStudy(@Param("study") Study study);
}
브랜치 전략 수립 그리고 수정 제안
예비군 기간에 진행됐었던 브랜칭 전략 특강과 팀원들이 설계한 브랜칭 전략을 살펴보고 동기화를 하던 과정에서 문제점을 발견했다. 하루스터디 팀에서는 merge 방식을 squash and merge로 통일하기로 결정했었다. 그런데 develop 브랜치를 관리할 때는 문제가 없었으나 배포용 main 브랜치로 squash and merge를 한 번 수행하고 나서 다음 merge를 진행하려 하면 충돌이 발생하는 문제가 생겼다. 이를 정리하고 팀원들에게 공유해서 팀 브랜칭 전략을 수정할 수 있었다.
회고 마무리
벌써 2차 스프린트까지 마무리가 되었다. 원래는 서비스 1차 배포를 완료하는 것이 우리 팀의 목표였으나 생각보다 해결해야할 문제가 많았어서 아쉽게도 3차 스프린트로 미루게 되었다. JPA를 처음 사용해보고 오랜만에 개발을 다시 하다보니 많이 정신없이 진행했었던 것 같다. 혼자서 구현 역량을 많이 연습하는 시간을 확보해야겠다는 생각이 절실하게 들었다. 하지만 3차 스프린트에서도 일정에 치이다 보면 또 눈 깜빡할 새에 다 지나가버릴 것 같다. 흘러가는 대로 몸을 맡기지 말고 어떤 목표가 있었는지, 지금 무엇을 해야하는지 길을 잃지 않고 지치지 않았으면 하는 바람이다.
'우아한테크코스 > 회고' 카테고리의 다른 글
[우아한테크코스] LV3 - 4차 스프린트 회고 (0) | 2023.09.06 |
---|---|
[우아한테크코스] LV3 - 3차 스프린트 회고 (0) | 2023.08.20 |
[우아한테크코스] LV2 - 웹 장바구니 협업 미션 회고 (2) | 2023.08.13 |
[우아한테크코스] LV3 - 1차 스프린트 회고 (2) | 2023.07.09 |
[우아한테크코스] 중간 회고 (2) | 2023.07.02 |