새로운 파트 인 댓글 부분을 작성할 생각이다. 

 

개발의 순서 에는 사람마다 의 취향 차이가 있다. 개인적으로 등록부터 하는 것을 좋아한다.

이렇게 해야 직접 테스트 해보면서 mysql에 쿼리도 날려보고 하면서 테스트하기 좋아서 이렇게 하는 걸 선호한다.

사실 제일 구현하기 간편하다. ㅎㅎㅎ 조회 가 페이징 과 querydsl 써서 해야 해서 하기 편한 등록 먼저 하고 조회를 하기로 했다 ㅎㅎ..


지난번 좋아요 와 싫어요 PR 이후로 컨탠트 쪽은 마무리되었다. 이번에는 댓글 파트 쪽을 다뤄 볼까 한다. 

Pull로 떙겨오고, 동료 분은 Group Meeting 등록 쪽을 하셨다. 아무래도 시간을 다루고 서비스 핵심 로직이기에 코드가 쫌 많다. ㅎㅎ 

 

 git checkout guiwoo
M       src/main/resources/application-dev.properties
Switched to branch 'guiwoo'

 

 

지난번과 동일하게 바로 컨트롤러 테스트 코드 먼저 작성하러 가자.

@Test
@DisplayName("멤버 피드 댓글 작성 성공")
public void contentCommentCreate() throws Exception{
    ContentCommentCreate.Request req
            = ContentCommentCreate.Request.builder().comment("test").build();

    mockMvc.perform(post("/api/v1/member/content/3/comment")
                    .contentType(MediaType.APPLICATION_JSON)
                    .content(objectMapper.writeValueAsString(req))
            )
            .andExpect(status().isOk())
            .andExpect(
                    jsonPath("$.success")
                            .value("T")
            )
            .andDo(print());
}

댓글 등록이다 보니 댓글의 정보를 가져올 리퀘스트 바디가 필요하다.

erd의 댓글 필드를 확인해보고 오자. 음? content  만 받아오면 해결된다. 해결해줄 클래스 작성해 주러 가자.

public class ContentCommentCreate {

    @Getter
    @Setter
    @AllArgsConstructor
    @NoArgsConstructor
    @Builder
    public static class Request{
        @NotNull(message = "내용은 필수 입력 사항입니다.")
        private String comment;
    }
}


요렇게 이너 클래스로 리퀘스트 지정해주자. 이번에 동료분 께서 알려주신 @NotBlank 가 가장 인텐시브 하게 체크를 한다고 한다. 

이참에 정리해보자 @NotEmpty와 @NotNull을 @NotBlank 프로젝트 진행하면서 이것저것 사용했던 거 같은데 이런 인텐시브 체킹

관련해서는 상관없이 그냥 직관적인 이름에 따라 사용해왔다.

 

바로 테스트해보자 궁금하니깐 @NotBlank 타고 올라가 보자 

The annotated element must not be null and must contain at least one non-whitespace character. Accepts CharSequence.

오 null 값이 아니고 적어도 하나 이상의 공백이 아닌 문자를 포함하고 있어야 한다고 한다. @NotNull 도 확인해보자. 

The annotated element must not be null. Accepts any type.

눌 만 아니면 어느 타입이든 받아들인다고 심플하게 적혀있다. 

테스트를 해보자.

/** 테스트 시작점 위쪽에 걸어 놓은 부분
private static Validator validator;

@BeforeAll
public static void init() {
    factory = Validation.buildDefaultValidatorFactory();
    validator = factory.getValidator();
}
*/
@Test
public void NotBlankTest() throws Exception{
    ContentCommentCreate.Request req =
            ContentCommentCreate.Request.builder().comment("Test").build();

    var test1
            = validator.validate(req);
    assertThat(test1.size()).isEqualTo(0);
    req.setComment("");
    var test2 = validator.validate(req);
    assertThat(test2.size()).isEqualTo(1);

    req.setComment(" ");
    var test3 = validator.validate(req);
    assertThat(test3.size()).isEqualTo(1);

    req.setComment(null);
    var test4 = validator.validate(req);
    assertThat(test4.size()).isEqualTo(1);
}

와 이 테스트가 통과된다. 공백 인 배분을 모조리 체크해준다.  서비스 구현이 마무리되는 대로 NotNull 과 NotEmpty 를 섞어서 사용했는데 모두 NotBlank 로 통일해 주어야 겠다.

이제 진짜 컨트롤러 테스트 하기 위한 코드를 작성해주자.

더보기
    @PostMapping("{memberContentId}/comment")
    public ResponseEntity<?> contentComment(
            @PathVariable("memberContentId") Long contentId,
            @RequestBody @Valid ContentCommentCreate.Request req,
            BindingResult bindingResult){
        
        if(bindingResult.hasErrors()){
        throw new CustomMethodArgumentNotValidException(bindingResult);
        }
        
//        memberContentService.contentCommentCreate(req);
        return new ResponseEntity<>(
                CommonResponse.ok(),
                HttpStatus.OK
        );
    }

바디의 값과, 성공 사례 총 2가지 의 경우를 테스트해주었다. 이제 서비스로 넘어가자.

 

