240607 Spring 심화 - QueryDSL 활용, 테스트 공부하기

노재원·2024년 6월 7일
0

내일배움캠프

목록 보기
56/90

QueryDSL 활용

이제 문법은 얼추 다 봤으니 실제로 활용하는 방법을 강의에서 다루게 됐다. QueryDSL도 프로젝트에서 공통적으로 사용할테니 infra에 QueryDslSupport을 만들어서 EntityManager, QueryFactory를 정의해 Abstract class로 만들고 QueryDsl을 다룰 Repository에서 상속받는 식으로 작성했다.

저번에 배운 Repository 의존성 분리대로 생각해보면 JpaRepository, QueryDslRepository를 분리해서 이걸 사용하는 Repository가 존재하는게 좋을 것 같다.

QueryDSL 사용시 kotlin plugins에 kotlin("kapt")을 추가해야 하는데 Kotlin annotation processing tools의 약자로 QClass를 생성하기 위해 어노테이션이 붙은 클래스를 분석해서 알려줘야 하기 때문에 사용한다고 보면 된다.
QClass는 의존성 설정한 다음 Compile후 보면 build/generated/source/kapt/main 에서 찾아볼 수 있다.
QClass는 {Type}Path 식으로 Java로 작성되어 있다.

  • private val entity = Q{entity}.{entity} 처럼 정의해두면 QClass를 일일이 지정해주지 않아도 된다.
  • containsIgnoreCase처럼 쓰면 대소문자를 구분하지 않는 검색으로 조건 설정이 편리해진다.
  • Pageable, Page를 쓰고있긴 했지만 얘넨 인터페이스니 주는 정보가 너무 많다 싶으면 직접 구현체로 만들어 처리할 수 있다.
  • Sort를 적용할 때 명시적인 orderBy를 사용할 수도 있지만 OrderSpecifier라는 조금 복잡하지만 유연성 좋게 사용할 수 있다. PathBuilder로 순회를 돌려서 한번에 Array<OrderSpecfier<*>>를 사용할 수 있게 된다.
    • QClass는 EntityPathBase 기반이라 EntityPathBase를 인자로 받으면 모든 QClass에 통하는 Util로 만들 수도 있다.

Fetch join

SQL에서 지원하지 않는 join인데 레퍼런스를 보다보면 종종 보이는게 Fetch join이다. JPQL의 성능 최적화를 위해 만들어진 join이라 볼 수 있는데 최적화하는 건 연관 관계의 Entity까지 불러올 때 JPA가 각각 테이블을 조회해서 가져오는 문제와 List<Entity>를 한개씩 조회하는 문제를 해결하는 것이다.

QueryDSL은 일반 Join을 쓰고 .fetchJoin을 추가해주면 바로 해결이 된다. 상상하기엔 fetchJoin이 없어도 Join이 된다고 봐야할 것 같았는데 fetchJoin을 안쓰면 동작 안하는 이유는 먼저 selectFrom(Entity)를 해버리는 순간 Entity를 먼저 조회했고 뒤늦게서야 Join이 걸린 하위 Entity를 조회해서 Join이 사실상 적용 안되기 때문이다.

그리고 튜터님도 몇 번 집어주셨던 Pagniation 을 쓰면서 Fetch join하면 경고가 뜨거나 제대로 작동이 안되거나 굉장히 느리다는 얘기를 해주셨었는데 그 문제도 강의에서 다뤘다.

Fetch join을 할 때는 일단 다 불러오고 offset, limit 처리를 어플리케이션 상에서 진행한다. 그렇기 때문에 10000건이 있는 테이블에 limit이 10이어도 10000건을 다 불러오고 10건으로 만드는 건 어플리케이션이 한다는 뜻이다.

그렇기 때문에 일대다, 다대일 관계에서 Pagination이 필요하면 Fetch join을 지양하는 것이 좋다.

그리고 OneToMany를 쓸 때 두 개 이상의 fetchJoin도 성능상의 이슈로 Hibernate에서 막혀있다. 반면에 ManyToOne 관계에선 여러 개의 fetchJoin을 사용해도 된다.

추가로 Fetch join에 대해 조사하다보면 Fetch join 사용 없이 Pagination을 적용해서 먼저 리스트를 불러오고 해당 목록에 대해 별도 조회를 진행하는 2단계 쿼리로 진행하는 것이 안전할 것 같다.
(Pagination이 적용된 Entity 목록을 조회 -> 해당 목록의 id를 기준으로 in query를 작성하는 fetch join 쿼리 수행)


