Kotlin의 확장함수를 사용하여 DSL을 만들어 사용성 높은 코드 만들기

Glen·2024년 1월 1일
0

배운것

목록 보기
32/37

서론

Kotlin과 Java를 비교했을 때 무엇이 좋은가 물어본다면, 나는 확장함수의 사용이라고 말하고 싶다.

확장함수를 사용하면, 기존에 작성되어 있던 코드를 수정하지 않고 새로운 기능을 추가할 수 있다.

또한 DSL(Domain-Specific Language)을 쉽게 만들 수 있고 적용할 수 있다.

본론

DSL

DSL(Domain-Specific Language)이란, 영역 특화 언어라고 해석할 수 있다.

여기서 영역이란 문제를 해결하고 싶은 범위라고 할 수 있다.

Java와 JPA를 사용했다면 아마 QueryDSL 라이브러리를 들어봤을 것이다.

QueryDSL의 특징은 기존에 문자열로 작성하던 SQL 대신에, 메서드 호출을 통해, 객체와 메서드를 기반으로 가독성이 높고, 재사용성이 높은 코드를 작성할 수 있게 해준다.

queryFactory.selectFrom(cat)
    .innerJoin(cat.mate, mate)
    .leftJoin(cat.kittens, kitten)
    .fetch();

QueryDSL은 빌더 패턴과 스트림처럼 메서드를 체이닝하여 호출하는데, 이러한 방식을 Fluent style이라고 부른다.

QueryDSL의 레퍼런스에도 fluent API라고 설명되어 있다.

DSL의 특징이라면 명령형 프로그래밍으로 작성하던 코드를 선언형 프로그래밍으로 작성하는 것과 비슷하다고 볼 수 있다.

작성하기는 어렵지만, 간결하며 가독성이 높고 유지보수성이 높다.

DSL을 만드는 것은 어렵지만, 만들고 나면 높은 생산성을 제공한다.

그렇다고 DSL은 반드시 QueryDSL 처럼 복잡하고 뛰어난 기능을 제공해야 하는 것은 아니다.

간단한 DSL을 만들어서 프로젝트에서 반복적으로 사용되는 상용구 코드를 제거하여 생산성을 크게 높일 수 있다.

코틀린을 사용하면 확장함수를 사용하여 쉽게 DSL을 만들 수 있다.

확장함수

확장함수(Extension Function)은 작성된 라이브러리의 코드를 수정하지 않고 새로운 기능을 추가할 수 있게 해주는 코틀린의 기능이다.

문자열을 URI로 만들고 싶을 때 다음과 같이 코드를 작성할 수 있다.

val url = URI.create("/api/v1/news")

하지만 역으로 생각하여, 문자열에 URI를 반환하는 함수가 있었으면 어땠을까 생각할 수 있다.

val url = "api/v1/news".toUri()

만약 자바를 사용했다면 이러한 생각은 단순한 상상으로 끝났을 것이다.

하지만 코틀린의 확장 함수를 사용한다면 이러한 상상은 현실이 된다.

fun String.toUri(): URI = URI.create(this)

확장함수를 선언하는 방법은 추가하려는 함수 이름 앞에 그 함수가 확장할 클래스의 이름을 적으면 된다.

여기서 확장할 클래스를 수신 객체 타입(Recevier Type) 이라 부르며, 확장 함수가 호출되는 대상이 되는 값(객체)을 수신 객체(Receiver Object) 라고 부른다.

toUri 확장함수의 경우 수신 객체 타입은 String이며, 수신 객체는 this이다.

만약 확장함수를 사용하지 않고, String 클래스에 이러한 메소드를 추가했다면 String 클래스에 URI에 대한 의존성이 생겼을 것이다.

MockWebServer

프로젝트에서 외부에 API를 호출하는 로직이 있다.

해당 로직이 정상적으로 동작하는 것을 확인하려면 테스트 코드에서 실제 서버에 요청을 보낼 수 있지만, 이것은 프로젝트에 불확실성을 야기한다.

