효율적인 Java 코드를 위한 성능 최적화
효율적인 Java 코드를 위한 성능 최적화
Java 애플리케이션의 성능을 개선하는 데 도움이 될 만한 실용적인 기법들을 정리했습니다. 각 섹션에서는 자주 마주치는 성능 이슈와 그 해결을 위한 코드 예제를 함께 살펴봅니다.
1. 문자열 처리 최적화
String은 불변 객체이므로, 반복적인 문자열 연결 시 많은 객체가 생성되어
성능이 저하됩니다.
// 비효율적 코드
String result = "";
for (int i = 0; i < 1000; i++) {
result += i; // 매 반복마다 새로운 String 객체 생성
}
위 코드는 루프가 실행될 때마다 새로운 String 객체를 생성하기 때문에, 1000번의 반복 동안 총 1000개의 임시 String 객체가 생성됩니다. 이는 메모리 할당 및 가비지 컬렉션 부하를 유발하여 애플리케이션의 전반적인 성능 저하로 이어질 수 있습니다.
이 문제를 해결하기 위해서는 StringBuilder를 사용하는 것이 바람직합니다. StringBuilder는 가변적인 문자열 연산에 최적화되어 있어 단일 객체 내에서 문자열을 효율적으로 처리할 수 있습니다.
// 효율적 코드
StringBuilder result = new StringBuilder();
for (int i = 0; i < 1000; i++) {
result.append(i);
}
2. 컬렉션 초기 용량 지정
기본 컬렉션은 작은 초기 크기를 가지므로 HashMap을 초기화할 때 예상되는 데이터 크기를 지정하는 것이 좋습니다.
// 기본 초기화
Map<String, String> map = new HashMap<>();
위 코드는 기본 초기 용량(16)으로 HashMap을 생성합니다. 데이터가 추가되면서 용량이 부족해지면 내부적으로 리사이징이 발생하는데, 이 과정에서 새로운 배열을 할당하고 기존 데이터를 복사하는 작업이 필요합니다. 대량의 데이터를 다룰 경우 빈번한 리사이징은 성능 저하의 원인이 됩니다.
이러한 문제를 해결하기 위해서는 HashMap 생성 시 초기 용량을 적절히 지정하는 것이 좋습니다. 예를 들어, 예상되는 데이터 크기가 10,000개 정도라면, new HashMap<>(10_000)과 같이 초기 용량을 지정하는 것이 좋습니다. 이때 HashMap은 실제로는 주어진 크기보다 큰 2의 거듭제곱 크기로 용량을 설정하므로, 정확한 크기보다 약간 큰 값을 지정하는 것이 좋습니다.
// 초기 용량 지정
Map<String, String> map = new HashMap<>(10_000);
3. 오토박싱 피하기
기본형과 래퍼 클래스 간 변환은 성능을 저하시킬 수 있습니다. 성능이 중요한 루프 내에서는 기본형(primitive type)을 사용하는 것이 좋습니다.
// 비효율적 코드
Long sum = 0L;
for (long i = 0; i < 1_000_000; i++) {
sum += i; // 박싱/언박싱 발생
}
위 코드는 Long 객체를 사용하여 박싱/언박싱이 발생하여 성능이 저하됩니다. 이를 해결하기 위해서는 primitive type을 사용하는것이 좋습니다. 기본형을 사용하면 불필요한 객체 생성이 없어 메모리 사용량이 감소되고, 비교 연산이 빠르므로 성능이 향상되며 가비지 컬렉션의 부하도 줄일 수 있습니다.
// 효율적 코드
long sum = 0L;
for (long i = 0; i < 1_000_000; i++) {
sum += i;
}
4. 스트림 사용 주의
스트림(Stream) API는 코드의 가독성을 높여주지만, 반복문에 비해 성능 오버헤드가 있을 수 있습니다. 특히 단순한 연산이나 작은 데이터셋의 경우 성능 차이가 더욱 두드러질 수 있습니다.
// 스트림 예시
int sum = list.stream()
.filter(n -> n % 2 == 0)
.mapToInt(Integer::intValue)
.sum();
스트림은 데이터를 처리하기 위해 컬렉션을 스트림으로 변환한 후,
조건에 맞는 요소를 필터링하고,
필요한 형태로 변환하여
최종적으로 결과를 집계하는 과정을 거칩니다.
반면, for 루프는 단순한 반복과 조건문만으로 동일한 작업을 더 효율적으로 처리할 수 있습니다.
``` java
// 전통적 반복문
int sum = 0;
for (int n : list) {
if (n % 2 == 0) sum += n;
}
스트림이 유리한 경우:
- 복잡한 데이터 처리 파이프라인
- 병렬 처리가 필요한 경우 (
.parallelStream()) - 코드의 가독성이 중요한 경우
5. 캐싱 활용
비용이 많이 드는 연산(데이터베이스 조회, 외부 API 호출, 복잡한 계산 등)의 결과를 캐싱하면 반복적인 연산을 방지하여 성능을 크게 향상시킬 수 있습니다.
private final Map<String, User> cache = new HashMap<>();
public User getUser(String id) {
// 캐시에 없을 경우에만 DB에서 조회
return cache.computeIfAbsent(id, this::fetchUserFromDB);
}
6. 자원 닫기
시스템 자원(데이터베이스 연결, 파일 스트림, 네트워크 소켓 등)은 반드시 닫아야 합니다. 시스템 자원을 사용한 후 적절히 해제하지 않으면 메모리 누수나 시스템 자원 고갈로 이어질 수 있습니다.
try (Connection conn = dataSource.getConnection()) {
// use connection
}
7. 지연 로딩 (Lazy Loading)
필요하지 않은 데이터를 모두 미리 불러오는 것은 비효율적입니다. 지연 로딩은 실제로 필요한 시점에만 데이터를 로드하는 기법으로, 애플리케이션의 초기 로딩 시간을 단축하고 불필요한 자원 사용을 줄여줍니다.
// 비효율적
List<User> users = userService.getAllUsers();
// 효율적: 필요한 데이터만 로딩
List<User> first10Users = userService.getUsers(0, 10);
8. 가비지 컬렉터(GC) 이해
가비지 컬렉션은 JVM의 메모리 관리를 담당하며, 애플리케이션의 성능에 큰 영향을 미칩니다. 적절한 GC 전략 선택은 시스템의 안정성과 성능을 결정짓는 중요한 요소입니다.
주요 GC 방식:
| GC 유형 | 동작 특징 | JVM 옵션 |
|---|---|---|
| Serial GC | 단일 스레드로 모든 GC 작업을 수행해 통신 오버헤드가 없으며 소형 힙 환경에 최적 | -XX:+UseSerialGC |
| Parallel GC | 여러 스레드가 동시에 수집을 수행해 처리량(throughput)을 극대화하는 병렬 generational collector | -XX:+UseParallelGC |
| G1 GC | 애플리케이션과 GC 단계를 병행 수행하면서 소규모 환경부터 대형 멀티코어 서버까지 확장되고, 높은 처리량과 짧은 정지 시간을 유지하도록 설계된 collector | -XX:+UseG1GC |
| ZGC | 대부분의 GC 작업을 애플리케이션과 동시 수행하고 짧은 정지 시간을 유지하는 저지연 collector | -XX:+UseZGC |
| Shenandoah | 힙 전 범위를 동시(compacting) 수집해 서브-밀리초 정지 시간을 목표로 하는 저지연 collector | -XX:+UseShenandoahGC |
GC 선택 시 고려사항
- Serial GC
- 약 100MB 이하의 소형 데이터셋을 사용하는 애플리케이션에 적합합니다. 긴 정지 시간을 허용할 수 있을 때 선택합니다.
- Parallel GC: 처리량(throughput)이 지연(latency)보다 중요할 때 최선의 선택으로, 긴 일시 정지를 허용하는 bulk 데이터 처리나 batch 작업에 적합합니다.
- Serial GC와 유사한 generational GC지만, 여러 스레드로 GC를 가속화합니다.
- 최고 처리량을 목표로 1초 이상의 일시 정지를 허용할 수 있는 중~대형 데이터셋의 멀티프로세서/멀티스레드 애플리케이션에 사용합니다.
- G1 GC: 응답 시간이 전체 처리량보다 중요하고 GC 일시 정지 시간을 더 짧게 유지해야 하는 대용량 힙 애플리케이션에서 선택합니다.
- 적합한 애플리케이션 기준:
- 6GB 이상의 대형 힙(50% 이상 live objects).
- 불규칙한 워크로드.
- 일시 정지를 몇 백 밀리초로 제한해야 하는 애플리케이션
- 적합한 애플리케이션 기준:
- ZGC
- 응답 시간이 최우선이거나 멀티 테라바이트 규모 힙을 사용하는 애플리케이션에서 선택합니다.
- ZGC는 10ms 미만의 정지 시간을 목표로 합니다.
- Shenandoah GC
- 낮은 지연시간과 짧은 일시 정지 시간이 필요한 애플리케이션에 적합하며, 힙 크기와 무관합니다.
9. 불필요한 동기화 피하기
메서드 전체 동기화 대신 필요한 부분만 동기화합니다. 동기화는 멀티스레드 환경에서 데이터 일관성을 보장하지만, 과도한 사용은 성능 저하의 주요 원인이 될 수 있습니다. 필요한 경우에만 최소한의 범위에서 동기화를 적용하는 것이 중요합니다.
// 비효율적: 메서드 전체 동기화
public synchronized void update() { ... }
// 효율적: 필요한 부분만 동기화
public void update() {
// 동기화가 필요 없는 작업
// ...
synchronized (lock) {
// 최소 영역만 동기화
}
}
10. 스레드 풀 활용
작업마다 새 스레드를 생성하지 말고 재사용 가능한 스레드 풀을 활용합니다. 스레드 관리를 효율적으로 하기 위해 ExecutorService를 사용한 스레드 풀을 적극 활용하는 것이 좋습니다. 스레드 풀은 스레드의 생성과 제거에 따른 오버헤드를 줄이고, 시스템 자원을 효율적으로 관리할 수 있게 해줍니다.
ExecutorService pool = Executors.newFixedThreadPool(10);
pool.submit(this::processTask);
11. 객체 생성 최소화
불필요한 객체 생성을 피하고 재사용하면 가비지 컬렉션 부하를 줄이고 성능을 크게 향상시킬 수 있습니다. 특히 루프 내부나 자주 호출되는 메서드에서는 객체 생성을 최소화하는 것이 중요합니다.
// 비효율적
for (int i = 0; i < 1000; i++) {
String s = new String("hello");
}
// 효율적 (String pool 활용)
for (int i = 0; i < 1000; i++) {
String s = "hello";
}
12. 프로파일링 후 최적화
성능 병목은 추측하지 말고 측정해야 합니다. 성능 최적화는 추측이 아닌 데이터에 기반해야 합니다. 프로파일링을 통해 실제 병목 지점을 정확히 파악하고, 그에 맞는 최적화 전략을 수립하는 것이 중요합니다.
주요 프로파일링 도구
- VisualVM
- JProfiler
- YourKit
- Flight Recorder