치고 있을 사람들 을 위해

-- use guiwoo;
create table Accounts (
	account_id serial primary key,
    account_name varchar(20),
    first_name varchar(20),
    last_name varchar(20),
    email varchar(100),
    password_hash char(64),
    portrait_image blob,
    hourly_rate numeric(9,2)
);

create table BugStatus(
	status varchar(20) primary key
);

create table Bugs(
	bug_id serial primary key,
    date_reported date not null,
    summary varchar(80),
    descriiption varchar(1000),
    resolution varchar(1000),
    reported_by bigint unsigned not null,
    assigned_to bigint unsigned,
    verified_by bigint unsigned,
    status varchar(20) not null default 'NEW',
    priority varchar(20),
    hours numeric(9,2),
    foreign key (reported_by) references Accounts(account_id),
    foreign key (assigned_to) references Accounts(account_id),
    foreign key (verified_by) references Accounts(account_id),
    foreign key (status) references BugStatus(status)
);

create table Comments(
	comment_id serial primary key,
    bug_id bigint unsigned not null,
    author bigint unsigned not null,
    commet_date datetime not null,
    comment text not null,
    foreign key (bug_id) references Bugs(bug_id),
    foreign key (author) references Accounts(account_id)
);

create table Screenshots(
	bug_id bigint unsigned not null,
    image_id bigint unsigned not null,
    screenshot_image blob,
    caption varchar(100),
    Primary key (bug_id,image_id),
    foreign key (bug_id) references Bugs(bug_id)
);

create table Tags(
	bug_id bigint unsigned not null,
    tag varchar(20) not null,
    primary key (bug_id,tag),
    foreign key (bug_id) references Bugs(bug_id)
);

create table Products(
	product_id serial primary key,
    product_name varchar(50)
);

create table BugsProducts(
	bug_id bigint unsigned not null,
    product_id bigint unsigned not null,
    primary key (bug_id,product_id),
    foreign key (bug_id) references Bugs(bug_id),
    foreign key (product_id) references Products(product_id)
);

antiPatterns.sql
0.00MB

27. Remove Element

주어진 배열 과 값이 나오고 배열 내에서 해당 값을 지워서 리턴해주면 되는 간단한 문제이다. 

다만 문제의 조건상 에 추가적인 메모리 가 없기 떄문에 배열 자체를 수정해서 리턴 해야한다. 

class Solution {
    public int removeElement(int[] nums, int val) {
        int left =0;
        for(int i=0;i<nums.length;i++){
            if(nums[i] != val){
                int tmp = nums[left];
                nums[left++] = nums[i];
                nums[i] = tmp;
            }
        }
        return left;
    }
}

left 값 하나 를 선정해서 투포인터 느낌으로 스왑 해가면서 풀었다.  for loop 자동으로 증가하는 값을 right 로 생각하면 보다 쉽게 풀듯하다.

leetcode post 

 

58. Length of Last Word

문자 가 주어지면 그 스트링 내에서 가장 마지막에 있는 문자 의 개수를 반환하는 문제이다.

class Solution {
    public int lengthOfLastWord(String s) {
        int len = s.length()-1;
        int cnt =0;
        for(int i =len;i>=0;i--){
            if(!Character.isDigit(s.charAt(i)) && s.charAt(i) != ' '){
                cnt++;
            }
            if(cnt != 0 && s.charAt(i) == ' '){
                return cnt;
            }
        }
        return cnt;
    }
}

 

 

뒷방향 부터 탐색해 나가면서 카운트 가 시작되어있는 상태에서 공백을 만나면 반환을 하는 함수를 작성했다. 

사실 트림을 이용해서 하면 아래 추가적인 if 문 없이 작성할수 있으나 trim 내부적으로 ' ' 이걸 만나지 않을때 까지 와일 문을 앞뒤로 돌린다. 그게 싫어서 저렇게 작성했다.

leetcode post

66. Plus One

나름 난감했던 문제 였다. Stack 을 이용하지 않고 구현을 해보려고 노력하다 보니 그런거 같다.

class Solution {
    public int[] plusOne(int[] digits) {
        int len = digits.length-1;
        StringBuilder answer = new StringBuilder();
        int willAdd = 1;

        for (int i = len; i >=0 ; i--) {
          int next = digits[i] + willAdd;
          answer.append(next%10);
          if(next > 9) willAdd = 1;
          else willAdd = 0;
        }
        if(willAdd == 1) answer.append(1);

        int[] ans = new int[answer.length()];
        for (int i = 0; i < ans.length; i++) {
          ans[i] = answer.charAt(ans.length-1-i)-'0';
        }

        return ans;
    }
}

1ms 가 나왔다 스택을 이용해서 속도적인 차이가 궁금해서 바로 구현해 봤다.

class Solution {
    public int[] plusOne(int[] digits) {
        Stack<Integer> stack = new Stack<>();
        int len = digits.length-1;
        int willAdd = 1;

        for (int i = len; i >= 0; i--) {
            int val = digits[i]+willAdd;
            stack.push(val%10);

            if(val > 9) willAdd = 1;
            else willAdd = 0;
        }

        if(willAdd == 1) stack.push(1);

        int[] answer = new int[stack.size()];

        for (int i = 0; i < answer.length; i++) {
            answer[i] = stack.pop();
        }
        
        return answer;
    }
}

2ms 가 나온다 더 느리다 .
스택은 백터 를 상속받아서 구현 된 클래스다. 따라서 스레드 세이프 하다는 의미이다. 대부분의 함수에 syncronize 가 걸려있다.

동기화가 제거된 버전인 Deque 를 사용해보자 보통 queue , stack 에서 deque 를 사용하면 성능적인 측면에서 좋은 효과가 있다 왜 ?

스레드 세이프 하지 않으니깐. 1ms 가 나온다. 

 

다른 사람의 풀이를 한번 확인해보자.

class Solution {
    public int[] plusOne(int[] digits) {
        
    int n = digits.length;
    for(int i=n-1; i>=0; i--) {
        if(digits[i] < 9) {
            digits[i]++;
            return digits;
        }
        
        digits[i] = 0;
    }
    
    int[] newNumber = new int [n+1];
    newNumber[0] = 1;
    
    return newNumber;
}
}

와.... 생각지도 못했던 부분이다. 물론 9 이하인경우 +1 해서 리턴 생각을 했지만 구현의 구체적인 방향성이 떠오르지 않아 위와같이 작성했는데.... 0ms 나온다. 저 9이하 리턴 부분이 최적화 가 완성된 부분이기떄문에 2배이상 빠른것 같다.

leetcode post

 

갑자기 내 첫번쨰 로직에 포함된 StringBuilder 와 StringBuffer 의 차이가 궁금해졌다.

라이브 코딩을 하게 되면 인터뷰어를 설득해야한다.

저런 클래스에 사용에 있어 명확한 이유가 있어야 한다. 이번기회 에 한번 찾아보고자 한다.

 

기본적으로 String 은 불변객체이다.

스트링은 + 연산자가 되던데 ?  라고 생각하면 착각이다. 새로운 메모리가 할당되어 포인팅 된다.

즉 기존에 가지고 있던 포인터 부분은 가비지컬렉터 제거 대상이 되어진다. 이렇게 되면 메모리 어뷰징이 발생되고 빈번한 문자 변경에 성능상 이슈가 야기될수 있기 때문에

자바에서 StringBuffer 를 처음 만들게 된다.(Java 1.0 에서 등장한다.)

최초 만들때 즉 가변성 있는 객체를 만들고자 buffer 를 만들고 쓰레드 세이프 한 방식으로 구현되어진다.

(실제 클래스 내부에 들어가보면 대부분의 함수들이 syncronize 가 달려있다)

스택 과 동일하게 동기화 함수가 걸려있다면 성능적으로 느려진다.

대부분의 스트링을 이용한 케이스를 고려해본다면 멀티쓰레드 환경에서 사용되어 지지 않는다. 이부분을 고려해 java 1.5 에서 StringBuilder 가 등장하게 된다. 당연히 동기화 함수 는 제거된 상태로 구현되어 있다. 

 

