[위드마켓 개발기] 지금까지 개발한 것을 리팩토링해보자!

Doccimann·2022년 7월 18일
0

위드마켓 개발기

목록 보기
8/10
post-thumbnail

👉 본격적인 글 작성 이전에 제가 리팩토링할 내용에 대해서 알려드리겠습니다!

우선 이전에 이렇게 말한적이 있을겁니다.

🚨 Handler가 직접적으로 Repository layer를 참조하는 것은 좋은 구조가 아니다.

따라서 이번 시간에는 Handler로부터 비지니스 로직을 분리시켜서 단위테스트까지 같이 시행해보는 걸로 한번 해보겠습니다.


🔥 우선 이전의 코드부터 한번 볼까요?

리팩토링 이전의 ShopReviewHandler 내용입니다.

ShopReviewHandler.kt

@Component
class ShopReviewHandler(
    private val shopReviewRepository: ShopReviewRepository,
    private val resultFactory: ResultFactory
) {

    /** reviewId와 reviewTitle을 기반으로 review 하나를 가져오는 메소드
     * @param reviewId review의 id
     * @param reviewTitle review의 제목
     */
    suspend fun findReviewByIdAndTitle(request: ServerRequest): ServerResponse = coroutineScope {
        // id와 title을 request로부터 받아오고, 존재하지 않으면 바로 에러 처리를 수행한다
        val reviewId = request.queryParamOrNull("id") ?: throw RequestParamLostException("reviewId is lost!!")
        val reviewTitle = request.queryParamOrNull("title") ?: throw RequestParamLostException("reviewTitle is lost!!")

        val reviewMono = shopReviewRepository.findShopReviewByIdAndTitleWithCaching(reviewId, reviewTitle)
        val reviewDeferred = CoroutinesUtils.monoToDeferred(reviewMono)

        return@coroutineScope withContext(Dispatchers.IO) {
            reviewDeferred.await()
        }?.let {
            ok().contentType(MediaType.APPLICATION_JSON)
                .bodyValueAndAwait(resultFactory.getSingleResult(toSimpleReadDto(it)))
        } ?: throw ShopReviewNotFoundException("Shop review is not found!!")
    }

    suspend fun getReviewListByShopIdAndName(request: ServerRequest): ServerResponse = coroutineScope {
        // query param으로부터 shopId와 shopName을 받아오고, 없으면 예외처리
        val shopId = request.queryParamOrNull("shop-id") ?: throw RequestParamLostException("shopId is lost!!")
        val shopName = request.queryParamOrNull("shop-name") ?: throw RequestParamLostException("shopName is lost!!")

        val reviewDtoList = mutableListOf<ShopReviewSimpleReadDto>()
        val reviewFlow = shopReviewRepository.getShopReviewListFlowByShopIdAndNameWithCaching(shopId, shopName)

        // 비동기적으로 reviewDtoList에 원소를 담는다
        withContext(Dispatchers.IO) {
            reviewFlow.buffer()
                .collect {
                    val reviewDeferred = CoroutinesUtils.monoToDeferred(it)
                    val review = reviewDeferred.await()!!
                    reviewDtoList.add(toSimpleReadDto(review))
                }
        }

        // review가 하나도 없다면 예외 처리
        check(reviewDtoList.size != 0) {
            throw ShopReviewNotFoundException("shopReview is not found!!")
        }

        return@coroutineScope ok().contentType(MediaType.APPLICATION_JSON)
            .bodyValueAndAwait(resultFactory.getMultipleResult(reviewDtoList))
    }

    private fun toSimpleReadDto(shopReview: ShopReview) = ShopReviewSimpleReadDto(
        reviewId = shopReview.reviewId,
        reviewTitle = shopReview.reviewTitle,
        shopId = shopReview.shopId,
        shopName = shopReview.shopName,
        reviewContent = shopReview.reviewContent,
        reviewScore = shopReview.reviewScore,
        reviewPhotoList = shopReview.reviewPhotoList,
        createdAt = shopReview.createdAt,
        updatedAt = shopReview.updatedAt
    )
}

