Skip to Content
Backend28초 걸리는 API, 어디부터 고쳐야 할까
💻 Backend2026년 2월 20일

28초 걸리는 API, 어디부터 고쳐야 할까

#identity-platform#performance#splunk#jpa#analysis
Identity Platform · Series 4 · Diagnostic & Performance15 / 15Backend
시리즈 마지막 편 — Splunk 로그와 코드 분석으로 병목을 찾고 개선 순서를 잡은 과정.

관리 플랫폼의 사용자 조회 API가 너무 느려서 사실상 기능 장애에 가까웠다. 조건에 따라 28초에서 최대 142초까지 걸렸고, 특정 조건에서는 504 Timeout으로 아예 화면이 멈췄다.

조건응답 시간심각도
사용자 목록 조회 (약 2,000건)약 28초느림
필터 조건 포함 목록 조회64초심각
전체 조회 (필터 없음)504 Timeout장애

“느린 API” 하나를 고치면 될 줄 알았는데, 파고 들어보니 병목이 여러 계층에 걸쳐 있었다. 이전 글에서 구축한 Splunk REST API 기반 분석 도구를 실전에 적용해 병목을 찾고, 어디부터 고쳐야 가장 큰 효과를 볼지 순서를 잡은 과정을 기록한다.

분석 방법: Splunk trace_id 추적

이전 글에서 만든 Splunk REST API 스크립트로 실제 요청의 trace_id를 추적했다. 64초가 걸린 요청 하나를 골라 구간별 시간을 측정한 결과는 이렇다.

순서구간소요 시간비율
1사용자 기본 정보 조회14ms0.01%
2상태별 목록 조회 (첫 번째)4,565ms (4.5초)3.1%
3상태별 목록 조회 (두 번째)142,578ms (142초)96.9%

BFF 서비스의 Read Timeout이 60초이기 때문에 클라이언트에서는 60초에 타임아웃이 되었지만, 뒷단 서비스는 142초까지 처리를 계속하고 있었다. 전체 시간의 97%가 한 구간에 집중돼 있다는 사실을 Splunk 로그 없이는 알 수 없었다.

발견한 세 가지 병목

코드 분석과 Splunk 로그를 조합한 결과, 세 축의 병목이 겹쳐 있었다.

병목증상영향도
외부 서비스 반복 호출전체 시간의 97% 차지가장 큰 절감 폭
JPA N+1 쿼리건당 14~15ms × 2,000건 누적쿼리 최적화로 해결 가능
페이지네이션 없음전체 데이터를 한 번에 반환타임아웃 직접 유발

병목 1: 외부 서비스 반복 호출 — 전체의 97%

사용자 목록을 조회한 뒤, 각 사용자의 부가 정보를 가져오기 위해 외부 서비스를 건건이 호출하고 있었다.

java
// 현재 코드 구조 (개념) for (User user : users) { // 사용자마다 외부 서비스 개별 호출 — O(N) 시간 복잡도 ProfileInfo profile = profileService.getProfile(user.getExternalId()); dto.setProfile(profile); }

1,000명이면 1,000번의 HTTP 호출이 발생한다. Splunk 로그에서 두 번째 상태별 조회가 142초 걸린 건, 이 반복 호출이 원인이었다.

병목 2: JPA N+1 쿼리

사용자 목록 조회 시, User 엔티티의 연관 데이터(역할, 소속 등)를 각각 추가 쿼리로 가져오고 있었다.

java
// 현재 구조 (개념) @Entity public class User { @ManyToMany(fetch = FetchType.EAGER) private Set<Role> roles; // 추가 쿼리 발생 @ManyToOne(fetch = FetchType.EAGER) private Organization org; // 추가 쿼리 발생 } // 2,000명 조회 → 건당 14-15ms × 2,000 = 약 28초

병목 3: 페이지네이션 미구현

DB 조회에 최대 건수 제한만 있을 뿐, 실제 offset/limit 기반 페이지네이션이 없었다. 프론트엔드에서는 전체 데이터를 받은 뒤 클라이언트 사이드에서 페이지 단위로 잘라 보여주고 있었다.

java
// 현재 구조 (개념) public List<User> findUsers(SearchCriteria criteria) { return em.createQuery(query).getResultList(); // 전체 로드 }

개선 방안과 예상 효과

병목 분석 결과를 바탕으로, 개선 순서를 줄였을 때 가장 많이 남는 시간 순서로 정리했다.

방안 1: 외부 서비스 배치 호출 + 캐싱

가장 큰 병목인 외부 서비스 반복 호출을 배치 API로 전환하면, N번의 HTTP 호출이 1번으로 줄어든다.

