-
코드폴리오 성능개선: GitHub API 98번 동기 호출을 3초로 줄이기Spring 2026. 6. 3. 19:52
문제 발견GitHub 연동으로 사용자의 기술 스택을 자동으로 동기화하는 syncSkills() API가 있었다. 기능은 정상 동작했지만 응답이 오래 걸린다는 느낌이 있어 실제로 측정해봤다.
System.currentTimeMillis()로 각 단계별 시간을 직접 찍어봤다.
long reposApiStart = System.currentTimeMillis(); List<GithubRepoResponse> repos = githubService.getRepositories(accessToken); long reposApiTime = System.currentTimeMillis() - reposApiStart; for (GithubRepoResponse repo : repos) { long langApiStart = System.currentTimeMillis(); Map<String, Long> languages = githubService.getLanguages(repo.getLanguagesUrl(), accessToken); totalGithubApiTime += System.currentTimeMillis() - langApiStart; // ... }전체 repo 수: 100, 처리된 repo 수(fork 제외): 98 GitHub API 호출 횟수: 99회 | 누적 시간: 41,751ms DB 쿼리 횟수: 405회 | 누적 시간: 3,600ms 총 실행 시간: 45,362ms
45초가 걸렸다. API 응답을 기다리는 동안 아무것도 못 하고 있었다.
GitHub API가 41초, DB가 3.6초 소요되었다. 전체의 92%가 네트워크 대기였다.
원인 분석
로직 구조를 보면 문제가 명확했다.
repo 1 언어 조회 요청 → 응답 대기(421ms) → 완료 repo 2 언어 조회 요청 → 응답 대기(421ms) → 완료 repo 3 언어 조회 요청 → 응답 대기(421ms) → 완료 ... repo 98 언어 조회 요청 → 응답 대기(421ms) → 완료 총 시간: 98 × 421ms = 41,751msGitHub API를 98번 순서대로 호출하면서, 응답이 올 때까지 다음 호출을 시작하지 않았다. 각 요청의 평균 응답 시간은 421ms였고 이것이 98번 쌓였다.
네트워크 I/O 대기 시간이 전체 실행 시간의 92% 를 차지하는 상황이었다.
해결 방향을 정하기 위해
- 이 98개 요청이 서로 의존 관계가 있는가?
repo 1의 언어 정보가 repo 2 조회에 영향을 주지 않는다. 순서대로 실행할 이유가 없다. 동시에 보내면 된다.
- 어떤 스레드 모델을 쓸 것인가?
이 작업은 CPU 연산이 아니라 네트워크 응답을 기다리는 I/O다. 일반 스레드풀(newFixedThreadPool)은 I/O 대기 중에도 OS 스레드를 점유하고 있는다. Java 21의 Virtual Thread는 I/O 대기 중에 스레드를 반납하고 다른 작업에 양보한다. 98개를 동시에 띄워도 실제 OS 스레드는 몇 개만 사용한다. 프로젝트에 이미 spring.threads.virtual.enabled: true가 설정되어 있기도 했다. spring.threads.virtual.enabled: true를 설정하면 톰캣 요청 스레드 자체가 Virtual Thread로 동작한다. 프로젝트 전체가 이미 Virtual Thread 기반으로 돌고 있다는 뜻이다. 여기서만 일반 스레드풀을 쓰면 프로젝트 안에 두 가지 스레드 모델이 섞인다. Virtual Thread로 맞추는 게 자연스러운 선택이었다.
- 트랜잭션은 어떻게 되는가?
여기서 제약이 생겼다. @Transactional이 메서드 전체에 걸려 있다. CompletableFuture 안에서 DB 작업을 하면 트랜잭션 컨텍스트가 별도 스레드로 전파되지 않아 깨진다. 해결책은 역할을 분리하는 것이었다. GitHub API 호출만 병렬로 처리하고, DB 작업은 기존 트랜잭션 스레드에서 순차적으로 처리한다.
해결: CompletableFuture + Virtual Thread 병렬화핵심 아이디어는 간단하다. 98개 요청을 순서대로 보낼 이유가 없다. 한꺼번에 보내고 다 올 때까지 기다리면 된다.
Before: ■─────■─────■─────■─── ... (98개 순차) After: ■ ■ ■ (98개 동시 → 가장 느린 1개의 응답 시간만큼만 기다림) ■
구현 포인트 1: 병렬 페이즈와 순차 페이즈 분리@Transactional이 메서드 전체에 걸려 있어서 CompletableFuture 안에서 DB 작업을 하면 트랜잭션 컨텍스트가 깨진다. 그래서 두 단계로 나눴다.
// 병렬 페이즈: GitHub API만 호출 (DB 없음) Map<String, Map<String, Long>> repoLanguageMap; try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { List<CompletableFuture<Map.Entry<String, Map<String, Long>>>> futures = nonForkRepos.stream() .map(repo -> CompletableFuture.supplyAsync( () -> Map.entry(repo.getName(), githubService.getLanguages(repo.getLanguagesUrl(), accessToken)), executor )) .collect(Collectors.toList()); repoLanguageMap = futures.stream() .map(CompletableFuture::join) .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue, (a, b) -> a)); } // 순차 페이즈: DB 작업 (트랜잭션 안전) for (GithubRepoResponse repo : nonForkRepos) { Map<String, Long> languages = repoLanguageMap.getOrDefault(repo.getName(), Map.of()); // ... DB 처리 }
구현 포인트 2: Virtual ThreadGitHub API 호출은 I/O 대기가 전부다. 일반 스레드풀은 대기 중에도 OS 스레드를 점유하지만, Virtual Thread는 I/O 대기 중에 스레드를 반납한다. 98개를 동시에 띄워도 OS 스레드는 몇 개만 쓴다.
프로젝트에 spring.threads.virtual.enabled: true가 설정되어 있어 톰캣 요청 스레드 자체가 이미 Virtual Thread로 동작하고 있었다. 여기서만 일반 스레드풀을 쓰면 두 가지 모델이 섞이니 newVirtualThreadPerTaskExecutor()로 맞췄다.
트러블슈팅: Duplicate key 에러.github이라는 이름의 repo가 2개 존재해서 Collectors.toMap()이 중복 키 예외를 던졌다. merge function을 추가해 해결했다.
// 중복 키 발생 시 첫 번째 값 유지 .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue, (a, b) -> a));결과
전체 repo 수: 100, 처리된 repo 수(fork 제외): 98 GitHub API 호출 횟수: 99회 | 병렬 처리 시간: 2,311ms DB 쿼리 횟수: 405회 | 누적 시간: 1,027ms 총 실행 시간: 3,346ms
45초 → 3.3초, 13.5배 빨라졌다.Before After 개선율 GitHub API 41,751ms 2,311ms 94.5% 감소 DB 쿼리 3,600ms 1,027ms 71.5% 감소 총 실행 시간 45,362ms 3,346ms 92.6% 감소 DB 쿼리가 71%나 줄어든 건 예상 밖이었다. 병렬 처리로 전체 실행 시간이 줄면서 DB 커넥션 점유 시간도 단축된 영향으로 보인다.
다음 개선 포인트DB 쿼리는 아직 405회가 남아 있다. repo마다 findByUserAndTitle, findByName, save를 개별 호출하는 N+1 구조다. 배치 조회로 개선하면 405회에서 수십 회로 줄일 수 있다.
두 번째 병목: DB 쿼리 405회
GitHub API 병렬화로 45초 → 3.3초를 달성했지만, 이번엔 DB 쿼리가 문제였다.
DB 쿼리 횟수: 405회 | 누적 시간: 3,600ms3.3초짜리 API에서 DB가 1초 이상을 차지하고 있었다. 405회로 많은 쿼리를 호출하고 있었다.
원인 분석루프 구조를 보면 문제가 그대로 보인다.
for (GithubRepoResponse repo : repos) { // 98번 반복 projectRepository.findByUserAndTitle(...) // 98회 SELECT for (String langName : languages.keySet()) { // 언어마다 반복 skillRepository.findByName(langName) // ~207회 SELECT skillRepository.save(...) } projectRepository.save(project) // 98회 INSERT/UPDATE }
쿼리 분포를 정리하면- findByUserAndTitle × 98 = 98회
- findByName × ~207 = 207회
- save(project) × 98 = 98회
- 기타 2회
합산 405회로 구조를 보면 당연한 결과였다. 루프가 한 바퀴 돌 때마다 DB 왕복이 따라붙는 N+1 패턴이었다.
해결: 루프 전 배치 조회 + 루프 내 메모리 맵 참조
핵심 아이디어는 단순하다. 루프 안에서 반복 조회하는 대신, 루프 시작 전에 필요한 데이터를 전부 가져와 Map에 올려두고, 루프 안에서는 Map을 참조한다.
1단계: 프로젝트 배치 조회 (98회 → 1회)
// Before: 루프마다 개별 조회 for (GithubRepoResponse repo : repos) { Project project = projectRepository.findByUserAndTitle(user, repo.getName()); // 98회 } // After: 루프 전에 한 번에 조회 Set<String> repoTitles = nonForkRepos.stream() .map(GithubRepoResponse::getName) .collect(Collectors.toSet()); long dbStart = System.currentTimeMillis(); Map<String, Project> projectMap = projectRepository.findByUserAndTitleIn(user, repoTitles) .stream().collect(Collectors.toMap(Project::getTitle, p -> p)); // 1회
ProjectRepository에 메서드 하나만 추가했다.// ProjectRepository.java List<Project> findByUserAndTitleIn(User user, Collection<String> titles);Spring Data JPA의 In 키워드 덕분에 구현 코드는 한 줄도 작성하지 않았다.
2단계: 스킬 배치 조회 (~207회 → 1회)
// Before: 언어마다 개별 조회 + 없으면 즉시 저장 for (String langName : languages.keySet()) { Skill skill = skillRepository.findByName(langName) .orElseGet(() -> skillRepository.save(...)); // ~207회 } // After: 전체 언어명 수집 후 한 번에 조회 Set<String> allLanguageNames = repoLanguageMap.values().stream() .flatMap(m -> m.keySet().stream()) .collect(Collectors.toSet()); Map<String, Skill> skillMap = skillRepository.findByNameIn(allLanguageNames) .stream().collect(Collectors.toMap(Skill::getName, s -> s)); // 1회 // DB에 없는 신규 스킬만 추려서 한 번에 저장 List<Skill> newSkills = allLanguageNames.stream() .filter(name -> !skillMap.containsKey(name)) .map(name -> Skill.builder().name(name).build()) .collect(Collectors.toList()); if (!newSkills.isEmpty()) { skillRepository.saveAll(newSkills).forEach(s -> skillMap.put(s.getName(), s)); }findByNameIn()은 이미 SkillRepository에 존재했다. 기존 코드를 활용하지 않고 있었다.
3단계: 루프 내 DB 완전 제거
배치 조회가 끝난 뒤 루프 안에서는 Map 참조만 한다.
// 루프 안에서는 DB 호출 없음 List<Project> projectsToSave = new ArrayList<>(); for (GithubRepoResponse repo : nonForkRepos) { Project project = projectMap.getOrDefault(repo.getName(), Project.builder().user(user).title(repo.getName()).build()); project.update(repo.getDescription(), repo.getHtmlUrl()); project.getSkills().clear(); Map<String, Long> languages = repoLanguageMap.getOrDefault(repo.getName(), Map.of()); for (String langName : languages.keySet()) { project.getSkills().add(skillMap.get(langName)); // Map 참조 } projectsToSave.add(project); } // 마지막에 일괄 저장 (98회 → 1회) projectRepository.saveAll(projectsToSave);루프 안에서 projectRepository.save()를 98번 호출하던 것도 루프 밖으로 빼서 saveAll() 한 번으로 교체했다.
결과
DB 쿼리 횟수: 6회 | 누적 시간: 395ms 총 실행 시간: 3,000ms
잔여 6회는 다음과 같다.- findById (user 조회) × 1
- findByUserAndTitleIn × 1
- findByNameIn × 1
- saveAll(newSkills) × 1 (신규 스킬이 있을 경우)
- saveAll(projects) × 1
- save(user) × 1
Before After DB 쿼리 수 405회 6회 DB 시간 3,600ms 395ms 405회 → 6회, 67배 감소했다
최종 정리Before AFTER-1 (병렬화) AFTER-2 (배치 조회) GitHub API 41,751ms 785ms 785ms DB 쿼리 수 405회 405회 6회 DB 시간 3,600ms 1,027ms 395ms 총 실행 시간 45,362ms 3,346ms 3,000ms 최종적으로 45초 → 3초, 15배 향상되었다.
두 가지 개선의 성격이 달랐다.
- 첫 번째 병렬화는 외부 I/O 대기를 겹쳐서 실행하는 구조적 변경이었고
- 두 번째 배치 조회는 N+1 쿼리를 제거하는 고전적인 패턴 적용이었다.
돌아보니 두 문제 모두 같은 질문으로 귀결됐다. 이 호출이 지금 여기 있어야 하는가
돌아보며: Claude Code와 페어 프로그래밍
이 작업 전반을 Claude Code와 함께 진행했다. 커밋 히스토리에 Co-Authored-By: Claude Sonnet 4.6으로 기록되어 있다.
병목 구간 측정, 원인 분석, 해결 방향 설계는 직접 했다.
- "98개 요청이 서로 의존하는가"
- "트랜잭션 컨텍스트가 깨지지 않으려면 어떻게 페이즈를 나눠야 하는가"
- "N+1을 어디서 끊을 것인가"
와 같은 질문을 기반으로 개선했다.
Claude Code는 그 설계를 구현하는 속도를 높여줬다. findByUserAndTitleIn 같은 Spring Data JPA 패턴 제안, Collectors.toMap 중복 키 처리 방법, 배치 저장 구조 리팩토링. 방향이 정해진 뒤 구현을 함께 짜나가는 방식이었다.
AI를 어떻게 쓰느냐는 결국 내가 무엇을 이해하고 있느냐에 달려 있다고 생각한다. 측정 없이 "빠르게 해줘"라고 했다면 엉뚱한 곳을 고쳤을 것이고, 분석 없이 "병렬화해줘"라고 했다면 트랜잭션이 깨진 채로 머지됐을 것이다.
'Spring' 카테고리의 다른 글
CPU는 남는데 서비스가 멈추는 현상: DB 커넥션 병목 (0) 2026.02.01 [Spring Study] 토비의 스프링 3주차 스터디 회고: 예외를 던지는 이유를 이해 (0) 2025.11.18 [Spring Study] 토비의 스프링 2주차 스터디 회고: JDBC 리팩토링과 JdbcTemplate의 발전 (0) 2025.11.05 [Spring Study] 토비의 스프링 1주차 스터디 회고: 스프링의 핵심 철학 (0) 2025.11.03 @MemberId 도입기: 스프링 MVC 요청 흐름과 커스ArgumentResolver 활용 (0) 2025.09.08