보시다시피 Handler에서 Repository를 직접 참조하고 있으며, 또한 비지니스 로직이 Handler 안에 들어있는 구조를 가지고 있습니다.

저는 여기서 비지니스 로직을 분리시켜서 별도의 테스트 코드까지 작성하도록 하겠습니다.

1️⃣ findReviewByIdAndTitle 메소드 리팩토링

여기서는 제가 분리하고 싶은 부분은 reviewId, reviewTitle을 받은 이후 부터 review를 가져오는 부분까지입니다.

우선 제가 이를 위해서 테스트를 해본 코드는 아래와 같습니다.

저는 이를 아래와 같이 분리를 시켜봤습니다.

	/** reviewId와 reviewTitle을 기반으로 ShopReview를 가져오는 메소드
     * @param reviewId review id
     * @param reviewTitle review title
     * @throws ShopReviewNotFoundException
     * @return ShopReview
     */
    suspend fun findReviewByIdAndTitle(reviewId: String, reviewTitle: String): ShopReview =
        withContext(Dispatchers.IO) {
            val reviewMono = shopReviewRepository.findShopReviewByIdAndTitleWithCaching(reviewId, reviewTitle)
            val reviewDeferred = CoroutinesUtils.monoToDeferred(reviewMono)

            return@withContext reviewDeferred.await() ?: throw ShopReviewNotFoundException("review is not found!!")
        }

이렇게 코드를 분리시키게 되면 비지니스 로직이 정확하게 handler로부터 분리가 되기 때문에, handler 부분은 아래와 같이 고쳐집니다.

	/** reviewId와 reviewTitle을 기반으로 review 하나를 가져오는 메소드
     * @param reviewId review의 id
     * @param reviewTitle review의 제목
     * @throws RequestParamLostException
     * @return ServerResponse
     */
    suspend fun findReviewByIdAndTitle(request: ServerRequest): ServerResponse = coroutineScope {
        // id와 title을 request로부터 받아오고, 존재하지 않으면 바로 에러 처리를 수행한다
        val reviewId = request.queryParamOrNull("id") ?: throw RequestParamLostException("reviewId is lost!!")
        val reviewTitle = request.queryParamOrNull("title") ?: throw RequestParamLostException("reviewTitle is lost!!")

        val review = shopReviewService.findReviewByIdAndTitle(reviewId, reviewTitle)

        return@coroutineScope ok()
            .contentType(MediaType.APPLICATION_JSON)
            .bodyValueAndAwait(resultFactory.getSingleResult(toSimpleReadDto(review)))
    }

handler에서는 reviewId, reviewTitle을 request의 query param으로부터 받아오고 검증하고 비지니스 로직은 모두 service에 위임한 모습을 확인할 수 있습니다.

두번째 메소드인 getReviewListByShopIdAndName을 리팩토링 하겠습니다.

2️⃣ getReviewListByShopIdAndName을 리팩토링 하겠습니다.

이 메소드도 리팩토링의 맥락은 비슷합니다. shopId, shopName을 받는거 까지는 handler가 책임지고, 그 이후의 비지니스 로직 부분은 모두 service가 맡게 만드는겁니다.

우선 제가 리팩토링한 코드부터 소개하겠습니다.

suspend fun getReviewListByShop(shopId: String, shopName: String): List<ShopReview> = withContext(Dispatchers.IO) {
        val reviewFlow = shopReviewRepository.getShopReviewListFlowByShopIdAndNameWithCaching(shopId, shopName)

        // flow에 item이 하나도 전달이 안 되는 경우의 예외 처리
        try {
            val firstItem = CoroutinesUtils.monoToDeferred(reviewFlow.first()).await()
            checkNotNull(firstItem)
        } catch (_: Exception) {
            throw ShopReviewNotFoundException("Shop review is not found!!")
        }

        val reviewList = mutableListOf<ShopReview>()

        reviewFlow.buffer().collect {
                val review = CoroutinesUtils.monoToDeferred(it).await()
                reviewList.add(review!!)
            }

        // review가 하나도 안 모였다면 바로 에러 처리
        check(reviewList.size != 0) {
            throw ShopReviewNotFoundException("Shop review is not found!!")
        }

        reviewList
    }

