WorkDuo라는 프로젝트를 Spring -> Go-Echo로 마이그레이션 하던 중 Jwt를 구현하는 데 있어 궁금증이 들어 벤치테스트 및 구현했던 과정에 대해서 작성하고자 한다.

 

토큰 발생 배경

Http 프로토콜의 특성중 하나는 바로 Stateless 하다는 점이다. Http프로토콜이 이러한 방식을 채택한 이유는 편리함 때문이다. 
각각의 요청이 독립적 으로 수행되다 보니 서버는 특정 클라이언트에 대해 상태를 유지할 필요가 없다.

이에 따라 서버는 많은 클라이언트와 동시에 통신할 수 있다

위와 같은 특징들 덕분에 Http 프로토콜은 Stateless 방식을 채택하고 있는데 이러한 무상태성 때문에, 애플리케이션 특정 상황에서 상태를 유지하기 위해 쿠키, 세션, 토큰 등을 이용해 상태를 관리한다. 

 

1. 대표적인 방법으로 쿠키-세션 방식이 널리 사용되어왔다.

쿠키
- 웹사이트 접속할 때 서버에 의해 사용자의 컴퓨터에 저장되는 정보를 의미한다.
세션 
- 사용자와 서버 사이의 연결을 확인하기 위한 정보를 일정하게 유지시키는 기술이다.

아래와 같은 방식으로 세션 아이디 값을 응답값에 포함시켜 주면 브라우저는 해당 세션 아이디를 쿠키에 저장해 매 요청마다 보내게 된다. 

Cookie에 대해 추가적으로 아래와 같은 특징들에 대해 알아보면 더욱 도움 된다.

더보기

물론 서버에서 Set-Cookie로 내려주고 , Cors Credential, Cookie SameSite 등등 설명하게 많지만 세션-쿠키에 대해 언급하지 않겠다.

이렇게 되면 위에 언급한 Http 프로토콜의 Stateless의 속성이 있지만 필요한 부분에 있어 세션-쿠키의 기술을 활용해 유저의 상태성을 유지할 수 있게 된다. 

더보기

그렇다면 Keep-alive는 왜 있을까?
Http 1.1부터는 Http 1.0 과는 달리 keep-alive 가 디폴트로 header에 포한된다. 
그러면 어? keep-alive 가 있으니깐 상태가 유지되는 게 아닌가?라는 의문이 들 수도 있는데 
keep-alive의 목적에 대해서 이해하면 된다. 해당 keep-alive기술의 목적은 tcp 연결을 재사용하여 연결의 오버헤드를 줄이기 위해 등장한 것이기 때문에 Http 프로토콜의 특징인 각 요청은 독립적으로 간주되어 여전히 상태는 공유하지 않는다.
keep-alive는 매번 새로운 열결을 맺는 오베허드를 감소시키고자 하는 목적이지, 상태를 공유하기 위해 사용되는 기술이 아니다.

위의 세션 쿠키 만으로도 충분한 상태를 관리할 수 있다고 생각되는데 왜 토큰이라는 개념이 등장하게 된 걸까?

 

1. 쿠키-세션 방식의 단점이 있다. 바로 유저의 증가에 따른 디비 혹은 서버 리소스가 많이 사용된다.
- 각 요청에 대해 서로 다른 수많은 유저가 있다면 서버는 해당 유저들을 관리하기 위해 리소스를 사용해야 하며, 디비의 리소스 또한 소모가 된다.

위의 이유라고 치기엔 요즘처럼 대부분의 서비스에서 토큰을 사용할 이유가 없다. 

토큰은 쿠키-세션 방식의 단점보다는 웹서비스 환경의 변화가 제일 크다. 

 

2. RESTful API의 선택

- 대부분의 웹페이지 와 서버는 현재 RESTful API 방식을 채택한다.

다양한 포맷의 지원과 Http 프로토콜을 기반으로 하기 때문에 추가적인 구현이 필요가 없다.

가장 중요한 점은 URI 자원식별을 통해 API의 이해와 사용이 간단해지기 때문에 대부분의 웹페이지는 REST방식을 이용한다.

더보기