StringBuffer,Builder 두종류 모두 comparable 을 구현한다. 따라서 priorityQueue 에 넣어서 사용할수 있는데 String 의 정렬과 동일한 방식으로 된다. 즉 사전시으로 정렬된다.

  public static void main(String[] args) {
    PriorityQueue<StringBuilder> a = new PriorityQueue<>();
    a.add(new StringBuilder("c"));
    a.add(new StringBuilder("a"));
    a.add(new StringBuilder("b"));
    a.add(new StringBuilder("132"));
  }

이렇게 넣게되면 ? [132, a, b, c] 이런식의 정렬된 값이 들어가게 된다.

 

이지 문제였지만 나름 혼자 이것저것 고민해보면서 재밌는 시간을 보낸거 같다.

아으... 어제 썻던 글들이 모두 날라가서 점심먹기 전 후다닥 작성하려고 한다. 



지난번 pr 에 이렇게 리뷰를 받았다, ㅎㅎ 생각해보니 멤버가 인자에 포함되어 있다면 본인 이 쓴 댓글에 만 좋아요와 좋아요 취소가 가능해지는 바보같은 실수를 해버렸지 뭔가 ㅎㅎㅎ ...

 

바로 피드백 반영해서 추가하고 머지 했다.

 

어제 오전 오후 는 서버 띄워서 완전 Api 테스트 하느라 시간을 보냈다. 자바버전 이 달라서 생기는오류,  각각 db ddl 이 달라서 생기는 오류 등 파일의 이전에 따른 querydsl 컴파일 미실시 등 다양한 에러를 경험하며 어제 동료분 에게 메세지 엄청 보냈던것 같다. ㅎㅎㅎ 

저런 자잘한 오류들 을 해결하고 나니 시간이 훌쩍 가있지 않은가.. 

바로 일정 관련 부분 오늘은 한번에 작성할 생각이다. 

 

이렇게 보면 도대체 리스트 와 달력 의 구분이 왜 필요한가 애초에 줄때 그냥 던져주면 안되는건가 ? 라고 생각할수도 있지만, 우리의 미리 만들어진 와이어프레임을 본다면 

이렇게 빨간색으로 점쳐진 부분이 보이는가 ? 그부분 을 한달치 달력으로 표현을 하고자 한다. 그렇기에 이렇게 따로 api 구분을 해놨다.

 

바로 테스트 코드 부터 들어가 보자.

@Nested
@DisplayName("멤버 월 일정 API 테스트")
class CalendarMonthTest{
@Test
@DisplayName("멤버 월 일정 API 실패 [날짜 데이트 이상할시]")
public void fail() throws Exception{
    //given
    //when
    mockMvc.perform(get("/api/v1/member/calendar?date=2022")
                    .contentType(MediaType.APPLICATION_JSON)
            )
            .andExpect(status().isBadRequest())
            .andExpect(jsonPath("$.success").value("F"))
            .andDo(print());

}

@Test
@DisplayName("멤버 월 일정 API 성공")
public void success() throws Exception{
    //given
    //when
    mockMvc.perform(get("/api/v1/member/calendar?date=2022-09-29")
                    .contentType(MediaType.APPLICATION_JSON)
            )
            .andExpect(status().isOk())
            .andDo(print());
    ;
    //then
}
}

 

 

 

날짜 관련 부분 에 있어 잘못된 데이터 를 받을 때 테스트를 위한 총 2가지를 작성하였다.

 

이렇게 테스트 를 작성할때 webmvctest 를 작성해야할지 아니면 springboottest 를 할지 고민이 많을것이다. 

이둘은 분명한 차이가 있으니 명확히 하고 지나가자 

바로 스프링 공홈 으로 가보자.

 

The 
@SpringBootTest annotation tells Spring Boot to look for a main configuration class (one with 
@SpringBootApplication
, for instance) and use that to start a Spring application context. You can run this test in your IDE or on the command line

스프링 부트 테스트 어노테이션 은 스프링 부트 의 기본구성 을 찾고 , 스프링 어플리케이션 컨텍스트 를 실행할때 사용한다고 한다
응 뭔소리인가 ?
SpringApplication을 통해 테스트에 사용되는 ApplicationContext를 생성하여 작동한다 라고 나와있다 이말은 즉슨 스프링실행 및 구성 을 위해 필요한 빈들을 모조리 등록해서 테스트에 임한다고 생각하면 된다.

 

그렇다면 WebMvcTest 는 ?

If you want to focus only on the web layer and not start a complete ApplicationContext, consider using @WebMvcTest instead.

설명을 타고 내려가다 보면 이렇게 명시 되어 있다. 완저히 어플리케이션을 실행하지 않고 웹레이어 에 초점을 맞추고 싶다면, mvcTest 를 고려하라고 말이다. 

 

@WebMvcTest auto-configures the Spring MVC infrastructure and limits scanned beans to  @Controller, 
@ControllerAdvice, @JsonComponent, Converter, GenericConverter, Filter, HandlerInterceptor, WebMvcConfigurer, WebMvcRegistrations, and HandlerMethodArgumentResolver

이렇게 나와 있다. 컨트톨러,어드바이스,제이슨 컴포넌트 ,컴버터 드등 이 제한적으로 스캔되어 스프링 어플리 케이션을 구성한다고 나온다. 이말은 다시말해 스프링 부트 테스트 와달리 테스트 에 필요한 최소한의 빈 스캔만 한다고 생각하면 된다.


이에 필요한 빈들이 있다면 @MockBean 을 이용해 빈을 대체 해주어 컨트롤러 본연 만 테스트 를 하면 된다.

 

이렇게 보면 단점 과 장점이 한눈에 들어올 것이다.

webmvctest 는 빠르다 대신 모든 테스트가 통과한다고 해서, 실제 모든빈이 등록된 부트 의 어플리케이션 상에서 오류 를 야기할수있다.

springboottest 는 실제 모든 빈을 등록 해 실행 하기 때문에 실제 프로덕트 환경과 가장 유사한 테스트 를 제공하나 , 시간이 오래걸릴수 있다. 
본인이 추가하고자 하는 테스트 코드를 작성해서 UnitTesting을 할지 e2e테스트 를 할지 정해서 하면 될것같다.
두개 모두 필요한 테스트 이다.

이런식의 로컬데이트 로 데이터를 받아오는 컨트롤러 뭐 특별할것 없다. 주석으로 예시를 적어 보다 직관적으로 만들어 주어야겠다.

과정을 쓰고싶었는데 어제 날라 가는 바람에 이렇게 결과물 밖에 보여줄수가 없다 .. ㅠ 

유저 의 이메일을 가져오고 상태값을 비교하기 위해 저렇게 함수로 빼주었다. 다음 서비스 함수에서도 사용 될거 같아 저렇게 작성했다.

데이트 의 형태가  "2022-09-30" 이러한 형식으로 오기 때문에 , 월별로 데이터를 얻기위해 파싱을 진행했다. 추후 함수 로 빼던가 해서 보다 직관적인 형태로 만들어볼까 생각중이다.


테스트 코드는 아래와 같이 작성하였다.

더보기
@Nested
@DisplayName("멤버 일정 월 서비스 테스트")
class month{

    @Test
    @DisplayName("멤버 일정 월 서비스 실패 [로그인 유저 미일치]")
    public void fail1() throws Exception{
        //given

        //when
        MemberException exception = assertThrows(
                MemberException.class,
                ()->memberCalendarService.getMonthCalendar(LocalDate.now()));
        //then
        assertEquals(MEMBER_EMAIL_ERROR,exception.getErrorCode());
    }
    @Test
    @DisplayName("멤버 일정 월 서비스 실패 [정지된 회원]")
    public void fail2() throws Exception{
        Member m = Member.builder().memberStatus(MemberStatus.MEMBER_STATUS_STOP).build();
        //given
        given(memberRepository.findByEmail(any()))
                .willReturn(Optional.of(m));
        //when
        MemberException exception = assertThrows(
                MemberException.class,
                ()->memberCalendarService.getMonthCalendar(LocalDate.now()));
        //then
        assertEquals(MEMBER_STOP_ERROR,exception.getErrorCode());
    }
    @Test
    @DisplayName("멤버 일정 월 서비스 실패 [탈퇴한 회원]")
    public void fail3() throws Exception{
        Member m = Member.builder().memberStatus(MemberStatus.MEMBER_STATUS_WITHDRAW).build();
        //given
        given(memberRepository.findByEmail(any()))
                .willReturn(Optional.of(m));
        //when
        MemberException exception = assertThrows(
                MemberException.class,
                ()->memberCalendarService.getMonthCalendar(LocalDate.now()));
        //then
        assertEquals(MEMBER_WITHDRAW_ERROR,exception.getErrorCode());
    }