왜냐하면 테스트 시점에 외부 서버에 장애가 있을 수 있기 때문에 올바른 테스트 코드라도 실패할 가능성이 생기기 때문이다.

따라서 테스트 코드에서는 실제 외부 서버에 요청을 보내지 않도록 해야 한다.

이러한 케이스를 해결하기 위해 MockWebServer 라이브러리를 사용할 수 있다.

MockWebServer는 Spring Boot에서 종속성 버전을 관리해주기 때문에, 버전을 명시하지 않고 사용할 수 있다.

dependencies {
    testImplementation("com.squareup.okhttp3:mockwebserver")
}

MockWebServer는 다음과 같이 사용할 수 있다.

class DiscordOAuth2ClientTest : DescribeSpec({

    isolationMode = IsolationMode.InstancePerLeaf

    val objectMapper = jacksonObjectMapper()
    val mockWebServer = MockWebServer()
    val discordOAuth2Client = DiscordOAuth2Client(  
        webClient = WebClient.builder()  
            .baseUrl("${mockWebServer.url("/")}")  
            .build(),  
        clientId = "client_id",  
        clientSecret = "client_secret",  
        redirectUri = "https://sc.galaxyhub.kr",  
        timeoutDuration = Duration.ofSeconds(10)  
    )

    afterEach {  
        mockWebServer.shutdown()  
    }

    describe("getAccessToken") {
        val response = DiscordAccessTokenResponse(  
            accessToken = "123123",  
            tokenType = "Bearer",  
            expiresIn = 3000,  
            refreshToken = "321321",  
            scope = "email"  
        )

        context("외부 서버가 200 응답을 반환하면") {  
            val mockResponse = MockResponse()  
            mockResponse.setResponseCode(200)  
            mockResponse.setBody(objectMapper.writeValueAsString(response))
            mockResponse.setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON)
            mockWebServer.enqueue(mockResponse)
            val actual = response.accessToken  
            
            val expect = discordOAuth2Client.getAccessToken("code")  
  
            it("AccessToken이 정상적으로 반환된다.") {  
                expect shouldBe actual  
            }  
        }
    }
})

참고로 문서에서는 다음과 같이 start() 메서드를 직접 호출하는 것을 볼 수 있다.

public void test() throws Exception {
    // Create a MockWebServer. These are lean enough that you can create a new
    // instance for every unit test.
    MockWebServer server = new MockWebServer();
    
    // Schedule some responses.
    server.enqueue(new MockResponse().setBody("hello, world!"));
    server.enqueue(new MockResponse().setBody("sup, bra?"));
    server.enqueue(new MockResponse().setBody("yo dog"));
    
    // Start the server.
    server.start();
    
    ...
    // Assertion Code
    ...
    
    // Shut down the server. Instances cannot be reused.
    server.shutdown();
}

하지만 직접 mockWebServer.start() 메서드를 호출하면 start() already called 메시지와 함께 예외가 발생하며 테스트가 실패한다.

원인은 mockWebServer.url() 메서드에 있는데, url() 메서드는 다음과 같이 구현되어 있다.

class MockWebServer : ExternalResource(), Closeable {
    fun url(path: String): HttpUrl {  
      return HttpUrl.Builder()  
          .scheme(if (sslSocketFactory != null) "https" else "http")  
          .host(hostName)  
          .port(port)  
          .build()  
          .resolve(path)!!  
    }
}

여기서 hostNameport는 필드인데, 다음과 같이 custom getter로 구현되어 있다.

val port: Int  
  get() {  
    before()  
    return portField  
  }  
  
val hostName: String  
  get() {  
    before()  
    return inetSocketAddress!!.address.canonicalHostName  
  }

여기서 before() 메서드를 호출하고 값을 반환하는데, 바로 이 녀석 before() 메서드가 범인이다.

@Synchronized override fun before() {  
    if (started) return  
    try {  
        start()  
    } catch (e: IOException) {  
        throw RuntimeException(e)  
    }  
}

before() 메서드는 MockWebServer가 상속하고 있는 ExternalResource 클래스의 메서드이다.