Rest 말고 무엇이 더 있을까? 
GraphQL -> REST 방식의 단점 중 하나인 필요한 정보 이외 즉 OVER-REQUEST 가 되는 경우가 존재한다. 해당 문제를 해결하기 위해 SQL과 유사한 쿼리언어를 만들어 서버에 필요한 데이터에 필요한 만큼 요청하는 것을 목적으로 개발된 방식이다. 
그래프라는 이름이 붙은 이유는 관계에 대해서 탐색이 가능하기 때문에 위와 같은 이름이 붙어있지 않을까 한다.

RPC -> 이전에 개발된 구성요소나 서드 파티 구성 요소를 사용하여 함수를 호출실행 할 수 있는 API를 의미한다.
REST의 URL을 메서드에 의해 표현하게 되는데 RPC는 메서드 호출을 통해 REST보다 복잡한 작업에 적합하다.
또한 RPC는 동기식 호출을 지향한다. REST의 비동기 방식이 적용되지 않는 경우에 해당 문제점들을 해결하기 위해 RPC가 등장하게 되었다.

3. 다중 플랫폼 및 디바이스 지원
- 쿠키-세션 방식은 브라우저에서만 유효한 방식이다. 현대 다양한 장비 스마트폰, 태블릿, IOT 등 다양한 접근방법이 생겨 쿠키-세션 방식보다는 보다 유연한 인증방식이 필요하게 되었다.

 

4. 분산 환경과 확장성

- 사용자가 늘어남에 따라, 서비스를 확장해 감에 따라 서버를 늘릴 수 있고 DB를 샤딩을 하던 스케일업을 하던 필연적으로 늘어나게 되어있다. 이렇게 되면 쿠키-세션 방식은? 1번에 언급한 것과 같이 DB를 늘리고 각 서버에서는 해당 DB의 유저인증을 위해 트랜잭션을 할당받아야 하고 복잡해지며, 리소스가 생각보다 많이 사용된다.

무어의 법칙에 따라 하드웨어의 발전은 점점 느려지고 있다. 소프트웨어 적으로 해결할 수 있으면 소프트웨어적으로 해결될 수 있는 부분인데 굳이...?
돈이 많고 프로젝트의 여유가 있다면 쿠키-세션 방식으로 엄청나게 좋은 서버에 좋은 디비를 써도 상관은 없다.

 

위의 4 가지 이유 덕에 토큰 방식이 조명받고 있다. 비즈니스를 운영함에 있어서 운영비용의 절감은 필연적인 게 아닌가? 


서론이 엄청 길었는데 해당 자료들을 조사하며 토큰에 대해 조금 더 이해할 수 있지 않았나 싶다.
WORKDUO는 JWT 토큰을 활용해서 작성했다. JWT에 대해 정의를 해보자.

JsonWebToken 이란?
- 인터넷 표준 인증 방식 중 하나로 인증에 필요한 정보를 token에 담아 암호화시켜서 사용하는 토큰이다.

- 헤더 : 어떤 알고리즘 형태인지, 어떤 타입인지가 정보로 포함된다. ("HS256", "JWT")

- 페이로드 : 토큰에 담고 싶은 내용이 들어간다. 
- 서명 : 개인키로 서명한 전자 서명이 들어있다.

이렇게 발급된 토큰을 서버는 어떻게 해결하는지 확인해 보자. 

 

만약 HS256 알고리즘이라면

위에 보이는 그림처럼 디코딩을 과정을 거쳐 페이로드와 시크릿 문자열이 합쳐져서 해쉬 결괏값을 비교하는 형태가 된다. 


 

만약 다른 알고리즘을 사용해 보면 어떨까 라는 궁금증이 생겼고 RSA, AES256, HS256을 각각 구현해서 벤치테스틀 수행해 봤다.

해쉬 구현(HS256))

더보기
func generateSymmetricWay(memberID, email string) (string, error) {
    t := Token{
       MemberID:  memberID,
       Email:     email,
       ExpiresAt: time.Now().Add(ExpiredTime),
    }

    token := jwt.NewWithClaims(jwt.SigningMethodHS256, &t)
    return token.SignedString([]byte("This is Secret Key"))
}


가장 일반적으로 사용하는 HS256 방식이다.
어떠한 알고리즘을 사용할 것인지 정하고 뭐 별 특별할 것 없는 부분이다.

