28초 걸리는 API, 어디부터 고쳐야 할까
관리 플랫폼의 사용자 조회 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 | 사용자 기본 정보 조회 | 14ms | 0.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%
사용자 목록을 조회한 뒤, 각 사용자의 부가 정보를 가져오기 위해 외부 서비스를 건건이 호출하고 있었다.
// 현재 코드 구조 (개념)
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 엔티티의 연관 데이터(역할, 소속 등)를 각각 추가 쿼리로 가져오고 있었다.
// 현재 구조 (개념)
@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 기반 페이지네이션이 없었다. 프론트엔드에서는 전체 데이터를 받은 뒤 클라이언트 사이드에서 페이지 단위로 잘라 보여주고 있었다.
// 현재 구조 (개념)
public List<User> findUsers(SearchCriteria criteria) {
return em.createQuery(query).getResultList(); // 전체 로드
}개선 방안과 예상 효과
병목 분석 결과를 바탕으로, 개선 순서를 줄였을 때 가장 많이 남는 시간 순서로 정리했다.
방안 1: 외부 서비스 배치 호출 + 캐싱
가장 큰 병목인 외부 서비스 반복 호출을 배치 API로 전환하면, N번의 HTTP 호출이 1번으로 줄어든다.
배치 호출
// 개선 방안 예시: 배치 호출
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 쿼리로 가져오면, 건당 추가 쿼리가 사라진다.
// 개선 방안 예시: 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 (개념)
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: 서버 사이드 페이지네이션
전체 데이터를 한 번에 반환하는 대신, 페이지 단위로 나눠 응답한다.
// 개선 방안 예시: 페이지네이션 + 서버 사이드 필터링
@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초 → 수 초 |
| 2 | DB 쿼리 | Fetch Join으로 N+1 해결 | 쿼리 수 대폭 감소 |
| 3 | API 응답 | 페이지네이션 추가 | 타임아웃 방지 |
| 4 | 프론트엔드 | 클라이언트 필터링 → 서버 사이드 | 불필요한 데이터 전송 제거 |
트레이드오프
| 개선 방안 | 생기는 비용 |
|---|---|
| 배치 호출 | 외부 서비스가 배치 API를 제공해야 함 |
| 로컬 캐시 | 데이터 최신성과 충돌 — TTL 정책 필요 |
| Entity Graph | 복잡한 그래프는 JOIN이 무거워질 수 있음 |
| 페이지네이션 | 프론트엔드 UX 재설계 필요 |
분석이 남긴 것
이번 작업에서 실제로 코드를 고친 건 아니다. 하지만 어디를 먼저 고쳐야 하는지 순서를 잡는 일이 최적화의 절반이었다.
1초짜리 쿼리를 0.3초로 줄여도, 뒤에서 142초 걸리는 외부 호출이 그대로면 체감은 거의 없다. Splunk 로그로 실측 데이터를 확보하고, 코드 분석으로 구조적 원인을 확인한 뒤, 가장 비싼 병목부터 제거 순서를 잡는 것 — 이 과정이 가장 중요했다.
정리하면 이런 흐름이다:
- 로그 기반으로 병목을 찾는다 (이전 글의 Splunk REST API)
- 호출 체인을 분해해 어디서 시간이 쌓이는지 본다
- 가장 큰 병목부터 개선 방안을 설계한다
- 각 방안의 예상 효과와 트레이드오프를 정리한다
이 분석을 바탕으로 실제 개선 작업을 진행하게 되면, 그 과정과 실측 결과도 별도로 기록할 계획이다.
이 글로 Identity Platform 시리즈 15편이 마무리됩니다. 기반 정비(Series 1) → 운영 모델(Series 2) → 이벤트 아키텍처와 AI 활용(Series 3) → 성능 진단(Series 4)까지, 10년 된 플랫폼을 현대화하며 내린 판단과 그 결과를 기록했습니다.
전체 시리즈는 Identity Platform 허브에서 확인할 수 있습니다.