서비스 테스트 코드

더보기
@Nested
@DisplayName("멤버 피드 댓글 등록 테스트")
class MemberFeedContent{
    Long contentId = 169L;

    @Test
    @DisplayName("멤버 피드 댓글 등록 실패 [로그인 유저 미 일치]")
    public void doesNotMatchUser() throws Exception {
        //given
        //when
        //then
    }

    @Test
    @DisplayName("멤버 피드 댓글 등록 실패 [피드 가 존재하지 않는 경우]")
    public void doesNotExistFeed() throws Exception{
        //given
        //when
        //then
    }

    @Test
    @DisplayName("멤버 피드 댓글 등록 실패 [피드가 삭제된 경우]")
    public void feedDeleted() throws Exception{
        //given
        //when
        //then
    }

    @Test
    @DisplayName("멤버 피드 댓글 등록 성공")
    public void contentCommentCreateSuccess() throws Exception{
        //given
        //when
        //then
    }
}

1번 ~ 3번까지는 지난번 테스트 와 동일하니 코드만 첨부한다. 

1번 로그인 유저 미 일치 

더보기
@Test
@DisplayName("멤버 피드 댓글 등록 실패 [로그인 유저 미 일치]")
public void doesNotMatchUser() throws Exception {
    //given
    given(commonRequestContext.getMemberEmail()).willReturn("True-Lover");
    //when
    MemberException exception = assertThrows(MemberException.class,
            ()->memberContentService.contentCommentCreate(req,contentId));
    //then
    assertEquals(MemberErrorCode.MEMBER_EMAIL_ERROR,exception.getErrorCode());
}

// 서비스
@Override
public void contentCommentCreate(ContentCommentCreate.Request req, Long contentId) {
    Member m = validCheckLoggedInUser();
}

2번 피드 가 존재하지 않는 경우

더보기
@Test
@DisplayName("멤버 피드 댓글 등록 실패 [피드 가 존재하지 않는 경우]")
public void doesNotExistFeed() throws Exception{
    doReturn(Optional.of(Member.builder().build()))
            .when(memberRepository).findByEmail(any());
    //when
    MemberException exception = assertThrows(MemberException.class,
            ()->memberContentService.contentCommentCreate(req,contentId));
    //then
    assertEquals(MemberErrorCode.MEMBER_CONTENT_DOES_NOT_EXIST
            ,exception.getErrorCode());
}

// servcie
@Override
public void contentCommentCreate(ContentCommentCreate.Request req, Long contentId) {
    Member m = validCheckLoggedInUser();
    MemberContent mc = getContent(contentId);
}

3번 피드 가 삭제된 경우

더보기
@Test
@DisplayName("멤버 피드 댓글 등록 실패 [피드가 삭제된 경우]")
public void feedDeleted() throws Exception{
    //given
    doReturn(Optional.of(Member.builder().build()))
            .when(memberRepository).findByEmail(any());
    doReturn(Optional.of(MemberContent.builder().deletedYn(true).build()))
            .when(memberContentRepository).findById(any());
    //when
    MemberException exception = assertThrows(MemberException.class,
            ()-> memberContentService.contentLikeCancel(12L) );
    //then
    assertEquals(MemberErrorCode.MEMBER_CONTENT_DELETED,
            exception.getErrorCode());
}

// servcie

@Override
public void contentCommentCreate(ContentCommentCreate.Request req, Long contentId) {
    Member m = validCheckLoggedInUser();
    MemberContent mc = getContent(contentId);

    isContentDeleted(mc);
}

private void isContentDeleted(MemberContent mc){
    if(mc.isDeletedYn()){
        throw new MemberException(MEMBER_CONTENT_DELETED);
    }
}

1~3번 모두 통과 완료! 이제 성공 케이스만 테스트하면 된다 바로 가보자.

지난번에 말한 대로 저장할 값을 캡처 해와 벨류 확인을 하는 작업을 추가하고

제일 좋아하는 verify times를 이용해 함수의 실행이 마지막까지 실행되는지 확인했다.

서비스 코드 작성하러 가보자.

더보기
    @Override
    public void contentCommentCreate(ContentCommentCreate.Request req, Long contentId) {
        Member m = validCheckLoggedInUser();
        MemberContent mc = getContent(contentId);

        isContentDeleted(mc);

        MemberContentComment mcc = MemberContentComment.builder()
                .member(m)
                .memberContent(mc)
                .content(req.getComment())
                .deletedYn(false)
                .build();

        memberContentCommentRepository.save(mcc);
    }

 

지난번  과제 테스트로 롬복 없이  계좌 시스템을 구현하는 경우가 있었는데 롬복의 소중함 매번 느낀다 ㅎㅎ

 

테스트는 모두 통과된다.

서버 올리고 테스트해보자

로그인해서 토큰 받아주고

헤더에 토큰 넣어주고 content-type 넣어주고

응?

게시글이 없네 🥲 4번으로 보내자 

예이 200 리스폰스 받았다. ㅎㅎ