다음과 같이 훅으로 구현되어 있다.

public abstract class ExternalResource implements TestRule {
    ...
    protected void before() throws Throwable {  
        // do nothing  
    }  
    
    protected void after() {  
        // do nothing  
    }
    ...
}

따라서 mockWebServer.url() 메서드를 호출했다면, start() 메서드를 호출하지 않아야 한다.

추가로 Kotest는 기본으로 하나의 테스트 인스턴스를 생성하기 때문에 isolationMode를 설정해야 한다.

나는 isolationMode = IsolationMode.InstancePerLeaf를 설정했다.
왜냐하면, 서버가 응답하지 않는 테스트를 수행할 때 명시적으로 shutdown() 메서드를 호출할 일이 있기 때문이다.
따라서 테스트마다 격리된 상태의 mockWebServer 인스턴스가 필요하다.

MockWebServer.enqueue()

테스트 코드의 작성은 단순히 성공 케이스만 작성하는 게 아닌, 예외 상황에 대한 테스트도 작성할 수 있어야 한다.

이 경우 서버에서 400, 500 응답이 반환되었을 때를 가정할 수 있다.

context("외부 서버가 400 응답을 반환하면") {  
    val mockResponse = MockResponse() 
    mockResponse.setResponseCode(400)
    mockResponse.setBody(errorResponse)
    mockResponse.setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON)
    mockWebServer.enqueue(mockResponse)
    
    // Assertion Code
}  

context("외부 서버가 401 응답을 반환하면") {  
    val mockResponse = MockResponse()  
    mockResponse.setResponseCode(401)
    mockResponse.setBody(errorResponse)
    mockResponse.setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON)
    mockWebServer.enqueue(mockResponse)
    
    // Assertion Code
}

context("외부 서버가 500 응답을 반환하면") {  
    val mockResponse = MockResponse()  
    mockResponse.setResponseCode(500)
    mockResponse.setBody(errorResponse)
    mockResponse.setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON)
    mockWebServer.enqueue(mockResponse)
  
    // Assertion Code
}

단순히 API 하나를 테스트하는데 적어도 3~4개의 테스트 코드를 작성한다.

이때 매번 MockResponse 객체를 생성하고, mockWebServer.enequeue() 메서드에 인자로 값을 넘겨주는 중복된 코드가 발생한다.

만약 테스트할 API가 많다면... 상상도 하기 싫다. 😂

이 문제는 MockResponse 타입의 인자를 받는 함수형 메서드를 만들면 중복을 간단하게 없앨 수 있다.

즉, 템플릿 콜백 패턴을 사용하면 된다.

fun enqueueMockResponse(dsl: (MockResponse) -> Unit) {
    val mockResponse = MockResponse()  
    dsl.invoke(mockResponse)  
    mockWebServer.enqueue(mockResponse)  
}

enqueueMockResponse {  
    it.setResponseCode(200)  
    it.setBody(objectMapper.writeValueAsString(response))  
    it.setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON)  
}

하지만 이 방법은 매 클래스마다 해당 메서드를 구현해야 한다.

왜냐하면 메서드 내에 mockWebServer 객체가 클래스의 필드로 선언되어 있어야 하기 때문이다.

생각해 보면 mockWebServer 객체가 위의 메서드를 구현한다면 굳이 외부에 선언된 MockWebServer 객체가 필요하지 않을 것이다.

이때 코틀린의 확장함수를 사용하면 이러한 문제를 쉽게 해결할 수 있다.

fun MockWebServer.enqueue(dsl: (MockResponse) -> Unit) {  
    val mockResponse = MockResponse()  
    dsl.invoke(mockResponse)  
    this.enqueue(mockResponse)  
}

mockWebServer.enqueue {  
    it.setResponseCode(200)  
    it.setBody(objectMapper.writeValueAsString(response))  
    it.setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON)  
}

정의한 확장함수는 외부 클래스에 선언하여 다른 테스트 클래스에서도 재사용 할 수 있다.

