한 동안 프로젝트 진행하고 있었다. 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