    @Test
    @DisplayName("멤버 일정 월 서비스 성공")
    public void success() throws Exception{
        Member m = Member.builder().build();
        //given
        given(memberRepository.findByEmail(any()))
                .willReturn(Optional.of(m));
        doReturn(List.of("2022-09-01","2022-09-30")).when(memberCalendarQueryRepository)
                .searchMemberMonthDate(any(), any(),any());

        //when
        List<String> monthCalendar = memberCalendarService.getMonthCalendar(LocalDate.now());
        //then
        verify(memberCalendarQueryRepository,times(1))
                .searchMemberMonthDate(any(),any(),any());
        assertThat(monthCalendar.size()).isEqualTo(2);
    }
}

1~3번 은 지난번 수 많은 테스트 의 반복으로 숙달 되었을 것이다. 성공 테스트를 보자. 

memberCalendarQuery 를 이용해 dto 없이 바로 뿌려준다. 이유는 바로 데이터 별로 필터를 해서 들고 오는데 굳이 dto 가 필요한가 생각해서 그냥 저렇게 작성해주었다.


테스트는 한번의 실행됨 을 체크하고 반환값의 사이즈를 측정하였다.

 

이 몇줄 안되는 쿼리를 작성 하는라 고민을 조금 많이 했다. 날짜부터 시작해서 이것저것 테스트를 해본후에 이렇게 작성할수 있었다.

월별로 데이터 만 뿌려서 빨간점을 찍어주면 되기때문에 그룹바이 로 중복 처리를 해버렸다. 

 

또한 string 타입을 groupby orderby 쪽에 작성을 바로 못하니 저렇게 stringpath 를 이용해 작성해 넘겨주자.

 

더보기
select
    DATE_FORMAT(groupmeeti1_.meeting_start_date,
    ?) as col_0_0_ 
from
    member_calendar membercale0_ 
inner join
    group_meeting groupmeeti1_ 
        on membercale0_.group_meeting_id=groupmeeti1_.group_meeting_id 
where
    membercale0_.member_id=? 
    and membercale0_.meeting_active_status=? 
    and (
        groupmeeti1_.meeting_start_date between ? and ?
    ) 
group by
    DATE_FORMAT(groupmeeti1_.meeting_start_date,
    ?)

크 생각한 대로 쿼리가 날아간다 만족 스럽다. 

테스트 도 만족 스럽게 통과 된다. 점심먹고 이어서 작성해보자.

 

자 다음 일정 목록을 위한 테스트 코드를 작성해보자.

노션 상에 일자를 받지 않아서 이렇게일자를 받는 방식으로 변경해 주었다.

통과 되기위해 컨트롤러 코드를 작성해보자.

@GetMapping("/list")
public ApiResult<?> getDayCalendar(
        // date type example "2022-09-30"
        @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) @Valid LocalDate date
){
//        memberService.getDayCalendar(date);
    return ApiUtils.success(null);
}

바로 통과 된다 기분이 매우좋다 ㅎㅎ

 

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

 

위의 서비스와 동일한 테스트에 한가지를 더 추가 하였다 물론 프론트 에서 달력 을 클릭할때 일정이 있는경우만 클릭하게 만들수 있으나, 2번 체크해서 나쁠거 없다고 생각해서 위와같은 방어선을 하나 더 추가했다 일정이 없는경우 에러를 터트려 400을 리턴할 생각이다.

 

아직 서비스가 만들어 지지않아 저렇게 빨간 불이 들어온다.
지난번 만든 함수 get member  를 이용해서  1~3번 까지 의 모든 테스트 를 통과 해 주자. 

4번 과 5번의 테스트 를 진행하기 전에 먼저 프론트에서 어떻게 데이터를 받아야 보다 편하게 다음 페이지로 넘겨줄수 있을까 에  대해 고민을 해보자.

1. 그룹미팅 아이디 가 포함되어 있어야 디테일을 확인할때 Api 를 쏴서 편하게 확인할수 있다.

2. 그룹 의 이름 정도는 표기 해 주는게 맞지 않겠는가 이름 도 넣어주자.

3. 시간 이 제일 중요하다 시간에 따라 표기 true flase 를 이용해  화면에 랜더링 해줄테니

 

요렇게 3개의 데이터만 넘겨주면 되니 이걸 위한 dto 를 작성해주자.

@Getter
@Setter
@Builder
@NoArgsConstructor
public class CalendarDayListDto {
    private Long groupMeetingId;
    private String groupMeetingTitle;
    private LocalDateTime startDate;
    private LocalDateTime endDate;

    @QueryProjection
    public CalendarDayListDto(Long groupMeetingId, String groupMeetingTitle, LocalDateTime startDate, LocalDateTime endDate) {
        this.groupMeetingId = groupMeetingId;
        this.groupMeetingTitle = groupMeetingTitle;
        this.startDate = startDate;
        this.endDate = endDate;
    }
}

Dto 를 작성하다 보니 시간 의 범위를 주어야 프론트에서 랜더링 하기가 편하다 그래서 이렇게 End 도 하나 추가 해 주었다.

 

테스트 코드 작성을 마무리 해보자.

 

@Test
@DisplayName("멤버 일정 일 서비스 실패 [일정이 없는 경우]")
public void fail4() throws Exception{
    Member m = Member.builder().build();
    //given
    given(memberRepository.findByEmail(any()))
            .willReturn(Optional.of(m));
    doReturn(null).when(memberCalendarQueryRepository)
            .searchMemberDayDate(any(), any(),any());
    //when
    MemberException exception = assertThrows(
            MemberException.class,
            ()->memberCalendarService.getDayCalendar(LocalDate.now()));
    //then
    assertEquals(MEMBER_CALENDAR_DOES_NOT_EXIST,exception.getErrorCode());
}

@Test
@DisplayName("멤버 일정 월 서비스 성공")
public void success() throws Exception{
    Member m = Member.builder().build();
    CalendarDayDto d = CalendarDayDto.builder().build();
    CalendarDayDto d2 = CalendarDayDto.builder().build();
    //given
    given(memberRepository.findByEmail(any()))
            .willReturn(Optional.of(m));
    doReturn(List.of(d,d2)).when(memberCalendarQueryRepository)
            .searchMemberDayDate(any(), any(),any());

    //when
    List<CalendarDayDto> dayCalendar = memberCalendarService.getDayCalendar(LocalDate.now());
    //then
    verify(memberCalendarQueryRepository,times(1))
            .searchMemberDayDate(any(),any(),any());
    assertThat(dayCalendar.size()).isEqualTo(2);
}

 

 

레포지토리 마무리 하러 가보자.

dto 로 바로 받고 싶어서 querydsl 사용 했다 저 where 부분이 겹치니 함수로 따로 만들어주자.

더보기
public List<CalendarDayDto> searchMemberDayDate(Long memberId, LocalDateTime startDate, LocalDateTime endDate){

    return jpaQueryFactory.select(
                    new QCalendarDayDto(
                            groupMeeting.id,
                            groupMeeting.title,
                            groupMeeting.meetingStartDate,
                            groupMeeting.meetingEndDate
                    )
            )
            .from(memberCalendar)
            .join(memberCalendar.groupMeeting, groupMeeting)
            .where(
                    validateMemberCalendar(memberId,startDate,endDate)
            )
            .orderBy(groupMeeting.meetingStartDate.asc())
            .fetch();
}


private BooleanExpression validateMemberCalendar(Long id,LocalDateTime start,LocalDateTime end){
    return memberIdEq(id).and(meetingActiveStatus().and(betweenDate(start,end)));
}
private BooleanExpression memberIdEq(Long id){
    return memberCalendar.member.id.eq(id);
}
private BooleanExpression meetingActiveStatus(){
    return memberCalendar.meetingActiveStatus.eq(MEETING_ACTIVE_STATUS_ING);
}
private BooleanExpression betweenDate(LocalDateTime start,LocalDateTime end){
    return memberCalendar.groupMeeting.meetingStartDate.between(start, end);
}

서비스 코드를 마무리 해보자. 