여기서 try-catch 문장을 주목해볼 필요는 있습니다.

try-catch 문장을 위와 같이 작성한 이유는, 코루틴의 Flow 같은 경우는 Flow에 적어도 하나의 아이템을 가지고 있어야하지만, 경우에 따라서는 아이템이 하나도 안 들어오는 경우가 발생하기 때문에 이에 대한 예외 처리를 시행하기 위함입니다.

그리고 아이템이 들어오더라도, empty mono가 날아오는 경우도 존재하기 때문에 두 경우를 한꺼번에 체크를 해버리기 위해서 try-catch문을 위와 같이 작성해주었습니다.

위와 같은 리팩토링으로 인해서 handler 부분은 아래와 같이 바뀝니다.

	/** shopId와 shopName을 기반으로 review의 목록을 가져오는 메소드
     * @param shopId shop id
     * @param shopName shop name
     * @throws RequestParamLostException
     * @return ServerResponse
     */
    suspend fun getReviewListByShopIdAndName(request: ServerRequest): ServerResponse = coroutineScope {
        // query param으로부터 shopId와 shopName을 받아오고, 없으면 예외처리
        val shopId = request.queryParamOrNull("shop-id") ?: throw RequestParamLostException("shopId is lost!!")
        val shopName = request.queryParamOrNull("shop-name") ?: throw RequestParamLostException("shopName is lost!!")

        val reviewList = shopReviewService.getReviewListByShop(shopId, shopName)
        val reviewDtoList = reviewList.map { toSimpleReadDto(it) }

        return@coroutineScope ok().contentType(MediaType.APPLICATION_JSON)
            .bodyValueAndAwait(resultFactory.getMultipleResult(reviewDtoList))
    }

비지니스 로직이 handler로부터 분리가 되었기 때문에 한층 깔끔해진 모습을 확인할 수 있습니다.

다음으로 전체 코드를 소개하겠습니다.

3️⃣ 전체 코드

ShopReviewHandler.kt

@Component
class ShopReviewHandler(
    private val shopReviewService: ShopReviewService,
    private val resultFactory: ResultFactory
) {

    /** reviewId와 reviewTitle을 기반으로 review 하나를 가져오는 메소드
     * @param reviewId review의 id
     * @param reviewTitle review의 제목
     * @throws RequestParamLostException
     * @return ServerResponse
     */
    suspend fun findReviewByIdAndTitle(request: ServerRequest): ServerResponse = coroutineScope {
        // id와 title을 request로부터 받아오고, 존재하지 않으면 바로 에러 처리를 수행한다
        val reviewId = request.queryParamOrNull("id") ?: throw RequestParamLostException("reviewId is lost!!")
        val reviewTitle = request.queryParamOrNull("title") ?: throw RequestParamLostException("reviewTitle is lost!!")

        val review = shopReviewService.findReviewByIdAndTitle(reviewId, reviewTitle)

        return@coroutineScope ok()
            .contentType(MediaType.APPLICATION_JSON)
            .bodyValueAndAwait(resultFactory.getSingleResult(toSimpleReadDto(review)))
    }

    /** shopId와 shopName을 기반으로 review의 목록을 가져오는 메소드
     * @param shopId shop id
     * @param shopName shop name
     * @throws RequestParamLostException
     * @return ServerResponse
     */
    suspend fun getReviewListByShopIdAndName(request: ServerRequest): ServerResponse = coroutineScope {
        // query param으로부터 shopId와 shopName을 받아오고, 없으면 예외처리
        val shopId = request.queryParamOrNull("shop-id") ?: throw RequestParamLostException("shopId is lost!!")
        val shopName = request.queryParamOrNull("shop-name") ?: throw RequestParamLostException("shopName is lost!!")

        val reviewList = shopReviewService.getReviewListByShop(shopId, shopName)
        val reviewDtoList = reviewList.map { toSimpleReadDto(it) }

        return@coroutineScope ok().contentType(MediaType.APPLICATION_JSON)
            .bodyValueAndAwait(resultFactory.getMultipleResult(reviewDtoList))
    }

    private fun toSimpleReadDto(shopReview: ShopReview) = ShopReviewSimpleReadDto(
        reviewId = shopReview.reviewId,
        reviewTitle = shopReview.reviewTitle,
        shopId = shopReview.shopId,
        shopName = shopReview.shopName,
        reviewContent = shopReview.reviewContent,
        reviewScore = shopReview.reviewScore,
        reviewPhotoList = shopReview.reviewPhotoList,
        createdAt = shopReview.createdAt,
        updatedAt = shopReview.updatedAt
    )
}

