Java 17 기준으로 실무에서 자주 놓치는 성능 포인트(String 연결, 컬렉션 초기 용량, 오토박싱, 메모리 누수 패턴)를 체크리스트와 코드로 정리합니다.
도입 (문제 상황)
로컬에서는 빨랐는데 운영에서만 유독 느려지는 코드가 있습니다. 대부분 “알고 보면 사소한 습관”에서 시작해요. 이번 글에서는 Java에서 특히 자주 밟는 성능 함정 4가지를 체크리스트로 정리해 보겠습니다.
핵심 개념: Java 성능 체크리스트(자주 터지는 4가지)
성능 최적화는 거창한 알고리즘 교체보다, “불필요한 객체 생성/복사”를 줄이는 쪽이 먼저인 경우가 많습니다. 아래 4가지는 코드 리뷰에서 꾸준히 등장하고, 개선 효과도 즉각적인 편이라 체크리스트로 가져가시기 좋아요.
1) String 연결: +가 항상 느린 건 아니지만 “반복문”에서는 다릅니다
Java에서 String은 불변(immutable)이라, 연결할 때마다 새 문자열 객체가 만들어집니다. 컴파일러가 한 줄짜리 "a" + b + "c" 같은 표현은 내부적으로 최적화(대개 invokedynamic 기반)해 주지만, 반복문에서 누적 연결은 여전히 비용이 큽니다.
- 반복 누적:
StringBuilder(단일 스레드),StringBuffer(동기화 필요 시) 고려 - 구분자 조합:
StringJoiner또는Collectors.joining()이 의도가 더 명확합니다
2) 컬렉션 초기 용량: 리사이즈는 “조용한 비용”입니다
ArrayList, HashMap은 내부 배열을 키우는 과정에서 재할당 + 복사가 발생합니다. 데이터가 커질수록 이 비용이 눈에 띄고, GC 압박도 증가합니다. 크기를 대략이라도 예측할 수 있다면 초기 용량을 주는 편이 안정적입니다.
아래 표처럼 “무엇을 어떻게 잡아야 하는지”가 실무에서 가장 헷갈립니다.
| 컬렉션 | 초기 용량을 주면 좋은 상황 | 주의할 점(실무 포인트) |
|---|---|---|
ArrayList(int initialCapacity) |
원소 개수를 대략 아는 경우(예: DB 조회 row 수 제한) | 너무 크게 잡으면 메모리 낭비 |
HashMap(int initialCapacity) |
키 개수를 대략 아는 캐시/집계 | loadFactor(기본 0.75) 때문에 “그냥 N” 넣으면 리사이즈가 날 수 있음 |
HashSet(int initialCapacity) |
중복 제거 대상 개수가 예측 가능 | 내부적으로 HashMap 사용이라 같은 이슈 |
HashMap은 임계치가 capacity * loadFactor라서, “키를 N개 넣을 것”이면 보통 initialCapacity ≈ ceil(N / 0.75)로 잡아야 리사이즈를 피하기 쉽습니다.
3) AutoBoxing: 편하지만, 핫패스에서는 객체 생성/언박싱 비용이 쌓입니다
int ↔ Integer 변환이 자동으로 일어나는 오토박싱/언박싱은 코드가 깔끔해지는 대신, 다음 비용을 만들 수 있습니다.
Integer같은 래퍼는 객체(또는 캐시)라서 추가적인 간접 참조가 생깁니다- 스트림/컬렉션에서
Integer를 다루면 언박싱이 반복될 수 있습니다 Map<Integer, ...>에 집계할 때get/put과정에서 박싱이 계속 발생합니다
핫패스(요청당 수만~수백만 번 도는 루프)라면 IntStream, long 누적, 혹은 primitive 배열 같은 선택지가 체감 차이를 만듭니다.
4) 메모리 누수 패턴: “GC가 못 치우는 참조”가 진짜 누수입니다