func parseSymmetricKWay(tokenStr string) (*Token, error) {
    t := &Token{}
    token, err := jwt.ParseWithClaims(tokenStr, t, func(token *jwt.Token) (interface{}, error) {
       if _, ok := token.Header["alg"]; !ok {
          return nil, fmt.Errorf("missing algorithm in token header")
       }
       if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
          return nil, fmt.Errorf("unexpected signing method : %v", token.Header["alg"])
       }
       return "This is Secret Key", nil
    })
    if err != nil {
       return nil, err
    }
    if claims, ok := token.Claims.(*Token); ok {
       return claims, nil
    } else {
       return nil, fmt.Errorf("fail to type cast token")
    }
}

 

대칭 구현(AES256)

더보기
func generateAESWay(memberID, email string) (string, error) {
    t := Token{
       MemberID:  memberID,
       Email:     email,
       ExpiresAt: time.Now().Add(ExpiredTime),
    }

    token, err := json.Marshal(&t)
    if err != nil {
       return "", err
    }

    cip, err := aes.NewCipher([]byte("This is must!@#$"))
    if err != nil {
       return "", err
    }
    length := (len(token) + aes.BlockSize) / aes.BlockSize
    plain := make([]byte, length*aes.BlockSize)
    copy(plain, token)
    pad := byte(len(plain) - len(token))
    for i := len(token); i < len(plain); i++ {
       plain[i] = pad
    }
    encrypted := make([]byte, len(plain))
    for bs, be := 0, cip.BlockSize(); bs <= len(token); bs, be = bs+cip.BlockSize(), be+cip.BlockSize() {
       cip.Encrypt(encrypted[bs:be], plain[bs:be])
    }
    return hex.EncodeToString(encrypted), nil
}

지난번 포스팅 했던 것처럼 암호화를 진행 한 부분이다. 블록 사이즈에 맞게 남은 공간을 패딩 처리해 주는 부분 때문에 코드가 긴 것뿐이다.

func parseAESWay(token string) (*Token, error) {
    encrypted, err := hex.DecodeString("This is must!@#$")
    if err != nil {
       return nil, err
    }
    cip, err := aes.NewCipher([]byte("This is must!@#$"))
    if err != nil {
       return nil, err
    }

    decrypted := make([]byte, len(encrypted))
    for bs, be := 0, cip.BlockSize(); bs < len(encrypted); bs, be = bs+cip.BlockSize(), be+cip.BlockSize() {
       cip.Decrypt(decrypted[bs:be], encrypted[bs:be])
    }

    trim := 0
    if len(decrypted) > 0 {
       trim = len(decrypted) - int(decrypted[len(decrypted)-1])
    }
    data := string(decrypted[:trim])
    var parsed Token
    if err := json.Unmarshal([]byte(data), &token); err != nil {
       return nil, err
    }
    return &parsed, nil
}

 


비대칭 구현(Ecdsa)

더보기
func Generate(memberID, email string) (string, error) {
    t := Token{
       MemberID:  memberID,
       Email:     email,
       ExpiresAt: time.Now().Add(ExpiredTime),
    }

    token := jwt.NewWithClaims(jwt.SigningMethodES256, &t)
    return token.SignedString(getPrivateKey())
}

func Parse(tokenStr string) (*Token, error) {
    t := &Token{}
    token, err := jwt.ParseWithClaims(tokenStr, t, func(token *jwt.Token) (interface{}, error) {
       if _, ok := token.Header["alg"]; !ok {
          return nil, fmt.Errorf("missing algorithm in token header")
       }
       if _, ok := token.Method.(*jwt.SigningMethodECDSA); !ok {
          return nil, fmt.Errorf("unexpected signing method : %v", token.Header["alg"])
       }
       return privateKey.Public(), nil
    })
    if err != nil {
       return nil, err
    }
    if claims, ok := token.Claims.(*Token); ok {
       return claims, nil
    } else {
       return nil, fmt.Errorf("fail to type cast token")
    }
}


