"느리다"는 증상이지, 진단이 아니다. 어디가 느린지를 모르면 최적화는 도박이 된다.
Spring Boot 기반 백엔드 시스템을 운영하다 보면 어느 시점에 반드시 성능 문제를 마주하게 된다. 그런데 "성능 최적화"라는 말 자체가 너무 넓다. 인덱스를 걸어야 하나? GC를 튜닝해야 하나? 캐시를 붙여야 하나? 문제의 층위를 구분하지 않으면 엉뚱한 곳에 시간을 쏟게 된다.
이 글에서는 Java Spring 시스템의 성능 최적화를 언어(Java), DB, 프레임워크(Spring), 애플리케이션 네 개의 계층으로 분리하고, 각 계층에서 실질적으로 효과가 큰 기법들을 정리한다. 각각의 계층은 관심사가 다르고, 측정 방법도 다르고, 투자 대비 효과도 다르다.
1. 언어 레벨 — Java 런타임과 코드 수준의 최적화
언어 레벨 최적화는 마이크로 단위의 개선이다. 단일 항목의 효과는 미미할 수 있지만, 초당 수천 건의 요청을 처리하는 시스템에서는 이 작은 차이들이 누적된다.
String 처리의 함정
반복문 안에서 + 연산자로 문자열을 연결하면, 매 반복마다 새로운 String 객체가 생성된다. 10만 건의 로그를 조합하는 배치 작업이라면 이것만으로 GC 부담이 눈에 띄게 증가한다.
// 반복문 안에서는 StringBuilder를 명시적으로 사용
StringBuilder sb = new StringBuilder(1024);
for (OrderItem item : items) {
sb.append(item.getName()).append(": ").append(item.getPrice()).append('\n');
}
Java 21+의 String Template(STR."...")은 가독성 면에서 매력적이지만, 성능이 민감한 핫 패스에서는 여전히 StringBuilder가 안전한 선택이다.
컬렉션, 제대로 고르고 있는가
ArrayList vs LinkedList 논쟁은 사실상 끝났다. CPU 캐시 지역성 때문에 현실적인 거의 모든 시나리오에서 ArrayList가 빠르다. 더 실질적으로 신경 써야 할 것은 HashMap의 초기 용량 설정이다. 1,000개의 엔트리가 예상되면 new HashMap<>(1024)로 잡아 리해싱 횟수를 줄이는 것이 간단하면서도 효과적이다.
Stream의 양면
Stream API는 선언적 코드를 작성하게 해주지만, 단순 반복에서는 전통적 for-loop보다 느리다. 더 중요한 건 parallelStream()의 함정이다. 이것은 내부적으로 ForkJoinPool.commonPool()을 공유하는데, 웹 서버처럼 이미 수백 개의 스레드가 동시에 요청을 처리하는 환경에서 ForkJoinPool까지 경합이 발생하면 오히려 전체 처리량이 떨어진다.
// 웹 요청 처리 중에는 이렇게 하지 말 것
List<Result> results = orders.parallelStream() // ForkJoinPool 경합 유발
.map(this::processOrder)
.collect(toList());
// 대신 명시적 스레드풀 또는 순차 처리
List<Result> results = orders.stream()
.map(this::processOrder)
.collect(toList());
GC 튜닝
Java 17+ 기준으로 G1GC가 기본이지만, 저지연이 중요한 API 서버라면 ZGC를 고려할 만하다. GC 튜닝에서 가장 기본적이면서도 자주 빠뜨리는 것은 -Xms와 -Xmx를 동일하게 설정하는 것이다. 힙 리사이징은 그 자체로 Stop-the-World를 유발할 수 있다.
# 힙 사이즈 고정으로 리사이징 오버헤드 제거
java -Xms4g -Xmx4g -XX:+UseZGC -jar app.jar
박싱/언박싱 회피
대량 루프에서 Long 대신 long, Integer 대신 int를 쓸 수 있는 곳에서는 반드시 primitive를 사용한다. 오토박싱이 발생할 때마다 래퍼 객체가 힙에 생성되고, 이것이 수십만 번 반복되면 Young Generation GC 빈도가 눈에 띄게 올라간다.
불변 객체의 이점
Value Object를 불변으로 설계하면 동시성 문제를 근본적으로 회피하면서 GC에도 유리하다. Java 16+의 record가 이 용도에 딱 맞는다.
public record Money(BigDecimal amount, Currency currency) {
public Money add(Money other) {
// 새 객체를 반환 — 원본은 변하지 않음
return new Money(this.amount.add(other.amount), this.currency);
}
}
2. DB 레벨 — 데이터 접근 효율의 최적화
현실적으로 대부분의 백엔드 성능 병목은 여기서 발생한다. 애플리케이션 코드를 아무리 다듬어도, 쿼리 한 줄이 Full Table Scan을 돌면 의미가 없다. ROI(투자 대비 효과)가 가장 높은 계층이기도 하다.
인덱스 전략
가장 효과 대비 비용이 낮은 최적화이며, 가장 먼저 점검해야 할 영역이다.
핵심 원칙은 세 가지다. 첫째, WHERE, JOIN, ORDER BY에 사용되는 컬럼에 인덱스를 건다. 둘째, 복합 인덱스의 컬럼 순서는 카디널리티가 높은 것을 앞에 둔다. 셋째, 커버링 인덱스를 활용하면 테이블 접근 자체를 생략할 수 있다.
-- 주문 조회: user_id로 필터링하고 created_at으로 정렬하는 패턴이 빈번하다면
CREATE INDEX idx_order_user_created ON orders (user_id, created_at DESC);
-- 커버링 인덱스: 조회 컬럼까지 인덱스에 포함
CREATE INDEX idx_order_covering ON orders (user_id, created_at DESC)
INCLUDE (status, total_amount);
EXPLAIN ANALYZE를 습관화하라
쿼리를 작성하고 EXPLAIN ANALYZE를 돌려보지 않는 것은, 코드를 작성하고 테스트를 돌리지 않는 것과 같다. PostgreSQL 기준으로 주의해야 할 패턴은 이렇다.
대량 테이블에서 Seq Scan이 발생하면 인덱스 누락이나 통계 정보 갱신이 필요한 상황이다. 대량 데이터에서 Nested Loop Join이 나타나면 Hash Join이나 Merge Join으로 유도해야 한다. 그리고 SELECT *는 불필요한 I/O를 유발하므로 필요한 컬럼만 명시한다.
커넥션 풀
HikariCP의 maximumPoolSize를 무조건 크게 잡는 것은 흔한 실수다. 공식 권장 공식은 (코어 수 × 2) + 유효 디스크 수이며, 대부분의 경우 10~20이면 충분하다. 과도하게 크게 잡으면 DB 서버에서 컨텍스트 스위칭 비용이 증가해 오히려 처리량이 떨어진다.
spring:
datasource:
hikari:
maximum-pool-size: 15
minimum-idle: 5
connection-timeout: 3000 # 3초 안에 커넥션을 못 얻으면 빠르게 실패
idle-timeout: 600000
max-lifetime: 1800000
전략적 반정규화
정규화는 데이터 정합성의 기본이지만, 읽기가 압도적으로 많은 테이블에서는 전략적 반정규화가 성능을 크게 개선한다. 예를 들어 정산 시스템에서 주문-결제-정산 3개 테이블을 매번 조인하는 대신, 정산 테이블에 주문 금액과 결제 수단을 스냅샷으로 보관하면 조인 비용을 없앨 수 있다.
파티셔닝
시계열 데이터(주문 로그, 정산 내역 등)는 날짜 기반 Range Partitioning을 적용하면 쿼리가 접근하는 물리적 데이터 범위를 줄일 수 있다.
-- PostgreSQL Declarative Partitioning
CREATE TABLE settlements (
id BIGSERIAL,
settled_at TIMESTAMP NOT NULL,
amount NUMERIC(15,2)
) PARTITION BY RANGE (settled_at);
CREATE TABLE settlements_2026_q1 PARTITION OF settlements
FOR VALUES FROM ('2026-01-01') TO ('2026-04-01');
읽기/쓰기 분리
트래픽이 높아지면 Primary-Replica 구조에서 읽기 쿼리를 Replica로 라우팅한다. Spring에서는 @Transactional(readOnly = true)를 선언한 서비스 메서드가 자동으로 Replica를 바라보도록 AbstractRoutingDataSource를 설정할 수 있다.
3. 프레임워크 레벨 — Spring과 JPA의 동작 원리를 아는가
이 계층의 최적화는 Spring과 JPA가 내부적으로 어떻게 동작하는지를 이해해야 가능하다. 프레임워크가 개발자 대신 해주는 일이 많을수록, 그 "대신 해주는 일"의 비용을 인식하지 못하면 성능 함정에 빠지기 쉽다.
JPA N+1 — 가장 흔하고 가장 치명적인 문제
부모 엔티티 1건을 조회한 뒤, 연관된 자식 엔티티를 건별로 추가 쿼리하는 N+1 문제는 JPA를 쓰는 모든 프로젝트에서 발생한다. LAZY 로딩을 기본 전략으로 설정하되, 한 번에 가져와야 하는 시점에서는 JOIN FETCH를 명시적으로 사용한다.
// QueryDSL에서 fetchJoin 활용
List<Order> orders = queryFactory
.selectFrom(order)
.join(order.orderItems, orderItem).fetchJoin()
.join(order.payment, payment).fetchJoin()
.where(order.userId.eq(userId))
.fetch();
@EntityGraph를 사용하는 방법도 있지만, 복잡한 조건이 들어가는 쿼리에서는 QueryDSL의 fetchJoin()이 더 세밀한 제어가 가능하다.
Dirty Checking의 숨겨진 비용
JPA는 트랜잭션이 커밋될 때 영속성 컨텍스트에 올라간 모든 엔티티의 스냅샷을 비교해서 변경된 필드를 감지한다. 이것이 Dirty Checking인데, 영속성 컨텍스트에 엔티티가 1,000개 올라가 있으면 flush 시점에 1,000번의 스냅샷 비교가 발생한다.
대응 전략은 명확하다. 읽기 전용 쿼리에는 반드시 @Transactional(readOnly = true)를 건다. 이렇게 하면 Hibernate가 스냅샷 자체를 생성하지 않아 메모리와 CPU를 절약한다. 벌크 업데이트는 엔티티를 하나씩 수정하지 말고, JPQL executeUpdate()나 QueryDSL 벌크 연산을 사용한다.
// 엔티티 하나씩 수정 — 느림
orders.forEach(order -> order.updateStatus(COMPLETED));
// 벌크 업데이트 — 빠름
queryFactory
.update(order)
.set(order.status, COMPLETED)
.where(order.id.in(orderIds))
.execute();
em.clear(); // 벌크 연산 후에는 영속성 컨텍스트를 반드시 초기화
캐싱 전략
Spring Cache Abstraction(@Cacheable, @CacheEvict)은 적용이 간편하지만, 캐시 대상의 선택이 핵심이다. "자주 읽히고, 자주 변하지 않는" 데이터가 캐시 대상이다. 상품 정보, 카테고리 목록, 설정값 같은 것이 대표적이고, 정산 결과처럼 변경이 잦은 데이터를 캐시하면 정합성 문제가 생긴다.
실무에서는 로컬 캐시(Caffeine)와 분산 캐시(Redis)를 계층화하는 것이 효과적이다. 로컬 캐시로 1차 방어를 하고, 미스가 발생하면 Redis를 조회하고, 거기서도 미스면 DB를 조회하는 구조다.
비동기 처리
이메일 발송, 슬랙 알림, 감사 로그 기록 같은 비핵심 로직은 @Async로 메인 스레드에서 분리한다. 단, Spring의 기본 SimpleAsyncTaskExecutor는 호출마다 새 스레드를 생성하므로, 반드시 ThreadPoolTaskExecutor를 별도로 설정해야 한다.
@Configuration
@EnableAsync
public class AsyncConfig {
@Bean("notificationExecutor")
public Executor notificationExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(5);
executor.setMaxPoolSize(20);
executor.setQueueCapacity(100);
executor.setThreadNamePrefix("notification-");
executor.setRejectedExecutionHandler(new CallerRunsPolicy());
return executor;
}
}
Tomcat 튜닝
Spring Boot 내장 Tomcat의 기본 설정은 범용적이지만 최적은 아니다. server.tomcat.threads.max(기본 200)를 워크로드에 맞게 조정하고, server.tomcat.accept-count로 대기열 크기를 제어한다. WebFlux 전환 없이도 Tomcat 튜닝만으로 상당한 처리량 개선이 가능하다.
Spring Security 필터 체인
인증이 필요 없는 경로(/health, /actuator, 정적 리소스)는 WebSecurity.ignoring()으로 필터 체인 자체를 우회시켜야 한다. 매 요청마다 불필요한 필터 10~15개를 통과하는 것은 순수한 오버헤드다.
4. 애플리케이션 레벨 — 아키텍처와 설계 판단
이 계층의 최적화는 코드 한 줄의 문제가 아니라, 시스템의 구조적 결정에 해당한다. 효과가 가장 크고, 나중에 바꾸기 가장 어려운 영역이기도 하다.
API 설계
Over-fetching은 API 레벨에서 가장 흔한 성능 낭비다. 목록 조회용 SummaryDTO와 상세 조회용 DetailDTO를 분리하는 것만으로도 네트워크 전송량과 직렬화 비용이 줄어든다.
페이지네이션은 Offset 방식(LIMIT 100 OFFSET 10000)은 오프셋이 커질수록 느려진다. 대량 데이터에서는 Cursor 기반(Keyset Pagination)이 일관된 성능을 보장한다.
// Offset 방식 — 뒤로 갈수록 느려짐
SELECT * FROM orders ORDER BY id DESC LIMIT 20 OFFSET 100000;
// Cursor 방식 — 항상 일정한 속도
SELECT * FROM orders WHERE id < :lastSeenId ORDER BY id DESC LIMIT 20;
이벤트 기반 아키텍처
동기 호출 체인이 길어지면 응답 시간이 선형으로 증가한다. 주문 완료 → 결제 확인 → 재고 차감 → 알림 발송 → 포인트 적립이라는 흐름에서, 사용자가 기다려야 하는 건 결제 확인까지다. 나머지는 이벤트로 비동기 전환하면 사용자 체감 응답 시간을 수백 밀리초 단위로 줄일 수 있다.
Spring의 ApplicationEventPublisher로 시작하되, 서비스 간 통신이 필요한 MSA 환경에서는 Kafka나 RabbitMQ 같은 메시지 브로커로 확장한다.
서킷 브레이커
MSA에서 하나의 서비스가 느려지면, 그것을 호출하는 모든 서비스의 스레드가 블로킹되면서 연쇄 장애(Cascading Failure)가 발생한다. Resilience4j로 서킷 브레이커를 적용하면 장애가 전파되기 전에 빠르게 차단하고, fallback 로직으로 graceful degradation을 구현할 수 있다.
@CircuitBreaker(name = "paymentService", fallbackMethod = "paymentFallback")
public PaymentResult processPayment(PaymentRequest request) {
return paymentClient.process(request);
}
private PaymentResult paymentFallback(PaymentRequest request, Throwable t) {
// 결제 서비스 장애 시 → 재시도 큐에 적재
retryQueue.enqueue(request);
return PaymentResult.pending();
}
배치 처리 최적화
정산 시스템처럼 대량 데이터를 주기적으로 처리하는 경우, Spring Batch의 chunk 사이즈가 성능을 좌우한다. JPA를 사용할 때는 chunk 단위로 entityManager.clear()를 호출해서 영속성 컨텍스트가 무한히 커지는 것을 방지해야 한다. 대량 삽입이 필요하면 JdbcBatchItemWriter가 JPA 대비 수 배 빠르다.
모니터링 — 측정 없는 최적화는 도박이다
Spring Actuator + Micrometer로 메트릭을 수집하고, Prometheus로 저장하고, Grafana로 시각화하는 조합이 사실상 표준이다. 최소한 다음 네 가지는 항상 추적해야 한다.
응답 시간 분포(P50, P95, P99)는 평균이 아니라 백분위로 봐야 한다. 평균 100ms인데 P99가 5초라면 100명 중 1명은 5초를 기다리고 있다는 뜻이다. 커넥션 풀 사용률이 80%를 넘기면 풀 크기를 키우거나 쿼리 속도를 개선해야 한다. GC 빈도와 pause time은 GC가 자주 돌거나 pause가 길면 힙 사이즈와 GC 알고리즘을 재검토해야 한다. 스레드 풀 상태에서 Tomcat 스레드가 전부 점유되어 있으면 외부 호출 지연이나 DB 병목을 의심해야 한다.
Graceful Shutdown
배포 시 진행 중인 요청을 안전하게 마무리하는 것도 시스템 안정성의 일부다. server.shutdown=graceful 한 줄 추가하면 Spring Boot가 새 요청은 거부하고 기존 요청이 완료될 때까지 기다린 뒤 종료한다.
계층별 ROI — 어디부터 손대야 하는가
네 개의 계층을 정리했지만, 실무에서 모든 것을 동시에 최적화할 수는 없다. 일반적으로 투자 대비 효과가 큰 순서는 이렇다.
DB > 애플리케이션 > 프레임워크 > 언어
인덱스 하나 추가하는 데 5분이 걸리지만, 쿼리 응답 시간을 10배 줄일 수 있다. 아키텍처 결정(동기→비동기 전환, 캐시 도입)은 시간이 더 걸리지만 시스템 전체의 처리량을 바꿀 수 있다. 프레임워크 레벨 튜닝(N+1 해결, Security 필터 최적화)은 중간 수준의 효과를 준다. 언어 레벨 마이크로 최적화는 위 세 가지를 다 한 뒤에 병목이 남아 있을 때 의미가 있다.
그리고 이 모든 것의 전제 조건은 모니터링이다. 측정 없이 최적화하면 감으로 코딩하는 것과 다를 바 없다. 프로파일링 도구를 켜고, 병목 지점을 확인하고, 거기를 최적화하고, 다시 측정하는 사이클을 반복하는 것이 성능 최적화의 정석이다.
이 글에서 다룬 기법들은 Spring Boot + JPA + PostgreSQL 조합을 기준으로 작성했지만, 계층별 사고방식 자체는 어떤 스택에서든 적용 가능하다. 결국 성능 최적화의 본질은 "어디서 시간이 소모되는지 찾아내고, 그 지점을 개선하는 것"이기 때문이다.
'Software > Architecture' 카테고리의 다른 글
| Java Spring + Node.js + Python, 한 프로젝트에서 공존할 수 있을까? — 폴리글랏 MSA의 현실과 전략 (1) | 2026.04.09 |
|---|---|
| 스프링 AI가 각광받는이유 (WIL4 .feat. CLUADE) (0) | 2026.04.03 |
| Spring Boot 4 + Virtual Thread 아키텍처 설계 (0) | 2026.03.21 |
| Keycloak 토큰 설계 실수 7가지와 실무 보안 아키텍처 정리 (JWT, Scope, Audience, RBAC) (0) | 2026.03.19 |
| Monolith vs MSA 아키텍처 비교 (0) | 2026.03.17 |