Java에서 메모리 누수는 보통 “메모리를 할당만 하고 해제하지 않음”이 아니라, 더 이상 필요 없는 객체를 여전히 참조하고 있어서 GC가 수거하지 못하는 상태를 말합니다. 실무에서 특히 자주 보는 패턴은 아래입니다.
static컬렉션/캐시에 무제한으로 쌓기 (만료/용량 제한 없음)ThreadLocal에 큰 객체를 넣고remove()를 안 함 (스레드 풀 환경에서 특히 위험)- 리스너/콜백 등록 후 해제 누락 (이벤트 버스, 옵저버 패턴)
Map키로 큰 객체를 쓰거나,equals/hashCode가 무거운 키를 과도하게 사용
아래 흐름처럼 “스레드 풀 + ThreadLocal” 조합에서 누수가 오래 지속될 수 있습니다.
flowchart LR
A["Request 1"] --> B["Worker Thread"]
B --> C["ThreadLocal set Large Object"]
C --> D["Request ends"]
D --> E["Worker Thread reused"]
E --> F["ThreadLocal still holds reference"]
스레드가 재사용되면 ThreadLocal 값도 남아 있어 GC가 큰 객체를 못 치우는 상황을 보여줍니다.
코드 예제: 한 번에 점검하는 “성능 함정” 데모 (Java 17)
아래 코드는 그대로 실행 가능하며, 각 항목의 “나쁜 예 / 좋은 예”를 한 파일에서 확인할 수 있게 구성했습니다. (정밀 벤치마크는 JMH가 정석이지만, 실무에서 빠르게 감을 잡는 용도로는 이런 스모크 테스트가 유용합니다.)
import java.util.*;
import java.util.concurrent.*;
public class PerformanceChecklistDemo {
private static final ExecutorService POOL = Executors.newFixedThreadPool(2);
private static final ThreadLocal<byte[]> TL = new ThreadLocal<>();
public static void main(String[] args) throws Exception {
stringConcatDemo(50_000);
collectionCapacityDemo(200_000);
autoboxingDemo(5_000_000);
threadLocalLeakPatternDemo();
POOL.shutdown();
}
// 1) String 연결: 반복문에서는 StringBuilder가 안전합니다.
static void stringConcatDemo(int n) {
long t1 = System.nanoTime();
String s = "";
for (int i = 0; i < n; i++) {
s += i; // 매번 새 String 생성(반복 누적은 비효율)
}
long t2 = System.nanoTime();
long t3 = System.nanoTime();
StringBuilder sb = new StringBuilder(n * 2);
for (int i = 0; i < n; i++) {
sb.append(i);
}
String s2 = sb.toString();
long t4 = System.nanoTime();
System.out.printf("[String concat] '+' loop: %d ms, StringBuilder: %d ms (len=%d/%d)%n",
(t2 - t1) / 1_000_000, (t4 - t3) / 1_000_000, s.length(), s2.length());
}
// 2) 컬렉션 초기 용량: 리사이즈/리해시 비용을 줄입니다.
static void collectionCapacityDemo(int n) {
long t1 = System.nanoTime();
List<Integer> a1 = new ArrayList<>(); // 기본 용량(10)에서 점진적 확장
for (int i = 0; i < n; i++) a1.add(i);
long t2 = System.nanoTime();
long t3 = System.nanoTime();
List<Integer> a2 = new ArrayList<>(n); // 예상 크기만큼 선할당
for (int i = 0; i < n; i++) a2.add(i);
long t4 = System.nanoTime();
int expectedKeys = n;
int initialCapacity = (int) Math.ceil(expectedKeys / 0.75); // HashMap 리사이즈 회피용 근사치
long t5 = System.nanoTime();
Map<Integer, Integer> m1 = new HashMap<>();
for (int i = 0; i < n; i++) m1.put(i, i);
long t6 = System.nanoTime();
long t7 = System.nanoTime();
Map<Integer, Integer> m2 = new HashMap<>(initialCapacity);
for (int i = 0; i < n; i++) m2.put(i, i);
long t8 = System.nanoTime();
System.out.printf("[Collection capacity] ArrayList default: %d ms, presized: %d ms%n",
(t2 - t1) / 1_000_000, (t4 - t3) / 1_000_000);
System.out.printf("[Collection capacity] HashMap default: %d ms, presized: %d ms%n",
(t6 - t5) / 1_000_000, (t8 - t7) / 1_000_000);
}
// 3) AutoBoxing: primitive 누적 vs 래퍼 누적의 비용 차이를 보여줍니다.
static void autoboxingDemo(int n) {
long t1 = System.nanoTime();
long sumPrimitive = 0;
for (int i = 0; i < n; i++) sumPrimitive += i;
long t2 = System.nanoTime();
long t3 = System.nanoTime();
Long sumBoxed = 0L;
for (int i = 0; i < n; i++) sumBoxed += i; // 언박싱/박싱이 반복될 수 있음
long t4 = System.nanoTime();
System.out.printf("[Autoboxing] primitive sum: %d ms, boxed sum: %d ms (%d/%d)%n",
(t2 - t1) / 1_000_000, (t4 - t3) / 1_000_000, sumPrimitive, sumBoxed);
}
// 4) 메모리 누수 패턴: ThreadLocal remove() 누락을 시뮬레이션합니다.
static void threadLocalLeakPatternDemo() throws Exception {
Callable<Void> bad = () -> {
TL.set(new byte[10 * 1024 * 1024]); // 10MB
// TL.remove(); // 누락 시: 스레드가 살아있는 동안 참조 유지 가능
return null;
};
Callable<Void> good = () -> {
try {
TL.set(new byte[10 * 1024 * 1024]);
return null;
} finally {
TL.remove(); // 스레드 풀에서는 반드시 정리
}
};
// 나쁜 패턴: 같은 워커 스레드에 값이 남을 수 있음
POOL.invokeAll(List.of(bad, bad));
System.out.println("[ThreadLocal] bad pattern executed (remove() omitted)");
// 좋은 패턴: 요청 단위로 정리
POOL.invokeAll(List.of(good, good));
System.out.println("[ThreadLocal] good pattern executed (remove() in finally)");
}
}
실무 팁
💡 실무에서는: String 연결 최적화는 “로그/메시지 생성”부터 보세요
- 요청당 로그 메시지를 여러 조각으로 붙이는 코드가 의외로 많습니다. 특히 디버그 로그가 비활성화돼도, 메시지 조립을 먼저 해버리면 비용은 그대로 나가요.
- SLF4J를 쓰신다면 문자열을 미리 합치기보다 파라미터 바인딩(
logger.debug("id={}, name={}", id, name))을 우선 적용해 보세요.
💡 실무에서는: “초기 용량”은 성능보다도 “지연 튐”을 줄이는 데 효과적입니다
- 리사이즈는 평균 성능보다 **특정 시점의 스파이크(지연 튐)**로 체감되는 경우가 많습니다.
- 트래픽 피크에 맞춰 갑자기 맵이 커지면서 리해시가 발생하면, p99 지연이 튈 수 있어요. 대략적인 상한을 알면 초기 용량을 주는 것만으로도 안정성이 올라갑니다.
핵심 요약
- 반복 String 연결은
StringBuilder로 바꾸는 것만으로도 효과가 큽니다. ArrayList/HashMap은 예상 크기가 있으면 초기 용량을 지정해 리사이즈 비용을 줄여보세요.- 오토박싱과
ThreadLocal remove()누락은 “조용히” 성능/메모리를 갉아먹는 대표 패턴입니다.
다음 글: #36 의존성 관리 — Maven & Gradle 핵심
'JAVA' 카테고리의 다른 글
| Java 클린 코드 실천 가이드: 네이밍부터 코드 리뷰 체크리스트까지 (0) | 2026.03.02 |
|---|---|
| Java 의존성 관리 — Maven & Gradle 핵심 (충돌 해결과 멀티 모듈 기초) (1) | 2026.03.02 |
| Java 단위 테스트 시작하기: JUnit 5 기초와 Given-When-Then 패턴 (0) | 2026.03.01 |
| Java 효과적인 로깅 전략: SLF4J + Logback, 로그 레벨 가이드, 안티패턴 정리 (0) | 2026.02.28 |
| Java Virtual Thread — 경량 스레드의 시대 (Project Loom 실무 가이드) (0) | 2026.02.28 |