ShopReviewService.kt

@Service
class ShopReviewService(
    private val shopReviewRepository: ShopReviewRepository
) {

    /** reviewId와 reviewTitle을 기반으로 ShopReview를 가져오는 메소드
     * @param reviewId review id
     * @param reviewTitle review title
     * @throws ShopReviewNotFoundException
     * @return ShopReview
     */
    suspend fun findReviewByIdAndTitle(reviewId: String, reviewTitle: String): ShopReview =
        withContext(Dispatchers.IO) {
            val reviewMono = shopReviewRepository.findShopReviewByIdAndTitleWithCaching(reviewId, reviewTitle)
            val reviewDeferred = CoroutinesUtils.monoToDeferred(reviewMono)

            return@withContext reviewDeferred.await() ?: throw ShopReviewNotFoundException("review is not found!!")
        }

    suspend fun getReviewListByShop(shopId: String, shopName: String): List<ShopReview> = withContext(Dispatchers.IO) {
        val reviewFlow = shopReviewRepository.getShopReviewListFlowByShopIdAndNameWithCaching(shopId, shopName)

        // flow에 item이 하나도 전달이 안 되는 경우의 예외 처리
        try {
            val firstItem = CoroutinesUtils.monoToDeferred(reviewFlow.first()).await()
            checkNotNull(firstItem)
        } catch (_: Exception) {
            throw ShopReviewNotFoundException("Shop review is not found!!")
        }

        val reviewList = mutableListOf<ShopReview>()

        reviewFlow.buffer().collect {
                val review = CoroutinesUtils.monoToDeferred(it).await()
                reviewList.add(review!!)
            }

        // review가 하나도 안 모였다면 바로 에러 처리
        check(reviewList.size != 0) {
            throw ShopReviewNotFoundException("Shop review is not found!!")
        }

        reviewList
    }
}

다음으로 테스트 코드를 소개하고 마치겠습니다.

4️⃣ 테스트 코드

ShopReviewServiceTest.kt

@ExtendWith(MockKExtension::class)
internal class ShopReviewServiceTest {
    @MockK(relaxed = true)
    private lateinit var shopReviewRepository: ShopReviewRepository

    private lateinit var shopReviewService: ShopReviewService

    @BeforeEach
    fun setUp() {
        shopReviewService = spyk(ShopReviewService(shopReviewRepository))
    }

    // 1-1. 잘못된 review Key로 인해서 review를 가져오지 못하는 경우 테스트
    @Test
    @DisplayName("[service] 잘못된 review key 정보로 인해 review를 가져오지 못하는 테스트")
    fun failFindReviewByIdAndTitle() = runBlocking {
        // given
        val reviewId = "review-fake-id"
        val reviewTitle = "review-fake-title"

        every { shopReviewRepository.findShopReviewByIdAndTitleWithCaching(reviewId, reviewTitle) } returns mono {
            null
        }

        // when
        val exception =
            shouldThrow<ShopReviewNotFoundException> { shopReviewService.findReviewByIdAndTitle(reviewId, reviewTitle) }

        // then
        verify(exactly = 1) { shopReviewRepository.findShopReviewByIdAndTitleWithCaching(reviewId, reviewTitle) }
        coVerify(exactly = 1) { shopReviewService.findReviewByIdAndTitle(reviewId, reviewTitle) }
        assert(exception is ShopReviewNotFoundException)

        println("[[service] 잘못된 review key 정보로 인해 review를 가져오지 못하는 테스트] passed!!")
    }