java
// 개선 방안 예시: 배치 호출 public List<UserDto> findUsers(SearchCriteria criteria) { List<User> users = userRepository.findByCriteria(criteria); Set<String> externalIds = users.stream() .map(User::getExternalId) .filter(Objects::nonNull) .collect(Collectors.toSet()); // N번 → 1번 배치 호출 Map<String, ProfileInfo> profiles = profileService.getProfilesBatch(externalIds); return users.stream() .map(user -> toDto(user, profiles.get(user.getExternalId()))) .collect(Collectors.toList()); }

예상 효과: 142초 → 수 초 (N번 API 호출 → 1번)

방안 2: Fetch Join 또는 Entity Graph로 N+1 해결

User 조회 시 연관 엔티티를 하나의 JOIN 쿼리로 가져오면, 건당 추가 쿼리가 사라진다.

java
// 개선 방안 예시: Named Entity Graph @Entity @NamedEntityGraph( name = "User.withRolesAndOrg", attributeNodes = { @NamedAttributeNode("roles"), @NamedAttributeNode("org") } ) public class User { ... } @EntityGraph("User.withRolesAndOrg") @Query("SELECT u FROM User u WHERE u.status = :status") List<User> findByStatusWithDetails(@Param("status") UserStatus status);
sql
-- 기대되는 SQL (개념) SELECT u.*, r.*, o.* FROM users u LEFT JOIN user_roles ur ON u.id = ur.user_id LEFT JOIN roles r ON ur.role_id = r.id LEFT JOIN organizations o ON u.org_id = o.id WHERE u.status = 'ACTIVE'

예상 효과: 2,000건 기준 수천 개 쿼리 → 1개 JOIN 쿼리

방안 3: 서버 사이드 페이지네이션

전체 데이터를 한 번에 반환하는 대신, 페이지 단위로 나눠 응답한다.

java
// 개선 방안 예시: 페이지네이션 + 서버 사이드 필터링 @GetMapping("/api/v2/users") public Page<UserDto> getUsers( @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "50") int size, @RequestParam(required = false) String search, @RequestParam(required = false) UserStatus status) { Pageable pageable = PageRequest.of(page, size, Sort.by("name")); if (search != null) { return userService.searchUsers(search, status, pageable); } return userService.findUsers(status, pageable); }

예상 효과: 타임아웃 방지 + 응답 크기 MB → KB 단위로 축소

개선 우선순위 요약

순위대상개선 내용예상 효과
1외부 서비스 호출배치화 / 캐싱142초 → 수 초
2DB 쿼리Fetch Join으로 N+1 해결쿼리 수 대폭 감소
3API 응답페이지네이션 추가타임아웃 방지
4프론트엔드클라이언트 필터링 → 서버 사이드불필요한 데이터 전송 제거

트레이드오프

개선 방안생기는 비용
배치 호출외부 서비스가 배치 API를 제공해야 함
로컬 캐시데이터 최신성과 충돌 — TTL 정책 필요
Entity Graph복잡한 그래프는 JOIN이 무거워질 수 있음
페이지네이션프론트엔드 UX 재설계 필요

분석이 남긴 것

이번 작업에서 실제로 코드를 고친 건 아니다. 하지만 어디를 먼저 고쳐야 하는지 순서를 잡는 일이 최적화의 절반이었다.

1초짜리 쿼리를 0.3초로 줄여도, 뒤에서 142초 걸리는 외부 호출이 그대로면 체감은 거의 없다. Splunk 로그로 실측 데이터를 확보하고, 코드 분석으로 구조적 원인을 확인한 뒤, 가장 비싼 병목부터 제거 순서를 잡는 것 — 이 과정이 가장 중요했다.

정리하면 이런 흐름이다:

  1. 로그 기반으로 병목을 찾는다 (이전 글의 Splunk REST API)
  2. 호출 체인을 분해해 어디서 시간이 쌓이는지 본다
  3. 가장 큰 병목부터 개선 방안을 설계한다
  4. 각 방안의 예상 효과와 트레이드오프를 정리한다
시리즈 전체에서 이 글의 위치: 구조화 로깅이 있었기에 병목을 찾을 수 있었고, 비동기 문맥이 유지됐기에 호출 흐름을 이어 볼 수 있었으며, 서비스 구조를 이해하고 있었기에 어디를 봐야 하는지 더 빨리 판단할 수 있었다.

이 분석을 바탕으로 실제 개선 작업을 진행하게 되면, 그 과정과 실측 결과도 별도로 기록할 계획이다.


이 글로 Identity Platform 시리즈 15편이 마무리됩니다. 기반 정비(Series 1) → 운영 모델(Series 2) → 이벤트 아키텍처와 AI 활용(Series 3) → 성능 진단(Series 4)까지, 10년 된 플랫폼을 현대화하며 내린 판단과 그 결과를 기록했습니다.

전체 시리즈는 Identity Platform 허브에서 확인할 수 있습니다.

Last updated on