// Pagination 적용해서 조회
val pageRequest: Pageable = PageRequest.of(pageNumber, pageSize)
val todoPage: Page<Todo> = todoRepository.findAll(pageRequest)
val todoIds: List<Long> = todoPage.content.map { it.id }

// todoIds를 기반으로 Fetch join을 적용함
val todosWithComments: List<Todo> = queryFactory
    .select(todo)
    .distinct()
    .from(todo)
    .leftJoin(todo.comments, comment).fetchJoin()
    .where(todo.id.`in`(todoIds))
    .fetch()

OneToMany 관계에서 Pagination과 Fetch join도 함께 사용하고 싶다면 hibernate 설정으로 deafult_batch_fetch_size를 적용하면 어느정도 해결이 가능하다고 한다.
deafult_batch_fetch_size는 연관 관계로 id값을 알기 때문에 최대 n개까지는 in query로 바꿔주는 역할을 해준다. 물론 해당 개수를 초과하면 안될 것 같고 너무 크게 설정해도 문제는 생길 여지가 있다.

테스트

테스트는 이전 프로젝트에서도 하기 귀찮고 오래 걸리는 번거로운 일이 많았다. 컴파일 타임에 시나리오대로 작동하는지 바로 측정이 가능하면 좋았을텐데 사실 그럴 때 쓰라고 있는게 이 테스트 코드들이 아닐까 싶다.

이 부분은 여러모로 집중하지 않았나 싶다.

테스트 기법의 구분

매뉴얼 테스팅

Swagger에서 직접 하던 것처럼 뭔가 직접 수행하는 형태는 매뉴얼 테스팅이라 볼 수 있다. QA처럼 테스트 케이스를 상세히 다루는 느낌도 매뉴얼 테스팅이고 개발자가 이 테스트를 할 수 있는 환경 구축까지 책임을 맡고 있다고 보면 된다.

물론 그만큼 비용과 시간과 인력도 많이 필요하다고 생각한다.

오토메이티드 테스팅

말 뜻 그대로 자동화 테스트로 어플리케이션이 테스트 스크립트를 자동으로 수행하는 방식을 의미한다. 작게는 단일 메소드부터 시작해서 UI 테스트나 통합 테스트까지도 발전할 수 있다.

메뉴얼 테스트에 비해 스크립트에 맞춰 확실하다는 장점이 있지만 이 테스트의 품질도 개발자가 작성을 잘해야 보장된다는게 문제다.

배포까지 이어지는 CI/CD를 위해 필수적인 요소다.

테스트 방법의 구분

유닛 테스트

가장 작은 단위의 테스트로 보통 하나의 메소드 단위로 테스트가 진행된다.
코드로만 작성하면 돼서 자동화하기 매우 좋고 종속성과 무관하게 테스트가 진행되어야 해서 가짜 종속성을 생성하는 Mocking의 과정이 중요하다.

서비스 테스트를 예를 들면 서비스가 주입받고 있는 Repository를 진짜 Repository를 쓰면 Repository의 테스트까지 진행되는 것이기에 가짜 Repository를 정의하고 특정 값을 return하게 가짜 객체를 생성하는 식으로 진행한다.

통합 테스트

모듈 단위로 구분한다 쳤을 때 서로 다른 모듈 2개 이상을 합쳐서 테스트하는 것이고 Mocking 하지 않고 Service, Repository를 실제로 사용해서 테스트하는 것이다.

E2E 테스트 (End-to-End)

해당 어플리케이션 환경에서 일어날 수 있는 유저의 행동과 동일하게 진행을 하는 테스트다.
그만큼 유저의 시나리오가 구체적이고 다양할 것이고 (로그인, 회원가입, 이메일 인증등) 자동화 하기도 무척 어렵다.

단위가 아니라 phase가 되면 Acceptance Test라는 이름으로도 부를 수 있다.

Performance Test (Stress Test)

부하를 줘서 어플리케이션의 속도, 안전성, 확장성이 어느정도의 성능을 보이는지 테스트한다.

Smoke Test

