안녕하세요!
이번 글에서는 지난 포스트에서 소개드린 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