이 정도만 해도 기존에 작성하던 상용구 코드를 대부분 제거할 수 있다.

그래도 약간의 아쉬운 점은 setBody()setHeader() 메서드를 매번 호출하는 것이다.

setBody() 메서드의 인자로 JSON으로 직렬화된 문자열을 넣기위해 어쩔 수 없이 objectMapper가 필요하다.

그리고 JSON으로 직렬화했기 때문에, setHeader() 메서드로 Content-Type이 application/json인 헤더를 설정해야 한다.

이것은 단순히 확장함수를 정의해도 해결할 수 없다.

fun MockWebServer.enqueue(dsl: (MockResponse) -> Unit) {  
    val objectMapper = jacksonObjectMapper() // body를 어떻게 직렬화하지..?
    val mockResponse = MockResponse()  
    dsl.invoke(mockResponse) 
    mockResponse.setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON) // 만약 다른 Content-Type이 요구되면..?
    this.enqueue(mockResponse)  
}

이제 약간의 정성을 추가하여, 더 세련된 버전의 DSL을 작성할 때가 왔다.

MockWebServer DSL

우리가 필요한 기능은 다음과 같다.

  1. setBody() 메서드의 인자로 String 대신 객체를 넣어 자동으로 직렬화가 되게한다.
  2. setBody() 메서드를 호출하면 Content-Type 헤더를 application/json이 자동으로 설정되게 한다.
  3. 굳이 setBody(), setResponseCode() 말고 body(), statusCode()와 같이 직관적인 메서드 명을 호출한다.

이것은 문제를 해결하고 싶은 범위이다.

즉, 도메인을 코드로 나타낸다면 다음과 같을 것이다.

mockWebServer.enqueue {  
    statusCode(200)  
    body(response)  
}

mockWebServer.enqueue {  
    statusCode(400)  
}

mockWebServer.enqueue {  
    statusCode(200)  
    body("Hello World")
    contentType(MediaType.TEXT_PLAIN)
}

이러한 기능을 구현하려면 MockWebServer에 확장함수로 body(), statusCode(), contentType() 메서드를 구현한 클래스의 타입을 가지는 함수형 인터페이스를 인자로 받게 할 수 있다.

class MockWebServerDsl {

    private var statusCode: Int? = null  
    private var body: String? = null  
    private var mediaType: MediaType? = null
    
    fun statusCode(statusCode: Int) {  
        this.statusCode = statusCode  
    }  
      
    fun body(body: Any) {  
        this.body = objectMapper.writeValueAsString(body)  
    }  
      
    fun contentType(mediaType: MediaType) {  
        this.mediaType = mediaType  
    }

    // 캡슐화를 위해 internal 접근자 지정
    internal fun perform(mockWebServer: MockWebServer) {  
        val response = MockResponse()  
        statusCode?.also { response.setResponseCode(it) }  
        body?.also {  
            response.setBody(it)  
            response.setHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON)  
        }  
        mediaType?.also {  
            response.setHeader(HttpHeaders.CONTENT_TYPE, it)  
        }  
        mockWebServer.enqueue(response)  
    }
    
    companion object {  
        val objectMapper = jacksonObjectMapper()  
    }
}

fun MockWebServer.enqueue(dsl: (MockWebServerDsl) -> Unit) {  
    val mockWebServerDsl = MockWebServerDsl()  
    dsl.invoke(mockWebServerDsl)  
    mockWebServerDsl.perform(this)  
}

하지만 사용해보면 우리가 원하는 동작을 하지 않는다.

mockWebServer.enqueue {  
    statusCode(200)  // 컴파일 에러
    body(response)  // 컴파일 에러
}

mockWebServer.enqueue {  
    it.statusCode(200)  // 정상 작동
    it.body(response)  // 정상 작동
}

이는 일반적인 람다 함수를 사용했기 때문인데, 해당 람다 함수를 더 풀어쓰면 다음과 같다.

mockWebServer.enqueue { it ->
    this.statusCode(200) // 람다 블럭의 this는 블럭 밖의 객체를 가르킨다.
    this.body(response) 
}