어플리케이션의 주요 기능(비즈니스 로직)을 확인하는 테스트다. 배포 직후에 빠르게 검증하기 위해 사용하기도 한다. 이게 통과가 안되면 빠르게 롤백하는 식으로 최소한의 서비스 이상은 없다는 걸 검증한다고 생각하면 될 것 같다.

Regression Test

새로운 기능을 추가했을 때 기존 기능들이 작동하는지 확인하는 테스트다. 별도의 테스트 종류라기보단 연관되어 있다면 모든 테스트 종류에서 같이 수행하면 좋다.

개발 환경과 테스트

현재는 각자의 데스크탑이나 노트북에서 개발하고 테스트하겠지만 개발환경은 좀 더 구체적이고 환경별로 테스팅이 달라질 수 있다.

Local

각자 PC에 설정된 개발 환경이고 여태까지 해오던 것이다. 유닛 테스트를 수행하기 쉽고 어플리케이션 전체에 접근 가능하다면 통합 테스트도 가능하다.

Dev

실제 운영 서버보다 낮은 스펙을 써서 테스트하는 테스트 서버 환경이다. 보통 Git에서 dev branch를 쓰는 그거다.

어플리케이션이 작으면 Dev에서 E2E 테스트도 수행 가능하다.

Integration

어플리케이션이 여러 컴포넌트로 나뉘고 다른 조직이 컴포넌트를 맡고 있거나 하면 이런 컴포넌트들을 통합해 테스트하는 환경이다.

작은 조직에선 없을 때가 많고 Branch가 아니라 Git repository 자체가 나뉘어지는 환경일 때 필요하다고 생각할 수 있다.

컴포넌트 레벨에서 통합 테스트를 수행하는데 이게 E2E 테스트가 될 수도 있다.

QA

QA 팀이 따로 있다면 만들만한 환경이다. 주로 Dev나 Integration 환경에서 통과한 소스코드를 기반으로 테스트가 진행된다.

기능 테스트와 비기능 테스트(퍼포먼스 테스트, 보안 등) 모두 이루어질 수 있고 비기능 테스트를 한다면 Production과 동일한 스펙의 시스템으로 만든다.

Staging

Production과 완전히 동일한 스펙의 환경이다. 이건 본적 있어서 친숙하다. 비기능을 테스트하기 위해 사용하고 퍼포먼스 테스트, 보안 관련 테스트, 장애 관련 테스트 등 내부 조직에선 이걸 사용했었다.

Production

실제 서비스가 올라간 환경이다.

나는 Local-Dev-Staging-Production 으로 환경을 구성한 곳에서 경험이 있으니 다른건 친숙하진 않지만 개념은 익혀두기로 했다.

테스트 설정

Spring도 테스트를 지원하긴 하지만 이걸로는 충분하지가 않아서 유닛 테스트에 JUnit, Mocking을 편하게 하기 위해 Mockito, 결과 체크를 위한 AssertJ 같은 프레임워크와 라이브러리들을 추가로 적용할 수 있다.

그런데 이건 Java로 작성된 프레임워크 / 라이브러리라 Kotlin의 강점을 다 쓸 수는 없어서 강의에서는 Kotlin 강의에서 썼던 Kotest, 그리고 Mocking은 Mockk을 다룬다.

의존성중 kotest-extensions-spring 의 경우 지원하는 Spring test에 맞게 Kotest 버전이 최신보다 낮으니 주의해서 설정해야한다.

유닛 테스트 작성

이전 강의는 Given-When-Then 형태의 BDD를 작성했었는데 Describe-Context-It 형태도 있다고 한다. 둘 다 사용자 기반에서의 시나리오 느낌이지만 Describe-Context-It의 경우엔

  • Describe: 설명할 테스트 대상 (GET /xxx/{id} Api는)
  • Context: 테스트 대상의 상황 (주어진 id가 없는 id일 때)
  • It: 테스트 대상의 행동 (404를 응답한다.)

뭔가 코드에 가까운 느낌인데 요약하면 GWT는 행동에 집중하고 DCI는 테스트 대상에 집중한다고 생각하면 될 것 같다. (GWT라면 강의 조회를 했을 때 / 없는 강의면 / 조회에 실패한다 같은 느낌일 것이다.)

Kotest spec

Kotest는 위 두가지 방식을 모두 지원하고 Spec 상속을 다르게 작성하면 된다.
공식적으로는 10가지나 되는 spec을 지원하는데 강의에선 Behavior, Describe, Should 만 다뤘다. Should가 제일 간단하게 작성이 가능하긴 하다.