    @Test
    @DisplayName("[service] review를 하나 성공적으로 가져오는 메소드")
    fun successFindReviewByIdAndTitle() = runBlocking {
        // given
        val reviewId = "review-id"
        val reviewTitle = "review-title"
        val shopId = "shop-id"
        val shopName = "shop-name"

        every { shopReviewRepository.findShopReviewByIdAndTitleWithCaching(reviewId, reviewTitle) } returns
                mono { getMockReview(reviewId, reviewTitle, shopId, shopName) }

        // when
        val shopReview = shopReviewService.findReviewByIdAndTitle(reviewId, reviewTitle)

        // then
        verify(exactly = 1) { shopReviewRepository.findShopReviewByIdAndTitleWithCaching(reviewId, reviewTitle) }
        coVerify(exactly = 1) { shopReviewService.findReviewByIdAndTitle(reviewId, reviewTitle) }
        assertNotNull(shopReview)
        shopReview.let {
            assertEquals(it.reviewId, reviewId)
            assertEquals(it.reviewTitle, reviewTitle)
            assertEquals(it.shopId, shopId)
            assertEquals(it.shopName, shopName)
        }

        println("[[service] review를 하나 성공적으로 가져오는 메소드] passed!!")
    }

    @Test
    @DisplayName("[service] shop review를 하나도 못 가져오는 케이스 테스트")
    fun failGetReviewList() = runBlocking {
        // given
        val shopId = "shop-fake-id"
        val shopName = "shop-fake-name"
        every { shopReviewRepository.getShopReviewListFlowByShopIdAndNameWithCaching(shopId, shopName) } returns
                flowOf(Mono.empty())

        // when
        val exception = shouldThrow<ShopReviewNotFoundException> { shopReviewService.getReviewListByShop(shopId, shopName)}

        // then
        verify(exactly = 1) { shopReviewRepository.getShopReviewListFlowByShopIdAndNameWithCaching(shopId, shopName) }
        coVerify(exactly = 1) { shopReviewService.getReviewListByShop(shopId, shopName) }
        assert(exception is ShopReviewNotFoundException)

        println("[[service] shop review를 하나도 못 가져오는 케이스 테스트] passed!!")
    }

    private fun getMockReview(reviewId: String, reviewTitle: String, shopId: String, shopName: String) =
        ShopReview(
            reviewId = reviewId,
            reviewTitle = reviewTitle,
            shopId = shopId,
            shopName = shopName,
            reviewContent = "저는 아주 불만족했어요! ^^",
            reviewScore = 1.0,
            reviewPhotoList = listOf(),
            createdAt = LocalDateTime.now(),
            updatedAt = null
        )
}

고립적인 단위 테스트를 위해서 Service에 대한 테스트는 Mockk와 Kotest를 이용해서 진행하였습니다.


🌲 마치며

다음 포스팅부터는 진짜로...카프카를 적용해서 CQRS를 구현하겠습니다.

다음 포스팅에서 뵙겠습니다. 감사합니다!!

profile
Hi There 🤗! I'm college student majoring Mathematics, and double majoring CSE. I'm just enjoying studying about good architectures of back-end system(applications) and how to operate the servers efficiently! 🔥

4개의 댓글

comment-user-thumbnail
2022년 7월 31일

오.. 상당히 흥미롭군용

1개의 답글
comment-user-thumbnail
2022년 7월 31일

왔다갑니다

1개의 답글