mockWebServer.enqueue { it ->  
    it.statusCode(200)  
    it.body(response)  
}

왜냐하면 일반 람다의 블럭 내의 this는 람다 파라미터를 가르키는게 아닌, 호출한 인스턴스를 가리키기 때문이다.

람다의 블럭 내에서 람다 파라미터를 this로 가르키려면 람다를 수신 객체 지정 람다(Lambda with a receiver)로 바꿔야 한다.

수신 객체 지정 람다로 변경하려면 다음과 같이 람다의 파라미터를 확장 함수 타입으로 변경한다.

fun MockWebServer.enqueue(dsl: MockWebServerDsl.() -> Unit) {  
    val mockWebServerDsl = MockWebServerDsl()  
    mockWebServerDsl.dsl() // 파라미터에 확장 함수를 정의해서 사용할 수 있다.
    mockWebServerDsl.perform(this) // this는 MockWebServer를 가르킨다.
}

위의 확장함수 섹션에서 설명했듯이, 확장함수에서 확장할 클래스의 타입을 수신 객체 타입이라고 했고, 해당 확장 함수가 호출되는 값을 this로 사용할 수 있었다.

여기서 파라미터인 dsl 변수는 다음과 같은 확장 함수이다.

fun MockWebServerDsl.dsl(func: () -> Unit) {
    ...
}

따라서 thisMockWebServerDsl 객체를 가르키게 할 수 있다.

mockWebServer.enqueue {  
    statusCode(200)
    this.body(response) // this는 MockWebServerDsl 클래스를 가르킨다.
}

또한 MockWebServer.enqueue() 확장 함수의 구현을 다음과 같이 간략하게 한 줄로 표현할 수 있다.

fun MockWebServer.enqueue(dsl: MockWebServerDsl.() -> Unit) = MockWebServerDsl().apply(dsl).perform(this)

왜냐하면 apply() 확장 함수의 시그니쳐가 다음과 같기 때문이다.

public inline fun <T> T.apply(block: T.() -> Unit): T {  
    contract {  
        callsInPlace(block, InvocationKind.EXACTLY_ONCE)  
    }  
    block()  
    return this  
}

이제 우리가 원했던 기능을 다음과 같이 사용할 수 있다!

context("외부 서버가 200 응답을 반환하면") {  
    mockWebServer.enqueue {
        statusCode(200)
        body(response)
    }
    
    // Assertion Code
}  

context("외부 서버가 400 응답을 반환하면") {  
    mockWebServer.enqueue {
        statusCode(400)
        body(errorResponse)
    }
    
    // Assertion Code
}  

context("외부 서버가 401 응답을 반환하면") {  
    mockWebServer.enqueue {
        statusCode(401)
        body(errorResponse)
    }
    
    // Assertion Code
}

context("외부 서버가 500 응답을 반환하면") {  
    mockWebServer.enqueue {
        statusCode(500)
        body(errorResponse)
    }
  
    // Assertion Code
}

결론

이렇게 코틀린의 확장함수를 사용하여 손쉽게 DSL을 만들 수 있었다.

또한 수신 객체 지정 람다를 사용하여 람다 내에서 it을 사용하지 않고 더욱 간략하게 메서드를 호출하는 방법 또한 익혔다.

여기에는 설명하지 않았지만, 중위 함수를 활용하면 더 뛰어난 DSL을 만들 수 있다.

중위 함수를 사용하여 DSL을 구현한 예시는 토스 기술 블로그에 게시되어 있다.

무엇이든 만드는 사람이 고생하면 사용하는 사람이 편해지는 것 같다.

참고

드미트리 제메로프, 스베트라나 이사코바. "Kotlin In Action". 오현석(역). 에이콘, 2017.
라울 게이브리얼 우르마, 마리오 푸스코, 앨런 마이크로프트. "모던 자바 인 액션". 우정은(역). 한빛미디어, 2021.

profile
꾸준히 성장하고 싶은 사람

0개의 댓글