class CalculatorTest: BehaviorSpec({

    given("계산기에 10이 입력되어 있을때") {
				val calculator = Calculator()
				calculator.take(10)

        `when`("1을 더하면") {
						calculator.plus(1)

            then("11이 나와야한다.") {
		            calulator.result() shouldBe 11
            }
        }
    }

})

class CalculatorTest: DescribeSpec({

    describe("계산기는") {
				val calculator = Calculator()
        
				context("10 + 1을 입력하면") {
						calculator.takeExpresion("10 + 1")
		
            it("11을 응답한다.") {
							calculator.result() shouldBe 11
            }
        }
    }
    
})

class CalculatorTest: ShouldSpec({

    should("계산기는 10 + 1을 입력하면 11을 반환한다") {
       	val calculator = Calculator()
				calculator.takeExpresion("10 + 1")
				calculator.result() shouldBe 11
    }

})

Kotest assertion

assert는 맨날 습관적으로 쓰는 단어라 자세히 뜻을 알아보니 단언하다, 강력히 주장하다같은 뜻이 있다고 한다. 뭔가 검증에 쓸법한 키워드긴 한 것 같다.

가장 많이 쓰는 걸로 본건 shouldBe, 에러에는 shouldThrow 가 있다.
그런데 문서 보면 QueryDSL처럼 생각보다 다양하게 문법을 지원해서 세세한 설정이 필요하면 문서를 찾아서 봐야할 것 같다.

Kotest hierarcy와 lifecycle hook

Kotest도 계층 구조가 있는데 이 계층에 맞춰서 life cycle 함수들을 지원해줘서 알아두면 좋다. Life cycle에 적용되는 함수들은 life cycle hook 이라는 명칭이 따로 존재한다고 하는데 처음 알았다.

우선 계층은 Spec - Container - TestCase 로 구성되어 있고 GWT 코드로 보면 다음처럼 구분된다고 한다.

class CalculatorTest: BehaviorSpec({ // Spec

    given("계산기에 10이 입력되어 있을때") { // Container
				val calculator = Calculator()
				calculator.take(10)

        `when`("1을 더하면") { // Container
						calculator.plus(1)

            then("11이 나와야한다.") { // TestCase
		            calulator.result() shouldBe 11
            }
        }
    }

})

이런 계층에서 Life cycle 목록중 자주 보이는 것은 다음과 같다.

  • beforeSpec, afterSpec
    Spec이 인스턴스화 되기 전 후로 실행할 코드를 작성합니다.
  • beforeContainer, afterContainer
    Container가 인스턴스화 되기 전후로 실행할 코드를 작성합니다
  • beforeEach, afterEach
    TestCase별로 인스턴스화 되기 전후로 실행할 코드를 작성합니다.

그 외에도 더 존재한다.

Life cycle을 쓴 GWT의 형태는 다음과 같을 수 있다.

class CalculatorTest: BehaviorSpec({
    
    beforeEach { 
        prepare()
    }
    
    afterEach { 
        cleanUp()
    }
    

    given("현재 저장되어 있는 값이 10인 상황에서") {
        `when`("1을 더하면") {
            then("11이 나와야한다.") {
            }
        }
    }

})

강의에서 나오는 팁

  • 강의에선 test 내부의 package 구조도 도메인 구조랑 똑같이 해서 알아보기 쉽게 한다고 한다. (domain/course/controller 처럼)
  • Test 하위 패키지는 JUnit5가 관리해서 Spring이 자동으로 처리하지 못하기 때문에 그냥 생성자 말고 @Autowired constructor를 써야한다. 그러면 JUnit5의 구현체인 Jupiter가 이걸 Spring IoC에 요청을 보낸다.
  • 이후 MockMvc를 생성자에 주입하면 Mocking이 쉽게 가능해진다.
  • 의존성에서 주입했던 것처럼 extension(SpringExtension) 을 처리해야 Kotest - SpringBootTest가 정상적으로 연결된다.
  • 인가 처리도 jwtPlugin 평범하게 주입받아서 내부에서 생성해서 Authorization 헤더에 넣어 테스트를 진행했다.
  • 한 테스트에서 Mocking을 여러번 할 수 있으니 afterContaier { clearAllMocks() } 처리를 진행해주면 간섭이 없다.