ECDSA(Elliptic Curve Digital Signature Algorithm)를 이용해서 구현했다. 
고언어는 아무래도 블록체인 쪽에 관련이 있다 보니 이러한 암호화에 있어 생각보다 지원을 많이 해주는데 그중 ECDSA라는 게 생소해서 사용했다. 별다른 이유는 없다.

보면 privateKey는 자체적으로 만든키이며 복호화할 때 위의 두구현 방식과는 상당히 다르다 
비대칭 키 이기 때문에  priavteKey와 Public키를 각각 받게 되고 해당 키들을 이용해 토큰을 검증하게 된다.

- 비대칭 방식은 구현하다 보니 의문점이 든다.

만약 비대칭 구현 방식을 선택한다면 서버에서 공개키와 비밀키를 둘 다 관리하게 되면 Aes 즉 대칭키 구현 방식과 별반 다르지 않다.
만약 서버가 탈취되면? 둘 다 그냥 복호화 가능해진다. 이것은 비대칭 방식에 어울리지 않는 키보관 방법이라고 생각한다. 
만약 정말로 보안이 중요해서 비대칭키 방식을 사용하게 된다면 모든 유저가 동일한 privateKey를 사용하는 게 아닌 유저 개별적으로 privateKey를 보관해야 하지 않을까 싶다.


벤치 테스트 

키 생성 코드전문

더보기
func BenchmarkGenerateToken(b *testing.B) {
    randomStr := func() string {
       uuid, _ := uuid.NewUUID()
       return uuid.String()
    }

    b.Run("Rsa 방법", func(b *testing.B) {
       for i := 0; i < b.N; i++ {
          Generate(randomStr(), randomStr())
       }
    })

    b.Run("해쉬 방법", func(b *testing.B) {
       for i := 0; i < b.N; i++ {
          generateSymmetricWay(randomStr(), randomStr())
       }
    })

    b.Run("대칭키 방법", func(b *testing.B) {
       for i := 0; i < b.N; i++ {
          generateAESWay(randomStr(), randomStr())
       }
    })
}

 

 

해석을 해보면 평균 1회 수행당 비대칭 방법은 20116 ns , 해쉬는 2013 ns, 대칭은 1129ns 만큼 걸린다.  중간에 있는 숫자는 돌아간 횟수이다. 

ns 여서 실제 서비스에서는 그렇게 큰 차이가 없을 것으로 보이긴 하는데 생각보다 차이가 많이 나서 놀랐다. 

비대칭 방법이 느리다 느리다 말 만들었지 실제로 벤치 돌려보니 10배, 20배가량 차이 나는 게.... 
유저의 수가 많다면 비대칭키에 대해서는 재고해 보는 것이 현명할 것 같다.

해시 방법이 대칭키 방법보다 느리다는 것이 생각보다 놀라웠다. 그냥 알고리즘에 넣어서 뚝딱 나오는 줄 알았는데 HS256의 알고리즘 내부적으로 무언갈 많이 수행하는 것 같다. 

 

복호화도 위와 비슷한 결과를 보일 것 같은데 한번 복호화를 확인해 보자.

 

키 복호화 코드 전문

더보기
func BenchmarkParsingToken(b *testing.B) {
    randomStr := func() string {
       uuid, _ := uuid.NewUUID()
       return uuid.String()
    }
    rsa, _ := Generate(randomStr(), randomStr())
    symmetric, _ := generateSymmetricWay(randomStr(), randomStr())
    aes, _ := generateAESWay(randomStr(), randomStr())

    b.Run("비대칭 방법", func(b *testing.B) {
       for i := 0; i < b.N; i++ {
          Parse(rsa)
       }
    })

    b.Run("해쉬 방법", func(b *testing.B) {
       for i := 0; i < b.N; i++ {
          parseSymmetricKWay(symmetric)
       }
    })

    b.Run("대칭키 방법", func(b *testing.B) {
       for i := 0; i < b.N; i++ {
          parseAESWay(aes)
       }
    })
}

개별적으로 키를 생성해서 각가의 키들에 대해서 복화를 진행했다.

 

위에 보이는 것처럼 암호화 할 때의 순위와 동일하다
다만 흥미로운 점은 해쉬의 경우 해쉬 된 값을 각각 비교하기 때문에 암호화와 동일한 성능이 나온다는 점이다.