테스트 는 모두 통과 된다 ㅎㅎ

 

사실 중간에 Null 리턴해서 nullPoitner 예외 터져서 비어있는 리스트로 변경해주었다... ㅎㅎ

 

서버를 띄우고 테스트를 해보자.

 

모두 만족스럽게 나간다 다만 시간 부분에 있어 포맷팅을 조금 해주어야 프론트가 편할꺼 같으니 포메팅을 하러가자.

 

더보기
@Data
@Builder
public class CalendarDay {
    private Long groupMeetingId;
    private String groupMeetingTitle;
    private String startDate;
    private String endDate;

    public static List<CalendarDay> from(List<CalendarDayDto> res){
         return res.stream().map(
                 (i)->CalendarDay.builder()
                         .groupMeetingId(i.getGroupMeetingId())
                         .groupMeetingTitle(i.getGroupMeetingTitle())
                         .startDate(dateParseToString(i.getStartDate()))
                         .endDate(dateParseToString(i.getEndDate()))
                         .build()
         ).collect(Collectors.toList());
    }

    private static String dateParseToString(LocalDateTime time){
        // 정책 에 따라 파싱 하는 방법이 달라져야 한다.
        int hour = time.getHour();
        int minute = time.getMinute();
        if(minute > 0) hour++;

        StringBuilder sb = new StringBuilder();

        if(hour < 10){
            sb.append("0").append(hour);
        }else{
            sb.append(hour);
        }

        sb.append(" : ").append("00");

        return sb.toString();
    }
}

 

클래스하나 더만들어서 포맷팅 해주었다 현재 시간 단위로만 예약이 가능하기 떄문에 이렇게 작성을 한것이고 추후 서비스 정책이 바뀐다면 이 파싱하는 함수 또한 바뀌어야할것이다.

최종  이렇게 리턴을 해주게 된다. ㅎㅎ 

 

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

delete 부분 까지 달리고 pr 을 날려주도록 하자.

지난번 하면서 유의해야할 부분으로는 jpa 에서 쿼리 짜서 보내야 쿼리가 2방 안나가는것 그것만 조심하면 될것같다.

 

바로 컨트롤러 테스트 들어가자

지난번 사용한거 들고오자 

더보기
@DeleteMapping("{memberContentId}/comment/{commentId}")
public ResponseEntity<?> deleteContentComment(
        @PathVariable("memberContentId") Long memberContentId,
        @PathVariable("commentId") Long commentId
){

//        memberContentService.contentConmmentDeltet(memberContentId,commentId);

    return new ResponseEntity<>(
            CommonResponse.ok(),
            HttpStatus.OK
    );
}

테스트 는 통과 된다.


삭제 의 실패 케이스 는 서비스 쪽에서 테스트 해주자.

 

이렇게 테스트 해주면 될것 같다.

1~4 번은 수정 과 유사하니 한번에 테스트 하겠다.

 

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

@Test
@DisplayName("멤버 피드 댓글 삭제 실패 [피드 가 존재하지 않는 경우]")
public void doesNotExistFeed() throws Exception{
    //given
    doReturn(Optional.of(m))
            .when(memberRepository).findByEmail(any());
    //when
    MemberException exception = assertThrows(MemberException.class,
            ()->memberContentService.contentConmmentDeltet(contentId,
                    commentId));
    //then
    assertEquals(MemberErrorCode.MEMBER_CONTENT_DOES_NOT_EXIST,exception.getErrorCode());
}

@Test
@DisplayName("멤버 피드 댓글 삭제 실패 [피드가 삭제된 경우]")
public void hasDeletedFeed() throws Exception{
    //given
    doReturn(Optional.of(m))
            .when(memberRepository).findByEmail(any());
    doReturn(Optional.of(MemberContent.builder().deletedYn(true).build()))
            .when(memberContentRepository).findById(any());
    //when
    MemberException exception = assertThrows(MemberException.class,
            ()->memberContentService.contentConmmentDeltet(contentId,
                    commentId));
    //then
    assertEquals(MemberErrorCode.MEMBER_CONTENT_DELETED,exception.getErrorCode());
}
@Test
@DisplayName("멤버 피드 댓글 삭제 실패 [댓글 이 존재하지 않는 경우]")
public void doesNotExistComment() throws Exception{
    //given
    doReturn(Optional.of(m))
            .when(memberRepository).findByEmail(any());
    doReturn(Optional.of(MemberContent.builder().build()))
            .when(memberContentRepository).findById(any());
    //when
    MemberException exception = assertThrows(MemberException.class,
            ()->memberContentService.contentConmmentDeltet(contentId,
                    commentId));
    //then
    assertEquals(MemberErrorCode.MEMBER_COMMENT_DOES_NOT_EXIST,exception.getErrorCode());
}

@Test
@DisplayName("멤버 피드 댓글 삭제 실패 [댓글 이 삭제된 경우]")
public void hasDeletedComment() throws Exception{
    //given
    doReturn(Optional.of(m))
            .when(memberRepository).findByEmail(any());
    doReturn(Optional.of(mc)).when(memberContentRepository).findById(any());
    doReturn(Optional.of(MemberContentComment.builder()
            .deletedYn(true)
            .build()))
            .when(memberContentCommentRepository)
            .findByIdAndMemberAndMemberContent(commentId,m,mc);
    //when
    MemberException exception = assertThrows(MemberException.class,
            ()->memberContentService.contentConmmentDeltet(contentId,
                    commentId));
    //then
    assertEquals(MemberErrorCode.MEMBER_COMMENT_DELETED,exception.getErrorCode());
}

성공 케이스 테스트 를 하러가자.

테스트 코드

더보기
@Test
@DisplayName("멤버 피드 댓글 삭제 성공")
public void success() throws Exception{

    //given
    doReturn(Optional.of(m))
            .when(memberRepository).findByEmail(any());
    doReturn(Optional.of(mc)).when(memberContentRepository).findById(any());
    doReturn(Optional.of(MemberContentComment.builder().build()))
            .when(memberContentCommentRepository)
            .findByIdAndMemberAndMemberContent(commentId,m,mc);

    //when
    memberContentService.contentConmmentDeltet(contentId,commentId);
    //then
    verify(memberContentCommentRepository,times(1)).deleteById(any());
}

서비스,레포지토리 코드

더보기
@Modifying
@Query("delete from MemberContentComment mcc where mcc.id = :commentId")
void deleteById(@Param("commentId") Long commentId);

//Service

@Override
public void contentConmmentDeltet(Long memberContentId, Long commentId) {
    Member m = validCheckLoggedInUser();
    MemberContent mc = getContent(memberContentId);
    MemberContentComment memberContentComment = getMemberContentComment(commentId, m, mc);

    memberContentCommentRepository.deleteById(memberContentComment.getId());
}

테스트 는 통과되는데 음 커맨트 좋아요 가 있지 않겠는가 ? 그것도 날려줘야한다. 까먹고 있엇다.

 

memberContentCommentLikeRepository.deleteAllByMemberContentCommentIn(
        new ArrayList<>(List.of(memberContentComment))
);

지난번 피드 만들때 생성한 함수를 이용하자 서비스 코드 쪽에 추가해주자 .

 

부트 를 띄워서 테스트 를 해보자. 

멤버,피드,댓글 가져오는 쿼리 총 3개, 지우는 쿼리 2개

 

다시 생각해보니깐 우리 워크듀오는 지우지 않고 피드 를 바꿔준다. 업데이트 쿼리 1개로 바꿔주고 terminate 라는 함수를 구현해주자.

public void terminate(){
    this.deletedAt = LocalDateTime.now();
    this.deletedYn = true;
}

삭제된 시간 과 불리언 값을 업데이트 해주는것이다. 서버를 띄워서 테스트 해보자.

멤버,피드,댓글 가져오는 쿼리 총 3개, 지우는 쿼리 1개 업데이트 쿼리 1개

 

더보기
2022-09-28 22:01:32.021 DEBUG 76860 --- [nio-8080-exec-2] org.hibernate.SQL                        : 
    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=?
2022-09-28 22:01:32.023  INFO 76860 --- [nio-8080-exec-2] p6spy                                    : #1664370092023 | took 1ms | statement | connection 9| url jdbc:mysql://localhost:3306/workduo?autoReconnect=true&allowPublicKeyRetrieval=true&rewriteBatchedStatements=true
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 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='client2@client.com';
2022-09-28 22:01:32.030 DEBUG 76860 --- [nio-8080-exec-2] org.hibernate.SQL                        : 
    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=?