// 멤버 찾아주고 
select
    member0_.member_id as member_i1_12_,
    member0_.created_at as created_2_12_,
    member0_.updated_at as updated_3_12_,
    member0_.deleted_at as deleted_4_12_,
    member0_.email as email5_12_,
    member0_.member_status as member_s6_12_,
    member0_.nickname as nickname7_12_,
    member0_.password as password8_12_,
    member0_.phone_number as phone_nu9_12_,
    member0_.profile_img as profile10_12_,
    member0_.status as status11_12_,
    member0_.username as usernam12_12_ 
from
    member member0_ 
where
    member0_.email=?

// 컨탠트 찾아주고
select
    membercont0_.member_content_id as member_c1_15_0_,
    membercont0_.created_at as created_2_15_0_,
    membercont0_.updated_at as updated_3_15_0_,
    membercont0_.content as content4_15_0_,
    membercont0_.deleted_at as deleted_5_15_0_,
    membercont0_.deleted_yn as deleted_6_15_0_,
    membercont0_.member_id as member_10_15_0_,
    membercont0_.notice_yn as notice_y7_15_0_,
    membercont0_.sort_value as sort_val8_15_0_,
    membercont0_.title as title9_15_0_ 
from
    member_content membercont0_ 
where
    membercont0_.member_content_id=?
// save 해주구
insert 
into
    member_content_comment
    (created_at, updated_at, content, deleted_at, deleted_yn, member_id, member_content_id) 
values
    (?, ?, ?, ?, ?, ?, ?)

원하는 쿼리 개수만큼 나갔다 의도한 대로 작성이 되었다.

 

마지막 확인을 위한 mysql_workBench 확인 좋다.

테스트를 하는데 이렇게 또 서버를 띄우고 이렇게 여러 번 확인해도 항상 실제 서버 배포하고 그러면 오류가 난다. 그러면 배포를 다시 하고 그래야 하는데 우리 같은 작은 구조의 서비스 면 금방 배포가 되겠지만 그게 아니라면? 끔찍하다 ㅎㅎ 이래서 대부분의 서비스는 배포용 서버와 테스트용 서버 두 개 운용을 한다고 들었는데 그래도 배포하고 이런 게 다 돈아 니겠는가? 간단히 핸드폰 게임만 해도 긴급 점검 이 빈번하게 일어나는 경우가 많다. 왜 그러겠는가 수많은 개발자들이 확인했는데도 확인하지 못한 에러가 존재하기 때문이다. 이런 전문가들도 놓치는 부분이 있는데 하물며 나라고 없겠는가?  내가 생각하는 예외가 전부라고 생각하지 말자.

이번 프로젝트 진행하면서 처음 몇 개의 api 구현하는 동안 와 완벽하다 테스트 도 다통과 하고 그랬는데 나중에 보니 다른 테이블 데이터를 넣어주지 않는 바보 같은 실수를 해서 디버그 찍으면서 왜 안 들어가지 테스트가 들어가는데 ? 서버를 띄우고 하면 왜 안들어가지 이러면서 많은 삽질을 했다. ㅎㅎㅎㅎ 

 

테스트 도 내가 작성하고 예외도 내가 생각하기 때문에, 테스트 가 통과 가 되는 것이다. 

열린 마음으로 에러를 받아들이자 스트레스 그만 받자 🧐

 

긴 글 읽어주셔서 감사합니다.

지난번에 이어 이번에는 피드 좋아요 부분을 작성할 예정이다. 

다양한 웹사이트에서 좋아요 토글을 테스트해본 결과, 큰 서비스 들은 대부분 좋아요 쿼리와 좋아요, 싫어요 쿼리 이렇게 두 개가 있어서 우리도 그렇게 분리하기로 했다.

노션은 추후 업데이트가 필요한 부분이다.

이 api 가 호출 되면 무엇부터 해주어야 할까?

1. like 부분의 엔티티를 생성해서 인서트 해주면 될 거 같다. 

음? 지난번보다 생각 보다 간단하다. 

예외처리를 생각해보자.

정책에 명시된 것부터 나열해보자.

1. 토큰과 로그인 유저가 일치하지 않는 경우,
2. 피드 가 존재하지 않는경우?,3. 피드가 이미 삭제된 경우 ? 

 

위 2  피드는 하나의 예외처리로 묶을 수 있을 거 같다. 

음 이미 좋아요 가 눌러진 경우라면? 좋아요 테이블 내에서 중복 여부도 체크해야 할 거 같다.

마땅히 생각나는 추가 예외적인 경우가 없는 것 같다.

우리 동료분은 그룹 내에서 좋아요 여부를 판별해야 해서 생각보다 예외처리 가 많다. 하 복 받았네 ㅋㅋ

 

바로 지난번 날려준 pr 머지해주고 컨트롤러 테스트 코드부터 작성해보자.

modified:   src/main/java/com/workduo/member/content/service/impl/MemberContentServiceImpl.java
modified:   src/main/resources/application-dev.properties
modified:   src/test/java/com/workduo/member/content/controller/MemberContentControllerTest.java

 

 

