mockk의 relaxed=true에 대한 고찰

2026년 05월 18일

kotlin

# Kotlin# testing# mocking

mockk란?

mockk는 Kotlin을 위한 mocking 라이브러리입니다. Java의 Mockito와 비슷한 역할을 하지만, Kotlin의 언어 특성(final class, extension function, coroutine, object 등)을 자연스럽게 지원한다는 차이가 있습니다.

val userRepository = mockk<UserRepository>()
every { userRepository.findById(1L) } returns User(1L, "Alice")

val service = UserService(userRepository)
val user = service.getUser(1L)

verify { userRepository.findById(1L) }

기본적인 사용법은 직관적이지만, mockk()로 만든 mock에는 한 가지 불편함이 있습니다.

기본 mockk()의 한계: 모든 메서드를 stub 해야 한다

mockk<T>()로 mock을 만들면, stub하지 않은 메서드를 호출할 때 예외가 발생합니다.

interface NotificationSender {
    fun sendEmail(to: String, subject: String)
    fun sendSms(to: String, message: String)
    fun sendPush(userId: Long, title: String)
}

val sender = mockk<NotificationSender>()

// 어떤 메서드도 stub하지 않은 상태
sender.sendEmail("a@b.c", "Hello")
// → io.mockk.MockKException: no answer found for NotificationSender(#1).sendEmail(...)

테스트에서 검증하고 싶은 동작은 sendEmail 하나뿐인데, NotificationSender의 다른 메서드(sendSms, sendPush)가 호출될 가능성이 있다면 그것들도 모두 every {}로 stub해야 합니다.

val sender = mockk<NotificationSender>()
every { sender.sendEmail(any(), any()) } just Runs
every { sender.sendSms(any(), any()) } just Runs   // 관심 없는데도 선언해야 함
every { sender.sendPush(any(), any()) } just Runs  // 관심 없는데도 선언해야 함

의존성이 큰 객체일수록 이 stub 선언이 길어지고, 테스트의 본질이 boilerplate에 묻혀버립니다.

relaxed = true

mockk(relaxed = true)는 이 문제를 해결합니다. stub하지 않은 메서드를 호출하면 반환 타입의 “기본값”을 자동으로 돌려줍니다.

val sender = mockk<NotificationSender>(relaxed = true)

sender.sendEmail("a@b.c", "Hello")   // 정상 동작 (Unit 반환)
sender.sendSms("010", "msg")         // 정상 동작
sender.sendPush(1L, "title")         // 정상 동작

타입별 기본값

relaxed mock이 자동으로 반환하는 값은 타입에 따라 다릅니다.

반환 타입 기본값
Unit Unit
Int, Long 0
Boolean false
String "" (빈 문자열)
List<T> emptyList()
Map<K, V> emptyMap()
일반 객체 T 또 다른 relaxed mock
nullable T? null

마지막 줄이 중요합니다. 반환 타입이 또 다른 객체라면, mockk가 그 객체에 대해서도 재귀적으로 relaxed mock을 만들어 반환합니다. 이를 통해 깊은 호출 체인도 NPE 없이 동작하게 됩니다.

interface OrderService {
    fun findOrder(id: Long): Order
}

class Order {
    fun getCustomer(): Customer = ...
}

class Customer {
    fun getName(): String = ...
}

val orderService = mockk<OrderService>(relaxed = true)

// 어떤 stub도 없지만 NPE 없이 동작
val name = orderService.findOrder(1L).getCustomer().getName()
// name == ""

relaxUnitFun = true

전체를 relaxed로 만드는 게 부담스러울 때 쓰는 더 약한 옵션이 있습니다. relaxUnitFun = trueUnit을 반환하는 함수만 relaxed로 처리합니다.

val sender = mockk<NotificationSender>(relaxUnitFun = true)

sender.sendEmail("a@b.c", "Hello")  // OK (Unit 반환)

// 반환값이 있는 함수는 여전히 stub 필요
every { sender.someMethodReturningString() } returns "value"

대부분의 의존성은 다음 패턴 중 하나입니다.

  1. 부수효과만 일으키는 메서드 (Unit 반환): 이메일 발송, 이벤트 발행, 로깅 등
  2. 데이터를 조회하는 메서드 (값 반환): Repository, Query Service 등

relaxUnitFun = true는 1번 케이스만 자동으로 통과시키고, 2번 케이스(값을 반환하는 메서드)는 명시적으로 stub하도록 강제합니다. 이 덕분에 “반환값이 있는 메서드는 테스트가 의도한 값을 명시적으로 선언했는가?”를 자연스럽게 유도합니다.