사실 비대칭, 대칭, 해쉬에 대해서 그렇게 크게 고민 없이 사용했던 것도 맞다.

위에 보이는 것처럼 단 1초 테스트에 이 정도 차이가 나는 것인데 만약 데이터의 암호화를 함에 있어 상황과 케이스에 맞게 적절한 방식을 채택해야 한다.

보안이 정말로 중요하지 않다면 웬만하면 비대칭 방식은 사용하지 않을 듯싶다. 성능이 생각보다 많이 좋지 않다. 
물론 n/s이지만 만약 데이터의 크기가 커진다면? 단순 유저의 정보가 아니라면? 고민해 볼 만한 주제이다.

해쉬와 대칭키는 취향차이인 것 같다.

이미 끝난 프로젝트 인 워크듀오를 멀티모듈로 바꿔보고자 한다. 전회사에서 프로젝트를 보면서 멀티모듈을 처음 알게 되었다.

모듈 별로 도메인 그리고 각 특정 서비스를 분류해서 한 프로젝트 내에서 여러 개의 서버를 띄우는 게 가능했었다. 

정말 좋다고 느꼈고, 특히나 프로젝트의 어떤 부분을 보고 코드를 확인해야 하는지 에 대한 명확성이 너무나 도 좋은 부분이라고 생각된다.

우리 프로젝트에 접목하지 않을 이유가 없다고 생각하여 분리를 해보고자 한다.

 

첫 번째로 멀티모듈 이란 하나의 프로젝트 내에서 다양한 모듈을 구성하고 공통점을 묶어서 하나의 모듈에서 관리하는 기능을 의미한다.

즉 다시 말해서 우리 워크듀오는 크게 멤버, 그룹의 형태로 서비스가 나뉜다. 이들의 공통점을 하나로 묶어서 관리한다면? 

 

만약 서버를 분리한다고 가정해 보자. 각각 의 서버에서 우리는 멤버를 그룹에서 호출하기도 하고 그룹을 멤버에서 호출하기도 한다. 그렇다면? 복사 붙여 넣기가 필연적으로 생길 수밖에 없다. 그렇다면 어떻게 해결해야 할까?

  • 첫 번째 이런 공통된 부분을 각 프로젝트에서 호출이 가능해야 하며,
  • 두 번째 빌듯이 에 공통된 부분을 포함시켜 주어야 한다.

최초 모듈 구성을 core/ member/ group으로 구성하였으며 코어는 말 그대로 멤버와 그룹이 컴파일될 시에 포함될 메인 소스 가 된다.

위와 같은 부분으로 변화시키고자 한다.

auth는 지금 고민 중이고 테스트 중인 부분이라서 언급하지 않았다.

Core에서 는 위의 사진과 같이 공통으로 관리되는 AWS 혹은 컨피그 파일, 그리고 모든 도메인을 코어에서 관리한다. 

우리는 현재 에러를 커스텀으로 작성하여 핸들링되기 때문에 공통으로 사용되는 에러코드 또한 코어에 위치시켰다. 

 

그룹 같은 경우는 config 상에 레디스를 추가했다. 그룹상 에서만 사용되기 때문에 위와 같이 분리했다.

 

잡설은 그만하고 어떻게 하면 될까 먼저 module 두 개를 만들어 주자. (Gradle version 7.5)

위와 같은 방식으로 기본모듈에 코어를 생성해 주자. 자 이렇게 생성이 완료되면 