브런치 생성해주고 , 스테이터스 찍어보니 impl 이랑 Test 가 약간 수정이 되어있다. 단순한 줄 바꿈 정도이니 바로 테스트 코드 작성하러 가자.

지난번 테스트와 다를 바 없다. 네스티드를 이용해 새로운 api 테스트임을 명시해주고, 바로 컨트롤러 테스트를 위한 코드를 작성했다. 

컨트롤러 작성하러 가 보자.

 

컨트롤러 위에 저렇게 코멘트를  박아주면 타인이 읽을 때 보다 빠르게 파악하기 좋다.

물론 함수 명도 동일한 의미를 지녀야 해서 굳이 필요한가 싶지만 한국인이라 그런지 나는 한글이 가장 먼저 눈에 들어온다. ㅎㅎㅎ 

서비스 코드는 미리 이름을 정해 놓고, 주석처리했다. 안 하고 돌리면 컴파일 에러니 주석 처리를 해주자.

 

 

기분 좋게 컨트롤러 테스트가 통과된다 ㅎㅎ

예외 처리 부분은 서비스 쪽에서 테스트할 것이니 바로 서비스 테스트 코드를 작성하러 가 보자.

 

더보기
@Test
@DisplayName("멤버 피드 좋아요 실패 [로그인 유저 미 일치]")
public void doesNotMatchUser() throws Exception{
    //given
    //when
    //then
}

@Test
@DisplayName("멤버 피드 좋아요 실패 [피드가 삭제된 게시글 인 경우]")
public void feedDeleted() throws Exception{
    //given
    //when
    //then
}

@Test
@DisplayName("멤버 피드 좋아요 실패 [피드가 존재하지 않는 경우]")
public void feedDoesNotExist() throws Exception{
    //given
    //when
    //then
}

@Test
@DisplayName("멤버 피드 좋아요 실패 [이미 피드 를 좋아요 한 경우]")
public void feedLikedAlready() throws Exception{
    //given
    //when
    //then
}

@Test
@DisplayName("멤버 피드 좋아요 성공")
public void feedLikeSuccess() throws Exception{
    //given
    //when
    //then
}

총 5개 정도 테스트하면 될꺼같다 이 테스트 코드를 작성하면서 의문이 들었다.

이미 좋아요 를 한 경우를 예외처리 가 굳이 필요한가 이다.

Return으로 함수를 종료하고 200으로 리턴하는 건 어떤가?  왜냐하면 프런트에서는 좋아요  캐시 처리한다고 가정해보자.

그렇다면? 이미 화면에 업데이트하고 쿼리가 날아간다. 쿼리의 리턴 값 여부에 상관없이 미리 예상해서 업데이트를 해버린 것이다.

 

이렇게 되면  굳이 예외처리가 필요한가 부분에서 의문이 든다. 이 부분은 pr에 포함해서 날려야겠다.
일단 정책 이 저렇게 정해져 있으니 동료분 코드와 통일성 있게 예외처리를 진행 해주자.

 

1. 토큰 인증 메일과 로그인 유저 미일치

지난번 테스트 코드와 동일하다 
딱히 뭐 추가해줄 부분이 없다 서비스 코드 부분에도 이미 유저 발리 데이팅 이 프라이빗 함수로 구현되어 있으니 그냥 그거 가져다가 사용하면 된다.

@Override
public void contentLike(Long contentId) {
    Member m = validCheckLoggedInUser();
}

ㅎㅎ 특별할 것 없는 member의 이메일은 매번 jwt 인증을 태울 때마다 컨택스트 홀더에 저장해놓고 사용한다.

따라서 별다른 파라미터 없이 저렇게 멤버 검증이 가능해진다.

스무스하게 테스트가 바로 통과된다.

 

2. 피드 가 존재하지 않는 경우

지난번 테스트 코드와 동일하다 

딱히 뭐 추가해줄 부분이 없다 서비스 코드 부분에도 이미 멤버 컨 탠트 가져오는  함수가  구현되어 있으니 그냥 그거 가져다가 사용하면 된다

이런 멤버나 컨 탠트 가져오는 부분은 나중에 join 쿼리로 가져오게 리팩터링을 해줘야겠다. 굳이 2개로 나눠서 보낼 필요가 없어 보인다.

 

    @Override
    public void contentLike(Long contentId) {
        Member m = validCheckLoggedInUser();
        MemberContent mc = getContent(contentId);

    }

딱히 뭐 유별난 게 없어 바로 다음 테스트 코드 작성하러 가자.

 

3. 피드 가 삭제된 경우

삭제된 경우 우리 워크 듀오 같은 경우는 피드 가 삭제되어도 테이블 상에서 지우지 않는다. 

 

유저의 복구 신청이 있다면?이라는 가정을 두고 테이블 상에서 지우지 않는다. 따라서 칼럼 하나를 따로 지정해주어 

삭제 여부를 판단한다.

 

deleteYn 저부 분만 트루가 되어서 들어간다면 정확하게 삭제된 피드 에러를 터트려야 한다.

    @Override
    public void contentLike(Long contentId) {
        Member m = validCheckLoggedInUser();
        MemberContent mc = getContent(contentId);
        if(mc.isDeletedYn()){
            throw new MemberException(MEMBER_CONTENT_DELETED);
        }
    }

