이번 주 개발일지를 쓰지 않았다. 레벨 3 막판이 되어 나태해진 것인지… (물론 그렇다고 작성해야 할 코드를 작성하지 않거나 해야 할 공부를 안하지는 않았다.) 그래도 레벨 3를 마무리하는 입장에서 이번 주 어떤 일들을 했고 어떤 이슈가 있었는지에 대해 남길 필요가 있을 것 같다.
JPA를 쓰다 보니 예상하지 못한 쿼리가 나가는 경우가 많았다. 게다가 N+1
문제가 발생할 것을 알고 있음에도 빠른 개발을 위해 우선은 fetch join
이나 batch size
를 사용하지 않고 넘어가기로 했기 때문에 어느 부분에서 얼만큼의 쿼리가 나가는지 예측하기 참 힘들었다. 이제 와서 N+1
문제를 해결하거나, 쿼리 성능을 개선하는 등의 작업이 필요해지게 되었는데 어떤 부분을 수정해야 할 지 알기 위해 우선은 한 번의 API 요청마다 몇 개의 쿼리가 나가는지 쿼리 개수를 카운팅하는 기능을 만들기로 했다.
몇가지 레퍼런스들을 찾아봤는데, 하이버네이트를 이용하는 방법과 JDBC, AOP, 다이나믹 프록시를 이용한 방법이 있었다. 개인적으로는 특정 구현체에 의존하지 않고 좀 더 스프링 기능을 사용해볼 수 있는 JDBC 방법을 사용하고 싶었지만, 팀 회의 끝에 빠르게 개발이 가능하며
구현체가 바뀔 일이 없을 것 같아서 상관 없다
는 이유로 하이버네이트를 사용하는 방법으로 쿼리 카운터를 만들기로 했다.
하이버네이트를 이용해 쿼리 카운터를 만드는 방법은 블로그 글로 정리를 해 두었다.
개인적으로 우리 팀도 유용하게 사용하겠지만, 다른 팀들도 혹시나 쿼리 개수를 세야 할 필요를 느끼게 된다면 얼마든 가져다가 비슷한 방법으로 적용하여 사용했으면 좋겠다는 작은 바람이 있다.
이번 주 거의 대부분은 코린과 함께 회원 프로필 팔로우 기능을 구현하는데 힘쓴 것 같다. 덕분에 야근도 하고 멘탈도 나가봤다… 결국 데모 영상을 촬영하기 이전까지 기능을 다 구현하고 QA도 무사히 마치기는 했지만, 마감에 쫓기는 기능 개발
이 힘들다는 것을 느꼈던 기간이었다. 사실 이건 내가 마음이 급하고 평정심을 잘 못찾아서 그런 탓이 좀 큰 것 같다. 그나마 코린이나 블링이 평정심을 유지시켜준 것이 이정도랄까… 아, 그리고 배포를 앞두고 빠듯하게 개발하느라 코드리뷰를 받기는 했는데 받은 사항을 반영하지 못한 것도 아쉽다. 이건 방학이나 레벨 4 시작하고 최우선적으로 리팩토링해야 할 부분이라고 생각한다.
팔로우 기능을 기술적으로 설명하자면, Member - Member
다대다 매핑을 엔티티로 풀어냈다고 보면 된다. 다만 굳이 연관관계는 필요하지 않을 것 같아 연관관계를 끊어주었다. 특별히 기술적인 포인트는 없었는데, 회원 목록 조회를 할 때 연관관계 없이 내가 팔로우하고 있는 사람과 아닌 사람의 구분
을 해주는 방법을 생각하는게 조금 어려웠다. 일단은 조회해 온 회원 목록을 가지고 in
쿼리를 활용해 해당 회원들 중 내가 팔로우하고 있는 회원의 id
를 가져오고 DTO를 만들 때 내부에서 true, false를 확인해서 DTO를 만들어주기로 했다.
@Transactional
까먹지 좀 말자팔로우 기능을 다 구현하고 테스트 서버에 merge까지 시킨 뒤 QA를 하는데, 팔로우가 되지 않았다. 처음에는 프론트엔드 이슈들이 먼저 터졌었는데, 프론트 이슈를 다 해결하자 이번에는 서버 단에서 에러가 터지고 있었다. 테스트를 다 통과했었는데 api 에러라니, 당황스러웠다. 로그를 확인해보니 한 단어가 눈에 보였다. read-only
라고…
상황을 파악해보자면, 우리 팀은 컨벤션으로 서비스 클래스의 클래스 단위에 @Transactional(readOnly = true)
를 적용하고 있다. 그렇기 때문에 C, U, D
작업, 즉 DB의 상태를 변화시키는 작업을 하게 될 경우 반드시 해당 메서드에 @Transactional
을 붙여주어야 한다. 하지만 팔로우 기능을 만들면서 follow
메서드와 unfollow
메서드에 @Transactional
을 붙이지 않고 있었다.
그런데 테스트에는 왜 통과했냐고? 테스트에는 h2 데이터베이스를 사용하는데, h2 데이터베이스는 @Transactional(readOnly = true)
가 작동하지 않는다고 한다. 그래서 테스트는 통과하는데 mysql 환경에 가서는 에러가 터지는 것이었다. 이런 문제를 잡기 위해 앞으로는 CD 전에 mysql 환경에서도 테스트를 할 필요가 있을 것 같다.
개인적으로 테스트를 개선하고 싶었다. 물론 이전의 테스트 코드도 그렇게 나쁜 코드는 아니었다고 생각하기는 한다. 심지어 백엔드 S모 크루는 "F12팀 테스트 코드 완전 이쁘던데?"
라고 하기도 했고, 또다른 S모 크루는 인수 테스트 픽스처 관련해서 우리 팀에 조언을 구하기도 했다.
하지만 내 눈에는 인수 테스트가 영 별로였다. 일단 우리 테스트가 HTTP 요청을 보내는 메서드는 유틸으로 빼고, 도메인의 픽스처를 사용하고 있어서 그래도 나름 깔끔해 보이기는 했다. 하지만 인수 테스트의 진행 상황을 알기에 적절하지 않다는 단점이 있었다.
@Test
void 로그인_상태에서_팔로우하지_않은_회원의_정보를_조회한다() {
// given
LoginResponse firstLoginResponse = 로그인을_한다(CORINNE_GITHUB.getCode());
String firstToken = firstLoginResponse.getToken();
MemberRequest memberRequest = new MemberRequest(JUNIOR_CONSTANT, BACKEND_CONSTANT);
로그인된_상태로_PATCH_요청을_보낸다("/api/v1/members/me", firstToken, memberRequest);
LoginResponse secondLoginResponse = 로그인을_한다(MINCHO_GITHUB.getCode());
String secondToken = secondLoginResponse.getToken();
LoginMemberResponse secondLoginResponseMember = secondLoginResponse.getMember();
로그인된_상태로_PATCH_요청을_보낸다("/api/v1/members/me", secondToken, memberRequest);
// when
ExtractableResponse<Response> response = 로그인된_상태로_GET_요청을_보낸다("/api/v1/members/" + secondLoginResponseMember.getId(), firstToken);
// then
Member expectedMember = Member.builder()
.id(secondLoginResponseMember.getId())
.name(secondLoginResponseMember.getName())
.gitHubId(secondLoginResponseMember.getGitHubId())
.imageUrl(secondLoginResponseMember.getImageUrl())
.careerLevel(JUNIOR)
.jobType(BACKEND)
.followerCount(0)
.build();
assertAll(
() -> assertThat(response.statusCode()).isEqualTo(HttpStatus.OK.value()),
() -> assertThat(response.as(MemberResponse.class)).usingRecursiveComparison()
.isEqualTo(MemberResponse.from(expectedMember, false))
);
}
여기서 memberRequest ~ 로그인된_상태로_PATCH_요청을_보낸다
가 로그인 이후 추가정보를 입력하여 회원가입을 완료한다
라는 의미라는 것을 우리 팀원들 말고 누가 알 수 있을까? 결국 인수 테스트는 시나리오를 테스트
하는데 의미가 있을텐데, 아무리 HTTP 요청을 하는 메서드를 가독성 좋게 한글로 만들어놓았다고 해도 부족해보였다. 결국 given
절에서 하는 요청과 로직에 대해서는 최대한 의미 있는 메서드
로 만들어보기로 했고, 픽스처 객체를 적절히 활용하여 다음과 같이 리팩토링했다.
@Test
void 로그인_하고_팔로우하지_않은_회원의_정보를_조회한다() {
// given
MemberRequest memberRequest = new MemberRequest(JUNIOR_CONSTANT, BACKEND_CONSTANT);
LoginResponse firstLoginResponse = MINCHO.로그인을_한다();
Long targetId = firstLoginResponse.getMember().getId();
MINCHO.로그인한_상태로(firstLoginResponse.getToken()).추가정보를_입력한다(memberRequest);
LoginResponse secondLoginResponse = CORINNE.로그인을_한다();
String loginToken = secondLoginResponse.getToken();
CORINNE.로그인한_상태로(loginToken).추가정보를_입력한다(memberRequest);
// when
ExtractableResponse<Response> response = 로그인된_상태로_GET_요청을_보낸다("/api/v1/members/" + targetId, loginToken);
// then
LoginMemberResponse firstLoginResponseMember = firstLoginResponse.getMember();
Member expectedMember = Member.builder()
.id(firstLoginResponseMember.getId())
.name(firstLoginResponseMember.getName())
.gitHubId(firstLoginResponseMember.getGitHubId())
.imageUrl(firstLoginResponseMember.getImageUrl())
.careerLevel(JUNIOR)
.jobType(BACKEND)
.followerCount(0)
.build();
assertAll(
() -> assertThat(response.statusCode()).isEqualTo(HttpStatus.OK.value()),
() -> assertThat(response.as(MemberResponse.class)).usingRecursiveComparison()
.isEqualTo(MemberResponse.from(expectedMember, false))
);
}
코드 길이는 줄지 않았지만, 중간 중간 메서드 체이닝을 활용한 로그인한_상태로.추가정보를_입력한다
와 같은 메서드들을 통해 어떤 시나리오로 테스트가 진행되고 있는지는 더 쉽게 알 수 있었다. 이런 메서드들을 만들어주기 위해 픽스처를 추가해주었다.
public enum AcceptanceFixture {
CORINNE(CORINNE_GITHUB.getCode(), MemberFixture.CORINNE),
MINCHO(MINCHO_GITHUB.getCode(), MemberFixture.MINCHO),
OHZZI(OHZZI_GITHUB.getCode(), MemberFixture.OHZZI);
private final String gitHubLoginCode;
private final MemberFixture memberFixture;
AcceptanceFixture(final String gitHubLoginCode, final MemberFixture memberFixture) {
this.gitHubLoginCode = gitHubLoginCode;
this.memberFixture = memberFixture;
}
public MemberFixture 객체를() {
return memberFixture;
}
public LoginResponse 로그인을_한다() {
return GET_요청을_보낸다("/api/v1/login?code=" + gitHubLoginCode)
.as(LoginResponse.class);
}
public AuthorizedAction 로그인을_하고() {
String token = GET_요청을_보낸다("/api/v1/login?code=" + gitHubLoginCode)
.as(LoginResponse.class)
.getToken();
return new AuthorizedAction(token);
}
public AuthorizedAction 로그인한_상태로(final String token) {
return new AuthorizedAction(token);
}
}
public class AuthorizedAction {
private final String token;
public AuthorizedAction(final String token) {
this.token = token;
}
public ExtractableResponse<Response> 추가정보를_입력한다(final MemberRequest memberRequest) {
return 로그인된_상태로_PATCH_요청을_보낸다("/api/v1/members/me", token, memberRequest);
}
public ExtractableResponse<Response> 자신의_프로필을_조회한다() {
return 로그인된_상태로_GET_요청을_보낸다("/api/v1/members/me", token);
}
public ExtractableResponse<Response> 팔로우한다(final Long targetId) {
return 로그인된_상태로_POST_요청을_보낸다("/api/v1/members/" + targetId + "/following", token);
}
public ExtractableResponse<Response> 리뷰를_작성한다(final Long productId, final ReviewFixture reviewFixture) {
return reviewFixture.작성_요청을_보낸다(productId, token);
}
}
기존의 MemberFixture
대신 RestAssured 요청을 보내는 메서드들을 담은 AcceptanceFixture
(인수 테스트는 거의 로그인부터 시작되므로 MemberFixture를 사용해도 되지만, 분리하는 것이 목적상 더 맞다고 생각했다.)와 로그인 이후에 다른 동작을 진행할 AuthorizedAction
클래스를 만들었다. 이렇게 하니 메서드 체이닝으로 시나리오를 표현할 수 있어서 장점이 많은 것 같다.
방학이다! 방학이야! 놀고 즐기고 휴식할 예정이다. 충전의 시간이 좀 필요할 것 같다.
h2 데이터베이스는
h2 데이터베이스는
h2 데이터베이스는
현기증 나네요... ;ㅅ;