지난 포스팅에서는 서버와 데이터베이스 간의 통신 부하를 줄여주는 캐시(Cache) 시스템에 대해 알아보았습니다. 인프라 레벨의 거대한 병목을 해결했다면, 이제는 애플리케이션 내부로 시선을 돌려볼 차례입니다.
자바(Java) 백엔드 애플리케이션에서 CPU 자원을 낭비하고 가비지 컬렉터(GC)를 과로하게 만드는 가장 흔한 원인은 무엇일까요? 복잡한 알고리즘의 실패보다 훨씬 빈번하게 발생하는 범인은 바로 개발자들이 무심코 사용하는 문자열(String) 연산과 불필요한 객체 생성입니다. 이번 글에서는 사소해 보이지만 시스템 전체의 성능을 좌우하는 코드 레벨의 문자열 최적화 기법을 알아보겠습니다.
1. String의 ‘불변성(Immutability)’이 낳은 비극
Java에서 String 객체는 한 번 생성되면 그 값을 절대 바꿀 수 없는 불변(Immutable)의 특성을 가집니다. 이는 보안과 스레드 안전성 측면에서는 엄청난 장점이지만, 메모리 관리 측면에서는 양날의 검이 됩니다.
Java
String result = "Hello";
result = result + " World";
초보 개발자들은 위 코드가 result라는 변수의 값을 수정한다고 생각하지만, 실제 메모리(Heap)에서는 전혀 다른 일이 일어납니다. 기존의 “Hello” 객체는 그대로 버려진 채 쓰레기(Garbage)가 되고, “Hello World”라는 완전히 새로운 객체가 메모리에 할당됩니다. 즉, + 연산자로 문자열을 더할 때마다 끊임없이 새로운 메모리가 소모되고 버려지는 과정이 반복되는 것입니다.
2. 반복문 안의 문자열 결합: OOM을 부르는 시한폭탄
실무 백엔드 개발에서 이 불변성이 가장 치명적인 문제를 일으키는 구간은 바로 반복문(Loop) 내부입니다.
예를 들어, 데이터베이스에서 대량의 쿼리 결과 리스트를 조회한 뒤 이를 하나의 거대한 JSON 파일 형식의 문자열로 변환하고 파싱하는 백엔드 로직이 있다고 가정해 보겠습니다. 10만 건의 데이터를 for문을 돌며 jsonString += "{...}" 형태로 결합한다면 어떻게 될까요?
메모리에는 10만 개의 버려진 String 객체가 쌓이게 됩니다. 힙 메모리는 순식간에 가득 차게 되고, 이를 청소하기 위해 가비지 컬렉터가 무한정 돌아가며 애플리케이션의 CPU 사용률을 100%로 치솟게 만듭니다. 최악의 경우 무거운 Stop-The-World 현상과 함께 OutOfMemoryError로 서버가 다운될 수 있습니다.
3. StringBuilder와 StringBuffer의 올바른 선택
이러한 문자열 결합의 병목을 해결하기 위해 Java는 내부적으로 값을 변경할 수 있는(Mutable) 클래스인 StringBuilder와 StringBuffer를 제공합니다. 이들은 메모리에 새로운 객체를 계속 생성하지 않고, 내부 버퍼(Buffer) 배열을 늘려가며 문자열을 안전하게 이어 붙입니다.
StringBuilder(실무 표준): 스레드 동기화(Synchronization)를 지원하지 않아 가장 처리 속도가 빠릅니다. 백엔드 개발 시 메서드 내부의 지역 변수로 문자열을 조작할 때는 무조건StringBuilder를 사용해야 합니다. 지역 변수는 각 스레드 스택에 격리되므로 동시성 문제가 발생하지 않기 때문입니다.StringBuffer: 스레드 동기화를 지원하여 멀티 스레드 환경에서 안전하게 사용할 수 있습니다. 하지만 락(Lock)을 획득하는 오버헤드가 발생하므로, 전역 변수 수준에서 여러 스레드가 동시에 접근하여 문자열을 조작해야 하는 극히 예외적인 상황에서만 사용합니다.
4. 컴파일러의 자동 최적화를 맹신하지 말라
Java 1.5 이후부터는 단순한 + 연산을 컴파일러가 알아서 StringBuilder로 변환해 주는 최적화(JIT) 기능이 들어갔습니다. 하지만 이 자동 최적화는 반복문(Loop) 내부에서는 작동하지 않습니다.
컴파일러는 for문 안의 + 연산을 최적화할 때, 반복문을 한 바퀴 돌 때마다 새로운 StringBuilder 객체를 계속해서 생성하는 바보 같은 최적화를 수행합니다. 따라서 루프를 돌며 문자열이나 JSON 텍스트, HTML 마크업 등을 생성할 때는 개발자가 반드시 반복문 외부에 명시적으로 StringBuilder 객체를 선언하여 사용해야 합니다.
결론: 좋은 코드가 최고의 튜닝이다
아키텍처를 이중화하고 스레드 풀을 늘리는 거창한 튜닝도 중요하지만, 그 기본은 결국 자원을 아껴 쓰는 깔끔한 코드입니다. 불필요한 String 결합 하나를 고치는 것만으로도 GC 발생 빈도가 획기적으로 줄어들고 전체 API 응답 속도가 개선될 수 있습니다.
메모리와 CPU 낭비를 막는 코드 작성법을 알아보았으니, 다음 포스팅에서는 애플리케이션과 외부 인프라 사이에서 병목을 일으키는 통신 최적화 기법, ‘I/O 병목 현상 진단 및 NIO(New I/O)를 활용한 네트워크 통신 최적화’에 대해 본격적으로 살펴보겠습니다.