ㅋㅋㅋㅋ 매우 심플하다. 코드를 작성하면서 함수를 작성한 나 매우 칭찬한다 ㅎㅎ

가볍게 3번째 테스트 케이스 또한 통과된다.

 

4. 이미 좋아요 가 된 경우

단순한 중복 여부의 체크를 위해 다양하게 쿼리를 짠다 단순히 구글 검색으로 duplicate check performance in sql로 검색만 하더라도 정말 다양한 방법으로 구현한다. find count exist 다양한 포스트를 읽다 보면 마무리 글에 강조하는 부분이 있다. exist 체크를 위해 count를 사용하지 말라는 글들이다. 궁금하면 한번 검색해서 읽어보는 것을 권장한다. 한글 영어 다양한 자료들이 있다.

 

레포지토리 함수 와, ErrorCode 작성이 필요하다 저 빨간불부터 지우자.

//Repository
boolean existsByMemberAndMemberContent(Member m,MemberContent mc);
//ErrorCode
MEMBER_CONTENT_LIKE_ALREADY(HttpStatus.BAD_REQUEST,"❌ 이미 좋아요 한 게시글 입니다."),
//Service
@Override
public void contentLike(Long contentId) {
    Member m = validCheckLoggedInUser();
    MemberContent mc = getContent(contentId);
    if(mc.isDeletedYn()){
        throw new MemberException(MEMBER_CONTENT_DELETED);
    }
    boolean exists = memberContentLikeRepository.existsByMemberAndMemberContent(m, mc);
    if(exists){
        throw new MemberException(MEMBER_CONTENT_LIKE_ALREADY);
    }
}

기분 좋게 테스트가 통과 가 된다  ㅎㅎ 자 대망의 성공 케이스를 작성하자.

 

5. 멤버 피드 좋아요 성공

음 좋아요는 마지막에 저장되는 레포지토리가 실행되는 부분만을 확인하고 싶다.

 

뭐 정확히 데이터가 들어가는지 확인하고 싶으면 캡처를 써서 메서드의 저장의 데이터가 올바르게 들어가는지 테스트 확인하는 것도

 

하나의 방법이 될 수 있으니 다음 테스트 세이브에서 활용해볼까 한다.

 

@Override
public void contentLike(Long contentId) {
    Member m = validCheckLoggedInUser();
    MemberContent mc = getContent(contentId);
    if(mc.isDeletedYn()){
        throw new MemberException(MEMBER_CONTENT_DELETED);
    }
    boolean exists = memberContentLikeRepository.existsByMemberAndMemberContent(m, mc);
    if(exists){
        throw new MemberException(MEMBER_CONTENT_LIKE_ALREADY);
    }
    memberContentLikeRepository.save(MemberContentLike.builder()
                    .member(m)
                    .memberContent(mc)
                    .build());
}

크 테스트 도 모두 통과한다.

자 서버를 띄워서 테스트해보기 전 총 나가야 하는 쿼리 수를 알아보자.

멤버를 가져오기 위한 쿼리 1방, 컨 탠트 확인 쿼리 1방, 컨탠트 라이크 쿼리 존재여부 1방, 컨탠트 라이크 세이브 1방 총 4개이다.

더보기
//멤버 확인
select
    member0_.member_id as member_i1_12_,
    member0_.created_at as created_2_12_,
    member0_.updated_at as updated_3_12_,
    member0_.deleted_at as deleted_4_12_,
    member0_.email as email5_12_,
    member0_.member_status as member_s6_12_,
    member0_.nickname as nickname7_12_,
    member0_.password as password8_12_,
    member0_.phone_number as phone_nu9_12_,
    member0_.profile_img as profile10_12_,
    member0_.status as status11_12_,
    member0_.username as usernam12_12_ 
from
    member member0_ 
where
    member0_.email=?

// 멤버 컨탠트 가져오기
select
    membercont0_.member_content_id as member_c1_15_0_,
    membercont0_.created_at as created_2_15_0_,
    membercont0_.updated_at as updated_3_15_0_,
    membercont0_.content as content4_15_0_,
    membercont0_.deleted_at as deleted_5_15_0_,
    membercont0_.deleted_yn as deleted_6_15_0_,
    membercont0_.member_id as member_10_15_0_,
    membercont0_.notice_yn as notice_y7_15_0_,
    membercont0_.sort_value as sort_val8_15_0_,
    membercont0_.title as title9_15_0_ 
from
    member_content membercont0_ 
where
    membercont0_.member_content_id=?
// 라이크 존재여부 exsit 체크여서 limit 1 로나감
select
    membercont0_.member_content_like_id as col_0_0_ 
from
    member_content_like membercont0_ 
where
    membercont0_.member_id=? 
    and membercont0_.member_content_id=? limit ?
// 인서트
insert 
into
    member_content_like
    (member_id, member_content_id) 
values
    (?, ?)

서비스 부분의 코드가 살짝 더러우니 리팩터링을 진행하자.