2022-09-28 22:01:32.034  INFO 76860 --- [nio-8080-exec-2] p6spy                                    : #1664370092034 | took 4ms | statement | connection 9| url jdbc:mysql://localhost:3306/workduo?autoReconnect=true&allowPublicKeyRetrieval=true&rewriteBatchedStatements=true
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=?
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=4;
2022-09-28 22:01:32.044 DEBUG 76860 --- [nio-8080-exec-2] org.hibernate.SQL                        : 
    select
        membercont0_.member_content_comment_id as member_c1_16_,
        membercont0_.created_at as created_2_16_,
        membercont0_.updated_at as updated_3_16_,
        membercont0_.content as content4_16_,
        membercont0_.deleted_at as deleted_5_16_,
        membercont0_.deleted_yn as deleted_6_16_,
        membercont0_.member_id as member_i7_16_,
        membercont0_.member_content_id as member_c8_16_ 
    from
        member_content_comment membercont0_ 
    where
        membercont0_.member_content_comment_id=? 
        and membercont0_.member_id=? 
        and membercont0_.member_content_id=? 
        and membercont0_.deleted_yn=?
2022-09-28 22:01:32.048  INFO 76860 --- [nio-8080-exec-2] p6spy                                    : #1664370092048 | took 2ms | statement | connection 9| url jdbc:mysql://localhost:3306/workduo?autoReconnect=true&allowPublicKeyRetrieval=true&rewriteBatchedStatements=true
select membercont0_.member_content_comment_id as member_c1_16_, membercont0_.created_at as created_2_16_, membercont0_.updated_at as updated_3_16_, membercont0_.content as content4_16_, membercont0_.deleted_at as deleted_5_16_, membercont0_.deleted_yn as deleted_6_16_, membercont0_.member_id as member_i7_16_, membercont0_.member_content_id as member_c8_16_ from member_content_comment membercont0_ where membercont0_.member_content_comment_id=? and membercont0_.member_id=? and membercont0_.member_content_id=? and membercont0_.deleted_yn=?
select membercont0_.member_content_comment_id as member_c1_16_, membercont0_.created_at as created_2_16_, membercont0_.updated_at as updated_3_16_, membercont0_.content as content4_16_, membercont0_.deleted_at as deleted_5_16_, membercont0_.deleted_yn as deleted_6_16_, membercont0_.member_id as member_i7_16_, membercont0_.member_content_id as member_c8_16_ from member_content_comment membercont0_ where membercont0_.member_content_comment_id=10 and membercont0_.member_id=2 and membercont0_.member_content_id=4 and membercont0_.deleted_yn=false;
2022-09-28 22:01:32.056 DEBUG 76860 --- [nio-8080-exec-2] org.hibernate.SQL                        : 
    delete 
    from
        member_content_comment_like 
    where
        member_content_comment_id in (
            ?
        )
2022-09-28 22:01:32.063  INFO 76860 --- [nio-8080-exec-2] p6spy                                    : #1664370092063 | took 7ms | statement | connection 9| url jdbc:mysql://localhost:3306/workduo?autoReconnect=true&allowPublicKeyRetrieval=true&rewriteBatchedStatements=true
delete from member_content_comment_like where member_content_comment_id in (?)
delete from member_content_comment_like where member_content_comment_id in (10);
2022-09-28 22:01:32.087 DEBUG 76860 --- [nio-8080-exec-2] org.hibernate.SQL                        : 
    update
        member_content_comment 
    set
        updated_at=?,
        content=?,
        deleted_at=?,
        deleted_yn=?,
        member_id=?,
        member_content_id=? 
    where
        member_content_comment_id=?

크 만족 스럽게 나간다.

 

이 딜리트 yn 을 업데이트 함에 따라 커멘트 가져오는 함수를 변경하였다.

@Transactional(readOnly = true)
protected MemberContentComment getMemberContentComment(Long commentId, Member m, MemberContent mc) {
    MemberContentComment memberContentComment = memberContentCommentRepository.
            findByIdAndMemberAndMemberContentAndDeletedYn(commentId, m, mc,false)
            .orElseThrow(() -> new MemberException(MEMBER_COMMENT_DOES_NOT_EXIST));
    isCommentDeleted(memberContentComment);
    return memberContentComment;
}

이렇게 해야 삭제 안된 올바른 쿼리값들을 들고온다.

 

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

오늘 피드 댓글 쪽 끝낼 생각으로 달려본다.

 

업데이트 관련 부분은 처음 포스팅한다. 업데이트 관련 http 메서드 중 patch와 put 이 있다. 이 차이를 몰라서 지난번에 한번 검색한 적 이 있는데 적어볼까 한다.

간단히 구글링 먼저 해보자 Difference between PUT and PATCH request 아래처럼 자세하게 설명이 나온다.

Put Patch
PUT is a method of modifying resource where the client sends data that updates the entire resource . PATCH is a method of modifying resources where the client sends partial data that is to be updated without modifying the entire data.

Put => 클라이언트 측에 서 보낸 데이터를 이용해 전체 자원을 업데이트 한다.

Patch => 업데이트되어야 할 부분의 데이터만 보내서 전체 자원을 수정하지 않고 업데이트한다.

 

심플하다 그런데 단지 이 이유만으로 http method를 이렇게 구분 지을 필요가 없다고 생각한다. 조금 더 자세하게 검색해보자.

검색하다 보면 이런 단어 가 자주 보인다 Idempotence  발음 도 힘들게 시리 멱등법칙 이라고 한다. 한글도 어렵다 ;;;

의미를 보자면 연산을 여러 번 적용하더라도 결과가 달라지지 않는 성질을 의미한다.

 

Put 은 멱등성을 지녀 여러 번 request를 보내더라도,한번의 request 를 보낸 결과와 같다. 오케이 인정한다
그렇다면 Patch는?

멱등성을 지니지 않는다 따라서 request를 N번 재시도하면 서버에 생성된 N개의 서로 다른 N개의 자원이 생긴다.라고 알려준다.
응? 

 

동일한 데이터를 이용해서 patch를 보내면 항상 같은 값을 리턴한다.

 

ex) Patch /user/1 => {email :"guiwoo@hotamil.com"} 

위 요청을 100만 번 쏘더라도 100만 번 같은 결과 값을 가진다. 검색해보자. 위키피디아 가  틀렸을 리가 없는데.. 

음 ㅋㅋㅋ 나와 같은 고민을 한 사람이 있다. (궁금하면 보러 가기)

 

요약해서 설명하자면 patch를 이용해서 user를 생성하는 operation 이 있는 api 가 있다고 가정해보자. 그렇게 되면 패치를 넣을 때마다. 유저의 개수는 증가해서 결과가 달라진다. 이래서 멱등성을 지니지 않는다고 한다.

이건 예제에 따라 엄청난 차이가 있다고 생각한다. 사용하는 방법에 따라 벽등이 될 수도 아닐 수도 있다는 의미 아닌가? 

 

구글 첫 번째 설명을 읽고 patch 가 항상 좋은 줄 알았는데 실버 불릿이 아니었던 거다 ㅎㅎ

 

긱포긱 에도 설명이 잘 나와 있는데 거기서 처음 보는 차이점을 발견했다. 
즉 전송되는 데이터가 전체 vs 일부분 이기 때문에 이에 따른 전송속도? 대역 폭의 차이에 대해서도 설명해주고 있다.

put 이 높고 patch는 전송해야 하는 일부분이기 때문에 대역폭? 이 낮다고 설명되어 있다.


그렇다면 전송해야 하는 데이터에 따라 다르 게 볼 수 있다. 만약에 patch를 이용해 데이터 전부를 보내면? 둘이 같은 대역폭 인 게 아닌가? 이게 차이점이 될 수 있나? 속도의 측에서 차이가 나나?


속도의 측면에서는 어느 게 빠르다고 딱히 단정할 수 없다고 한다 궁금해서 검색해 봤다 ㅎㅎ

 

그리고 애초에 put을 사용하지 patch 를 사용 하지 않는다 ㅎㅎ 바보다 ㅋㅋ 그렇다면 전송 데이터의 값에 따라 patch, put을 선택해서 사용하면 될 것 같다. 

 

