“왜 이렇게 느려?”를 마주했을 때
들어가며
서비스를 만들다 보면, 어느 순간부터 “이거 왜 이렇게 느리지?” 하는 순간이 온다. 처음엔 코드 탓을 했다. 쿼리를 잘못 짰나? 로직이 복잡해서 그런가?
근데 조금 더 들여다보면, 정작 느린 건 코드가 아니라 '기다림'일 때가 많았다. 외부 API 응답, 느린 쿼리, 과도한 반복 로직 같은 것들. 나도 처음엔 이런 걸 잘 몰랐고, 막연히 리팩토링하거나 서버 사양을 탓하곤 했다.
이 글은, 내가 실무에서 성능 문제를 겪고 그걸 어떻게 관찰하고, 어디서부터 개선해봤는지 개발자 입장에서 솔직하게 정리한 기록이다.
아주 특별한 기술은 없다. 그냥 평범한 코드 위에서, 조금씩 겪은 이야기들이다.
“왜 이렇게 느려?” 🤯
느림의 원인은 항상 코드 안에 있는 건 아니다. 대부분은 기다림에서 시작된다.
응답이 느린 외부 API
오래 걸리는 DB 쿼리
반복 호출되는 I/O 작업
심지어는 로그 출력 하나까지
그런데도 처음엔 보통 로직만 들여다보게 된다. 그래서 진짜 문제는 더 늦게 발견되곤 한다.
느려졌다면, 어디서 느려졌는지부터 파악해야 한다. 측정 없이는 개선도 없다.
일단, 어디가 막혔는지부터 보자
성능 개선은 추측으로 하기엔 너무 위험하다. 직접 측정해서 병목 지점을 찾아내는 것부터 시작해야 한다.
Java에서 자주 쓰는 방법은 두 가지다.
1. System.currentTimeMillis()
가장 간단하게 사용할 수 있는 측정 도구다. 특정 코드 블록의 실행 시간을 빠르게 확인할 수 있다.
long start = System.currentTimeMillis();
// 실행할 코드
long end = System.currentTimeMillis();
System.out.println("실행 시간: " + (end - start) + "ms");
2. StopWatch (Spring)
작업이 여러 개일 때, 어디서 시간이 많이 소모되는지 파악할 수 있게 도와준다.
StopWatch stopWatch = new StopWatch();
stopWatch.start("Task 1");
// Task 1 실행
stopWatch.stop();
stopWatch.start("Task 2");
// Task 2 실행
stopWatch.stop();
System.out.println(stopWatch.prettyPrint()); // 결과 출력
// 결과
StopWatch '': running time (millis) = 1502
-----------------------------------------
ms % Task name
-----------------------------------------
0501 33% Task 1
1001 67% Task 2
출력 결과로 어떤 작업이 전체 시간의 몇 %를 차지했는지도 알 수 있다. 시각적으로 병목 구간을 확인하기 좋다.
병목 구간이 명확해지면, 이제 어디를 먼저 손봐야 할지도 보이기 시작한다.
기다리느라 느린 거면, 비동기로 보내자
실제로는 느린 게 아니라, 그냥 ‘기다리고 있는’ 경우가 많다.
외부 API 응답, 파일 I/O, DB 쿼리 등 CPU는 놀고 있는데, 응답을 기다리느라 전체가 느려진다. 이럴 땐 비동기 처리로 병렬화하면 성능이 확 달라진다.
예전에 외부 API 수백 건을 호출하는 작업이 있었다. 처음엔 순차적으로 처리하다 보니, 대기 시간 누적으로 전체 실행 시간이 길어졌다. 비동기로 전환하면서 모든 요청을 한꺼번에 보내고 기다리는 구조로 바꾸면서 처리 시간은 크게 줄었다.
단, 비동기는 무조건 빠른 게 아니다.
계산 중심 작업에 쓰면 오히려 느려질 수 있고
너무 많은 요청을 병렬로 보내면 리소스만 낭비된다
핵심은 이거다
지금 이 작업이 계산을 하고 있는지, 아니면 기다리고만 있는지 구분해야 한다.
기다리는 작업이라면, 비동기나 병렬 처리를 고려할 타이밍이다.

스레드를 늘렸더니 더 느려졌다?
멀티스레드 = 빠르다? 항상 그런 건 아니다. 나도 예전에 “스레드를 더 쓰면 빨라지겠지” 싶어서 값을 마구 올렸던 적이 있다. 결과는 예상과 정반대였다. 더 느려졌다.
이유는 간단했다. 스레드가 많아지면, CPU는 일보다 ‘전환’을 더 많이 하게 된다. 이걸 컨텍스트 스위칭 비용이라고 한다. 즉, 일을 많이 하는 게 아니라 누굴 먼저 할지 정하느라 바빠진다.
그래서 결국 “스레드를 얼마나 써야 적당한가?” 라는 질문에 다다른다.
적정 스레드 수는 아래 공식으로 계산할 수 있다:
스레드 수 = CPU 코어 수 × (1 + 대기시간 ÷ 서비스시간)
연산 위주 서비스라면 → CPU 코어 수에 맞추는 게 적절
대기시간이 긴 서비스라면 → 조금 더 많이 쓸 수 있다
Virtual Thread
추가로, Java 19+에선 가상 스레드(Virtual Thread) 라는 것도 있다.
기존 스레드보다 훨씬 가볍고
수천 개도 무리 없이 생성 가능하다
비동기 처리와 궁합도 좋다
가볍게 도입해볼 만한 기술이고, 기존 플랫폼 스레드의 한계를 극복할 수 있어서 앞으로 더 자주 보게 될 가능성도 크다.
최근 스프링캠프 2025에서도 버추얼 스레드를 주제로 한 세션이 있었는데, 실제 적용 사례가 꽤 많이 소개된 걸 보고 확실히 요즘 뜨는 주제라는 걸 느꼈다.
관심 있다면 다음 기술 블로그도 참고해볼 만하다.
마무리하며
처음엔 성능 이슈가 뭔가 거창한 문제처럼 느껴졌는데, 막상 겪어보니 대부분은 기본적인 관찰에서 시작됐다.
어디가 느린지 측정해보고
기다리고 있는 부분을 비동기로 바꿔보고
스레드 개수도 그냥 늘리지 말고 계산해보고
특별한 기술보다도 지금 코드를 잘 들여다보는 습관이 더 중요했던 것 같다.
다음 글에서는 DB, 캐시, 인프라 쪽에서 겪은 성능 개선 경험을 정리해보려고 한다. 그때도 삽질은 여전했다.
Last updated