조금 더 괜찮아진 것 같다. 멤버와 컨 탠트 그리고 검증 이후 저장 깔끔하다. 의심의 여지없이 저장하는 함수이다.


매우 만족스럽게 이번에도 구현 완료 , 내일 취소까지 마무리해서 pr 올려야겠다.

 

긴 글 읽어주셔서 감사합니다.

한 동안 프로젝트 진행하고 있었다. API 구현 내용과 과정을 여기에 서술해 볼까 한다.

약 한달? 전에 미리 회의를 통해서 왼쪽과 같이 미리 스펙 등을 정해 놓았다. 저기 에 명시되어 있지 않지만. 구현하려면 저기에 엮인 모든 부분들을 날려 주어야 하는데

1. 멤버 컨탠트 좋아요 부분

2. 멤버 컨탠트 댓글 부분

3. 멤버 컨탠트 댓글 좋아요 부분

cascade 가 걸려있지 않아서 일일이 날려 주어야 한다. 왜? 최초 설계 할 때 매니 투 원을 이용해서 설계를 시작했고, 추후 필요한 부분이 생길 시 양방향으로 연관관계를 설정하려고 했기에 cascade 가 없다. 

원투 매니 부분은 디비상에 영향을 주지 않으니 추후 리팩터링 과정에서 추가할지 말지 결정할 생각이다.

바로 가보자

 

 

1. Git pull부터 해서 떙겨오자 커밋된 내용들을 

동료 분 이 그룹 컨 탠트 댓글 쪽 작업을 하셨네요 ㅎㅎㅎ 얼른 브런치 만들고 pr 날립시다

현재 스테이터스를 찍어보자
 git status
On branch guiwoo
Changes not staged for commit:
  (use "git add <file>..." to update what will be committed)
  (use "git restore <file>..." to discard changes in working directory)
        modified:   src/main/resources/application-dev.properties

no changes added to commit (use "git add" and/or "git commit -a")

프로퍼티 쪽에 aws 코드들이 있어 따로 커밋 할부분은 아니다. 

자 먼저 컨트롤러 테스트 코드부터 작성해보자.

더보기
@Nested
    @DisplayName("멤버 피드 삭제 API 테스트")
    class delete{
        @Test
        @DisplayName("멤버 피드 삭제 성공")
        public void successUpdate() throws Exception{
            //given
            doNothing().when(memberContentService).contentUpdate(any(),any());
            //when
            mockMvc.perform(delete("/api/v1/member/content/3")
                            .contentType(MediaType.APPLICATION_JSON)
                    )
                    .andExpect(status().isOk())
                    .andExpect(jsonPath("$.success").value("T"))
                    .andDo(print());
        }
    }

음 바디가 따로 필요 없다 content 부분 넣어줄 필요 없고, 이 포스트 쓰기 전 프로젝트 개발을 하면서 저 Nested 어노테이션이 효자노릇을 톡톡히 한다. 테스트를 돌리면 저 네스티드 분기 별로 나눠서 결과를 보여준다

이렇게 구분 선을 지어준다. 피드 삭제 부분은 컨트롤러가 없어서 에러가 났으니 얼른 작성해주러 가자.

@DeleteMapping("{memberContentId}")
    public ResponseEntity<?> deleteContent(
            @PathVariable("memberContentId") Long contentId
    ){
        //서비스 코드 작성
        return new ResponseEntity<>(
                CommonResponse.ok(),
                HttpStatus.OK
        );
    }

 

이러고 테스트 돌리면 통과한다. 실패하는 경우를 스펙에서 보자.

1. 로그인 안 한 유저가 악의적으로 api 호출 떄리는경우

2. 글 작성 유저와 로그인 한 유저가 일치하지 않는 경우

3. 글 이 이미 삭제된 경우

음 이렇게 3가지 경우가 되는데 이건 컨트롤러 쪽이 아닌 서비스 쪽에서 핸들링을 해야 할부분인 거 같다. 이런 실패 케이스는 서비스 코드 쪽에서 테스트 하자.

 

더보기

물론 네스트를 이용해서 둘러싸주자

@Nested
@DisplayName("멤버 피드 삭제 테스트")
class TestDeleteContent{
    Long contentId = 169L;
    ContentUpdate.Request req = ContentUpdate.Request.builder()
            .title("Holy")
            .content("Moly")
            .build();
    @Test
    @DisplayName("멤버 피드 삭제 실패 [로그인 유저 미 일치]")
    public void doseNotMatchUser(){
        //given
        given(commonRequestContext.getMemberEmail()).willReturn("True-Lover");
        //when
        MemberException exception = assertThrows(MemberException.class,
                ()->memberContentService.contentUpdate(contentId,req));
        //then
        assertEquals(MemberErrorCode.MEMBER_EMAIL_ERROR,exception.getErrorCode());
    }
}

지난번에 작성한 멤버 발리드 함수를 이 딜리트 함수에도 써주자

어 생각해보니깐 피드가 존재하지 않는 경우도 있지 않겠는가? 테스트 코드 2개 작성해주고 오자