서론이 엄청 길었다 기본적인 차이점만 알고 있었지 찾다 보니 시간이 많이 흘렀다. 바로 컨트롤러 테스트 구현해보자. 

이제 이 정도 했으면 테스트 통달했다. 바로 컨트롤러 작성해주러 가보자.

깔끔하게 통과한다. 음? 뭐하나 빼먹었다. ㅎㅎ 업데이트인데 코멘트를 받지 않는다 response body 받을 클래스 만들어주자

더보기
package com.workduo.member.content.dto;

import lombok.*;

import javax.validation.constraints.NotBlank;

public class ContentCommentUpdate {
    
    @Getter
    @Setter
    @AllArgsConstructor
    @NoArgsConstructor
    @Builder
    public static class Request{
        
        @NotBlank
        private String comment;
        
    }
    
}

이에 따른 바인딩 검증 추가를 테스트로 추가적으로 해주면 될듯하다. 지난번 이용한 notblank를 이용해서 작성하자.

최종 테스트 코드

@Nested
@DisplayName("멤버 피드 댓글 업데이트 API 테스트")
class contentCommentListUpdate{

    @Test
    @DisplayName("멤버 피드 댓글 업데이트 실패 [리퀘스트 검증 테스트]")
    public void NotBlankTest() throws Exception{
        ContentCommentUpdate.Request req =
                ContentCommentUpdate.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);
    }

    @Test
    @DisplayName("멤버 피드 댓글 업데이트 성공")
    public void successCommentList() throws Exception{

        ContentCommentUpdate.Request req
                = ContentCommentUpdate.Request.builder().comment("test").build();

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

 

자 업데이트를 위한 서비스 코드를 작성해야 하는데 이번에는 예외처리를 빡빡하게 해야 하니 미리 정해보고 가자

 

1. 로그인 유저 미일치

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

3. 피드 가 삭제된 경우

4. 로그인 한 유저가 작성한 댓글 이 아닌 경우

5. 댓글 이 존재하지 않는 경우

6. 댓글 이 삭제된 경우

 

이 정도 생각  이 나는데 추가적으로 뭐가  더 있을지 고민해보자 흠... 일단 발리 데이트 함수에 전부 때려 박고 추후에 업데이트 날려주자  

어우 길다 1 ~ 3번까지는 코드의 반복이기에  지난번 코드를 가져와서 사용하겠습니다.

 

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

@Test
@DisplayName("멤버 피드 댓글 업데이트 실패 [피드 가 존재하지 않는 경우]")
public void doesNotExistFeed() throws Exception{
    //given
    doReturn(Optional.of(m))
            .when(memberRepository).findByEmail(any());
    //when
    MemberException exception = assertThrows(MemberException.class,
            ()->memberContentService.contentCommentUpdate(contentId,
                    commentId,req));
    //then
    assertEquals(MemberErrorCode.MEMBER_CONTENT_DOES_NOT_EXIST,exception.getErrorCode());
}

@Test
@DisplayName("멤버 피드 댓글 업데이트 실패 [피드가 삭제된 경우]")
public void hasDeletedFeed() throws Exception{
    //given
    doReturn(Optional.of(m))
            .when(memberRepository).findByEmail(any());
    doReturn(Optional.of(MemberContent.builder().deletedYn(true).build()))
            .when(memberContentRepository).findById(any());
    //when
    MemberException exception = assertThrows(MemberException.class,
            ()->memberContentService.contentCommentUpdate(contentId,
                    commentId,req));
    //then
    assertEquals(MemberErrorCode.MEMBER_CONTENT_DELETED,exception.getErrorCode());
}

이렇게1~3번 테스트 가 통과한다 서비스 코드는 단 2줄이면 클리어

 

4번 테스트 코드

생각을 해봤는데 댓글의 권한보다 존재 여부를 먼저 파악하는 게 우선인 거 같아서 순서를 바꿨다.

쿼리를 짜다가 문득 아니 댓글 아이디도 알고 코멘트도 알고, 멤버도 알고 있는데 굳이 자바에 들고 와서 해야 하는가? 딜레마에 빠졌다.

select * member_content_comment mcc where mcc.member_content_comment_id = 9;

select * from member_content_comment mcc
where mcc.member_content_comment_id = 9 and member_content_id = 4 and member_id = 2;

별반 차이가 없다 둘 다 인덱스 조회로 들고 온다. 그렇다면 어떤 걸 해야 할까? 


https://stackoverflow.com/questions/7976374/filter-data-in-sql-or-in-java 

 

Filter data in SQL or in Java?

What is the general guideline/rule for filtering data? I am accustomed to seeing filters in an SQL statement in the WHERE clause, although there are occasions that filters introduce complexity to t...

stackoverflow.com

여기에는 상황에 맞게 사용하라고 한다.  

이번에는 그럼 한방에 들고 오면 코드의 양도 줄 것으로 예상되고 그렇게 어려운 쿼리가 아니니깐 한방에 들고 오자. 

 

//REPOSITORY
Optional<MemberContentComment> findByIdAndMemberAndMemberContent(
            Long mcc,Member m, MemberContent mc);

//TEST
@Test
@DisplayName("멤버 피드 댓글 업데이트 실패 [댓글 이 존재하지 않는 경우]")
public void doesNotExistComment() throws Exception{
    //given
    doReturn(Optional.of(m))
            .when(memberRepository).findByEmail(any());
    doReturn(Optional.of(MemberContent.builder().build()))
            .when(memberContentRepository).findById(any());
    //when
    MemberException exception = assertThrows(MemberException.class,
            ()->memberContentService.contentCommentUpdate(contentId,
                    commentId,req));
    //then
    assertEquals(MemberErrorCode.MEMBER_COMMENT_DOES_NOT_EXIST,exception.getErrorCode());
}

//SERVICE
@Override
public void contentCommentUpdate(Long memberContentId, Long commentId, ContentCommentUpdate.Request req) {
    Member m = validCheckLoggedInUser();
    MemberContent mc = getContent(commentId);
    isContentDeleted(mc);

    MemberContentComment memberContentComment = memberContentCommentRepository.
            findByIdAndMemberAndMemberContent(commentId, m, mc)
            .orElseThrow(() -> new MemberException(MEMBER_COMMENT_DOES_NOT_EXIST));

}

테스트가 통과된다 굿

 

5번 테스트 코드

@Test
@DisplayName("멤버 피드 댓글 업데이트 실패 [댓글 이 삭제된 경우]")
public void hasDeletedComment() throws Exception{
    //given
    doReturn(Optional.of(m))
            .when(memberRepository).findByEmail(any());
    doReturn(Optional.of(mc)).when(memberContentRepository).findById(any());
    doReturn(Optional.of(MemberContentComment.builder()
            .deletedYn(true)
            .build()))
            .when(memberContentCommentRepository)
            .findByIdAndMemberAndMemberContent(commentId,m,mc);
    //when
    MemberException exception = assertThrows(MemberException.class,
            ()->memberContentService.contentCommentUpdate(contentId,
                    commentId,req));
    //then
    assertEquals(MemberErrorCode.MEMBER_COMMENT_DELETED,exception.getErrorCode());
}

//SERVICE
@Override
public void contentCommentUpdate(Long memberContentId, Long commentId, ContentCommentUpdate.Request req) {
    Member m = validCheckLoggedInUser();
    MemberContent mc = getContent(commentId);
    isContentDeleted(mc);

    MemberContentComment memberContentComment = memberContentCommentRepository.
            findByIdAndMemberAndMemberContent(commentId, m, mc)
            .orElseThrow(() -> new MemberException(MEMBER_COMMENT_DOES_NOT_EXIST));

    if(memberContentComment.isDeletedYn()){
        throw new MemberException(MEMBER_COMMENT_DELETED);
    }

}

작성하다 보니 권한이 없는 경우는 발생될 수가 없다.

토큰 인증받은 유저의 아이디와 피드 아이디 코멘트 아이디로 조회해온 댓글이
로그인한 유저와 맞지 않을 수 있는 방법이 있는가?  이 리퀘스트 가 진행되는 동안에는 발생할 수 없는 경우인 것 같지만

혹시 모르니 테스트 코드만 내버려두고 넘어가자.

 

6번 테스트 성공 케이스

@Test
@DisplayName("멤버 피드 댓글 업데이트 성공")
public void success() throws Exception{
    MemberContentComment build = MemberContentComment.builder().build();
    MemberContentComment spy = spy(build);
    //given
    doReturn(Optional.of(m))
            .when(memberRepository).findByEmail(any());
    doReturn(Optional.of(mc)).when(memberContentRepository).findById(any());
    doReturn(Optional.of(spy))
            .when(memberContentCommentRepository)
            .findByIdAndMemberAndMemberContent(commentId,m,mc);

    //when
    memberContentService.contentCommentUpdate
            (contentId,commentId,req);
    //then
    verify(spy,times(1)).updateComment(any());
 }
 
 //SERVICE
 @Override
public void contentCommentUpdate(Long memberContentId, Long commentId, ContentCommentUpdate.Request req) {
    Member m = validCheckLoggedInUser();
    MemberContent mc = getContent(commentId);
    isContentDeleted(mc);

    MemberContentComment memberContentComment = memberContentCommentRepository.
            findByIdAndMemberAndMemberContent(commentId, m, mc)
            .orElseThrow(() -> new MemberException(MEMBER_COMMENT_DOES_NOT_EXIST));

    if(memberContentComment.isDeletedYn()){
        throw new MemberException(MEMBER_COMMENT_DELETED);
    }
    memberContentComment.updateComment(req.getComment());
}

가볍게 통과된다 저 스파이 기법은 전에도 설명했다시피 해당 인스턴스의 함수가 실행되는지 확인해서 함수의 마지막까지 실행되는지 그 여부를 파악했다.

 

모든 테스트 가 통과된다.

서버를 띄우고 실행해보자. 쿼리는 총 멤버 찾는 쿼리, 컨탠트 찾는쿼리, 커맨트 찾는쿼리 3개 에 업데이트 쿼리 1방 총 4번이다.

에구.. 저기 memberContent 아이디 똑바로 안 넣어줘서 피드를 찾지 못하는 에러가 나왔다... ㅠ 

// 멤버 가져오는 쿼리 1방
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=?
                      
// Content 가져오는 쿼리 1방
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=?
        
// Comment 가져오는 쿼리 1방

select
    membercont0_.member_content_comment_id as member_c1_16_,
    membercont0_.created_at as created_2_16_,
    membercont0_.updated_at as updated_3_16_,
    membercont0_.content as content4_16_,
    membercont0_.deleted_at as deleted_5_16_,
    membercont0_.deleted_yn as deleted_6_16_,
    membercont0_.member_id as member_i7_16_,
    membercont0_.member_content_id as member_c8_16_ 
from
    member_content_comment membercont0_ 
where
    membercont0_.member_content_comment_id=? 
    and membercont0_.member_id=? 
    and membercont0_.member_content_id=?
        
// Update 쿼리 한방

update
    member_content_comment 
set
    updated_at=?,
    content=?,
    deleted_at=?,
    deleted_yn=?,
    member_id=?,
    member_content_id=? 
where
    member_content_comment_id=?

코멘트에 where 절에 and로 묶여서 잘 날아간다. ㅎㅎ 기분 이 좋다

 

자 이제 서비스 부분의 코드를 리펙토링 하자.

ㅎㅎ 저기 코멘트 가져오는 코드는 다음 API에서도 사용할 거 같아 따로 빼주었다  삭제 체크하는 함수도 따로 빼주고 매우 맘에 든다.

 

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

지난번에 이어 이번에는 댓글 조회 기능 을 구현할 생각이다. 페이징 이 들어가야 해서 querydsl 로 구현 해야한다. 

jpql 로 한다면 ㅎㅎ.... string 잘라서 이어붙이고 있을 나 를 생각해보면 끔찍하다.

 

지난글 pr 을 머지 하고 조회 쪽을 하자 .

 

지난번 pr 브런치 삭제해주고, pull master 땡겨주고 시작해보자.

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")

바로 컨트롤러 테스트 작성 부터 가자

더보기
@Test
@DisplayName("멤버 피드 댓글 리스트 성공")
public void successCommentList() throws Exception{
    mockMvc.perform(get("/api/v1/member/content/3/comment")
                    .contentType(MediaType.APPLICATION_JSON)
            )
            .andExpect(status().isOk())
            .andExpect(
                    jsonPath("$.success")
                            .value("T")
            )
            .andDo(print());
}

get 이니 따로 뭐 실패 케이스 테스트 할 그게 없다.

생각 해 보니깐 응답값이 저게 아니다. 커맨트 리스트로 돌려줘야 한다. 이걸위한 리스폰스 클래스를 만들어주자.

더보기
package com.workduo.member.content.dto;

import com.querydsl.core.annotations.QueryProjection;
import lombok.Builder;
import lombok.Data;

import java.time.LocalDateTime;

@Data
@Builder
public class MemberContentCommentDto {
    private Long id;
    private Long memberId;
    private String username;
    private String content;
    private String nickname;
    private String profileImg;
    private Long likeCnt;
    private LocalDateTime createdAt;

    @QueryProjection
    public MemberContentCommentDto(Long id, Long memberId, String username, String content, String nickname, String profileImg, Long likeCnt, LocalDateTime createdAt) {
        this.id = id;
        this.memberId = memberId;
        this.username = username;
        this.content = content;
        this.nickname = nickname;
        this.profileImg = profileImg;
        this.likeCnt = likeCnt;
        this.createdAt = createdAt;
    }
}

이 클래스로 querydsl 의 응답값을 Page<MemberContentDto> 로 받을 생각이다.

이렇게 가공해서 프론트 로 뿌려주자 정책에 적힌 것처럼 반환이 될것으로 예상된다.

리턴 값도 바꿔주자

@GetMapping("{memberContentId}/comment")
public ResponseEntity<?> getCommentList(
        Pageable pageable,
        @PathVariable("memberContentId") Long memberContentId){
    ContentCommentList.Response result = 
            ContentCommentList.Response.from(
                    memberContentService
                            .getContentCommentList(memberContentId, pageable));

    return new ResponseEntity<>(
            result,
            HttpStatus.OK
    );
}


저 서비스 없으면 바로 통과된다. pageable 을 인자로 받아 주자.

 

그러면 pageable 객체를 생성한다. 궁금하면 그냥 디버그 찍어보자 ㅎㅎ

좋다 굳이 객체 생성 안해도 되니깐 인자로 넘겨서 제대로 들어가는지 한번더 해보자 해보니깐 노션에 정책중에 offset 대신에 size 로 바꾸어 주어야 할꺼같다. 

"/api/v1/member/content/3/comment?page=3&size=10&sort=test"

현재 디폴트 가 20 으로 되어있기 때문에 일단 진행하자.

 

페이저블 들어가는거 확인 했으니 서비스 테스트 코드 작성하러 가자.

 

1. 멤버 피드 가 존재하지 않는경우

더보기
PageRequest pageRequest = PageRequest.of(0, 20);

@Test
@DisplayName("멤버 피드 댓글 리스트 실패 [피드 가 존재하지 않는 경우]")
public void doesNotExistFeed() throws Exception{
    //when
    MemberException exception = assertThrows(MemberException.class,
            ()->memberContentService.getContentCommentList(12L,pageRequest));
    //then
    assertEquals(MemberErrorCode.MEMBER_CONTENT_DOES_NOT_EXIST
            ,exception.getErrorCode());
}

//service
@Override
@Transactional(readOnly = true)
public Page<MemberContentCommentDto> getContentCommentList(Long memberContentId, Pageable pageable) {
    MemberContent mc = getContent(memberContentId);

    return null;
}

2. 멤버 피드 가 삭제된 경우

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

// service

@Override
@Transactional(readOnly = true)
public Page<MemberContentCommentDto> getContentCommentList(Long memberContentId, Pageable pageable) {
    MemberContent mc = getContent(memberContentId);
    isContentDeleted(mc);

    return null;
}

이 번 페이지는 회원이 아니더라도 누구나 접근이 가능한 요청 이기에  멤버의 발리드를 뺴주면 간단하게 해결된다.

만약 테스트 코드를 복붙한다면 에러난다 왜 ? 필요없는 걸 모킹 했다고 불평한다 .

 

2개의 테스트 가 모두 통과했다. 

 

서비스  코드 의 성공 케이스를 작성해보자.

 

@Test
@DisplayName("멤버 피드 댓글 리스트 성공")
public void sucess() throws Exception{
    //given
    doReturn(Optional.of(MemberContent.builder().build()))
            .when(memberContentRepository).findById(any());
    //when
    memberContentService.getContentCommentList(12L,pageRequest);
    //then
    verify(memberContentQueryRepository,times(1))
            .getCommentByContent(any(),any());
}

단순히 쿼리디에스엘 함수가 호출 되는 여부만 파악하면 이 서비스는 테스트가 완료된다.

 

서비스 코드 작성해주러 가자.

    @Override
    @Transactional(readOnly = true)
    public Page<MemberContentCommentDto> getContentCommentList(Long memberContentId, Pageable pageable) {
        MemberContent mc = getContent(memberContentId);
        isContentDeleted(mc);

        return memberContentQueryRepository.getCommentByContent(memberContentId,pageable);
    }

 

쿼리 만들러가자.

 

@Override
public Page<MemberContentCommentDto> getCommentByContent(Long memberContentId, Pageable pageable) {
    List<MemberContentCommentDto> list = jpaQueryFactory.select(
                    new QMemberContentCommentDto(
                            memberContentComment.id,
                            member.id,
                            member.username,
                            memberContentComment.content,
                            member.nickname,
                            member.profileImg,
                            Expressions.as(
                                    select(memberContentCommentLike.count())
                                            .from(memberContentCommentLike)
                                            .where(
                                                    memberContentCommentLike
                                                            .memberContentComment
                                                            .eq(memberContentComment)
                                            ), "likeCnt"),
                            memberContentComment.createdAt
                    )
            )
            .from(memberContentComment)
            .join(memberContentComment.member,member)
            .join(memberContentComment.memberContent,memberContent)
            .where(
                    memberContentComment.memberContent.id.eq(memberContentId),
                    memberContentComment.deletedYn.eq(false)
            )
            .groupBy(memberContentComment)
            .offset(pageable.getOffset())
            .limit(pageable.getPageSize())
            .orderBy(findByContentComment().toArray(OrderSpecifier[]::new))
            .fetch();

    JPAQuery<Long> countQuery = jpaQueryFactory
            .select(memberContentComment.count())
            .from(memberContentComment)
            .where(memberContentComment.memberContent.id.eq(memberContentId));

    return PageableExecutionUtils.getPage(
            list,
            pageable,
            countQuery::fetchOne);
}

음 쿼리 프로젝션 위에 dto 아래 붙인거 기억하는가 ? 그걸 만들고 querydsl compile 을 우리 코끼리 눌러주면

Q"dto 이름" 요런식으로 클래스를 생성해주는데 이 생성자를 이용해서 쿼리로 받을 데이터 일치시켜주면된다.

사실 엔티티 로 그냥 싸그리 받아서 돌려줘도 되지만, 그러면 tuple 의 혼합된 값으로 받는데 어차피 데이터 가공이 필요한 부분이다.

 

추가적인 쿼리프로젝션 을 생성해서 했다 이게 싫으면
1. Projection.bean() 을 이용해  세터를 이용해 인자들 을 뽑아오면 된다.
("기본생성자 가 없으면 에러나니 주의하자 맨날 이거 안해서 에러난다. ㅎㅎ")

 

2. Projection.filed() 를 이용해 field 이름 별로 필드 찍어서 넣어주면된다.
("게터 세터 무시하고 그냥 필드에 박힘")

 

3. Projection.constructor() 를 이용해 생성자 함수 만들어서 필요인자 넣어주면 된다.

("필요 이상의 인자들도 넣어서 받아 버릴 수 있기 때문에 정확하게 dto 내에 필요한 데이터만 호출해야함")

 

우리 는 Q파일 을 따로 만들어서 진행하기 에 정책상 따라 갔다. 그 이유로는 컴파일 오류 로 인자 오류를 엮어낼수 있다.

컨스터럭터 방식은 컴파일 시점에서는 불평없다가 런타임 시점에 문제가 발생하기 떄문에 이 차이가 있기에 우리는 안정성 있는 방식을 

추구하고자 Q파일 을 컴파일하는 방법으로 진행했다. (그외의 선택지 에는 위의 3가지 방법이 있고 개인적으로 생성자 를 선호한다.)

 

그 외에는 일반적인 쿼리와 다를바 없다. 크 쿼리디에스엘 이 정말 직관적이여서 코드가 길어도 한번에 이해가 간다.

 

밑에는 리턴 값을 타입에 맞춰주기 위해 가공을 해서 반환을 하게 된다.

 

저기서 orderby 부분 에 함수를 한번 보자.

private List<OrderSpecifier> findByContentComment() {
    StringPath aliasCommentLikes = Expressions.stringPath("likeCnt");

    List<OrderSpecifier> orders = new ArrayList<>(List.of(
            new OrderSpecifier<>(DESC, aliasCommentLikes),
            new OrderSpecifier<>(DESC, memberContentComment.createdAt)
    ));

    return orders;
}

List<OrderSpecifier> 를 이용해 원하는 오더 방식을 여기에 넣어주면 된다. 디폴트 값으로 좋아요 순으로 정렬되도록 넣어주었다.

추후 시간순의 정렬이 필요한 부분이면 if 분기 태워서 추가해주면 될꺼같다.

 

자 테스트 를 돌리러 가보자 

더보기
@Test
@DisplayName("멤버 피드 댓글 리스트 성공")
public void sucess() throws Exception{
    //given
    doReturn(Optional.of(MemberContent.builder().build()))
            .when(memberContentRepository).findById(any());
    //when
    memberContentService.getContentCommentList(12L,pageRequest);
    //then
    verify(memberContentQueryRepository,times(1))
            .getCommentByContent(any(),any());
}

//service

@Override
@Transactional(readOnly = true)
public Page<MemberContentCommentDto> getContentCommentList(Long memberContentId, Pageable pageable) {
    MemberContent mc = getContent(memberContentId);
    isContentDeleted(mc);

    return memberContentQueryRepository.getCommentByContent(memberContentId,pageable);
}

쿼리가 코드가 길어서 그렇지 조회 함수 자체는 엄청 심플하다.

 

테스트 는 모두 통과 한다. 역시 초록불 보면 기분이 매우 좋다.

 

실제 서버 띄워서 테스트 를 해보자.

 

select
    membercont0_.member_content_comment_id as col_0_0_,
    member1_.member_id as col_1_0_,
    member1_.username as col_2_0_,
    membercont0_.content as col_3_0_,
    member1_.nickname as col_4_0_,
    member1_.profile_img as col_5_0_,
    (select
        count(membercont3_.member_content_comment_like_id) 
    from
        member_content_comment_like membercont3_ 
    where
        membercont3_.member_content_comment_id=membercont0_.member_content_comment_id) as col_6_0_,
    membercont0_.created_at as col_7_0_ 
from
    member_content_comment membercont0_ 
inner join
    member member1_ 
        on membercont0_.member_id=member1_.member_id 
inner join
    member_content membercont2_ 
        on membercont0_.member_content_id=membercont2_.member_content_id 
where
    membercont0_.member_content_id=? 
    and membercont0_.deleted_yn=? 
group by
    membercont0_.member_content_comment_id 
order by
    col_6_0_ desc,
    membercont0_.created_at desc limit ?

크 왕쿼리 한방으로 댓글로 예쁘게 가져온다. 쿼리는 총 2방, 컨탠트 찾는 쿼리, 이렇게 조인으로 보내서 퍼올리는 쿼리

 

사실 이렇게 쿼리로 나오기 까지 mysql 에서 쿼리 를 엄청 셀렉트해서 보냈다. 일 대 다의 관계 에서는 데이터가 다에 맞춰서 펌핑 된다. 

즉 일 은 한개의 데이터 지만 다에 맞춰 그냥 전부 중복으로 매꿔 로우로 보내준다. 매우 좋지 않다.

그렇게 되면 페이징 이 매우어렵다 왜 ? 일 로 페이징 하고 싶지만  다 에 맞춰서 줘서 오기떄문이다.

 

결론 페이징,다이나믹 쿼리 ? 쿼리디에스엘 짱이다. 

 

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

+ Recent posts