IntelliJ IDEA의 Profiler는 애플리케이션의 성능 병목 지점을 찾아내는 강력한 도구입니다.
IntelliJ IDEA는 여러 Profiler를 지원합니다:
IntelliJ IDEA Ultimate 버전에는 기본적으로 포함되어 있습니다.
File → Settings → Plugins → "Profiler" 검색
Run → Run... → 원하는 Configuration 선택 → Profile 'ApplicationName'
Shift + F9 후 Profiler 선택Ctrl + Shift + A → "Attach Profiler to Process"┌─────────────────────────────────────────────────────┐
│ Toolbar (Start/Stop, Snapshot, Export) │
├─────────────────────────────────────────────────────┤
│ ┌──────────────────┐ ┌───────────────────────┐ │
│ │ Call Tree │ │ Flame Graph │ │
│ │ (계층적 호출) │ │ (시각적 분석) │ │
│ └──────────────────┘ └───────────────────────┘ │
│ ┌──────────────────┐ ┌───────────────────────┐ │
│ │ Method List │ │ Thread Timeline │ │
│ │ (메서드별 통계) │ │ (스레드 상태) │ │
│ └──────────────────┘ └───────────────────────┘ │
└─────────────────────────────────────────────────────┘
CPU Profiling은 어떤 메서드가 가장 많은 CPU 시간을 소비하는지 분석합니다.
메서드 이름 Total Time Self Time Count
├─ main() 1000ms 10ms 1
│ ├─ processData() 900ms 50ms 1
│ │ ├─ loadData() 500ms 500ms 1 ← 병목!
│ │ ├─ transformData() 300ms 100ms 1
│ │ │ └─ validate() 200ms 200ms 1000 ← 과다 호출!
│ │ └─ saveData() 50ms 50ms 1
│ └─ cleanup() 90ms 90ms 1
Flame Graph는 CPU 사용량을 시각적으로 보여줍니다.
┌─────────────────────────────────────────────────────┐
│ main() │ ← 가장 위: 진입점
├──────────────────────┬───────────────┬──────────────┤
│ processData() │ cleanup() │ other() │
├──────┬───────────────┼───────────────┤ │
│ load │ transform │ │ │
│ Data │ Data │ │ │
└──────┴───────────────┴───────────────┴──────────────┘
↑
가장 넓은 부분 = 가장 많은 시간 소비
해석 방법:
// 문제가 있는 코드
public class DataProcessor {
public void processLargeDataset(List<String> data) {
// 병목 1: 불필요한 반복문
for (String item : data) {
if (isValid(item)) { // 매번 호출
String processed = processItem(item);
saveToDatabase(processed); // 병목 2: 개별 저장
}
}
}
private boolean isValid(String item) {
// 복잡한 정규식 검증 (느림)
return item.matches("^[A-Za-z0-9]{10,20}$");
}
private String processItem(String item) {
// 문자열 연결 (비효율적)
String result = "";
for (char c : item.toCharArray()) {
result += Character.toUpperCase(c); // String 불변성으로 인한 성능 저하
}
return result;
}
private void saveToDatabase(String item) {
// 매번 DB 연결 (병목)
// ... DB 저장 로직
}
}
Profiler 결과:
메서드 Total Self Count
processLargeDataset() 5000ms 100ms 1
├─ isValid() 2000ms 2000ms 10000 ← 병목 1
├─ processItem() 1500ms 200ms 8000
│ └─ String concatenation 1300ms 1300ms 80000 ← 병목 2
└─ saveToDatabase() 1400ms 1400ms 8000 ← 병목 3
public class OptimizedDataProcessor {
// Pattern을 미리 컴파일 (재사용)
private static final Pattern VALIDATION_PATTERN =
Pattern.compile("^[A-Za-z0-9]{10,20}$");
public void processLargeDataset(List<String> data) {
// 1. 병렬 처리 도입
List<String> validItems = data.parallelStream()
.filter(this::isValid)
.map(this::processItem)
.collect(Collectors.toList());
// 2. 배치 저장으로 변경
saveBatchToDatabase(validItems);
}
private boolean isValid(String item) {
// Pattern 재사용으로 성능 개선
return VALIDATION_PATTERN.matcher(item).matches();
}
private String processItem(String item) {
// StringBuilder 사용으로 성능 개선
StringBuilder result = new StringBuilder(item.length());
for (char c : item.toCharArray()) {
result.append(Character.toUpperCase(c));
}
return result.toString();
}
private void saveBatchToDatabase(List<String> items) {
// 배치 처리로 DB 호출 횟수 감소
// ... 배치 저장 로직
}
}
최적화 후 Profiler 결과:
메서드 Total Self Count
processLargeDataset() 800ms 50ms 1
├─ isValid() 200ms 200ms 10000 ← 10배 개선
├─ processItem() 400ms 400ms 8000 ← 3.75배 개선
└─ saveBatchToDatabase() 150ms 150ms 1 ← 9.3배 개선
총 성능: 5000ms → 800ms (6.25배 개선)
메모리 누수와 과도한 객체 생성을 찾아냅니다.
클래스 Instances Shallow Size Retained Size
String 1,234,567 49,382,680 49,382,680
char[] 1,234,567 123,456,700 123,456,700
ArrayList 50,000 2,400,000 500,000,000 ← 큰 메모리!
HashMap$Node 200,000 9,600,000 50,000,000
// 문제가 있는 코드
public class CacheManager {
// 메모리 누수: 캐시가 무한정 증가
private static final Map<String, byte[]> cache = new HashMap<>();
public byte[] loadData(String key) {
if (!cache.containsKey(key)) {
byte[] data = fetchFromDatabase(key);
cache.put(key, data); // 메모리 누수!
}
return cache.get(key);
}
private byte[] fetchFromDatabase(String key) {
// 큰 데이터 로드 (1MB)
return new byte[1024 * 1024];
}
}
// 사용 예시
public class Application {
public void processRequests() {
CacheManager cache = new CacheManager();
// 계속 새로운 키로 요청
for (int i = 0; i < 10000; i++) {
cache.loadData("key_" + i); // 10GB 메모리 소비!
}
}
}
Profiler 메모리 스냅샷:
Heap Dump 분석:
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
CacheManager.cache (HashMap)
├─ Size: 10,000 entries
├─ Shallow: 480 KB
└─ Retained: 10.24 GB ← 문제!
├─ byte[] × 10,000 (각 1MB)
└─ String keys × 10,000
메모리 증가 추이:
0min: 100MB
5min: 2GB
10min: 5GB
15min: 8GB
20min: OutOfMemoryError!
import java.util.concurrent.TimeUnit;
import com.google.common.cache.*;
public class OptimizedCacheManager {
// LRU 캐시로 메모리 제한
private final LoadingCache<String, byte[]> cache = CacheBuilder.newBuilder()
.maximumSize(100) // 최대 100개만 유지
.expireAfterAccess(10, TimeUnit.MINUTES) // 10분 후 만료
.recordStats() // 통계 기록
.build(new CacheLoader<String, byte[]>() {
@Override
public byte[] load(String key) throws Exception {
return fetchFromDatabase(key);
}
});
public byte[] loadData(String key) {
try {
return cache.get(key);
} catch (ExecutionException e) {
throw new RuntimeException("Failed to load data", e);
}
}
private byte[] fetchFromDatabase(String key) {
return new byte[1024 * 1024];
}
// 캐시 통계 확인
public void printStats() {
CacheStats stats = cache.stats();
System.out.println("Hit rate: " + stats.hitRate());
System.out.println("Miss rate: " + stats.missRate());
System.out.println("Eviction count: " + stats.evictionCount());
}
}
최적화 후 메모리 사용:
Heap Dump 분석:
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
OptimizedCacheManager.cache (LoadingCache)
├─ Size: 100 entries (최대)
├─ Shallow: 4.8 KB
└─ Retained: 102.4 MB ← 100배 개선!
├─ byte[] × 100 (각 1MB)
└─ String keys × 100
메모리 안정화:
0min: 100MB
5min: 150MB
10min: 150MB ← 안정적
15min: 150MB
20min: 150MB ✓
@RestController
public class UserController {
@Autowired
private UserRepository userRepository;
@GetMapping("/api/users")
public List<UserDTO> getAllUsers() {
List<User> users = userRepository.findAll();
// N+1 문제 발생
return users.stream()
.map(user -> {
UserDTO dto = new UserDTO();
dto.setId(user.getId());
dto.setName(user.getName());
dto.setPosts(user.getPosts()); // 각 사용자마다 DB 쿼리!
dto.setComments(user.getComments()); // 각 사용자마다 DB 쿼리!
return dto;
})
.collect(Collectors.toList());
}
}
Profiler 분석 결과:
메서드 Time Count
getAllUsers() 5,420ms 1
├─ findAll() 120ms 1
└─ Stream operations 5,300ms 1
├─ getPosts() (LazyLoading) 2,800ms 100 ← N+1 문제
└─ getComments() (LazyLoading) 2,500ms 100 ← N+1 문제
DB 쿼리:
SELECT users: 1 query
SELECT posts: 100 queries ← 문제!
SELECT comments: 100 queries ← 문제!
Total: 201 queries
@RestController
public class OptimizedUserController {
@Autowired
private UserRepository userRepository;
@GetMapping("/api/users")
public List<UserDTO> getAllUsers() {
// Fetch Join으로 한 번에 로딩
List<User> users = userRepository.findAllWithPostsAndComments();
// ModelMapper 사용으로 변환 최적화
return users.stream()
.map(this::convertToDTO)
.collect(Collectors.toList());
}
private UserDTO convertToDTO(User user) {
return modelMapper.map(user, UserDTO.class);
}
}
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
// Fetch Join으로 N+1 문제 해결
@Query("SELECT DISTINCT u FROM User u " +
"LEFT JOIN FETCH u.posts " +
"LEFT JOIN FETCH u.comments")
List<User> findAllWithPostsAndComments();
}
개선 결과:
메서드 Time Count
getAllUsers() 180ms 1 ← 30배 개선!
├─ findAllWithPostsAndComments() 150ms 1
└─ Stream operations 30ms 1
DB 쿼리:
SELECT with JOIN: 1 query ← 개선!
Total: 1 query
성능 개선:
응답 시간: 5,420ms → 180ms (30배 개선)
DB 쿼리: 201 → 1 (201배 개선)
public class FileProcessor {
public void processLargeFile(String filePath) {
try {
// 전체 파일을 메모리에 로드 (위험!)
List<String> lines = Files.readAllLines(Paths.get(filePath));
List<String> results = new ArrayList<>();
for (String line : lines) {
String processed = processLine(line);
results.add(processed);
}
// 결과를 한 번에 저장
Files.write(Paths.get("output.txt"), results);
} catch (IOException e) {
e.printStackTrace();
}
}
private String processLine(String line) {
// 복잡한 처리
return line.toUpperCase().trim();
}
}
Profiler 결과:
Input: 1GB 파일 (1,000만 라인)
메모리 사용:
Heap: 3.5GB
├─ lines (List): 2GB
└─ results (List): 1.5GB
실행 시간: 45초
CPU 사용률: 25% (단일 스레드)
문제:
1. OutOfMemoryError 위험
2. CPU 활용도 낮음
3. 처리 속도 느림
import java.util.concurrent.*;
import java.util.stream.*;
public class OptimizedFileProcessor {
private static final int BATCH_SIZE = 10000;
private static final int THREAD_POOL_SIZE =
Runtime.getRuntime().availableProcessors();
public void processLargeFile(String filePath) {
ExecutorService executor =
Executors.newFixedThreadPool(THREAD_POOL_SIZE);
try (BufferedReader reader = Files.newBufferedReader(Paths.get(filePath));
BufferedWriter writer = Files.newBufferedWriter(Paths.get("output.txt"))) {
List<String> batch = new ArrayList<>(BATCH_SIZE);
String line;
while ((line = reader.readLine()) != null) {
batch.add(line);
// 배치 단위로 처리
if (batch.size() >= BATCH_SIZE) {
processBatch(batch, writer, executor);
batch.clear();
}
}
// 마지막 배치 처리
if (!batch.isEmpty()) {
processBatch(batch, writer, executor);
}
} catch (IOException e) {
e.printStackTrace();
} finally {
executor.shutdown();
}
}
private void processBatch(List<String> batch,
BufferedWriter writer,
ExecutorService executor) throws IOException {
// 병렬 처리
List<String> processed = batch.parallelStream()
.map(this::processLine)
.collect(Collectors.toList());
// 순차적으로 쓰기 (동기화)
synchronized (writer) {
for (String line : processed) {
writer.write(line);
writer.newLine();
}
}
}
private String processLine(String line) {
return line.toUpperCase().trim();
}
}
개선 결과:
Input: 1GB 파일 (1,000만 라인)
메모리 사용:
Heap: 150MB ← 23배 개선!
├─ batch (10,000 라인): 40MB
└─ processed batch: 40MB
실행 시간: 8초 ← 5.6배 개선!
CPU 사용률: 90% (멀티 스레드)
개선 사항:
1. 메모리: 3.5GB → 150MB
2. 시간: 45초 → 8초
3. CPU 활용: 25% → 90%
public class DataAnalyzer {
public Map<String, Integer> analyzeData(List<Record> records) {
Map<String, Integer> result = new HashMap<>();
for (Record record : records) {
String key = record.getCategory();
// 반복적인 containsKey 호출
if (result.containsKey(key)) {
result.put(key, result.get(key) + 1); // 두 번 조회
} else {
result.put(key, 1);
}
}
return result;
}
public List<Record> findDuplicates(List<Record> records) {
List<Record> duplicates = new ArrayList<>();
// O(n²) 알고리즘
for (int i = 0; i < records.size(); i++) {
for (int j = i + 1; j < records.size(); j++) {
if (records.get(i).getId().equals(records.get(j).getId())) {
duplicates.add(records.get(i));
break;
}
}
}
return duplicates;
}
}
Profiler 결과:
Input: 100,000 records
analyzeData():
Time: 450ms
├─ containsKey(): 180ms (100,000 calls)
└─ get(): 120ms (100,000 calls)
findDuplicates():
Time: 12,500ms ← 매우 느림!
└─ equals(): 5,000,000,000 calls (50억 회!)
public class OptimizedDataAnalyzer {
public Map<String, Integer> analyzeData(List<Record> records) {
// getOrDefault 사용
Map<String, Integer> result = new HashMap<>();
for (Record record : records) {
String key = record.getCategory();
result.put(key, result.getOrDefault(key, 0) + 1); // 한 번만 조회
}
return result;
// 또는 Stream 사용
// return records.stream()
// .collect(Collectors.groupingBy(
// Record::getCategory,
// Collectors.counting()
// ));
}
public List<Record> findDuplicates(List<Record> records) {
Set<String> seen = new HashSet<>();
List<Record> duplicates = new ArrayList<>();
// O(n) 알고리즘
for (Record record : records) {
if (!seen.add(record.getId())) { // add는 중복 시 false 반환
duplicates.add(record);
}
}
return duplicates;
}
}
개선 결과:
Input: 100,000 records
analyzeData():
Time: 120ms ← 3.75배 개선!
└─ getOrDefault(): 120ms (100,000 calls)
findDuplicates():
Time: 25ms ← 500배 개선!
└─ hashCode/equals: 100,000 calls
전체 성능:
이전: 12,950ms
이후: 145ms
개선: 89배
✅ Profiling을 해야 할 때:
❌ 조기 최적화 피하기:
1. 문제 정의
↓
2. Baseline 측정 (최적화 전)
↓
3. Profiler로 병목 지점 식별
↓
4. 가장 큰 병목부터 최적화
↓
5. 다시 측정 (최적화 후)
↓
6. 개선 효과 검증
↓
7. 필요시 반복
// ❌ 나쁜 예: 불필요한 연산 반복
public String formatData(List<String> items) {
String result = "";
for (String item : items) {
result += item + ", "; // String 연결 반복
}
return result;
}
// ✅ 좋은 예: 효율적인 연산
public String formatData(List<String> items) {
return String.join(", ", items); // 최적화된 메서드 사용
}
// ❌ 나쁜 예: 메모리 낭비
public List<String> loadUsers() {
List<String> users = new ArrayList<>();
// 매번 새 객체 생성
for (int i = 0; i < 1000000; i++) {
users.add(new String("User" + i));
}
return users;
}
// ✅ 좋은 예: 메모리 효율적
public List<String> loadUsers() {
List<String> users = new ArrayList<>(1000000); // 초기 용량 지정
for (int i = 0; i < 1000000; i++) {
users.add("User" + i); // String 리터럴 사용
}
return users;
}
// 패턴 1: N+1 쿼리 문제
// ❌ 나쁜 예
for (User user : users) {
List<Order> orders = orderRepository.findByUserId(user.getId());
}
// ✅ 좋은 예
List<Order> orders = orderRepository.findByUserIdIn(userIds);
// 패턴 2: 과도한 객체 생성
// ❌ 나쁜 예
for (int i = 0; i < 1000000; i++) {
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
sdf.format(date);
}
// ✅ 좋은 예
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd");
for (int i = 0; i < 1000000; i++) {
formatter.format(date);
}
// 패턴 3: 동기화 병목
// ❌ 나쁜 예
public synchronized void process() {
// 긴 작업...
}
// ✅ 좋은 예
private final ReadWriteLock lock = new ReentrantReadWriteLock();
public void read() {
lock.readLock().lock();
try {
// 읽기 작업
} finally {
lock.readLock().unlock();
}
}
Time 비율로 우선순위 정하기:
80% 이상: 즉시 최적화 필요 (Critical)
50-80%: 최적화 검토 (High)
20-50%: 필요시 최적화 (Medium)
20% 미만: 최적화 불필요 (Low)
호출 횟수로 판단:
높은 호출 횟수 + 낮은 개별 시간 = 알고리즘 개선
낮은 호출 횟수 + 높은 개별 시간 = 메서드 내부 최적화
# Snapshot 저장
Profiler UI → Capture Snapshot → 파일 저장 (.jfr)
# Snapshot 비교
1. 최적화 전 Snapshot 저장
2. 코드 개선
3. 최적화 후 Snapshot 저장
4. 두 Snapshot 비교 → 개선 효과 확인
Production 환경에서는 Async Profiler나 JFR을 사용하세요.
# JFR로 Production 앱 Profiling
java -XX:StartFlightRecording=duration=60s,filename=recording.jfr \
-jar application.jar
# Recording 파일 분석
jfr print recording.jfr
주의사항:
IntelliJ Profiler는 성능 문제를 과학적으로 접근할 수 있게 해주는 강력한 도구입니다.
핵심 원칙:
1. 측정 없이 최적화하지 말 것 - "추측하지 말고 측정하라"
2. 가장 큰 병목부터 - 80/20 법칙 적용
3. 개선 효과 검증 - Before/After 비교 필수
4. 가독성과 성능의 균형 - 과도한 최적화 지양
올바른 Profiling과 체계적인 최적화로 애플리케이션의 성능을 크게 개선할 수 있습니다!