보면서 느낀건 이것도 QueryDSL처럼 문법을 새로 배워야 하는 느낌이고 초기엔 GPT와 레퍼런스를 보면서 계속 써봐야 기억에 남지 않을까 싶다.

어쨌든 이로써 강의는 마무리됐고 과제를 진행하게 됐다.


코드카타 - 프로그래머스 달리기 경주

얀에서는 매년 달리기 경주가 열립니다. 해설진들은 선수들이 자기 바로 앞의 선수를 추월할 때 추월한 선수의 이름을 부릅니다. 예를 들어 1등부터 3등까지 "mumu", "soe", "poe" 선수들이 순서대로 달리고 있을 때, 해설진이 "soe"선수를 불렀다면 2등인 "soe" 선수가 1등인 "mumu" 선수를 추월했다는 것입니다. 즉 "soe" 선수가 1등, "mumu" 선수가 2등으로 바뀝니다.

선수들의 이름이 1등부터 현재 등수 순서대로 담긴 문자열 배열 players와 해설진이 부른 이름을 담은 문자열 배열 callings가 매개변수로 주어질 때, 경주가 끝났을 때 선수들의 이름을 1등부터 등수 순서대로 배열에 담아 return 하는 solution 함수를 완성해주세요.

문제 링크

fun solution(players: Array<String>, callings: Array<String>): Array<String> {
    val playerKeyRankMap = mutableMapOf<Int, String>()
    val playerKeyNameMap = mutableMapOf<String, Int>()
    
    players.forEachIndexed { index, player ->
        playerKeyRankMap[index + 1] = player
        playerKeyNameMap[player] = index + 1
    }
    
    callings.forEach { calling ->
        val currentPlayerRank = playerKeyNameMap[calling]!!
        val nextPlayerRank = currentPlayerRank - 1
        val currentPlayer = playerKeyRankMap[currentPlayerRank]!!
        val nextPlayer = playerKeyRankMap[nextPlayerRank]!!
        
        playerKeyRankMap[currentPlayerRank] = nextPlayer
        playerKeyRankMap[nextPlayerRank] = currentPlayer
        playerKeyNameMap[currentPlayer] = nextPlayerRank
        playerKeyNameMap[nextPlayer] = currentPlayerRank
    }
    
    return playerKeyRankMap.values.toTypedArray()
}

문제를 보고 이번에도 가장 쉬운 방법부터 시도해보려고 우선 list를 대충 만들어놓고 Collections.swap 을 써서 아주 짧게 제출해봤는데 결과는 예상대로 느려터져서 최대 제한 횟수인 50000번의 순서 교체를 겪으면 6000ms씩 찍혔다.

그러니 효율을 위해 바꿔보기로 했는데 이번에도 또 Map을 써보기로 했고 Map을 잘 뒤죽박죽 제어해서 앞뒤 순서를 바꿀 때 Map을 썼으니 효율이 좋아지긴 했겠지 하고 제출해보니 calling에 해당하는 등수를 뽑아오는 탐색 부분에서 filter를 썼더니 이것도 조금만 빨라졌지 여전히 느려터졌었다.

그래서 map을 아예 안쓰고 풀어야하는지 고민하면서 탐색을 최소화할 수 있는 부분이 있나 꼼수를 찾아보다가 key를 각각 서로 갖고있는 2개의 Map을 쓰는 꼼수를 봤다.

이걸로 진행하니 코드는 아주 안예뻐졌지만 속도가 크게 개선되어 제출을 진행했다. 코드를 더 줄일만한 부분은 애초에 Map이 2개고 서로 동일한 등수를 보존해야 하다보니 어쩔 수가 없었다.

애초에 이런 꼼수는 두 개가 항상 동일하다는 무결성을 보장하기가 힘들다는 점도 좀 마음에 걸렸다.

나름 고민에 빠진 시간이 길어서 이 문제를 푸는 데도 오래 걸렸고 당연히 이런 꼼수보다 깔끔한 방법이 있을테니 이제서야 찾아봤다.

그런데 의외인 점은 이게 꼼수가 아니라 정석이었나 싶을 정도로 비슷한 풀이가 대부분이었고 솔직히 좀 당황스러웠다. map을 통해 효율을 개선하는게 아주 효율적이긴 했는데 정답일줄은 몰랐던 것 같다.

0개의 댓글