어떤 옵션을 언제 써야 할까?

상황 권장 옵션
모든 메서드를 명확히 통제하고 싶다 (엄격한 단위 테스트) mockk<T>()
부수효과 메서드는 신경 안 쓰고, 반환값만 통제하고 싶다 mockk<T>(relaxUnitFun = true)
의존성이 너무 크고, 호출 자체보다 결과 검증이 중심이다 mockk<T>(relaxed = true)

개인적으로는 relaxUnitFun = true를 기본값으로 두고, 정말 모든 메서드를 무시해도 되는 경우에만 relaxed = true를 사용하는 편을 선호합니다. 그 이유는 다음 절에서 설명합니다.

relaxed = true의 함정

편하지만 무분별하게 쓰면 테스트의 의미가 흐려집니다.

1. 잘못된 기본값이 테스트를 통과시킨다

relaxed = true로 만든 mock은 Boolean을 묻는 메서드에 무조건 false를 돌려줍니다.

interface PermissionChecker {
    fun canDelete(userId: Long, postId: Long): Boolean
}

val checker = mockk<PermissionChecker>(relaxed = true)
val service = PostService(checker)

// 실제로는 권한이 없는 사용자도 통과되는지 검증하고 싶었는데,
// checker.canDelete()가 항상 false를 반환하므로
// "권한 없음" 케이스만 우연히 통과해버린다
service.deletePost(userId = 999L, postId = 1L)

테스트는 통과하지만, 무엇을 검증했는지 모호해집니다. canDelete가 어떤 값을 돌려준 시점인지 명시되어 있지 않기 때문입니다.

2. 빈 컬렉션이 의도와 다르게 동작한다

List<T>를 반환하는 메서드는 기본적으로 emptyList()를 돌려줍니다. 이 때문에 “비어있을 때의 분기”가 항상 타게 됩니다.

interface OrderRepository {
    fun findRecentOrders(userId: Long): List<Order>
}

val repo = mockk<OrderRepository>(relaxed = true)
val service = RecommendationService(repo)

// "최근 주문이 비어있을 때 인기 상품을 추천한다"는 분기만 실행됨
// 다른 분기는 테스트되지 않음
val recommendations = service.recommend(userId = 1L)

이 테스트는 “주문 이력 기반 추천 로직”을 검증한다고 착각하기 쉽지만, 실제로는 fallback 경로만 검증하고 있습니다.

3. verify로 검증하기 모호해진다

relaxed = true는 호출 자체를 허용하기 때문에, “이 메서드가 정말 호출되어야 했는가?”를 별도로 verify하지 않으면 누락을 잡지 못합니다.

val sender = mockk<NotificationSender>(relaxed = true)
val service = OrderService(sender)

service.placeOrder(order)

// sendEmail이 호출되지 않아도 테스트는 통과한다
// verify { sender.sendEmail(any(), any()) }를 명시해야 검증됨

relaxed = true를 쓸수록 verify의 책임이 무거워진다는 점을 기억해야 합니다.

실무에서의 권장 패턴

1. 기본은 relaxUnitFun = true

부수효과 메서드(이벤트 발행, 로깅, 알림 등)는 대부분 Unit을 반환합니다. 이런 메서드까지 stub하는 건 noise이므로, relaxUnitFun = true로 자동 통과시키되, 값을 반환하는 메서드는 명시적으로 stub합니다.

@Test
fun `주문 생성 시 알림이 발송된다`() {
    val orderRepository = mockk<OrderRepository>(relaxUnitFun = true)
    val notificationSender = mockk<NotificationSender>(relaxUnitFun = true)

    every { orderRepository.save(any()) } returns Order(id = 1L)

    val service = OrderService(orderRepository, notificationSender)
    service.placeOrder(orderRequest)

    verify { notificationSender.sendEmail(any(), any()) }
}

2. 검증 대상이 아닌 의존성에만 relaxed = true

테스트가 “이 메서드의 결과” 가 아니라 “이 메서드가 호출되는지” 에 관심이 있을 때, 또는 의존성이 너무 깊어 stub 비용이 큰 경우에만 relaxed = true를 씁니다.

@Test
fun `결제 실패 시 보상 트랜잭션이 실행된다`() {
    // 로거는 어떻게 호출되든 상관없음 → relaxed
    val logger = mockk<Logger>(relaxed = true)

    // 결제 클라이언트는 명시적으로 실패 시나리오 설정
    val paymentClient = mockk<PaymentClient>()
    every { paymentClient.charge(any()) } throws PaymentException("declined")

    val refundService = mockk<RefundService>(relaxUnitFun = true)

    val service = OrderService(paymentClient, refundService, logger)
    service.placeOrder(orderRequest)

    verify { refundService.refund(any()) }
}

