본문 바로가기
작디 작은 나만의 라이브러리/BridgeApi

[신입 개발자의 '작디 작고 작디 작고 자그만' 첫 라이브러리 제작기] 1. Bridge-Api 초기 구현 및 리팩토링 과정

by 시니성 2024. 7. 23.

안녕하세요!
이번 글에서는 지난 포스트에서 소개드린 Bridge-Api 라이브러리의 구체적인 구현 과정을 적어볼텐데요.
초기 구현과 성능 개선을 위한 리팩토링 과정에 대해 다루어 보겠습니다.

결론 부터 말씀 드리자면, 초기 구현에 비해 리팩토링 후 라우팅 로직에 약 2배의 성능 향상이 있었습니다.

1. 초기 구상 (리플렉션과 정규식을 활용)

처음 Bridge-Api를 설계할 때, 저는 리플렉션과 정규식을 활용한 방식을 채택했습니다.
이 접근법은 유연성과 간결성 측면에서 장점이 있었습니다.

1.1 초기 라우팅 find 코드

초기 라우팅 코드는 다음과 같았습니다:

fun handleRequest(pathAndQueryString: String, method: MethodType, jsonStringBody: String = ""): String {
    val (path, queryString) = pathAndQueryString.split("?", limit = 2).let {
        it[0] to (it.getOrNull(1) ?: "")
    }
    val queryParams = queryString.split("&").mapNotNull {
        val (key, value) = it.split("=", limit = 2).let { it[0] to it.getOrNull(1) }
        if (key.isNotEmpty() && value != null) key to value else null
    }.toMap()

    for ((basePath, controller) in controllers) {
        //리플렉션을 통한 클래스 리터럴 조회.
        val controllerClass = controller::class
        for (function in controllerClass.memberFunctions) {
            val matchedAnnotation = when (method) {
                MethodType.GET -> function.findAnnotation<Get>()
                MethodType.POST -> function.findAnnotation<Post>()
                MethodType.PATCH -> function.findAnnotation<Patch>()
                MethodType.DELETE -> function.findAnnotation<Delete>()
                MethodType.PUT -> function.findAnnotation<Put>()
            }
            val annotationPath = when (matchedAnnotation) {
                is Get -> matchedAnnotation.path
                is Post -> matchedAnnotation.path
                is Patch -> matchedAnnotation.path
                is Delete -> matchedAnnotation.path
                is Put -> matchedAnnotation.path
                else -> null
            }

            if (matchedAnnotation != null) {
                val fullPath = basePath + annotationPath
                // 정규식 생성후 매칭
                val (pathPattern, groupNames) = createPathPattern(fullPath)
                val matcher = pathPattern.matcher(path)
                if (matcher.matches()) {
                    val pathVariables = groupNames.associateWith { matcher.group(it) }
                    val result = invokeFunction(controller, function, queryParams, jsonStringBody, pathVariables)
                    return objectMapper.writeValueAsString(result)
                }
            }
        }
    }
    return "404"
}
    private fun createPathPattern(path: String): Pair<Pattern, List<String>> {
        val groupNames = mutableListOf<String>()
        val regex = path.replace(Regex(":([a-zA-Z][a-zA-Z0-9]*)")) {
            val groupName = it.groupValues[1]
            groupNames.add(groupName)
            "(?<$groupName>[^/]+)"
        }
        return Pattern.compile(regex) to groupNames
    }

이 코드는 매 요청마다 리플렉션을 통해 컨트롤러의 함수를 순회하며 적절한 핸들러를 찾았습니다.

2. 문제 파악 (리팩토링의 필요성)

초기 접근 방식에서 몇 가지 주요 문제점을 발견했습니다:

  • 매 요청마다 리플렉션을 수행하여 상당한 성능 저하 발생
  • 정규식 사용으로 인한 추가적인 성능 저하
  • 라우트 매칭 시 모든 컨트롤러와 함수를 순회하는 비효율적인 구조

이러한 문제점들은 대량의 요청을 처리할 때 특히 두드러지게 나타났습니다.

3. 리팩토링 전략

성능 개선을 위해 다음과 같은 전략을 세웠습니다:

  • 세그먼트 기반의 트리 구조 도입
  • 초기 한 번의 리플렉션으로 함수와 컨트롤러 정보 저장
  • 트리 조회 방식으로 라우트 검색 최적화
  • 정규식 사용 최소화

4. 구체적인 리팩토링 과정

4.1 라우트 트리 구조 설계

private class RouteNode(
    val isPathVariable: Boolean = false,
    val pathVariableName: String? = null
) {
    val children: MutableMap<String, RouteNode> = mutableMapOf()
    val routeInfoByMethod: MutableMap<MethodType, RouteInfo> = mutableMapOf()
}

private data class RouteInfo(
    val function: KFunction<*>,
    val controller: Any,
    val method: MethodType
)

이 구조를 통해 URL 세그먼트별로 트리를 구성하고, 각 노드에 해당 라우트의 정보를 저장할 수 있습니다.

4.2 라우트 빌드 로직 구현

