우아한테크코스 레벨4, 모아모아팀의 스프린트4 기간 동안 느낀점 등을 기록하려고 한다.
(4차 데모 기간, 8월 8일 ~ 8월 19일)
스프린트4, 마지막 스프린트 기간인 만큼 프로젝트를 최종적으로 정리하는 느낌으로 진행을 하였다.
SonarQube 를 적용하고, 이를 기반으로 해서 Code Smells
을 제거하는 작업도 진행하였으며, 최종적으로 QA를 진행하여 우리 서비스가 버그가 무엇인지 확인하는 작업도 진행하였다. 그리고 데모시연 영상을 찍기 몇시간 전에 버그가 있음을 확인하는 경험도 하는 등 가슴조리는 스프린트 기간을 보냈다. 뿐만 아니라 약 일주일동안 RefreshToken에만 매달리며 여러 오류들도 만나고, 프론트 팀원과 함께 고민하는 등의 시간도 보냈다.
보다 자세한 작업 진행 내용은 아래 링크에서 확인할 수 있을 것 같다!
MOAMOA Daily Sprint4
크게 스프린트4에 필수로 진행해야 하는 사항인 정적 분석 리포트
, CloudWatch logs 대시보드
, 성능 리포트
, 테스트 자동화
뿐 아니라 스터디 공지사항 및 커뮤니티
, 스터디 링크모음
등등 우리 서비스에서 사용자에게 제공하면 가치를 얻을 수 있을 것이라고 생각하는 몇가지 사용자 스토리들을 구현하였다.
스터디 공지사항이나 스터디 링크모음, 커뮤니티와 같은 사용자 스토리는 완전히 새롭게 구현해야하는 기능이기 때문에 시간이 조금 많이 걸렸으며, "스터디 가입날짜 및 스터디 개수"와 같은 기존 기능에 추가적으로 붙는 부가적인 기능도 구현하였다.
이번 스프린트를 끝으로 우리 서비스의 개발이 멈추는 것이 아니라 계속해서 레벨4에도 이어서 작업을 하게 되므로 스터디 썸네일 이미지 업로드
등과 같은 과제들은 남겨두었다.
이번 스프린트4는 8월 9일 내린 집중호우로 게더에서 팀원들과 모여 작업을 진행하기도 하였는데, 예전 우테코를 처음 시작할 때의 기억도 나며, 팀플 막바지지막 새로운 마음가짐으로 임할 수 있는 시간을 가지기도 하였다.
(우리팀원들과 함께 테트리스 게임도 하였는데, 팀원들과 처음 함께 게임하는 것이었어서 특히 더 재미있는 시간이었다.)
데일리를 진행하기 위해 옹기종기 모여있는 모아모아 팀원들
데일리 이후에 간단하게 테트리스를 팀원들 전체와 몇판 진행하였따. 본인은 거의 5등..6등을 했던 것 같다...어려워 ㅠㅠ
Refresh Token
진행의 경우에는 별도의 글에서 자세하게 작성하였으므로 여기서는 생략한다.
구현 자체는 어렵지 않았지만, 에러를 마주하였고 원인을 찾아 수정하는데에는 조금 시간을 들였다. (Interceptor와 ArgumentResolver 처리를 간과하여 만료된 AccessToken으로 부터 payload를 얻는 것이 문제였다.) 이외에동 프론트팀원과 함께 고민하는 과정등에서도 많은 인사이트를 얻을 수 있는 작업이었다.
전체적인 Refresh Token의 동작과정은 다음 그림과 같다.
JdbcTemplate
을 제거하면서 Fixture 또한 보완한다.이번에 테스트코드를 개선하면서 스터디 생성시 발생한 버그 또한 수정을 진행하였다.
먼저 스터디 생성시에 발생하는 버그를 수정한 내용을 정리해보겠다.
기존에는 CreatingStudyRequest
DTO에서 RecruitPlanner
를 생성할 때 다음과 같은 로직으로 구성되어 있었다.
@AllArgsConstructor
@NoArgsConstructor
@Getter
@Builder
public class CreatingStudyRequest {
@NotBlank
private String title;
@NotBlank
private String excerpt;
@NotBlank
private String thumbnail;
@NotBlank
private String description;
@Min(1)
private Integer maxMemberCount;
@DateTimeFormat(pattern = "yyyy-MM-dd")
@NotNull
private LocalDate startDate;
@DateTimeFormat(pattern = "yyyy-MM-dd")
private LocalDate enrollmentEndDate;
@DateTimeFormat(pattern = "yyyy-MM-dd")
private LocalDate endDate;
private List<Long> tagIds;
...
public RecruitPlanner mapToRecruitPlan() {
return new RecruitPlanner(maxMemberCount, RecruitStatus.RECRUITMENT_START, enrollmentEndDate);
}
...
}
코드에서 알 수 있다시피 무조건 RECRUITMENT_START
즉 모집중인 상태로 RecruitPlanner
를 만들고 있었고, 이를 사용하는 서비스단에서는 이렇게 생성한 RecruitPlanner
를 가지고 Study
를 생성해 저장해주고 있었다.
@Service
@Transactional
public class StudyService {
private final StudyRepository studyRepository;
private final MemberRepository memberRepository;
private final DateTimeSystem dateTimeSystem;
public StudyService(final StudyRepository studyRepository,
final MemberRepository memberRepository,
final DateTimeSystem dateTimeSystem
) {
this.studyRepository = studyRepository;
this.memberRepository = memberRepository;
this.dateTimeSystem = dateTimeSystem;
}
public Study createStudy(final Long githubId, final StudyRequest request) {
final LocalDateTime createdAt = dateTimeSystem.now();
final Member owner = findMemberBy(githubId);
final Participants participants = request.mapToParticipants(owner.getId());
final RecruitPlanner recruitPlanner = request.mapToRecruitPlan();
final StudyPlanner studyPlanner = request.mapToStudyPlanner(createdAt.toLocalDate());
final AttachedTags attachedTags = request.mapToAttachedTags();
final Content content = request.mapToContent();
return studyRepository.save(
new Study(content, participants, recruitPlanner, studyPlanner, attachedTags, createdAt));
}
...
}
그러니 당연하게도 Study 생성시에 스터디 최대 인원이 1명인 경우(혼자 스터디 하는 경우는 없겠지만..어쨋든 스터디장도 스터디 인원에 포함이므로..) 스터디 모집중인 상태로 저장되게 되는 것이었다. (위의 StudyRequest가 CreatingStudyRequest와 동일한 클래스이며 로직도 크게 변경되지 않았다. 다만 해당 클래스를 update 시에도(스터디 상세 정보 수정) 동일하게 필요하게 되어 네이밍을 변경해주었다.) 그래서 다음과 같이 StudyRequest 의 로직 중 mapToRecruitPlan()
메소드만을 다음과 같이 수정해주었다.
public RecruitPlanner mapToRecruitPlan() {
if (maxMemberCount != null && maxMemberCount == 1) {
return new RecruitPlanner(maxMemberCount, RECRUITMENT_END, enrollmentEndDate);
}
return new RecruitPlanner(maxMemberCount, RECRUITMENT_START, enrollmentEndDate);
}
스터디 최대인원은 optional한 값이다. 즉 사용자가 모집 기한만을 통해서 스터디를 모집하겠다라고 하면 스터디 최대인원을 기입하지 않을 수도 있다. 따라서 먼저 null 검사를 해준 뒤에 스터디 최대인원이 1명(스터디 생성시에 최대인원으로 인해서 모집 마감이 되는 경우는 최대인원이 1명인 케이스밖에 없다.)과 같으면 RECRUITMENT_END
상태로 설정해주었다.
이제 jdbcTemplate 을 제거한 과정을 이야기해보려고 한다.
해당 부분을 개선하게 된 이유는 테스트 준비
작업이 굉장히 비대해졌기 때문이다.
특히나 Study
와 관련된 준비가 거의 모든 테스트에서 필요해졌다. 에를 들어 커뮤니티에 대한 테스트를 진행한다고 하자. 하지만 이는 비즈니스적으로 Study에 종속적이기 때문에 스터디에 대한 생성이 필요한데 이 때, 스터디는 스터디 참여자나 스터디 태그등 다른 자원들 또한 필요하다. 따라서 테스트 준비
를 위한 작업이 굉장히 비대해졌으며 다른 거의 모든 테스트에서 Study 관련된 테스트가 필요해지자 이를 편리하게 하기 위해 Fixture 를 고려하면서 리팩토링을 진행하게 되었다.
그리고 무엇보다 jdbcTemplate 은 테스트 준비를 위해서는 전혀 가독성이 없다.
예시로 jdbcTemplate
을 사용하던 MyStudyDaoTest
를 보자.
@RepositoryTest
class MyStudyDaoTest {
@Autowired
private MyStudyDao myStudyDao;
@Autowired
private JdbcTemplate jdbcTemplate;
@BeforeEach
void initDataBase() {
jdbcTemplate.update("INSERT INTO member(id, github_id, username, image_url, profile_url) VALUES (1, 1, 'jjanggu', 'https://image', 'github.com')");
jdbcTemplate.update("INSERT INTO member(id, github_id, username, image_url, profile_url) VALUES (2, 2, 'greenlawn', 'https://image', 'github.com')");
jdbcTemplate.update("INSERT INTO member(id, github_id, username, image_url, profile_url) VALUES (3, 3, 'dwoo', 'https://image', 'github.com')");
jdbcTemplate.update("INSERT INTO member(id, github_id, username, image_url, profile_url) VALUES (4, 4, 'verus', 'https://image', 'github.com')");
final LocalDateTime now = LocalDateTime.now();
jdbcTemplate.update("INSERT INTO study(id, title, excerpt, thumbnail, recruitment_status, study_status, description, current_member_count, max_member_count, created_at, start_date, owner_id) "
+ "VALUES (1, 'Java 스터디', '자바 설명', 'java thumbnail', 'RECRUITMENT_START', 'PREPARE', '그린론의 우당탕탕 자바 스터디입니다.', 3, 10, '" + now + "', '2021-12-08', 2)");
jdbcTemplate.update("INSERT INTO study(id, title, excerpt, thumbnail, recruitment_status, study_status, description, current_member_count, max_member_count, created_at, enrollment_end_date, start_date, end_date, owner_id) "
+ "VALUES (2, 'React 스터디', '리액트 설명', 'react thumbnail', 'RECRUITMENT_START', 'PREPARE', '디우의 뤼액트 스터디입니다.', 4, 5, '" + now + "', '2021-11-09', '2021-11-10', '2021-12-08', 3)");
jdbcTemplate.update("INSERT INTO study(id, title, excerpt, thumbnail, recruitment_status, study_status, description, current_member_count, max_member_count, created_at, start_date, owner_id) "
+ "VALUES (3, 'javaScript 스터디', '자바스크립트 설명', 'javascript thumbnail', 'RECRUITMENT_START', 'PREPARE', '그린론의 자바스크립트 접해보기', 3, 20, '" + now + "', '2022-08-03', 2)");
jdbcTemplate.update("INSERT INTO study(id, title, excerpt, thumbnail, recruitment_status, study_status, description, max_member_count, created_at, start_date, owner_id) "
+ "VALUES (4, 'HTTP 스터디', 'HTTP 설명', 'http thumbnail', 'RECRUITMENT_END', 'PREPARE', '디우의 HTTP 정복하기', 5, '" + now + "', '2022-08-03', 3)");
jdbcTemplate.update("INSERT INTO study(id, title, excerpt, thumbnail, recruitment_status, study_status, description, current_member_count, created_at, owner_id, start_date) "
+ "VALUES (5, '알고리즘 스터디', '알고리즘 설명', 'algorithm thumbnail', 'RECRUITMENT_END', 'PREPARE', '알고리즘을 TDD로 풀자의 베루스입니다.', 1, '" + now + "', 4, '2021-12-06')");
jdbcTemplate.update("INSERT INTO study(id, title, excerpt, thumbnail, recruitment_status, study_status, description, current_member_count, created_at, owner_id, start_date, enrollment_end_date, end_date) "
+ "VALUES (6, 'Linux 스터디', '리눅스 설명', 'linux thumbnail', 'RECRUITMENT_END', 'PREPARE', 'Linux를 공부하자의 베루스입니다.', 1, '" + now + "', 4, '2021-12-06', '2021-12-07', '2022-01-07')");
jdbcTemplate.update("INSERT INTO study(id, title, excerpt, thumbnail, recruitment_status, study_status, description, current_member_count, created_at, owner_id, start_date, enrollment_end_date, end_date) "
+ "VALUES (7, 'OS 스터디', 'OS 설명', 'os thumbnail', 'RECRUITMENT_END', 'PREPARE', 'OS를 공부하자의 그린론입니다.', 1, '" + now + "', 2, '2021-12-06', '2021-12-07', '2022-01-07')");
jdbcTemplate.update("INSERT INTO study_tag(study_id, tag_id) VALUES (1, 1)");
jdbcTemplate.update("INSERT INTO study_tag(study_id, tag_id) VALUES (1, 2)");
jdbcTemplate.update("INSERT INTO study_tag(study_id, tag_id) VALUES (1, 3)");
jdbcTemplate.update("INSERT INTO study_tag(study_id, tag_id) VALUES (2, 2)");
jdbcTemplate.update("INSERT INTO study_tag(study_id, tag_id) VALUES (2, 4)");
jdbcTemplate.update("INSERT INTO study_tag(study_id, tag_id) VALUES (2, 5)");
jdbcTemplate.update("INSERT INTO study_tag(study_id, tag_id) VALUES (3, 2)");
jdbcTemplate.update("INSERT INTO study_tag(study_id, tag_id) VALUES (3, 4)");
jdbcTemplate.update("INSERT INTO study_tag(study_id, tag_id) VALUES (4, 2)");
jdbcTemplate.update("INSERT INTO study_tag(study_id, tag_id) VALUES (4, 3)");
jdbcTemplate.update("INSERT INTO study_member(study_id, member_id) VALUES (1, 3)");
jdbcTemplate.update("INSERT INTO study_member(study_id, member_id) VALUES (1, 4)");
jdbcTemplate.update("INSERT INTO study_member(study_id, member_id) VALUES (2, 1)");
jdbcTemplate.update("INSERT INTO study_member(study_id, member_id) VALUES (2, 2)");
jdbcTemplate.update("INSERT INTO study_member(study_id, member_id) VALUES (2, 4)");
jdbcTemplate.update("INSERT INTO study_member(study_id, member_id) VALUES (3, 3)");
jdbcTemplate.update("INSERT INTO study_member(study_id, member_id) VALUES (3, 4)");
}
@DisplayName("내가 참여한 스터디 목록을 조회한다.")
@Test
void getMyStudies() {
final List<MyStudySummaryData> studySummaryData = myStudyDao.findMyStudyByMemberId(2L);
assertThat(studySummaryData)
.hasSize(4)
.filteredOn(myStudySummaryData -> myStudySummaryData.getId() != null)
.extracting("title", "studyStatus", "currentMemberCount", "maxMemberCount", "startDate", "endDate")
.contains(
tuple("Java 스터디", PREPARE, 3, 10, "2021-12-08", null),
tuple("javaScript 스터디" ,PREPARE, 3, 20, "2022-08-03", null),
tuple("React 스터디", PREPARE, 4, 5, "2021-11-10", "2021-12-08"),
tuple("OS 스터디", PREPARE, 1, null, "2021-12-06", "2022-01-07")
);
}
@DisplayName("스터디 ID가 비어있을 경우, 스터디 방장 빈 맵을 반환한다.")
@Test
void findStudyOwnersByEmptyStudyId() {
final Map<Long, MemberData> owners = myStudyDao.findOwners(List.of());
assertThat(owners.size()).isZero();
}
}
실제 테스트하려는 것은 내가 참여한 스터디 목록을 조회한다.
와 스터디 ID가 빈 경우 스턴디 방장 빈 맵을 반환한다.
라고 하는 2가지 동작 밖에 없다. 하지만 이를 준비하기 위해서는 굉장히 긴 초기 준비 작업을 필요로 한다.
심지어는 이것이 한 눈에 들어오지도 않으며 무엇을 하려고 하는지도 모르겠다. 그리고 이를 이해하기 위해서 우리의 DB 테이블이 어떻게 구성되어있는지 또한 이해해주어어야 하며 우리의 비즈니스를 완전히 이해하고 있어야한다.
우선 Study를 생성하기 위해서는 스터디에 붙는 태그들 또한 필요하게 되며, 스터디 방장과 스터디의 참여자가 필요하다. 그리고 우리는 이 모든것을 jdbcTemplate의 update() 메소드를 통해서 일일이 다 넣어주고 있었다. 굉장히 별로인 코드다.
우리는 이를 Fixture 를 이용하여 다음과 같이 수정하였다.
@RepositoryTest
class MyStudyDaoTest {
@Autowired
private MyStudyDao myStudyDao;
@Autowired
private MemberRepository memberRepository;
@Autowired
private StudyRepository studyRepository;
@Autowired
private EntityManager entityManager;
private Member 짱구;
private Member 그린론;
private Member 디우;
private Member 베루스;
private Study 자바_스터디;
private Study 리액트_스터디;
private Study 자바스크립트_스터디;
private Study HTTP_스터디;
private Study 알고리즘_스터디;
private Study 리눅스_스터디;
@BeforeEach
void initDataBase() {
짱구 = memberRepository.save(짱구());
그린론 = memberRepository.save(그린론());
디우 = memberRepository.save(디우());
베루스 = memberRepository.save(베루스());
자바_스터디 = studyRepository.save(자바_스터디(짱구.getId(), Set.of(그린론.getId(), 디우.getId())));
리액트_스터디 = studyRepository.save(리액트_스터디(디우.getId(), Set.of(짱구.getId(), 그린론.getId(), 베루스.getId())));
자바스크립트_스터디 = studyRepository.save(자바스크립트_스터디(그린론.getId(), Set.of(디우.getId(), 베루스.getId())));
HTTP_스터디 = studyRepository.save(HTTP_스터디(디우.getId(), Set.of(베루스.getId(), 짱구.getId())));
알고리즘_스터디 = studyRepository.save(알고리즘_스터디(베루스.getId(), Set.of(그린론.getId(), 디우.getId())));
리눅스_스터디 = studyRepository.save(리눅스_스터디(베루스.getId(), Set.of(그린론.getId(), 디우.getId())));
entityManager.flush();
}
@DisplayName("내가 참여한 스터디 목록을 조회한다.")
@Test
void getMyStudies() {
final List<MyStudySummaryData> studySummaryData = myStudyDao.findMyStudyByMemberId(그린론.getId());
assertThat(studySummaryData)
.hasSize(5)
.filteredOn(myStudySummaryData -> myStudySummaryData.getId() != null)
.extracting("title", "studyStatus", "currentMemberCount", "maxMemberCount", "startDate", "endDate")
.containsExactlyInAnyOrder(
tuple(자바_스터디_내용.getTitle(), 자바_스터디_계획.getStudyStatus(), 자바_스터디_참가자들.getSize(),
자바_스터디_모집계획.getMax(), 자바_스터디_계획.getStartDate().toString(),
자바_스터디_계획.getEndDate().toString()),
tuple(리액트_스터디_내용.getTitle(), 리액트_스터디_계획.getStudyStatus(), 리액트_스터디_참가자들.getSize(),
리액트_스터디_모집계획.getMax(), 리액트_스터디_계획.getStartDate().toString(),
자바_스터디_계획.getEndDate().toString()),
tuple(자바스크립트_스터디_내용.getTitle(), 자바스크립트_스터디_계획.getStudyStatus(), 자바스크립트_스터디_참가자들.getSize(),
자바스크립트_스터디_모집계획.getMax(), 자바스크립트_스터디_계획.getStartDate().toString(),
자바스크립트_스터디_계획.getEndDate().toString()),
tuple(알고리즘_스터디_내용.getTitle(), 알고리즘_스터디_계획.getStudyStatus(), 알고리즘_스터디_참가자들.getSize(),
알고리즘_스터디_모집계획.getMax(), 알고리즘_스터디_계획.getStartDate().toString(), null),
tuple(리눅스_스터디_내용.getTitle(), 리눅스_스터디_계획.getStudyStatus(), 리눅스_스터디_참가자들.getSize(),
리눅스_스터디_모집계획.getMax(), 리눅스_스터디_계획.getStartDate().toString(),
리눅스_스터디_계획.getEndDate().toString())
);
}
@DisplayName("스터디 ID가 비어있을 경우, 스터디 방장 빈 맵을 반환한다.")
@Test
void findStudyOwnersByEmptyStudyId() {
final Map<Long, MemberData> owners = myStudyDao.findOwners(List.of());
assertThat(owners).isEmpty();
}
@DisplayName("스터디 ID가 비어있을 경우, 스터디 태그 빈 맵을 반환한다.")
@Test
void findStudyTagsByEmptyStudyId() {
final Map<Long, List<TagSummaryData>> tags = myStudyDao.findTags(List.of());
assertThat(tags).isEmpty();
}
}
짱구, 그린론, 디우, 베루스라는 member들을 저장한다. 그리고 자바 스터디, 리액트 스터디 등등을 생성한다. 이 때 매개변수로 스터디장의 id와 스터디 참여자들의 memberId를 넘겨주고 있다. 아직 자바_스터디 생성시에 넘기는 인자들이 무엇을 의미하는지는 잘 모를 수는 있지만 이전보다 훨씬 가독성이 좋아진 것을 알 수 있다.
여기에 더해서 JPA 의 장점 또한 누릴 수 있는데, 스터디를 저장하면서 동시에 스터디 참여자들(study_member 테이블)을 저장할 수 있는 것이다. 이전에는 각 테이블 별로 insert 문을 작성해 날렸어야하지만, JPA 를 활용하여 테스트 준비 작업을 진행하게 되면서 JPA가 알아서 객체를 저장할 때 연관된 테이블이 있다면 insert 문을 날려주게 되는 것이다. 보다 객체지향적으로 우리는 테스트를 준비할 수 있게 되었다.
그리고 이 때, 사용한 Fixture는 다음과 같이 구성되어 있다.
public class MemberFixtures {
/* 짱구 */
public static final Long 짱구_아이디 = 1L;
public static final Long 짱구_깃허브_아이디 = 1L;
public static final String 짱구_유저네임 = "jjanggu";
public static final String 짱구_이미지 = "https://jjanggu.png";
public static final String 짱구_프로필 = "https://jjanggu.com";
public static final MemberData 짱구_응답 = new MemberData(짱구_깃허브_아이디, 짱구_유저네임, 짱구_이미지, 짱구_프로필);
/* 그린론 */
public static final Long 그린론_아이디 = 2L;
public static final Long 그린론_깃허브_아이디 = 2L;
public static final String 그린론_유저네임 = "greenlawn";
public static final String 그린론_이미지 = "https://greenlawn.png";
public static final String 그린론_프로필 = "https://greenlawn.com";
public static final MemberData 그린론_응답 = new MemberData(그린론_깃허브_아이디, 그린론_유저네임, 그린론_이미지, 그린론_프로필);
/* 디우 */
public static final Long 디우_아이디 = 3L;
public static final Long 디우_깃허브_아이디 = 3L;
public static final String 디우_유저네임 = "dwoo";
public static final String 디우_이미지 = "https://dwoo.png";
public static final String 디우_프로필 = "https://dwoo.com";
public static final MemberData 디우_응답 = new MemberData(디우_깃허브_아이디, 디우_유저네임, 디우_이미지, 디우_프로필);
/* 베루스 */
public static final Long 베루스_아이디 = 4L;
public static final Long 베루스_깃허브_아이디 = 4L;
public static final String 베루스_유저네임 = "verus";
public static final String 베루스_이미지 = "https://verus.png";
public static final String 베루스_프로필 = "https://verus.com";
public static final MemberData 베루스_응답 = new MemberData(베루스_깃허브_아이디, 베루스_유저네임, 베루스_이미지, 베루스_프로필);
public static Member 짱구() {
return new Member(짱구_깃허브_아이디, 짱구_유저네임, 짱구_이미지, 짱구_프로필);
}
public static Member 그린론() {
return new Member(그린론_깃허브_아이디, 그린론_유저네임, 그린론_이미지, 그린론_프로필);
}
public static Member 디우() {
return new Member(디우_깃허브_아이디, 디우_유저네임, 디우_이미지, 디우_프로필);
}
public static Member 베루스() {
return new Member(베루스_깃허브_아이디, 베루스_유저네임, 베루스_이미지, 베루스_프로필);
}
}
public class StudyFixtures {
/* 자바 스터디 */
public static final Member 자바_스터디장 = new Member(짱구_아이디, 짱구_깃허브_아이디, 짱구_유저네임, 짱구_이미지, 짱구_프로필);
public static final Content 자바_스터디_내용 = new Content("신짱구의 자바의 정석", "자바 스터디 요약", "자바 스터디 썸네일", "자바 스터디 설명입니다.");
public static final Participants 자바_스터디_참가자들 = new Participants(짱구_아이디, Set.of(그린론_아이디, 디우_아이디));
public static final RecruitPlanner 자바_스터디_모집계획 = new RecruitPlanner(10, RECRUITMENT_START, LocalDate.now());
public static final StudyPlanner 자바_스터디_계획 = new StudyPlanner(LocalDate.now().plusMonths(1), LocalDate.now().plusMonths(2), IN_PROGRESS);
public static final AttachedTags 자바_스터디_태그 = new AttachedTags(List.of(new AttachedTag(자바_태그_아이디), new AttachedTag(우테코4기_태그_아이디), new AttachedTag(BE_태그_아이디)));
public static final Study 자바_스터디 = new Study(자바_스터디_내용, 자바_스터디_참가자들, 자바_스터디_모집계획,
자바_스터디_계획, 자바_스터디_태그, LocalDateTime.now());
public static Study 자바_스터디(final Long ownerId, final Set<Long> participants) {
return new Study(자바_스터디_내용, new Participants(ownerId, participants), 자바_스터디_모집계획,
자바_스터디_계획, 자바_스터디_태그, LocalDateTime.now());
}
/* 리액트 스터디 */
public static final Member 리액트_스터디장 = new Member(디우_아이디, 디우_깃허브_아이디, 디우_유저네임, 디우_이미지, 디우_프로필);
public static final Content 리액트_스터디_내용 = new Content("디우의 이것이 리액트다.", "리액트 스터디 요약", "리액트 스터디 썸네일", "리액트 스터디 설명입니다.");
public static final Participants 리액트_스터디_참가자들 = new Participants(디우_아이디, Set.of(짱구_아이디, 그린론_아이디, 베루스_아이디));
public static final RecruitPlanner 리액트_스터디_모집계획 = new RecruitPlanner(5, RECRUITMENT_START, LocalDate.now());
public static final StudyPlanner 리액트_스터디_계획 = new StudyPlanner(LocalDate.now().plusMonths(1), LocalDate.now().plusMonths(2), PREPARE);
public static final AttachedTags 리액트_스터디_태그 = new AttachedTags(List.of(new AttachedTag(우테코4기_태그_아이디), new AttachedTag(FE_태그_아이디), new AttachedTag(리액트_태그_아이디)));
public static final Study 리액트_스터디 = new Study(리액트_스터디_내용, 리액트_스터디_참가자들, 리액트_스터디_모집계획,
리액트_스터디_계획, 리액트_스터디_태그, LocalDateTime.now());
public static Study 리액트_스터디(final Long ownerId, final Set<Long> participants) {
return new Study(리액트_스터디_내용, new Participants(ownerId, participants), 리액트_스터디_모집계획,
리액트_스터디_계획, 리액트_스터디_태그, LocalDateTime.now());
}
/* 자바스크립트스크립트 스터디 */
public static final Member 자바스크립트_스터디장 = new Member(그린론_아이디, 그린론_깃허브_아이디, 그린론_유저네임, 그린론_이미지, 그린론_프로필);
public static final Content 자바스크립트_스터디_내용 = new Content("그린론의 모던 자바스크립트 인 액션", "자바스크립트 스터디 요약", "자바스크립트 스터디 썸네일", "자바스크립트 스터디 설명입니다.");
public static final Participants 자바스크립트_스터디_참가자들 = new Participants(그린론_아이디, Set.of(디우_아이디, 베루스_아이디));
public static final RecruitPlanner 자바스크립트_스터디_모집계획 = new RecruitPlanner(20, RECRUITMENT_START, LocalDate.now());
public static final StudyPlanner 자바스크립트_스터디_계획 = new StudyPlanner(LocalDate.now().plusMonths(1), LocalDate.now().plusMonths(2), PREPARE);
public static final AttachedTags 자바스크립트_스터디_태그 = new AttachedTags(List.of(new AttachedTag(우테코4기_태그_아이디), new AttachedTag(FE_태그_아이디)));
public static final Study 자바스크립트_스터디 = new Study(자바스크립트_스터디_내용, 자바스크립트_스터디_참가자들, 자바스크립트_스터디_모집계획,
자바스크립트_스터디_계획, 자바스크립트_스터디_태그, LocalDateTime.now());
public static Study 자바스크립트_스터디(final Long ownerId, final Set<Long> participants) {
return new Study(자바스크립트_스터디_내용, new Participants(ownerId, participants), 자바스크립트_스터디_모집계획,
자바스크립트_스터디_계획, 자바스크립트_스터디_태그, LocalDateTime.now());
}
/* HTTP 스터디 */
public static final Member HTTP_스터디장 = new Member(디우_아이디, 디우_깃허브_아이디, 디우_유저네임, 디우_이미지, 디우_프로필);
public static final Content HTTP_스터디_내용 = new Content("디우의 HTTP", "HTTP 스터디 요약", "HTTP 스터디 썸네일", "HTTP 스터디 설명입니다.");
public static final Participants HTTP_스터디_참가자들 = new Participants(디우_아이디, Set.of(베루스_아이디, 짱구_아이디));
public static final RecruitPlanner HTTP_스터디_모집계획 = new RecruitPlanner(4, RECRUITMENT_END, LocalDate.now());
public static final StudyPlanner HTTP_스터디_계획 = new StudyPlanner(LocalDate.now().plusMonths(1), LocalDate.now().plusMonths(2), PREPARE);
public static final AttachedTags HTTP_스터디_태그 = new AttachedTags(List.of(new AttachedTag(우테코4기_태그_아이디), new AttachedTag(BE_태그_아이디)));
public static final Study HTTP_스터디 = new Study(HTTP_스터디_내용, HTTP_스터디_참가자들, HTTP_스터디_모집계획,
HTTP_스터디_계획, HTTP_스터디_태그, LocalDateTime.now());
public static Study HTTP_스터디(final Long ownerId, final Set<Long> participants) {
return new Study(HTTP_스터디_내용, new Participants(ownerId, participants), HTTP_스터디_모집계획,
HTTP_스터디_계획, HTTP_스터디_태그, LocalDateTime.now());
}
/* 알고리즘 스터디 (모집 기간과 스터디 종료일자가 없음) */
public static final Member 알고리즘_스터디장 = new Member(베루스_아이디, 베루스_깃허브_아이디, 베루스_유저네임, 베루스_이미지, 베루스_프로필);
public static final Content 알고리즘_스터디_내용 = new Content("알고리즘 주도 개발 1타 강사 베루스", "알고리즘 스터디 요약", "알고리즘 스터디 썸네일", "알고리즘 스터디 설명입니다.");
public static final Participants 알고리즘_스터디_참가자들 = new Participants(베루스_아이디, Set.of(그린론_아이디, 디우_아이디));
public static final RecruitPlanner 알고리즘_스터디_모집계획 = new RecruitPlanner(null, RECRUITMENT_END, null);
public static final StudyPlanner 알고리즘_스터디_계획 = new StudyPlanner(LocalDate.now().plusMonths(1), null, PREPARE);
public static final AttachedTags 알고리즘_스터디_태그 = new AttachedTags(List.of());
public static final Study 알고리즘_스터디 = new Study(알고리즘_스터디_내용, 알고리즘_스터디_참가자들, 알고리즘_스터디_모집계획,
알고리즘_스터디_계획, 알고리즘_스터디_태그, LocalDateTime.now());
public static Study 알고리즘_스터디(final Long ownerId, final Set<Long> participants) {
return new Study(알고리즘_스터디_내용, new Participants(ownerId, participants), 알고리즘_스터디_모집계획,
알고리즘_스터디_계획, 알고리즘_스터디_태그, LocalDateTime.now());
}
/* 리눅스 스터디 (최대 인원 없음) */
public static final Member 리눅스_스터디장 = new Member(베루스_아이디, 베루스_깃허브_아이디, 베루스_유저네임, 베루스_이미지, 베루스_프로필);
public static final Content 리눅스_스터디_내용 = new Content("벨우스의 린우스", "리눅스 스터디 요약", "리눅스 스터디 썸네일", "리눅스 스터디 설명입니다.");
public static final Participants 리눅스_스터디_참가자들 = new Participants(베루스_아이디, Set.of(그린론_아이디, 디우_아이디));
public static final RecruitPlanner 리눅스_스터디_모집계획 = new RecruitPlanner(null, RECRUITMENT_START, LocalDate.now());
public static final StudyPlanner 리눅스_스터디_계획 = new StudyPlanner(LocalDate.now().plusMonths(1), LocalDate.now().plusMonths(2), PREPARE);
public static final AttachedTags 리눅스_스터디_태그 = new AttachedTags(List.of());
public static final Study 리눅스_스터디 = new Study(리눅스_스터디_내용, 리눅스_스터디_참가자들, 리눅스_스터디_모집계획,
리눅스_스터디_계획, 리눅스_스터디_태그, LocalDateTime.now());
public static Study 리눅스_스터디(final Long ownerId, final Set<Long> participants) {
return new Study(리눅스_스터디_내용, new Participants(ownerId, participants), 리눅스_스터디_모집계획,
리눅스_스터디_계획, 리눅스_스터디_태그, LocalDateTime.now());
}
/* OS 스터디 */
public static final Member OS_스터디장 = new Member(디우_아이디, 디우_깃허브_아이디, 디우_유저네임, 디우_이미지, 디우_프로필);
public static final Content OS_스터디_내용 = new Content("디우의 OS 스터디", "OS 스터디 요약", "OS 스터디 썸네일", "OS 스터디 설명입니다.");
public static final Participants OS_스터디_참가자들 = new Participants(디우_아이디, Set.of(그린론_아이디, 짱구_아이디, 베루스_아이디));
public static final RecruitPlanner OS_스터디_모집계획 = new RecruitPlanner(10, RECRUITMENT_START, LocalDate.now());
public static final StudyPlanner OS_스터디_계획 = new StudyPlanner(LocalDate.now().plusMonths(1), LocalDate.now().plusMonths(2), PREPARE);
public static final AttachedTags OS_스터디_태그 = new AttachedTags(List.of(new AttachedTag(우테코4기_태그_아이디), new AttachedTag(BE_태그_아이디)));
public static Study OS_스터디(final Long ownerId, final Set<Long> participants) {
return new Study(OS_스터디_내용, new Participants(ownerId, participants), OS_스터디_모집계획,
OS_스터디_계획, OS_스터디_태그, LocalDateTime.now());
}
public static StudyRequest 자바_스터디_신청서(LocalDate now) {
return StudyRequest.builder()
.title("java 스터디").excerpt("자바 설명").thumbnail("java image").description("자바 소개")
.startDate(now)
.build();
}
public static StudyRequest 자바_스터디_신청서(List<Long> tagIds, int maxMemberCount, LocalDate now) {
return StudyRequest.builder()
.title("Java 스터디").excerpt("자바 설명").thumbnail("java thumbnail").description("그린론의 우당탕탕 자바 스터디입니다.")
.startDate(now).tagIds(tagIds).maxMemberCount(maxMemberCount)
.build();
}
public static StudyRequest 리액트_스터디_신청서(LocalDate now) {
return StudyRequest.builder()
.title("react 스터디").excerpt("리액트 설명").thumbnail("react image").description("리액트 소개")
.startDate(now)
.build();
}
public static StudyRequest 리액트_스터디_신청서(List<Long> tagIds, int maxMemberCount, LocalDate now) {
return StudyRequest.builder()
.title("React 스터디").excerpt("리액트 설명").thumbnail("react thumbnail").description("디우의 뤼액트 스터디입니다.")
.startDate(LocalDate.now()).endDate(now).enrollmentEndDate(LocalDate.now())
.tagIds(tagIds).maxMemberCount(maxMemberCount)
.build();
}
public static StudyRequest 자바스크립트_스터디_신청서(List<Long> tagIds, LocalDate now) {
return StudyRequest.builder()
.title("javaScript 스터디").excerpt("자바스크립트 설명").thumbnail("javascript thumbnail").description("자바스크립트 설명")
.startDate(now).tagIds(tagIds)
.build();
}
public static StudyRequest HTTP_스터디_신청서(List<Long> tagIds, LocalDate now) {
return StudyRequest.builder()
.title("HTTP 스터디").excerpt("HTTP 설명").thumbnail("http thumbnail").description("HTTP 설명")
.startDate(now).tagIds(tagIds)
.build();
}
public static StudyRequest 알고리즘_스터디_신청서(List<Long> tagIds, LocalDate now) {
return StudyRequest.builder()
.title("알고리즘 스터디").excerpt("알고리즘 설명").thumbnail("algorithm thumbnail").description("알고리즘 설명")
.startDate(now).tagIds(tagIds)
.build();
}
}
여기서 member를 static한 하나의 객체를 만들어주지 않고 호출시마다 생성하는 이유가 궁금할 수 있다.
(구체적으로는 id를 가지지 않는다는게 핵심이다. id를 가지지 않는 Member 객체를 매번 생성해서 반환한다.)
그 이유는 JPA를 통해서 저장을 하고 있고, 그러한 객체들이 모두 Auto Increment
값으로 PK(id)를 가지고 있기 때문이다.
예를 들어 다음과 같은 객체를 생각해보자.
public static final Member 디우 = new Member(디우_아이디, 디우_깃허브_아이디, 디우_유저네임, 디우_이미지, 디우_프로필);
그리고 이 때, 디우_아이디
는 PK이며 3L
이라는 값이라고 하자.
그런데 만약 @BeforeEach
가 붙은 메소드, 즉 테스트 준비에 해당하는 부분에서 디우를 Member 들 중 맨 처음으로 저장하면 어떻게 될까? 우선 현재 DB에 3L 이라는 값이 있는지를 확인한다. 즉 우선적으로 select 구문이 나가게 된다. 하지만 현재 DB에 3L 에 해당하는 Member 가 없으므로 merge가 일어나지 않고 persist 가 수행된다.
위의 사진은 이를 테스트하기 위한 간단한 테스트를 작성한 모습이다. 아래 쿼리문이 나간 것과 sout 으로 출력하는 구문을 보자.
가장 먼저 (그림에서 짤리긴 했지만) member 객체가 id값을 가지고 있기 때문에 select문이 날아간다. 하지만 DB에는 없다는 결과가 나오게 되고, 따라서 merge가 아닌 persist 가 일어나게 된다. 그러므로 Auto Increment에 따라서 id에 default(자동 증가) 가 들어간 insert 쿼리가 날아가게 되고 그 결과 id 값이 1인 member 가 저장되게 되는 결과를 우리는 sout 을 통해서 확인할 수 있다.
여기서 문제가 발생한다. 만약 Study에 owner로써 우리는 디우
를 저장했다고 해보자.
그리고 검증문에서 다음과 같은 검증문을 사용한다고 생각해보자.
assertThat(participants.getOwnerId()).isEqualTo(디우.getId());
이 때, 디우.getId()
는 Fixture 로써 우리가 미리 메모리에 만들어 두었으므로 3L
이라는 값으로 고정적이다. 하지만 여기에 우리가 스터디 생성시에 디우
라는 Fixture를 사용했는지와는 무관하게 DB에 저장되는 순간 어떤 id값이 할당되었을지 우리는 알 수 없다.
즉 테스트 픽스쳐의 다음과 같은 정의에서 매번 동일한 결과를 얻을 수 있게..
라는 말에도 어긋나는 것이다.
(물론 메모리에 저장된 객체 자체는 매번 동일한 결과지만 이를 DB에 저장한 이후에 는 매번 동일한 결과를 얻을 것이라고 기대하기 어렵다. 위의 ownerId
와 디우.getId()
를 비교하는 것과 같이..)
테스트 픽스쳐란 테스트를 반복적으로 수행할 수 있게 도와주고 매번 동일한 결과를 얻을 수 있게 도와주는 기반이 되는 상태나 환경을 의미한다. 여러 테스트에서 공용으로 사용할 수 있는 테스트 픽스쳐는 테스트의 인스턴스 변수 혹은 별도의 클래스로 모아둔다.
따라서 id 값이 없는 객체를 생성하는 메소드를 static final
하게 선언해놓고 필요한 곳에서 해당 메소드를 호출해 사용하도록 하였다. 그리고 해당 메소드에서 반환되는 객체를 저장하여 auto-increment 하는 id 값을 사용할 수 있도록 해주었다.
결론적으로 이렇게 해서 훨씬 가독성 있는 테스트 준비 작업을 구성할 수 있도록 해주었으며, 테스트 픽스쳐 또한 id(식별자)를 애초에 가지고 있는 객체를 활용하는 것이 아니라 이를 포함하지 않는 객체를 생성해주는 메소드를 활용할 수 있도록 보완해주는 리팩토링 과정을 진행하였다. 프로덕션 코드 뿐 아니라 테스트 코드 또한 리팩토링 대상이라는 것을 이전보다 훨씬 강하게 느낄 수 있는 시간이었다.
[BE] issue245: 스터디 가입날짜 및 스터디 개수
기존에 스터디를 조회하면 위와같이 해당 유저가 가입한 스터디의 갯수와 스터디에 가입한 날짜를 함께 조회하지 않고 있어서 프런트에서 임의의 날짜와 함께 10개라는 숫자를 보여주고 있었다.
가장 개선하고 싶은 기능이기도 했으면서 스터디 가입날짜와 스터디의 갯수를 기존의 조회해오던 데이터와 함께 조회해오는 과정이 굉장히 즐거울 것 같았기 때문에 해당 기능을 맡아 진행하게 되었다.
최종적으로 도출해야하는 API 명세는 다음과 같다.
기존의 우리는 스터디장과 스터디 멤버들을 owner 와 member로 별도로 반환해주고 있었기 때문에 이를 지켜야했으며, owner와 members 구분 없이 둘 모두에 대해서 parsiticpationDate
와 numberOfStudy
필드를 추가해주어야했다.
처음에는 단순하게 생각하고 접근하였다. Service에서 가져온 방장과 참여자들의 정보(memberId)로 필요한 정보를 가져오면 된다고 생각하였다. 우선 날짜정보의 경우 study_member
테이블의 생성된 날짜, 즉 participation_date
컬럼을 추가해주고 이를 가져오면 되고, 방장의 경우에는 study의 created_at
이 곧 스터디에 참여한 날짜가 되므로 이를 가져오면 된다. 또한 참여한 스터디의 갯수의 경우에는 각각의 유저들(memberId)에 대해서 해당하는 study
테이블의 owner_id
와 study_member
테이블의 member_id
가 같은 컬럼들을 count하면 되는 것이었다.
CREATE TABLE study
(
id BIGINT PRIMARY KEY AUTO_INCREMENT,
title VARCHAR(30) NOT NULL,
excerpt VARCHAR(50) NOT NULL,
thumbnail VARCHAR(255) NOT NULL,
recruitment_status VARCHAR(255) NOT NULL,
study_status VARCHAR(255) NOT NULL,
description MEDIUMTEXT,
current_member_count INTEGER DEFAULT 1,
max_member_count INTEGER,
created_at DATETIME not null,
enrollment_end_date DATE,
start_date DATE not null,
end_date DATE,
owner_id BIGINT NOT NULL,
FOREIGN KEY (owner_id) REFERENCES member (id)
);
CREATE TABLE study_member
(
id BIGINT PRIMARY KEY AUTO_INCREMENT,
study_id BIGINT,
member_id BIGINT,
participation_date DATE not null,
FOREIGN KEY (study_id) REFERENCES study (id),
FOREIGN KEY (member_id) REFERENCES member (id)
);
그럼 이쯤에서 기존 Service단의 로직을 보자.
public StudyDetailResponse getStudyDetails(final Long studyId) {
final StudyDetailsData content = studyDetailsDao.findBy(studyId)
.orElseThrow(StudyNotFoundException::new);
final List<MemberData> participants = memberDao.findMembersByStudyId(studyId);
final List<TagData> attachedTags = tagDao.findTagsByStudyId(studyId);
return new StudyDetailResponse(content, participants, attachedTags);
}
@AllArgsConstructor(access = AccessLevel.PACKAGE)
@Getter
public class StudyDetailsData {
private final Long id;
private final String title;
private final String excerpt;
private final String thumbnail;
private final String recruitmentStatus;
private final String description;
private final LocalDate createdDate;
private final MemberData owner;
private final Integer currentMemberCount;
private final Integer maxMemberCount;
private final LocalDate enrollmentEndDate;
private final LocalDate startDate;
private final LocalDate endDate;
public static StudyDetailsDataBuilder builder() {
return new StudyDetailsDataBuilder();
}
}
여기서 StudyDetailsData
의 owner
를 보면 다른 참여자들을 조회할 때와 동일하게 MemberData
를 사용하고 있다. 즉, API 명세 상으로는 구분되기는 하지만 그 내용은 동일하다고 볼 수 있다.
@Getter
@NoArgsConstructor(access = AccessLevel.PRIVATE)
@AllArgsConstructor
@EqualsAndHashCode
@ToString
public class MemberData {
@JsonProperty("id")
private Long githubId;
private String username;
private String imageUrl;
private String profileUrl;
}
우리는 CQRS
를 적용하여 조회와 나머지 CUD 를 분리하고 있다. 따라서, 조회시에는 엔티티를 조회해오는 것이 아니라 필요한 data 들만 뽑아서 반환해주고 있었다. 그렇기 때문에 가져온 MemberData
에는 memberId
도 들어있지 않기 때문에 변화가 필요하였다.
물론, MemberData 를 변경해서 memberId 를 추가할 수 있다. 하지만, client로 반환하게 되는 StudyDetailResponse
에 다음과 같이 우리가 조회해온 data 가 그대로 반환되기 때문에 client에서는 불필요한 정보도 함께 조회해가게 되는 어떻게 보면 사소할 수 있는 문제가 있다.
@AllArgsConstructor
@Getter
@ToString
@NoArgsConstructor
public class StudyDetailResponse {
private Long id;
private String title;
private String excerpt;
private String thumbnail;
private String recruitmentStatus;
private String description;
private Integer currentMemberCount;
private Integer maxMemberCount;
private LocalDate createdDate;
private LocalDate enrollmentEndDate;
private LocalDate startDate;
private LocalDate endDate;
private OwnerData owner;
private List<ParticipatingMemberData> members;
private List<TagData> tags;
...
}
뿐만 아니라 쿼리도 정말 많이 나가게 된다. owner 뿐 아니라 해당 스터디의 참여자들 까지 하면 말그대로 스터디 회원 수(n)만큼 쿼리가 나가게 된다. 또한 애플리케이션단(Service 로직 상)에서도 반복(Iteration)을 돌면서 작업을 해주어야한다.
쿼리만 여러번 나가는 비용 뿐 아니라 애플리케이션 내에서도 반복을 돌면서 비용을 지불해야한다는 것이 마음에 들지 않았다. 따라서 다음 코드와 같이 스터디 멤버들의 정보를 조회해오면서 멤버들이 가입한 날짜와 멤버들이 가입한 스터디의 갯수를 함께 조회해오고 싶게 되었다. 머릿속으로만 생각한 것이기는 하지만 충분히 기존 정보를 조회해오면서 같이 가져올 수 있겠다는 생각이 들었다. 그렇게 되면 기존과 같이 쿼리 한 번
으로 원하는 정보를 조회해올 수 있게 되는 것이다.
final List<MemberData> participants = memberDao.findMembersByStudyId(studyId);
가장 먼저 기존의 MemberData
이외에 participationDate
와 numberOfStudy
필드를 추가한 ParticipatingMemberData
를 생성해주었다. 기존의 MemberData를 다른 곳에서도 많이 사용하고 있었기 때문에 기존에서 수정을 진행하지 않고 새롭게 만들어주었다.
@Getter
@NoArgsConstructor(access = PRIVATE)
@AllArgsConstructor
@EqualsAndHashCode
@ToString
public class ParticipatingMemberData {
@JsonProperty("id")
private Long githubId;
private String username;
private String imageUrl;
private String profileUrl;
private LocalDate participationDate;
private int numberOfStudy;
}
그리고 이어서 도메인에 대해서도 수정을 진행해주었는데, study_member
테이블에 참여일자가 추가됨에 따라서 Study 도메인 내부에 존재하는 Particpant
에도 participationDate
를 추가해주었다.
@Getter
@Embeddable
@NoArgsConstructor(access = PROTECTED)
@AllArgsConstructor
@ToString
public class Participant {
@Column(name = "member_id", nullable = false)
private Long memberId;
@Column(updatable = false, nullable = false)
private LocalDate participationDate;
public Participant(final Long memberId) {
this.memberId = memberId;
this.participationDate = LocalDate.now();
}
...
}
그러면서 생성자에서는 해당 객체가 생성된 날짜를 넣어주도록 하였다.
DAO를 살펴보기 전에 Service를 보면 앞서 언급한 것과 같이 기존 data를 조회해오면서 추가적으로 스터디에 참여한 날짜와 각 멤버들이 스터디에 참여한 갯수를 조회해오는 것이기 때문에 기존코드에 큰 변화없이 기존의 MemberData
에서ParticipatingMemberData
을 반환하도록 수정해주었다.
public StudyDetailResponse getStudyDetails(final Long studyId) {
final StudyDetailsData content = studyDetailsDao.findBy(studyId)
.orElseThrow(StudyNotFoundException::new);
final List<ParticipatingMemberData> participants = memberDao.findMembersByStudyId(studyId);
final List<TagData> attachedTags = tagDao.findTagsByStudyId(studyId);
return new StudyDetailResponse(content, participants, attachedTags);
}
마지막으로 DAO쪽은 결과적으로 다음과 같은 코드가 되었다.
@Repository
public class MemberDao {
private static final RowMapper<ParticipatingMemberData> MEMBER_FULL_DATA_ROW_MAPPER = createMemberFullDataRowMapper();
private static final RowMapper<MemberData> MEMBER_DATA_ROW_MAPPER = createMemberDataRowMapper();
private final NamedParameterJdbcTemplate jdbcTemplate;
public MemberDao(final NamedParameterJdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
public List<ParticipatingMemberData> findMembersByStudyId(final Long studyId) {
final String sql = "SELECT member.id, github_id, username, image_url, profile_url, "
+ "study_member.participation_date as participation_date, "
+ countStudyNumber()
+ "FROM member JOIN study_member ON member.id = study_member.member_id "
+ "JOIN study ON study_member.study_id = study.id "
+ "WHERE study_member.study_id = :id";
return jdbcTemplate.query(sql, Map.of("id", studyId), MEMBER_FULL_DATA_ROW_MAPPER);
}
private String countStudyNumber() {
return countParticipationStudy() + " + " + countOwnerStudy();
}
private String countParticipationStudy() {
return "((SELECT count(case when (study_member.member_id = member.id) then 1 end) "
+ "FROM study JOIN study_member ON study.id = study_member.study_id) ";
}
private String countOwnerStudy() {
return "(SELECT count(case when (study.owner_id = member.id) then 1 end) "
+ "FROM study)) as number_of_study ";
}
public Optional<MemberData> findByGithubId(final Long githubId) {
try {
final String sql = "SELECT github_id, username, image_url, profile_url "
+ "FROM member "
+ "WHERE member.github_id = :id";
final MemberData data = jdbcTemplate.queryForObject(sql, Map.of("id", githubId), MEMBER_DATA_ROW_MAPPER);
return Optional.of(data);
} catch (EmptyResultDataAccessException e) {
return Optional.empty();
}
}
private static RowMapper<ParticipatingMemberData> createMemberFullDataRowMapper() {
return (resultSet, resultNumber) -> {
Long githubId = resultSet.getLong("github_id");
String username = resultSet.getString("username");
String imageUrl = resultSet.getString("image_url");
String profileUrl = resultSet.getString("profile_url");
LocalDate participationDate = resultSet.getDate("participation_date").toLocalDate();
int numberOfStudy = resultSet.getInt("number_of_study");
return new ParticipatingMemberData(githubId, username, imageUrl, profileUrl, participationDate, numberOfStudy);
};
}
private static RowMapper<MemberData> createMemberDataRowMapper() {
return (resultSet, resultNumber) -> {
Long githubId = resultSet.getLong("github_id");
String username = resultSet.getString("username");
String imageUrl = resultSet.getString("image_url");
String profileUrl = resultSet.getString("profile_url");
return new MemberData(githubId, username, imageUrl, profileUrl);
};
}
}
우선 참여일자와 같은 경우에는 추가해준 study_member.participation_date
필드를 단순히 조회해오면 되었다.
하지만 스터디 참여 갯수의 경우에는 조금 더 복잡했다. 우선 본인이 개설한 스터디, 즉 study
테이블의 owner_id
도 확인해주어야 했고, study_member
테이블의 member_id
와의 일치여부도 확인해주어야한다. 왜냐하면 우리는 현재 스터디장의 경우에는 study의 owner_id 로 관리하고 참여자는 별도로 study_member에서 관리해주고 있기 때문이다. 따라서 우리가 주목해야할 부분은 countParticipationStudy()
와 countOwnerStudy
부분이다.
첫번째 메소드의 경우에는 study_member
테이블의 member_id
와 동일한 member_id
를 가지는 row를 count하고 있으며, 두번째 메소드는 study
테이블의 owner_id
와 member_id
가 같은 경우를 count해주고 있고, countStudyNumber()
메소드에서는 이렇게 구한 두 count의 값을 합하여 반환해주고 있다. 이 때의 member_id
는 FROM member JOIN study_member ON member.id = study_member.member_id
조건에 해당하는 member_id
들이 된다.
하지만 이렇게 구현하였더니 아직 owner쪽에는 날짜와 스터디 가입 개수가 출력되지 않는 문제가 있었다.
그래서 다음PR에서와 같이 스터디장도 스터디 가입날짜와 스터디 가입 개수를 포함하도록 수정해주었다.
[BE] fix: 스터디장 또한 참여한 스터디 개수 표시
[BE] 스터디장도 스터디 가입 날짜 포함
가장 먼저 OwnerData를 만들어 주었다.
@Getter
@NoArgsConstructor(access = PRIVATE)
@AllArgsConstructor
@EqualsAndHashCode
@ToString
public class OwnerData {
@JsonProperty("id")
private Long githubId;
private String username;
private String imageUrl;
private String profileUrl;
private LocalDate participationDate;
private int numberOfStudy;
}
그리고 StudyDetailsDao
쪽의 findBy()
메소드에서 "member.image_url as owner_image_url, member.profile_url as owner_profile_url, created_at as participation_date, "
와 같이 스터디 참여 날짜를 추가해주는데 이 때 study 테이블의 created_at
을 그대로 활용하도록 해주었다.
또한 기존 MemberDao
와 동일하게 스터디 참여 개수를 count하는 쿼리를 추가해주었다.
private String countOfStudy() {
return "((SELECT count(case when (study_member.member_id = member.id) then 1 end) FROM study JOIN study_member ON study.id = study_member.study_id) "
+ "+ "
+ "(SELECT count(case when (study.owner_id = member.id) then 1 end) FROM study)) as number_of_study ";
}
그러면서 기존의 MemberData owner
부분을 모두 OwnerData owner
로 수정해주면서 작업을 마무리했다.
아래는 StudyDetailsDao
의 최종적인 모습이다.
@Repository
@RequiredArgsConstructor
public class StudyDetailsDao {
private final JdbcTemplate jdbcTemplate;
public Optional<StudyDetailsData> findBy(Long studyId) {
try {
String sql =
"SELECT study.id, title, excerpt, thumbnail, recruitment_status, description, current_member_count, "
+ "max_member_count, created_at, enrollment_end_date, start_date, end_date, owner_id, "
+ "member.github_id as owner_github_id, member.username as owner_username, "
+ "member.image_url as owner_image_url, member.profile_url as owner_profile_url, created_at as participation_date, "
+ countOfStudy()
+ "FROM study JOIN member ON study.owner_id = member.id "
+ "WHERE study.id = ?";
final StudyDetailsData data = jdbcTemplate.query(sql, new StudyDetailsDataExtractor(), studyId);
return Optional.of(data);
} catch (EmptyResultDataAccessException e) {
return Optional.empty();
}
}
private String countOfStudy() {
return "((SELECT count(case when (study_member.member_id = member.id) then 1 end) FROM study JOIN study_member ON study.id = study_member.study_id) "
+ "+ "
+ "(SELECT count(case when (study.owner_id = member.id) then 1 end) FROM study)) as number_of_study ";
}
...
}
이번에 해당 작업을 진행하면서 생각보다 요구사항의 변화에 까른 쿼리를 작성하는게 쉽지만은 않은 작업이라는 것을 알게 되었다. 쿼리의 변경 뿐 아니라 우리 애플리케이션의 경우 Data 라고 하는 자바 코드 또한 변경을 유발하게 되었으며 그 영향은 Service를 거쳐 Response DTO 까지 번져나갔다. 심지어 쿼리 그 자체도 수정하기가 까다로웠다. 특히, 이번 케이스의 경우 개인적으로 owner를 count하고 참여자(study_member쪽)를 count해서 이를 합친다? 라는 것이 쉽게 다가오지만은 않았던 것 같다. 그리고 이번에 스터디 참여 날짜를 위해서 DDL 의 변경과 함께 도메인에도 변경이 유발되게 되었다.
하지만 결국 서버라는 것을 두는 것은 DB에 영속적으로 저장된 데이터를 조회해오기 위해서라고 생각한다. 그리고 클라이언트측의 요구사항 변경이 일어나는 것은 하드웨어가 아닌 소프트웨어이기 때문에 당연하다고 생각한다. 이렇게 단순히 조회해오는 데이터 필드가 추가됨에 따라서 도메인, DDL, 쿼리, DTO, 서비스측 코드
등등 많은 곳에 변경을 유발하고 있는데, 이를 어떻게 관리하면 좋을지에 대해서는 고민이 많다.
그리고 스스로는 이렇게 기존의 쿼리에 추가적으로 데이터를 조회해옴으로써 쿼리 한 번으로 데이터를 조회해온다는 점에서 굉장히 뿌듯햇는데 막상 팀원들의 피드백을 받으니 쿼리가 너무 복잡해져서 알아보기 힘들다는 피드백을 받았다. (당시에는 메소드로 분리해놓지도 않았었다.) 지금은 countOfStudy()
라는 별도의 메소드로 분리하였기 때문에 심지어는 스터디 owner 갯수와 참여한 스터디 갯수를 count하는 SELECT문을 구분해주고 있기 때문에 조금 더 가독성이 있을 수 있지만 쿼리가 길어지고 조회해올 때 사용하는 테이블이 3개까지 많아지다 보니 복잡하다는 의견이었다. 이를 또 어떻게 개선해볼 수 있을 지에 대해서도 고민이 든다. 하지만 어떻게 보면 trade off 인 것 같다.
하지만 결과적으로 효율적인 쿼리를 작성했다는 점에서는 스스로 칭찬해주고 싶다. 쿼리를 분리함으로써 여러번의 쿼리가 나가지만 가독성을 챙길수도 있지만, 이는 비용이 많이 드는 부분이다. 심지어 스터디의 디테일 정보 조회는 꽤나 빈번한 API이다. 따라서 복잡하긴 해도 위와 같이 하나의 쿼리로 조회해오게끔 구현하였는데, 이러한 판단(?)에 대해서는 만족하는 부분이다.
마지막으로 스프리트4동안 구현한 기능은 스터디 상세 정보 수정
기능이다. 다음과 같은 요구사항을 구현하였다.
이렇게 위와 같은 요구사항이 있다 보니, 기존의 CreatingStudyRequest
DTO를 생성시에만 사용하는 것이 아니라 update시에도 사용할 수 있게 되었다. 따라서 이를 StudyRequest
라는 네이밍으로 변경해주었고, StudyController 쪽 updateStudy()
메소드를 다음과 같이 구현해주었다.
@PutMapping("/{study-id}")
public ResponseEntity<Void> updateStudy(
@AuthenticatedMember final Long memberId,
@PathVariable("study-id") final Long studyId,
@Valid @RequestBody(required = false) final StudyRequest request
) {
studyService.updateStudy(memberId, studyId, request);
return ResponseEntity.ok().build();
}
@AllArgsConstructor
@NoArgsConstructor
@Getter
@Builder
public class StudyRequest {
@NotBlank
private String title;
@NotBlank
private String excerpt;
@NotBlank
private String thumbnail;
@NotBlank
private String description;
@Min(1)
private Integer maxMemberCount;
@DateTimeFormat(pattern = "yyyy-MM-dd")
@NotNull
private LocalDate startDate;
@DateTimeFormat(pattern = "yyyy-MM-dd")
private LocalDate enrollmentEndDate;
@DateTimeFormat(pattern = "yyyy-MM-dd")
private LocalDate endDate;
private List<Long> tagIds;
...
}
주요 로직은 결국 Service쪽의 updateStudy()
메소드와 Study
도메인이다. 다음과 같이 구현해주었다.
@Service
@Transactional
public class StudyService {
private final StudyRepository studyRepository;
private final MemberRepository memberRepository;
private final DateTimeSystem dateTimeSystem;
public StudyService(final StudyRepository studyRepository,
final MemberRepository memberRepository,
final DateTimeSystem dateTimeSystem
) {
this.studyRepository = studyRepository;
this.memberRepository = memberRepository;
this.dateTimeSystem = dateTimeSystem;
}
...
public void updateStudy(Long memberId, Long studyId, StudyRequest request) {
Study study = studyRepository.findById(studyId)
.orElseThrow(StudyNotFoundException::new);
study.update(memberId, request.mapToContent(), request.mapToRecruitPlan(), request.mapToAttachedTags(),
request.mapToStudyPlanner(LocalDate.now()));
}
}
서비스쪽에서는 Repository 를 이용하여 매개변수로 받은 studyId 를 통해서 스터디를 조회해온다. 그리고 update를 수정하는데, 이 때, udpate를 요청한 memberId를 함께 넘긴다. 왜냐하면 스터디 정보에 대한 수정은 스터디장(owner)만이 가능하기 때문이다. study.update() 로직을 보면 다음과 같다.
@Entity
@NoArgsConstructor(access = PROTECTED)
@Getter
public class Study {
...
public void update(Long memberId, Content content, RecruitPlanner recruitPlanner, AttachedTags attachedTags,
StudyPlanner studyPlanner
) {
checkOwner(memberId);
this.content = content;
this.recruitPlanner = recruitPlanner;
this.attachedTags = attachedTags;
this.studyPlanner = studyPlanner;
}
private void checkOwner(Long memberId) {
if (!participants.isOwner(memberId)) {
throw new UnauthorizedException("스터디 방장만이 스터디를 수정할 수 있습니다.");
}
}
}
그리고 이외에는 변경되는 부분이 없었다. 변경점은 기존 CreatingStudyRequest
에서 StudyRequest
로 변경됨에 따라서 이를 사용하던 CreatingStudyRequestBuidler
를 사용하던 부분에 대한 변경이 조금 많았던 것으로 기억한다.
하지만 이렇게 구현하니 문제가 발생하였다. 이를 아래 QA 쪽에서 이어서 이야기해보려한다.
4차 데모데이는 영상을 찍어서 제출하는 방식으로 진행되었다. 그리고 영상을 찍는 당일 QA를 계속해서 진행하였다. 생각보다 이번 스프린트4에서 구현하는 내용이 많다보니 일정이 많이 미뤄진 것이다.
특히, 본인이 구현하였던 스터디 상세 정보 수정
쪽은 백엔드는 배포하였지만, 프론트 쪽에서 아직 구현이 완료되지 않다보니 QA를 진행할 수 없었고, 영상을 찍기 약 한 두 시간전에 배포되어 QA를 진행하였다. 그리고 여기서 문제가 발생하였다.
오늘 이전 날짜로 마감 일정 변경시에 마감 상태로 변경되지 않으며 스터디 인원을 현재보다 더 적게 수정이 가능하나, 스터디 상태는 마감 상태로 변경되지 않는 다는 것이다.(애초에 현재 인원보다 적게하는 것이 누군가를 탈퇴시키는 것이 아니면 불가능 해야한다.)
많은 생각이 들었다. 왜 이런 케이스들을 테스트 코드로 작성하지 못했을까? 왜 이런 생각을 해볼 수 없었지? 등등 많은 생각이 들면서 초조해졌다. 그래서 최대한 빠르게 수정을 하고 PR을 날렸다. 심지어 내가 퇴근한 이후였기 때문에 실시간으로 팀원들과 소통이 가능하지도 않았다.
그래도 엄청 급하게나마 현재 인원보다 적게 수정이 불가능하게 막았고, 스터디 마감 일자 또한 오늘 날짜 이전으로 수정을 못하게 막도록 수정하였으며 스터디 수정 시에 스터디 생성시에 진행하는 검증 로직을 동일하게 수행하여 불변식을 유지하도록 수정해주었다.
@Entity
@Getter
@NoArgsConstructor(access = PROTECTED)
public class Study {
...
public void update(Long memberId, Content content, RecruitPlanner recruitPlanner, AttachedTags attachedTags,
StudyPlanner studyPlanner
) {
if (isRecruitingAfterEndStudy(recruitPlanner, studyPlanner) ||
isRecruitedOrStartStudyBeforeCreatedAt(recruitPlanner, studyPlanner, createdAt)) {
throw new InvalidUpdatingException();
}
if (studyPlanner.isInappropriateCondition(createdAt.toLocalDate())) {
throw new InvalidUpdatingException();
}
if ((recruitPlanner.getMax() != null && recruitPlanner.getMax() < participants.getSize())) {
throw new InvalidUpdatingException();
}
checkOwner(memberId);
this.content = content;
this.recruitPlanner = recruitPlanner;
this.attachedTags = attachedTags;
this.studyPlanner = studyPlanner;
}
private void checkOwner(Long memberId) {
if (!participants.isOwner(memberId)) {
throw new UnauthorizedException("스터디 방장만이 스터디를 수정할 수 있습니다.");
}
}
@Service
@Transactional
public class StudyService {
...
public void updateStudy(Long memberId, Long studyId, StudyRequest request) {
Study study = studyRepository.findById(studyId)
.orElseThrow(StudyNotFoundException::new);
final Content content = request.mapToContent();
final RecruitPlanner recruitPlanner = request.mapToRecruitPlan();
final StudyPlanner studyPlanner = request.mapToStudyPlanner(LocalDate.now());
study.update(memberId, content, recruitPlanner, request.mapToAttachedTags(), studyPlanner);
}
다행히 해당 구현을 진행하고 PR을 날리고 난뒤 팀원과 통화를 해보니 데모 영상에는 예외 상황(이전 날짜나 더 적은 최대 멤버수로의 수정)은 포함하지 않으므로 큰 문제가 없을 것 같다고 이야기해주었다. 하지만 많은 반성을 하게 하는 경험이었다. 그러면서 테스트 코드의 부재의 비용을 크게 실감하였다.
이외에도 QA를 진행하면서 만난 버그가 적지않게 있었는데 다음과 같은 생각을 해볼 수 있었다.
평소에 우리가 우리 서비스를 직접 자주 사용해보면서 QA를 진행하면 어땠을까?
우리가 서비스를 만드는 이유는 우리 스스로가 그 필요성을 느끼기 때문이다. 하지만 정작 개발을 진행하면서 우리 서비스를 테스트해보고 아이디어를 얻지는 않았던 것 같다. 우리가 우리의 서비스를 실제로 사용하면서 어떤 불편함을 느끼고 그것이 새로운 아이디어, 혹은 서비스의 개선점으로 나타날 수 있는데 그러한 작업이 없었던 것 같다. 특히, 버그와 관련된 부분에서는 평소에 우리 서비스를 직접 사용해보아야 한다 는 생각을 많이 하게끔 하였다.
특히 이번에 [BE] fix: 스터디장 또한 참여한 스터디 개수 표시 와 같은 실수도 있었고, 스터디 상세 정보 수정에서의 에러 등을 보면서 마음이 급해지다보니 꼼꼼하지 못한 구현이 많이 아쉬움으로 남고, 이것이 QA에서 드러났다고 생각한다.
CloudWath Logs 대시보드를 구성한 내용은 CloudWatch Logs 에 정리하였다.
베루스와 페어로 진행하였는데, 나는 소주캉 크루가 작성하였떤 세상에서 제일👍 쉬운 CloudWatch Logs 도입기 - 연결 을 참고하여 진행하면 될 것이라고 생각하였다. 그런데, 베루스의 생각은 달랐다. 공식문서를 보면서 진행하되 막히는 부분이 생기면 소주캉 크루의 글을 참고하면 될 것 같다는 것이다. 머리를 한 대 맞은 느낌이었다.
레벨2 때, 스프링을 학습할 때 코치인 브리가 항상하던 말이 있다. 공식문서를 보는 힘을 기르는 것이다. 나는 그게 항상 잘 안되었었다. 영어를 잘 못한다는 핑계로, 안읽힌다는 핑계로 항상 블로그글만 참고하였다. 그런데 아마 처음부터 익숙하고 잘 하는 사람은 굉장히 드물것이다. 아마 베루스 또한 그랬을 것이라고 생각한다. 하지만 계속해서 시도했던 베루스와 그와 달리 어쩌면 당연하게 다른 사람이(한국인이 한글로) 작성한 블로그글을 거의 100% 신뢰하며 받아들였던 나와는 그 차이가 컸다.
레벨3가 끝나가는 4차 스프린트 기간까지도 나는 팀원들에게 배워가는 점이 있었던 것이다. CloudWatch 대시보드를 구성하는 작업은 약 2시간 정도도 안되게 짧은 시간에 끝났다. 짧은 시간이었지만 큰 교휸을 얻는 시간이었으며, 만약 우리가 소주캉 크루만의 글로 진행하였다면 동일한 시간으로 혹은 더 짧은 시간으로 진행했을 수 있을까? 에 대해서 yes라고 답하지는 못할 것 같다. (물론 소주캉이 정리해준 글은 👍이다.)
물론 공식문서를 보았을 때에도 우리는 알려주는대로 설치하고 설정을 하는게 끝이었지만, 만약 우리가 소주캉 글을 보면서 진행했다면 정말 우리가 한 우리 것이 아니라 그저 다른 사람이 한 것을 그대로 가져온 것 밖에 되지 않는다. 즉 우리가 한 것이 아니게 된다. 이해하는 것에도 차이가 있다. 그냥 블로그글을 따라서 명령을 칠 때에는 왜 그렇게 해야하는지 모르지만, 공식문서에는 왜 해당 명령을 수행하는지 그리고 해당 명령이 무엇을 위한 명령어인지를 간략하게라도 설명해준다. 정말 큰 교훈을 얻을 수 있었다.
베루스가 말해준 것처럼 처음에는 공식문서를 보고 하나씩 따라해보거나 공부하는 것이 좋을 것 같다. 그리고 막히는 부분이 있으면 블로그글을 참고하거나 주변사람에게 묻는 등의 방식으로 공부를 시도해보아야겠다.
이번에 그린론이 SonarQube 를 이용한 정적 분석 리포트를 구현해줌으로써 우리 코드의 smell, 즉 버그가 발생할 가능성이 있는 코드를 확인하고 개선해볼 수 있게 되었다.
나는 공식 팀의 주디가 추천해준 SonarLint
라는 플러그인을 활용해서 code smell 을 제거해보았다.
Files changed 만 56개이다...(모든 SonarLint 에서 제시하는 내용을 반영해야하는 것도 아니겠지만, 모든 code smell 을 제거한 것도 아닌데..56개이다.)
프로덕션 코드에서 가장 많이 걸렸던 것은 사용하지 않는 import 문이 남아있던 것이었다.
물론 이런 부분이 버그로 이어지지는 않을 것 같다는 생각이 들지만, 사용하지 않는 import 가 있는지도 몰랐던 것을 아마 해당 파일을 수정할 일이 있는 것 아니면(현재 파일을 수정할 때 파일을 건드리면 불필요한 import 구문이 자동으로 지워지게 IntelliJ 를 설정해두었다.) 아마 평생 찾지 못할 수도 있었을 것이라는 생각이 들었다.
이 외에도 불필요한 오버라이딩 등이 있었다. 코드를 정적으로(실행시키지 않고) 파악함에도 굉장히 똑똑한 툴이라는 생각이들었다.
그런데 프로덕션 코드 보다는 테스트 코드에서 굉장히 많은 code smell 이 존재했다.
가장 많은 수정을 유발한 것은 테스트 메소드의 public 접근 제어자와 테스트 클래스의 public 접근 제어자였다.
class 에 대해서 그리고 메소드에 대해서 public 접근제어자에 대한 SonarLint 의 충고는 다음과 같다.
이것은 어떠한 버그나 예외를 발생시키지는 않으므로 코드 스멜은 아니지만 JUnit5 에서 적절한 경고없이 무시하는 것에 대한 규칙으로써 경고를 내주는 것 같다.
이외에도 조금 신선한 SonarLint의 제안이 있었다.
assertThat(response.getTotalCount()).isEqualTo(0);
위와 같은 구문을 다음과 같이 수정하라는 것이었다.
assertThat(response.getTotalCount()).isZero();
AssertJ에는 일반적인 유형에 특화된 많은 method(방법)을 제공하고 있으므로 이를 사용하라는것이다. 이렇게 되면 더 나은 오류 메시지를 제공하며 디버깅 프로세스를 단순화해준다는 것이다.
특히 이와 관련된 Rule에는 isEqualTo(null) 대신 isNull() 이나 isEqualTo(true) 대신 isTrue() 등 다양한 제안을 하고 있었다.
테스트 코드를 작 작성하는 법에 대해서 고민해보고 익혀야겠다는 생각이 들었다.
이와 비슷하게 다음과 같은 수정도 진행해주었다. 그리고 이 외에는 크게 수정을 제안하는 부분은 없었다.
assertThat(parts.length).isEqualTo(3);
assertThat(parts).hasSize(3);
이 외에도 Sonar Qube 를 이용하면 버그나 테스트 Coverage 등을 알려주기도 하는데, 이러한 정적 분석 툴을 처음 써봐서 그런지 굉장히 신선하고 재밌는 경험이었다.
아무래도 AccessToken에 대해서 쿠키로 전환을 하고 이를 우리 프로젝트에 지금 바로 적용해보지 못한 부분이 조금은 아쉬운 부분으로 남는다. 하지만 현재 프로젝트가 여기서 그치지 않고 레벨4에도 계속 이어서 유지보수 해나가는 프로젝트 이기 때문에 크게 아쉬움이 남지는 않고, 여러 예외를 또 만날 것 같아 재밌을 것 같으면서도 한 편으로는 긴장감도 든다. (제발 내 생각대로 되길...)
다음으로는 스터디 상세 정보 수정을 꼼꼼하게 하지 못한 부분이 아쉬움으로 남는다. 조금 더 디테일하게 테스트 코드를 작성하고 여러 예외상황을 고려하였으면, QA시에 이렇게 많은 예외를 만나지는 않았을 것 같다. 너무 구현에 급급하다 보니 어떤 예외상황이 있을지 고민해보지 못한 것도..그렇게 됨에 따라 테스트 코드도 부실하게 작성하였던 것도 너무 큰 아쉬움이 남는다.
아쉬운 점도 많지만 잘한 점도 많은 것 같다. RefreshToken을 하면서 끝까지 포기하지 않고, 어떤 방법이 있을지 고민하고 또 그 방법을 적용해보는 등 나의 장점 중 하나인 포기하지 않는 끈기를 보였던 것 같다.
그리고 실수가 있어 수정을 진행하긴 했었지만, 스터디 상세 정보 조회시에 스터디 참여 인원이나 스터디 가입 날짜를 조회하는 쿼리 작성에 있어서도 많이 고민하고 적절하게 최적화하여 조회해올 수 있도록 구현한 것 같아 뿌듯함이 있다.
두 달이라는 적지 않은 시간동안 매일 팀원들과 함께하며 큰 트러블 없이 팀원 모두가 100% 만족한 것은 아니지만, 그래도 나름 만족스러운 팀 프로젝트를 진행해보았던 것 같고, 협업과 분업의 차이를 확실하게 느껴볼 수 있어 굉장히 가치있는 시간을 보냈다고 생각한다.