문제
이런 코드가 있다.
fun String.toSnakeCase(): String =
replace(Regex("([a-z0-9])([A-Z])"), "$1_$2").lowercase()
이 코드에는 문제점이 몇 가지 있는데, 무엇일까?
이 코드는 사실 내가 회사에서 작업한 코드 중 일부다.
이렇게 문자열 확장 함수를 작성해서 PR을 올렸을 때, 한 동료가 이런 리뷰를 달아주었다.
"정규식 객체가 무겁다던데, 이 함수가 불릴 때마다 매번 새로 만들면 성능에 부담이 되지 않을까요?"
그제야 의문이 들기 시작했다.
정규식이 매번 생성하기엔 무거운 객체였구나.
그럼 어떻게 관리해야 할까?
혹시 확장함수에서는 코틀린이 자동으로 캐싱해서 관리하고 있진 않을까?
그렇지 않는다면, 매번 생성하지 않게 할 가장 좋은 방법은 뭘까?
매번 생성하지 않는다는 건 메모리를 항상 점유하고 있다는 게 아닐까?
그럼 어떤 상황에서 어떤 방식으로 구현하는 게 가장 효율적일까?
이런 질문들을 따라가다 보니, 단순한 코드 최적화 너머로 코틀린과 JVM이 객체를 어떻게 관리하는지까지 이해할 수 있었다.
그 고민의 과정을 기록해보려 한다.
+) Regex는 왜 무거울까?
본격적으로 들어가기 전에, 추가로 하나를 더 알아보려 한다.
이 문제의식의 발단이 된 정규식, 왜 무거운 걸까?
Regex는 내부적으로 문자열 패턴을 분석하고, 그것을 바이트코드 수준에서 빠르게 매칭할 수 있도록 "컴파일"한다.
이 컴파일 과정에는 패턴을 파싱하고, 또 이를 효율적으로 매칭하기 위한 내부 구조를 생성하는 작업까지 포함된다.
-> 상당한 연산 리소스를 소모할 수 있다는 의미이다.
좀 더 자세하게는,
자바의 Pattern.compile(...)을 생각하면 이해하기 쉽다.
Java에서는 Pattern 클래스로 정규 표현식을 사용할 수 있고, Pattern.compile(String regex) 메서드를 통해 이 패턴을 컴파일한다.
이렇게 컴파일 된 패턴은 Matcher 객체를 생성해 문자열 매칭에 사용된다.
자바는 이 정규식을 효율적으로 처리하기 위해 “오토마타”라는 알고리즘 구조를 만든다.
이 구조는 문자열을 빠르게 탐색할 수 있도록 만들어주는 일종의 상태 기계(state machine)인데, 바로 이 과정이 생성하는 데 시간이 꽤나 많이 걸리는 것이다.
Kotlin에서도 동일하다.
Kotlin에서는 Regex 클래스를 통해 정규 표현식을 다루지만, 내부적으로는 Java의 Pattern 클래스를 활용한다.
그 말인 즉슨 Kotlin의 Regex 객체도 생성 시 Java와 마찬가지로 리소스를 많이 잡아먹는다.
Regex를 확장 함수로 쓰는 게 성능이 좋지 않은 이유
그래서, 다시 위에서 제시했던 코드로 돌아가,
fun String.toSnakeCase(): String =
replace(Regex("([a-z0-9])([A-Z])"), "$1_$2").lowercase()
이렇게 쓰면, toSnakeCase()를 호출할 때마다 그 무겁다는 Regex(...)가 새로 생성된다.
그런데 이렇게 생각해볼 수도 있다. (사실 내가 그렇게 생각했다.)
- 확장 함수가 혹시 내부적으로는 마치 람다처럼 처리되고,
- 람다는 어딘가에 저장되면 재사용되니까,
- 확장 함수 안의 Regex도 자동으로 캐싱되지 않을까?
하지만 아니다.
첫 추측부터 빗나갔는데, 확장 함수는 실제로는 정적(static) 함수로 컴파일된다.
내부에 선언한 Regex(...)는 호출될 때마다 새 객체를 만든다. 따라서 아무런 캐싱이 일어나지 않는다.
해결 방법
그럼 어떻게 하면 좋을까?
단순하게 생각해보면, 생성이 무거운 이 객체를 한 번만 생성되도록 하면 된다.
그 방법에는 크게 두 가지가 있다.
1) Regex를 val로 빼기
val은 불변 변수이고,
그래서 클래스나 파일 수준(top-level)에 선언된 val은 처음 로딩될 때 초기화된 후 사라지지 않고 계속 유지된다.
private val SNAKE_CASE_REGEX = Regex("([a-z0-9])([A-Z])")
fun String.toSnakeCase(): String =
replace(SNAKE_CASE_REGEX, "$1_$2").lowercase()
특히, 클래스 내부가 아닌 top-level에 선언된 val은 코틀린 컴파일러가 숨겨진 JVM 클래스의 static 변수처럼 관리한다.
(코드로 보면 이런 식이다.)
public final class StringExtKt { // JVM이 관리하는 숨겨진 클래스
private static final Regex SNAKE_CASE_REGEX = new Regex("([a-z0-9])([A-Z])");
public static final String toSnakeCase(String str) { ... }
}
즉, 프로그램이 이 부분을 처음 참조할 때 한 번만 실행하고, 그 이후엔 계속 유지한다.
대부분의 경우 .toSnakeCase()가 처음 불리는 상황일 것이고,
매우 드물게 리플렉션, 자동 wiring 등으로 참조될 때 불려 메모리에 유지될 수 있다.
2) object로 싱글톤으로 관리하기
Kotlin의 object는 싱글톤이다. 그리고 lazy initialization 방식이다.
즉, 최초로 접근하는 시점에 단 한 번 생성되고, 프로그램이 종료될 때까지 메모리에서 유지된다.
이 말은 곧, 정규식을 담은 object는 한 번 생성되면 재생성되거나 다른 곳에서 중복 생성되지 않고, 앱 실행 중 계속 살아 있다는 뜻이다.
따라서 캐싱 효과를 누릴 수 있고 성능도 향상된다.
코드로 보면 아래처럼 쓸 수 있다.
object StringUtil {
private val SNAKE_CASE_REGEX = Regex("([a-z0-9])([A-Z])")
fun String.toSnakeCase(): String =
replace(SNAKE_CASE_REGEX, "$1_$2").lowercase()
}
이 방식은 클래스 내부에서도 유사하게 적용할 수 있다.
companion object 안에 정규식을 선언하면, 해당 클래스나 객체가 로딩될 때 한 번만 생성되고 이후 계속 재사용된다.
val로 생성하는 방식과 마찬가지로, StringUtil object가 불리는 시점에 관계 없이 toSnakeCase()가 불려 처음으로 SNAKE_CASE_REGEX가 참조되는 시점에 초기화된다.
한 번만 생성하는 게 무조건 좋은가?
이쯤되면, 한 가지 의문이 생긴다.
생성이 무거운 객체를 한 번만 생성할 수 있다는 점은 좋은데,
그럼 반대로 생각해보면 메모리에 한 번 올라가면 내려가지 않는다는 거니 불필요하게 메모리를 많이 잡아먹고 있는 게 아닐까?
정확히 맞다.
그래서 상황에 따라 적절하게 취사선택해 사용해야 한다.
- 자주 쓰이는 무거운 util → val이나 object에 넣고 재사용 (성능 ↑)
- 아주 큰 데이터나 무거운 util → object에 넣는 건 주의 (메모리 부담 ↑)
- 무겁지만 한 번만 쓰이는 util -> 필요할 때 생성해서 사용 (생성 비용 vs 메모리 절충)
- 가볍고 자주 쓰이는 util -> 매번 재생성해서 사용해도 무방 (GC 부담도 적음)
각각이 잘 와닿지 않는다면 아래처럼 예시를 들어볼 수 있다.
1. 자주 쓰이는 무거운 유틸
예: 정규식, 날짜 포맷터, Jackson ObjectMapper 등
DateTimeFormatter도 자주 쓰이는 무거운 객체 중 하나다.
자주 쓰이지만 무거운 유틸이므로 재생성 되지 않도록 관리하면 좋다.
object TimeUtil {
val ISO_FORMATTER: DateTimeFormatter = DateTimeFormatter.ISO_DATE_TIME
}
fun printTimestamp() {
val now = LocalDateTime.now()
println("현재 시간: ${TimeUtil.ISO_FORMATTER.format(now)}")
}
2. 아주 큰 데이터나 무거운 유틸
예: 100MB짜리 JSON schema, 수천 개의 캐시 리스트 등
객체 하나에 이렇게 큰 걸 넣으면 앱 전체 메모리를 잡아먹으므로, 필요한 시점에만 lazy, 혹은 파일에서 읽어서 처리하는 구조가 더 적절하다.
object Cache {
val massiveList = List(1_000_000) { it } // 너무 크다!
}
3. 무겁지만 한 번만 쓰이는 유틸
예: DB 스키마 검증기 (예: Hibernate Validator, Liquibase Validator, 자체 DSL 등)
DB schema validator와 같은 검증 도구는 초기화 비용이 크고, 특정 상황(앱 시작 시, 테스트 환경 등)에서만 딱 한 번 쓰인다.
따라서 메모리에 올려 관리하기보다 필요할 때 그때그때 생성해 사용하는 게 더 효율적이다.
class SchemaValidator {
fun validate(schemaText: String): Boolean {
// 복잡한 내부 로직 가정
return schemaText.contains("TABLE")
}
}
fun validateSchemaOnce(schemaText: String) {
val validator = SchemaValidator() // 생성 비용 큼
val isValid = validator.validate(schemaText)
}
4. 가볍고 자주 쓰이는 유틸
예: 문자열 포맷팅, 리스트 필터링 등
가장 쉬운 케이스다.
아래 예시의 val message는 매번 생성돼도 가볍고 무해하다. 캐싱을 고민할 필요도 없다.
fun printGreeting(name: String) {
val message = "Hello, $name!"
println(message)
}
정규식이 들어간 단순한 String util의 성능 문제를 고민하면서 출발했지만,
파고 들다 보니 객체의 생명 주기와 메모리 관리 방식까지 함께 고민하게 됐다.
파고들수록 그냥 돌아가게 만드는 건 쉽지만 '잘' 만드는 건 정말 많은 지식과 공부를 필요로 한다는 걸 다시금 깨닫게 됐다.
이래서 서버 개발자들의 로망이 한 번쯤 대규모 트래픽 받아보는 거인가보다...
그냥 만들 때는 생각을 안 해도 됐던 부분까지 최적화를 생각하게 된다.
'Backend' 카테고리의 다른 글
Kotlin Spring에서 Jackson MixIn 활용하기 (0) | 2025.03.16 |
---|---|
Spring MVC vs. WebFlux (feat. Kotlin) (0) | 2025.03.01 |
스프링부트, 빈의 Lazy 로딩 (@Lazy) (0) | 2025.02.16 |
DB Connection Pool에 대해 (0) | 2025.01.31 |
사람들은 왜 자바가 아닌 코틀린에 열광할까? (0) | 2025.01.05 |