settings.gradle 파일을 확인해 보자. (https://docs.gradle.org/current/userguide/multi_project_builds.html)

모듈을 생성한다면 아래와 같이 자동 기입되어 있는 것이 보일 것이다.

rootProject.name = 'workduo'
include 'workduo_core'

이 세팅 그래들이 존재하기 때문에 우리는 그대를 멀티 프로젝트를 구성할 수 있는 부분이니 확인하고 넘어가자. 우리 똑순이 인텔리제이 그래 들 을 눌러보면 위와 같이 코끼리 하나 더 생겼다 즉 하위 프로젝트가 생성된 부분을 확인할 수 있다.

대망의 build.gradle을 보러 가자. 

현재 워크듀오 의 빌드 그래 들 이다. 위에서부터 차근차근 알아보자.

(https://docs.gradle.org/current/javadoc/org/gradle/api/initialization/dsl/ScriptHandler.html)

 

buildscript 블록 안에서는 어떤 태스크를 부여할지, 어떤 플러그인을 사용할지, 어떤 클래스가 나머지 빌드 스크립트 안에 포함되어야 하는지 를 결정할 수 있는 공간이다. 또한 추가적인 외부 서드파티 라이브러리를 사용할 때 클래스 패스를 기입해 주기 위해서 작성하기도 한다.

어떻게 보면 글로벌 레벨 에서의 디펜던시 혹은 레퍼지토리 의 섹션이라고 볼 수도 있다. 

buildscript {
    ext {
        queryDslVersion = "5.0.0"
    }
}

현재 워크듀오에서는 이렇게 스크립트를 작성해서 쓰고 있다. ext는 변수명을 위와 같이 선언해 줄 수 있고 아래 스크립트에서 이 변수를 호출할 수 있다. 

 

plugins(https://docs.gradle.org/current/userguide/what_is_gradle.html)

플러그인 블록 안에서는 프로젝트에 플러그인을 적용하면 플러그인이 프로젝트의 기능을 확장할 수 있습니다.라고 간단하게 정리할 수 있다. 특히나 저 공홈 문서를 보면 플러그인을 프로젝트 로직을 추가하는 대신 플러그인 사용 시 이점에 대해 아래와 같이 설명한다.

  • 재사용을 촉진하고 여러 프로젝트에서 유사한 논리를 유지 관리하는 오버헤드를 줄입니다.
  • 더 높은 수준의 모듈화를 허용하여 이해력과 구성을 향상합니다.
  • 명령형 논리를 캡슐화하고 빌드 스크립트가 가능한 한 선언적일 수 있도록 합니다.

종류 또한 두 가지 가 존재한다.

바이너리 플러그인 (gradle dsl 언어로 작성, plugin interface를 구현한 방식),

plugins {
    id 'com.jfrog.bintray' version '1.8.5'
}

스크립트 플러그인

apply from: 'other.gradle'

차이점이라면 플러그인은 종종 스크립트 플러그인으로 시작하고(작성하기 쉽기 때문에) 코드의 가치가 높아짐에 따라 쉽게 테스트하고 여러 프로젝트 또는 조직 간에 공유할 수 있는 바이너리 플러그인으로 마이그레이션 됩니다.

이중 우리는 바이너리 플러그인을 사용하며, 버전이 명시된 건 커뮤니티 버전, 버전이 없다면? 코어를 사용하는 플러그인이다. 

이렇게 작성된 플러그인 들은 프로젝트를 빌드할 때 먼저 그래 들은 플러그인에서 주어지는 값을 해석해 jar 파일 스크립트 클래스 경로에 추가하는 방식이다.

 

repositories(https://docs.gradle.org/current/userguide/declaring_repositories.html)

 

Gradle은 Maven, Ivy 또는 플랫 디렉터리 형식을 기반으로 하는 하나 이상의 레포지토리에서 종속성을 해결할 수 있습니다. 자세한 내용은 모든 유형의 레포지토리에 대한 전체 참조를 확인하십시오.

통상 메이븐 센트럴을 사용하나 구글을 선택적으로 선택이 가능하기도 하다.

repositories {
    mavenCentral()
    maven {
        url "https://repo.spring.io/release"
    }
    maven {
        url "https://repository.jboss.org/maven2"
    }
}

 

이런 방식의 여러 레포지토리 도 다음과 같이 작성해 빌드스크립트 안에 명시 가 가능하다.

 

dependencies(https://docs.gradle.org/current/userguide/dependency_management_for_java_projects.html)

위에서 레포지토리 대상을 정했으면 어떤 것을 들고 올지 정해주는 부분이다.

다시 말해 여기 지정되는 대상이 위에서 지정한 레포지토리에서 조회 되어야 오류 없이 빌드가 가능하다.

가끔 가다 org. 모시꺵이 하면서 찾을 수 없다는 에러가 보인다면 조회가 안된다는 의미이니 오타 혹은 직접 주소를 가보자.

그렇다면 이런 대상을 들고 올 때 앞에 써주는 키워드 들을 여러 개 보았을 것이다. 당장 스프링. io에서 만들더라도 디펜던시를 확인하면 다양하게 확인이 가능하다.

현재워크듀오 의 디펜던시 일부다.

dependencies{
	"""생략"""
    implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE'
    implementation 'org.bgee.log4jdbc-log4j2:log4jdbc-log4j2-jdbc4.1:1.16'

    compileOnly 'org.projectlombok:lombok'

    runtimeOnly 'com.h2database:h2'
    runtimeOnly 'mysql:mysql-connector-java'

    annotationProcessor 'org.projectlombok:lombok'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
}

여기 보이는 것처럼 다양하게 있는데 한번 다이브 해보자(https://docs.gradle.org/current/userguide/java_library_plugin.html)

  • 오직 초록색 만이 유저가 사용할 수 있는 부분이다.
  • 핑크색 은 컴포넌트가 컴파일될 당시에 한번 사용되며, 라이브러리에 대해 사용된다.
  • 파랑이는 자체적으로 실행될 때 사용되는 부분이다. 

1. api : 종속적이지 않으며 소비하는 곳에 의해 호출되며 런타임, 컴파일 시점에 호출된다.

-  과거 compie() 이 deprecate 되고 현재 api()로 대체되어 사용 중이다.

- 또한 많이 추천하지 않는다 api를 호출하는 클래스 안에 다른 클래스를 호출하게 된다면 내가 부르는 시점에서 이거는 두 개다 호출하는 경우가 생기기 때문에 의도치 않는 import 가 발생되기에 추천하지 않는다고 한다.

 

2. implementation : 위의 발생되는 문제점을 없애기 위해 생긴 게 이 키워드이다. 종속적이지 않기에 compile에서 잘 작동되던 자바 애플리케이션 중 compile() 키워드를 -> implementation 으로 바꾼다면 호출 못하는 코드들이 생길 수도 있다. 왜? 의도치 않은 임포트를 이용해서 사용하고 있을 수 있기 때문에

 

3. compileOnly: 컴파일 타임에는 필요하지만 런타임에는 필요 없는 것을 의미하는 키워드이다. 위에서는 롬복이 해당되는데 컴파일 시점에 롬복 어노테이션을 이용해서 클래스를 만든 이후 런타임 시점에서는 필요 없는 경우에 해당되기에 이 키워드를 사용한다.

 

4. compileOnlyApi : 3번과 동일하다. 

 

5. runtimeOnly : 3,4번과 는 반대로 컴파일 시점에는 필요하지 않으나 런타임에 필요한 종속성에 표기하는 키워드이다.  대표적인 예로는 로거를 많이 든다. 

 

configurations(https://docs.gradle.org/current/dsl/org.gradle.api.artifacts.Configuration.html)

configuration 블록이 사실 제일 이해가 안 간다. 설명되는 내용만 봐도 그룹과 그에 따른 종속성을 나타낸다는 데 잘 모르겠다 왜 쓰여야 하는지 조차.

    compileQuerydsl {
        compileOnly {
            extendsFrom annotationProcessor
        }
        querydsl.extendsFrom compileClasspath
    }

워크듀오 의 컨피규레이션이다.  저 주소로 찾아가서 검색해 보면 컴파일 시점에는 어노테이션 프로세스를 확장하고 , 쿼리디에스엘 은 컴파일 클래스 패스를 확장한다. 왜 필요할까. from의 대상으로부터 확장되는 것인데 쿼리디에스 엘은쓸떄 우리는 따로 컴파일 쿼리디에스엘을 누르고 자바를 빌드한다. 이때 빌드할 때 쿼리디에스엘에서 먼저 컴파일된 클래스 패스를 가져오기 위해 사용된다고 추측된다. 실제로 빼고 돌려보면 컴파일 조차 되지 않는다. 왜? 컴파일되지 않았기 때문에 Q클래스 가 존재할 수가 없는 이유이다.

 

다음 2편에서는 실제로 build.gradle을 세팅해 보자. 이 쿼리디에스 엘 설정이 계속 괴롭힐 예정이다 ㅠ.ㅠ

 

 

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



지난번 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