실제 사례
한 번 요청 시 5000명의 사용자를 요청하고, 처리 과정에서 응답시간이 20초 걸리는 사이트가 있는데, 원인을 알아보니 5000명의 정보를 다 System.out.println()으로 처리하고 있던 것이다. 이는 System.out.println()을 줄임으로써 응답시간이 6초까지 줄었다.
- 이상민, 자바 성능 튜닝이야기, 인사이트, 2013
20초에서 6초로, 14초가 단축되었습니다. 정말 이게 가능한 일일까요?
System.out.println()이 느린 이유
1. 동기화(Synchronized) 오버헤드
System.out.println()은 내부적으로 PrintStream의 synchronized 메서드를 사용합니다.
public void println(String x) {
synchronized (this) {
print(x);
newLine();
}
}
멀티스레드 환경에서 thread-safe를 보장하기 위해 매번 락을 획득하고 해제하는 과정이 필요합니다.
2. I/O 작업의 본질적인 느림
콘솔 출력은 결국 I/O 작업입니다. CPU 연산에 비해 매우 느리며, 특히 대량의 데이터를 출력할 때는 성능 저하가 두드러집니다.
3. 자동 버퍼 플러시
각 println() 호출마다 자동으로 버퍼를 flush하여 즉시 출력하려고 하기 때문에 추가적인 오버헤드가 발생합니다.
성능 계산
- 20초 → 6초 = 14초 절약
- 5000명으로 나누면 사용자당 약 2.8ms 절약
- 이는 충분히 현실적인 수치입니다
Logback은 어떨까?
Logback도 동기화를 사용합니다
Logback 역시 기본적으로 동기화를 사용합니다. 그럼에도 불구하고 System.out.println()보다 빠른 이유는 무엇일까요?
1. 비동기 로깅 옵션
<appender name="ASYNC" class="ch.qos.logback.classic.AsyncAppender">
<appender-ref ref="FILE" />
<queueSize>512</queueSize>
</appender>
AsyncAppender를 사용하면 로그를 큐에 넣고 별도 스레드가 처리하므로, 메인 로직은 블로킹되지 않습니다.
2. 로그 레벨 필터링 (가장 중요!)
// 프로덕션에서 INFO 레벨로 설정했다면
logger.debug("User: {}", user); // 아예 실행 안 됨
System.out.println("User: " + user); // 무조건 실행됨
디버그용 로그를 코드에 남겨둬도 프로덕션에서는 오버헤드가 거의 없습니다.
3. Lazy 문자열 평가
// GOOD - 로그 레벨이 OFF면 문자열 연산 자체가 일어나지 않음
logger.debug("User: {}, Data: {}", user, expensiveOperation());
// BAD - 무조건 문자열 연산 발생
System.out.println("User: " + user + ", Data: " + expensiveOperation());
내부 동작 차이:
// 문자열 연결 방식 (BAD)
String temp = "User: " + user + ", Data: " + expensiveOperation(); // 먼저 실행
logger.debug(temp); // 로그 레벨 체크 후 버림
// 파라미터 치환 방식 (GOOD)
if (logger.isDebugEnabled()) { // 먼저 체크
// 여기서만 파라미터 평가 및 문자열 생성
logger.debug("User: {}, Data: {}", user, expensiveOperation());
}
4. 효율적인 버퍼링
- System.out은 매번 자동 flush
- Logback은 버퍼를 모아서 한 번에 쓰기 (설정 가능)
비동기 모드를 사용하지 않는다면?
비동기 모드를 사용하지 않아도 로그 레벨 필터링만으로도 큰 성능 이점이 있습니다.
// 프로덕션에서 INFO 레벨로 설정된 상황
// 나쁜 예 - 매번 연산 발생
for (int i = 0; i < 5000; i++) {
logger.debug("Data: " + heavyCalculation()); // heavyCalculation() 무조건 실행
}
// 좋은 예 - 거의 오버헤드 없음
for (int i = 0; i < 5000; i++) {
logger.debug("Data: {}", heavyCalculation()); // 아예 실행 안 됨
}
주의사항: Logback도 잘못 쓰면 느립니다
// 이렇게 쓰면 Logback도 System.out.println()과 비슷하게 느립니다
logger.debug("User: " + user + ", Data: " + expensiveOperation());
문자열 연결 방식(+)을 사용하면:
- 로그 레벨과 상관없이 무조건 문자열 연산 발생
- expensiveOperation()도 무조건 실행됨
- 로그 레벨 필터링의 이점을 못 살림
반드시 파라미터 치환 방식({})을 사용해야 합니다!
실무 권장사항
1. 프로덕션 코드에서 System.out.println() 제거
// ❌ 절대 하지 마세요
for (User user : users) {
System.out.println("Processing: " + user);
// 비즈니스 로직
}
// ✅ 이렇게 하세요
for (User user : users) {
logger.debug("Processing: {}", user);
// 비즈니스 로직
}
2. 적절한 로그 레벨 설정
- 개발: DEBUG
- 스테이징: INFO
- 프로덕션: WARN 또는 INFO
3. 반복문 안의 로깅 주의
대량 데이터를 처리할 때 반복문 안에서 로깅하면 성능에 큰 영향을 줍니다.
// 필요하다면 조건부로
if (i % 1000 == 0) {
logger.info("Processed {} records", i);
}
4. 비동기 로깅 고려
높은 처리량이 필요한 경우 AsyncAppender 사용을 검토하세요.
결론
- System.out.println()은 생각보다 훨씬 느립니다
- 특히 대량 데이터 처리 시 병목이 될 수 있습니다
- Logback을 사용하되, **파라미터 치환 방식({})**으로 사용해야 성능 이점을 누릴 수 있습니다
- 사소해 보이는 코드도 대량 처리 시에는 큰 영향을 미칠 수 있습니다
프로덕션 환경에서 디버깅용 출력문을 제거하는 것만으로도 상당한 성능 개선을 얻을 수 있습니다.
'IT' 카테고리의 다른 글
| @Async 적용하기 (0) | 2026.01.14 |
|---|---|
| 커넥션 타임아웃과 리드 타임아웃 (0) | 2026.01.13 |
| 실행계획과 인덱스 (0) | 2025.12.16 |
| 실서비스에서 커넥션 풀(hikari Cp) 제대로 이해하기 (0) | 2025.12.16 |
| Business Exception vs System Exception (0) | 2025.12.15 |
댓글