더보기
@Test
@DisplayName("멤버 피드 삭제 실패 [피드 작성자가 아닌 경우]")
public void doNotHaveAuthorization() throws Exception{
    //given
    given(commonRequestContext.getMemberEmail()).willReturn("True-Lover");
    doReturn(Optional.of(Member.builder().email("True-Lover").build()))
            .when(memberRepository).findByEmail(any());
    doReturn(Optional.of(MemberContent.builder().build()))
            .when(memberContentRepository).findById(any());
    //when
    MemberException exception = assertThrows(MemberException.class,
            ()->memberContentService.contentDelete(contentId));
    //then
    assertEquals(MemberErrorCode.MEMBER_CONTENT_AUTHORIZATION
            ,exception.getErrorCode());
}

@Test
@DisplayName("멤버 피드 삭제 실패 [피드가 삭제된 게시글 인 경우]")
public void feedTerminatedBefore() throws Exception{
    Member m = Member.builder().id(2L).build();
    //given
    doReturn(Optional.of(m))
            .when(memberRepository).findByEmail(any());
    doReturn(Optional.of(MemberContent.builder().member(m)
            .deletedYn(true).build()))
            .when(memberContentRepository).findById(any());
    //when
    MemberException exception = assertThrows(MemberException.class,
            ()->memberContentService.contentDelete(contentId));
    //then
    assertEquals(MemberErrorCode.MEMBER_CONTENT_DELETED
            ,exception.getErrorCode());
}

테스트 코드를 돌려보자. 샷샷샷 , 매우 만족스럽다. 사실 수정 코드와 겹치는 부분이 많아서 긁어 왔다.

이제 성공 테스트를 해 야 한다. 이알디를 보고 오자

1. 피드 좋아요를 날려준다.

2. 피드 코멘트 좋아요 를 날려준다.

3. 피드 코멘트를 날려준다.

4. 피드 컨 탠트 내용을 지우며 업데이트 해주자.
(추후 코멘트 쪽 작업 하닥 원투 매니 걸고 커스 케이드 걸리면 리팩터링 하자.)

 

테스트 코드 먼저 작성하고 테스트 가 통과되도록 해보자.

verify(memberContentLikeRepository,times(1))
                    .deleteAllByMemberContent(any());

좋아요 날려주러 가보자.

// 레포지토리 쪽에 작성해주고
void deleteAllByMemberContent(Long memberContent);

// 서비스 쪽에 서 사용해주자 
//피드 좋아요 날려
memberContentLikeRepository.
        deleteAllByMemberContent(contentId);

음 좋아 통과된다. 

피드 코멘트 좋아요를 날려 주자 레포지토리에 작성하는데 문득 피드 안에는 다양한 댓글들이 있을 텐데 그 댓글 들을 모조리 가져와서 쿼리를 날려야 한다 이게 왜 문제가 될까? 우리 쏜이 글 쓰고 지운다고 생각해보자 수많은 댓글과 좋아요 과 삽시간에 달릴 거다. 댓글이 100만 개 가 됐다고 쳐보자. 지워달라는 api 요청이 와서 우리 코드는 실행을 하는데 게시글 하나를 지우기 위해서 댓글당 좋아요를 지우기 위해 쿼리 100만 번을 날린다 ㅋㅋㅋㅋ 1방 쿼리로 변경해주자.

void deleteAllByMemberContentCommentIn(List<Long> commentsId);

in을 활용해서 날려주자.

    @Override
    public void contentDelete(Long contentId) {
        Member member = validCheckLoggedInUser();
        MemberContent content = getContent(contentId);
        validEditCheck(member,content);
        //댓글 아이디 어떻게 가져올래 ?
        List<MemberContentComment> cList = 
                memberContentCommentRepository.findAllByMemberContent(content);
        //피드 좋아요 날려
        memberContentLikeRepository.
                deleteAllByMemberContent(content);
        //댓글 좋아요들 날려
        memberContentCommentLikeRepository.
                deleteAllByMemberContentCommentIn(cList);
    }

 

최종적으로 완성된 테스트 코드이다.

더보기
@Test
@DisplayName("맴버 피드 삭제 성공")
public void feedTerminate() throws Exception{
    //given
    MemberContent build = MemberContent.builder().member(m)
            .deletedYn(false).build();
    MemberContent spy = spy(build);

    doReturn(Optional.of(m))
            .when(memberRepository).findByEmail(any());
    doReturn(Optional.of(spy))
            .when(memberContentRepository).findById(any());
    doReturn(new ArrayList<>())
            .when(memberContentCommentRepository).findAllByMemberContent(any());
    //when
    memberContentService.contentDelete(contentId);

    verify(memberContentLikeRepository,times(1))
            .deleteAllByMemberContent(any());
    verify(memberContentCommentLikeRepository,times(1))
            .deleteAllByMemberContentCommentIn(any());
    verify(memberContentCommentRepository,times(1))
            .deleteAllByMemberContent(any());
    verify(spy,times(1)).terminate();
}

스파이를 한 이유는 코드가 마지막까지 닿아서 실행되는지 확인을 하고 싶어서 넣었다. 서비스 코드 쪽을 봐보자