Mock은 설계의 거울이다

mockk의 옵션들을 자세히 보면, 단순한 편의 기능을 넘어 두 가지 객체지향 원칙을 라이브러리 차원에서 권장하고 있다는 걸 알 수 있습니다.

재귀 mock과 Law of Demeter

앞서 relaxed = true가 객체 반환 메서드에 대해 재귀적으로 mock을 만든다고 설명했습니다. 이 동작이 편리하다고 느껴진다면, 한 번 의심해봐야 합니다.

// 이 코드가 stub 하나 없이 통과한다는 사실 자체가...
val name = orderService.findOrder(1L).getCustomer().getName()

a.b().c().d() 형태의 깊은 체이닝은 Law of Demeter(최소 지식 원칙)를 위반합니다. “친구의 친구에게 말하지 말라” — 객체는 직접적인 협력자하고만 대화해야 한다는 원칙입니다.

mock 없이 자연스럽게 통과하는 깊은 체이닝은 프로덕션 코드에 다음과 같은 문제를 시사합니다.

  • 호출자가 Customer의 내부 구조에 결합되어 있다
  • order.getCustomer().getName() 대신 order.customerName()처럼 위임된 인터페이스가 없다
  • Customer 구조가 바뀌면 호출하는 모든 코드가 깨진다

재귀 mock의 편의성을 “리팩토링이 필요하다는 신호” 로 해석할 수 있습니다.

relaxUnitFun과 CQS

relaxUnitFun 옵션이 별도로 존재한다는 사실은 Command-Query Separation(CQS) 원칙을 라이브러리 차원에서 권장한다고 볼 수 있습니다.

CQS는 메서드를 두 종류로 나눕니다.

  • Command: 부수효과를 일으키고, 값을 반환하지 않는다 (Unit)
  • Query: 값을 반환하고, 부수효과가 없다

relaxUnitFun = true는 Command는 자동으로 통과시키되, Query는 명시적으로 stub하도록 강제합니다. 이 구분은 mock 사용 패턴을 자연스럽게 유도합니다.

  • Command는 verify로 검증한다 (“호출되었는가?“)
  • Query는 every로 통제한다 (“어떤 값을 돌려주는가?“)

같은 메서드에 everyverify를 동시에 걸어야 한다면, 그 메서드는 Command와 Query를 혼합하고 있을 가능성이 높습니다.

// JPA Repository의 save는 영속화(Command)하면서 동시에 엔티티를 반환(Query)
interface UserRepository {
    fun save(user: User): User
}

// 같은 메서드에 stub과 verify가 모두 필요해진다
every { userRepository.save(any()) } returns User(id = 1L, name = "Alice")
// ... 비즈니스 로직 실행
verify { userRepository.save(match { it.name == "Alice" }) }

JPA save처럼 현실에서는 CQS를 엄격히 지키기 어려운 경우도 많습니다. 다만 mock 코드의 모양을 통해 “이 메서드는 두 역할을 동시에 하고 있다”는 점을 인지할 수 있습니다.

시그널 정리

mocking이 어색하게 느껴진다면, 그게 보내는 신호는 다음과 같습니다.

mocking 시 느끼는 불편 시사하는 설계 문제 원칙
재귀 mock이 너무 편하다 깊은 체이닝, 객체 그래프 침투 Law of Demeter
같은 메서드에 every + verify가 필요 Command와 Query 혼합 CQS

테스트가 어색하면, 옵션을 추가하기 전에 한 번 의심해보세요. 프로덕션 코드가 보내는 신호일 수 있습니다.

정리

옵션 동작 적합한 경우
mockk<T>() stub 없으면 예외 모든 호출을 명시적으로 통제하고 싶을 때
mockk<T>(relaxUnitFun = true) Unit 반환 메서드만 자동 통과 일반적인 단위 테스트의 기본값
mockk<T>(relaxed = true) 모든 메서드가 타입별 기본값 반환 (재귀 mock 포함) 검증 대상이 아닌 의존성, 큰 인터페이스

relaxed = true편의 기능이지 품질 도구가 아닙니다. 무엇을 검증하는 테스트인지 모호해지지 않도록, 가능한 좁은 범위에서 사용하는 것이 좋습니다.

참고 자료

© 2026, 미나리와 함께 만들었음