private fun buildRoutes() {
    controllers.forEach { (path, controller) ->
        val controllerClass = controller::class
        for (function in controllerClass.memberFunctions) {
            val matchedAnnotation = when {
                function.findAnnotation<Get>() != null -> function.findAnnotation<Get>()
                function.findAnnotation<Post>() != null -> function.findAnnotation<Post>()
                function.findAnnotation<Patch>() != null -> function.findAnnotation<Patch>()
                function.findAnnotation<Delete>() != null -> function.findAnnotation<Delete>()
                function.findAnnotation<Put>() != null -> function.findAnnotation<Put>()
                else -> null
            }
            val annotationPath = when (matchedAnnotation) {
                is Get -> matchedAnnotation.path.ensureLeadingSlash()
                is Post -> matchedAnnotation.path.ensureLeadingSlash()
                is Patch -> matchedAnnotation.path.ensureLeadingSlash()
                is Delete -> matchedAnnotation.path.ensureLeadingSlash()
                is Put -> matchedAnnotation.path.ensureLeadingSlash()
                else -> null
            }

            if (matchedAnnotation != null) {
                val fullPath = path + annotationPath
                val routeInfo = RouteInfo(
                    function = function,
                    controller = controller,
                    method = matchedAnnotation.toMethodType()
                )
                addRoute(fullPath, routeInfo)
            }
        }
    }
    logger.debug("Routes built.")
}

이 함수는 초기화 시점에 한 번만 실행되며, 모든 컨트롤러와 함수를 순회하면서 라우트 정보를 트리에 추가합니다.

4.3 라우트 추가 로직

private fun addRoute(path: String, routeInfo: RouteInfo) {
    val segments = path.split("/").filter { it.isNotEmpty() }
    tailrec fun add(currentNode: RouteNode, remainingSegments: List<String>) {
        if (remainingSegments.isEmpty()) {
            currentNode.routeInfoByMethod[routeInfo.method] = routeInfo
            return
        }

        val segment = remainingSegments.first()

        val nextNode =
            if (segment.startsWith(":"))
                currentNode.children.getOrPut("{pathVariable}") {
                    RouteNode(isPathVariable = true, pathVariableName = segment.substring(1))
                }
            else
                currentNode.children.getOrPut(segment) { RouteNode() }

        add(nextNode, remainingSegments.drop(1))
    }
    add(routeTree, segments)
}

이 함수는 주어진 경로를 세그먼트로 분할하고, 각 세그먼트에 대한 노드를 생성하거나 기존 노드를 찾아 트리를 구성합니다.

4.4 라우트 검색 로직

private fun findRoute(segments: List<String>, method: MethodType): Pair<RouteInfo?, Map<String, String>> {
    tailrec fun find(
        currentNode: RouteNode,
        remainingSegments: List<String>,
        pathVariables: MutableMap<String, String>,
    ): Pair<RouteInfo?, Map<String, String>> {
        if (remainingSegments.isEmpty()) {
            return currentNode.routeInfoByMethod[method] to pathVariables
        }

        val segment = remainingSegments.first()
        val nextNode = currentNode.children[segment] ?: currentNode.children["{pathVariable}"]
        if (nextNode == null) return null to emptyMap()

        if (nextNode.isPathVariable) pathVariables[nextNode.pathVariableName!!] = segment

        return find(nextNode, remainingSegments.drop(1), pathVariables)
    }

    return find(routeTree, segments, mutableMapOf())
}

이 함수는 주어진 URL 세그먼트를 따라 트리를 탐색하며, 매칭되는 라우트 정보와 경로 변수를 반환합니다.

5. 성능 개선 결과

리팩토링 전후의 성능을 비교하기 위해 200만 회의 요청을 처리하는 테스트를 수행했습니다.

"POST: TimeMeasure - 2000000회 요청 시간 측정 /:id/name/:name/user-type/:type/age/:age"{
    val start = System.currentTimeMillis()
    repeat(2000000) {
        router.routingRequest("api/v1/users/1", MethodType.GET)
        router.routingRequest("api/v1/users/1/name/John/user-type/1/age/20", MethodType.POST)
    }
    val end = System.currentTimeMillis()
    println("TimeMeasure: ${end - start}ms")
}

결과는 다음과 같습니다:

  • 리팩토링 전: 9989ms
  • 리팩토링 후: 4460ms

처리 시간이 절반 이상 단축된 것을 확인할 수 있었습니다. 이는 리플렉션과 정규식 사용을 최소화하고, 보다 효율적인 트리 구조를 도입한 결과 입니다.

6. 마치며

이번 리팩토링을 통해 Bridge-Api의 성능을 대폭 개선할 수 있었습니다. 주요 개선 사항을 정리하면 다음과 같습니다:

  • 리플렉션 사용 최소화: 초기화 시점에만 리플렉션을 사용하여 라우트 정보를 구성
  • 정규식 사용 제거: 문자열 비교 기반의 트리 구조로 대체
  • 효율적인 라우트 매칭: 트리 구조를 통해 빠른 라우트 검색 가능

리팩토링을 진행하며 느낀점은 다음과 같습니다.

  • 리플렉션은 강력하지만 비용이 크다: 리플렉션은 큰 유연성을 제공하지만, 비용이 따릅니다. 특히 성능이 중요한 경우에는 신중하게 사용해야 하며, 리플렉션을 최소화 할 방안을 생각해야 합니다.
  • 사전 계산은 효과적이다: 무거운 작업(리플렉션 및 라우트 파싱)을 시작 단계로 옮김으로써, 요청별 오버헤드를 크게 줄였습니다.
  • 데이터 구조가 중요하다: 적절한 데이터 구조(이번 라이브러리의 경우 트리)를 선택하면 상당한 성능 향상을 얻을 수 있습니다.
  • 추측하지 말고 측정하라: 되도록 변경 사항을 벤치마크 하여, 정량적 지표로 결과물을 평가하는 것이 중요합니다.

이번 글은 여기서 줄이도록 하고, 다음 글 에서는 클라이언트 측 구현에 대해 다루어볼 예정입니다.

+ 라이브러리 레포지토리 : https://github.com/shiniseong/bridge-api

728x90