@Override
public void contentDelete(Long contentId) {
    Member member = validCheckLoggedInUser();
    MemberContent content = getContent(contentId);
    validEditCheck(member,content);
    //댓글 아이디 어떻게 가져올래 ?
    List<MemberContentComment> cList =
            memberContentCommentRepository.findAllByMemberContent(content);
    //피드 좋아요 날려
    memberContentLikeRepository.
            deleteAllByMemberContent(content);
    //댓글 좋아요들 날려
    memberContentCommentLikeRepository.
            deleteAllByMemberContentCommentIn(cList);
    //댓글 날려
    memberContentCommentRepository.
            deleteAllByMemberContent(content);
    //컨탠트 딜리트 yn true 로 업데이트
    content.terminate();
}

멤버 아이디 가져오는 쿼리 1, 컨 탠트 가져오는 쿼리 1, 코멘트 리스트 가져오는 쿼리 1, 
지우는 쿼리 총 3개 , 마지막 업데이트 쿼리 1 개 총 7개의 쿼리가 날아가야 한다.
서버를 띄워서 테스트를 해보자. 로그인을 해주고 딜리트 api로 날려주자
(개인적으로 포스트맨 보다 인썸니아 좋아한다. ㅎㅎ, 더 예쁘서 좋아한다 더 이유가 필요한가 ㅎㅎㅎ)

더보기

2022-09-25 19:18:37.264 DEBUG 6000 --- [nio-8080-exec-3] org.hibernate.SQL                        : 
update
member_content 
set
updated_at=?,
content=?,
deleted_at=?,
deleted_yn=?,
member_id=?,
notice_yn=?,
sort_value=?,
title=? 
where
member_content_id=?
2022-09-25 19:18:37.282 DEBUG 6000 --- [nio-8080-exec-3] org.hibernate.SQL                        : 
delete 
from
member_content_like 
where
member_content_like_id=?
2022-09-25 19:18:37.283 DEBUG 6000 --- [nio-8080-exec-3] org.hibernate.SQL                        : 
delete 
from
member_content_like 
where
member_content_like_id=?
2022-09-25 19:18:37.287 DEBUG 6000 --- [nio-8080-exec-3] org.hibernate.SQL                        : 
delete 
from
member_content_comment_like 
where
member_content_comment_like_id=?
2022-09-25 19:18:37.287 DEBUG 6000 --- [nio-8080-exec-3] org.hibernate.SQL                        : 
delete 
from
member_content_comment_like 
where
member_content_comment_like_id=?
2022-09-25 19:18:37.289 DEBUG 6000 --- [nio-8080-exec-3] org.hibernate.SQL                        : 
delete 
from
member_content_comment 
where
member_content_comment_id=?
2022-09-25 19:18:37.289 DEBUG 6000 --- [nio-8080-exec-3] org.hibernate.SQL                        : 
delete 
from
member_content_comment 
where
member_content_comment_id=?

음  뭔가 이상하다. 위로 스크롤 해서 보니 심지어 셀렉트 해서 들고와서 일일이 단건삭제로 넘기는거 아닌가 ?  아 ..... 직접 쿼리를 써줘야겠다..
쿼리

@Modifying
@Query("delete from MemberContentLike mcl where mcl.memberContent = :contentId")
void deleteAllByMemberContent(@Param("contentId") MemberContent memberContent);

@Modifying
@Query("delete from MemberContentComment mcc where mcc.memberContent = :memberContent")
void deleteAllByMemberContent(@Param("memberContent") MemberContent memberContent);

@Modifying
@Query("delete from MemberContentCommentLike mccl where mccl.memberContentComment in :comments")
void deleteAllByMemberContentCommentIn(
@Param("comments")
List<MemberContentComment> memberContentComment);

다시 테스트 해보자.


크 아름답다 정확히 딜리트가 3번 나가고 1번 업데이트 쿼리가 날아간다 ㅎㅎㅎ 만족스럽다.

고민이 든다 만약에 그냥 지우는 게 아니라 그냥 업데이트 쿼리를 이용해 셀렉트 해서 퍼올릴 때 필터링을 해주면 어떨까 비용적인 측면에서 차이가 있을까라고 고민해본다. 검색을 조금 해봤지만. 정책에 따라 가는 것 같다. 지워야 하면 무조건 지우고 그게 아니라면 업데이트시켜서 쿼리 퍼올릴 때 필터 하는 것도 한 방법이 될 수 있을 것 같다.

솔직히 셀렉트 쪽에서는 쿼리 디에스엘을 이용해서 한 번에 퍼올리기가 가능하기에 7번이라는 숫자가 많다고 느껴질 수도 있다. 이런 인서트 업데이트, 딜리트 쪽은 쿼리의 정확성이 보다 중요하다고 생각하기 때문에 7번이라는 쿼리 숫자가 크게 많다고 느껴지지 않는다.

기분 좋게 api 한 부분을 구현했다. 다음에도 시간이 되면 추가적으로 글을 작성할 예정이다. 
긴 글 읽어주셔서 감사합니다.

+ Recent posts