[척척학사] 포털 연동 과정 16초를 0.8초로 94.8% 개선한 과정

2026. 8. 4. 12:55·Project/척척학사

이 글은 Velog에서 이전한 글입니다. Velog 원문 보기

1. 문제 정의

척척학사의 포털 연동은 외부 포털에서 원시 데이터를 받아온 뒤, 우리 서비스가 바로 사용할 수 있는 학업 데이터로 다시 매핑하는 과정입니다.

이전 글에서 포털 연동 이후 내부 처리만 약 16초 정도 걸린다는 것을 확인했습니다.
개발 서버에서 다시 계측해 봐도 대표 요청 기준 14.6 ~ 14.9초 수준이 나와, 관측값 자체는 크게 다르지 않았습니다.

문제는 느리다는 사실보다, 어디가 느린지 바로 설명할 수 없었다는 점이었습니다.

당시에는 sync.done ... took_ms= 로그가 일정 시간 이상 느릴 때만 남는 수준이었습니다.
그래서 로그만 봐서는 아래를 분리해서 말하기 어려웠습니다.

  • 정말 StudentCourse INSERT가 핵심 병목인지
  • 교수, 과목, 개설강좌를 준비하는 과정이 더 비싼지

병목을 제대로 이해하려면 먼저 포털 연동에서 다루는 핵심 엔티티를 봐야 했습니다.

  • AcademicRecord: 학생의 전체 학업 요약 정보
  • CourseOffering: 특정 연도/학기/분반에 실제로 열린 강좌
  • StudentCourse: 한 학생이 어떤 강좌를 수강했고 성적이 어땠는지 나타내는 기록

포털 연동은 아래 흐름으로 동작합니다.

이번 글에서 다루는 대표 측정 시나리오는 학생 1명의 포털 연동 요청에서 수강 이력 50건이 신규 반영되는 케이스입니다.

예시 로그에서도 ins_cnt=50, del_cnt=0인 요청을 기준으로 설명합니다.
이번 사례는 삭제 최적화보다는 신규/갱신 중심 동기화 경로에 초점을 맞춥니다.


2. 기존 구조: 기능은 단순했지만 DB 왕복이 많았다

초기 구조는 기능적으로는 단순하고 안전했습니다.
다만 데이터가 많아질수록 DB round-trip이 빠르게 늘어나는 구조였습니다.

  • 학업 요약은 AcademicRecordRepository로 upsert
  • 교수는 ProfessorService.getOrCreate
  • 과목은 CourseService.getOrCreateCourse
  • 개설강좌는 CourseOfferingService.getOrCreateOffering
  • 학생 수강 기록은 diff 계산 후 StudentCourseRepository.saveAll

특히 눈에 들어온 건 getOrCreate 패턴이었습니다.

각 교수명, 과목코드, 개설강좌 키마다 조회하고 없으면 insert하는 흐름을 반복하고 있었기 때문에, 포털 데이터가 50건만 되어도 수십 번의 SELECT/INSERT가 발생할 수 있는 구조였습니다.

코드 관점에서 보면 대략 이런 형태였습니다.

Professor professor = professorService.getOrCreate(professorName);
Course course = courseService.getOrCreateCourse(courseCode, courseName);
CourseOffering offering = courseOfferingService.getOrCreateOffering(command);

이 호출이 과목 수만큼 반복되면, 서비스 로직은 단순해 보여도 DB 입장에서는 꽤 비싼 루프가 됩니다.

처음부터 병목이 여기라고 단정할 수는 없었습니다.
하지만 작은 쿼리를 반복해서 보내는 구조가 숨어 있는 이상, 이 구간을 의심할 이유는 충분했습니다.


3. 1차 실험: 정말 INSERT가 주병목일까

처음 가장 의심한 구간은 StudentCourse 저장 단계였습니다.
최종 저장 지점이고 건수도 많았기 때문에, 가장 먼저 눈에 띄는 후보였습니다.

첫 번째 가설은 아래와 같았습니다.

전체 지연의 핵심 원인은 StudentCourse 저장 구간일 것이다.

이 가설을 확인하기 위해 두 가지를 먼저 적용했습니다.

  1. Hibernate JDBC batch 옵션 추가
  2. [PERF] portal.sync ... 로그를 항상 남기도록 변경

이 단계에서 확인하고 싶었던 것은 단순했습니다.

  • INSERT/DELETE 시간이 실제로 얼마나 큰지
  • batch 옵션만으로 체감 성능이 얼마나 좋아지는지

측정 결과는 아래와 같았습니다.

[PERF] portal.sync
academic_ms=276
insert_ms=3577
delete_ms=0
total_ms=14676
ins_cnt=50
upd_cnt=0
del_cnt=0

결과는 예상과 조금 달랐습니다.

  • insert_ms=3577은 분명 느렸습니다.
  • 하지만 total_ms=14676 기준으로 보면, INSERT만 줄여도 여전히 10초 이상이 남습니다.

여기서 첫 번째 결론을 내릴 수 있었습니다.

INSERT는 병목이 맞다.
하지만 유일한 병목은 아니다.

처음 가설은 절반만 맞았습니다.
최종 저장 구간이 느린 것은 사실이었지만, 전체 지연을 설명하기에는 부족했습니다.


4. 2차 실험: 세부 계측으로 병목을 더 잘게 쪼개 보기

1차 실험에서 StudentCourse INSERT는 약 3.5초였습니다.
그런데 전체는 14.6초였습니다.
남은 11초가 어디서 쓰이는지 설명되지 않았습니다.

그래서 두 번째 가설은 자연스럽게 이어졌습니다.

교수, 과목, 개설강좌를 건마다 getOrCreate 하는 과정이 더 큰 병목일 수 있다.

이 가설을 검증하기 위해 SyncAcademicRecordService 내부를 더 잘게 계측했습니다.

추가한 로그 항목은 아래와 같습니다.

  • professor_map_ms
  • course_map_ms
  • curriculum_merge_ms
  • course_get_or_create_ms
  • offering_fetch_ms
  • insert_ms

이름은 실제 로그 필드명을 그대로 사용했습니다.

  • professor_map_ms: 교수명 기준 조회/생성 후 in-memory map 구성
  • course_map_ms: 과목코드 기준 조회/생성 후 in-memory map 구성
  • course_get_or_create_ms: 개설강좌 준비 구간
  • offering_fetch_ms: 기존 개설강좌 조회 구간

측정 결과는 아래와 같았습니다.

[PERF] portal.sync
academic_ms=280
professor_map_ms=3593
course_map_ms=3615
curriculum_merge_ms=21
course_get_or_create_ms=3532
offering_fetch_ms=145
insert_ms=3613
delete_ms=0
total_ms=14911
ins_cnt=50
upd_cnt=0
del_cnt=0

이 로그를 보면 병목이 거의 그대로 드러납니다.

  • professor_map_ms=3593
  • course_map_ms=3615
  • course_get_or_create_ms=3532
  • insert_ms=3613

반면 아래 구간은 매우 작았습니다.

  • curriculum_merge_ms=21
  • offering_fetch_ms=145

전체 14.9초 중 대부분은 아래 네 구간에 몰려 있었습니다.

  1. 교수 준비
  2. 과목 준비
  3. 개설강좌 준비
  4. StudentCourse INSERT

여기서 중요했던 건 병목이 데이터를 합치는 계산이 아니라, DB를 반복해서 왕복하는 준비 과정이었다는 점입니다.

두 번째 가설은 이렇게 정리할 수 있었습니다.

진짜 병목은 연산 자체보다, 건별 getOrCreate가 유발하는 반복적인 DB round-trip이었다.


5. 원인 해석: 왜 getOrCreate 반복 호출이 비쌌나

이제 원인을 코드 구조로 해석할 수 있었습니다.

기존 구조는 과목 하나를 처리할 때마다 아래를 반복했습니다.

Professor professor = professorService.getOrCreate(professorName);
Course course = courseService.getOrCreateCourse(courseCode, courseName);
CourseOffering offering = courseOfferingService.getOrCreateOffering(command);

문제는 메서드 이름이 아니라 쿼리 패턴에 있었습니다.

  • 교수 50건이면 교수 조회/삽입이 반복
  • 과목 50건이면 과목 조회/삽입이 반복
  • 개설강좌 50건이면 개설강좌 조회/삽입이 반복

대표 시나리오 기준으로 보면, 건별 getOrCreate는 엔티티마다 수십 회 이상의 조회/삽입 왕복을 유발했습니다.

반대로 이 반복 호출을 일괄 조회 + 누락분 저장 구조로 바꾸면, 엔티티별로 조회 1회 + 저장 1회 수준까지 줄일 수 있습니다.

정리하면 병목의 본질은 엔티티 개수가 많다는 사실보다,
그 개수만큼 작은 조회/삽입을 계속 왕복하는 구조에 있었습니다.


6. 3차 개선: 건별 getOrCreate를 bulk 조회/삽입으로 전환

세 번째 개선 방향은 분명했습니다.

getOrCreate 자체를 없애는 것이 아니라,
건별 getOrCreate를 일괄 조회 후 누락분만 insert 하는 구조로 바꾸자.

구조는 아래처럼 바뀌었습니다.

  1. 포털 데이터에서 필요한 교수명, 과목코드, 개설강좌 키를 먼저 모두 수집
  2. findBy...In으로 기존 데이터를 한 번에 조회
  3. 누락된 것만 saveAll
  4. 이후 로직은 in-memory map을 재사용

코드 형태는 대략 이렇게 바뀌었습니다.

Map<String, Professor> professorMap = professorService.getOrCreateAll(professorNames);
Map<String, Course> courseMap = courseService.getOrCreateAll(courseCodes, courseNamesByCode);
Map<OfferingKey, CourseOffering> offeringMap =
        courseOfferingService.getOrCreateAll(offeringCommands);

측정 결과는 아래와 같았습니다.

[PERF] portal.sync
academic_ms=301
professor_map_ms=94
course_map_ms=96
curriculum_merge_ms=17
course_get_or_create_ms=106
offering_fetch_ms=12
insert_ms=3682
delete_ms=0
total_ms=4431
ins_cnt=50
upd_cnt=0
del_cnt=0

변화는 꽤 선명했습니다.

  • professor_map_ms: 3593ms -> 94ms
  • course_map_ms: 3615ms -> 96ms
  • course_get_or_create_ms: 3532ms -> 106ms
  • total_ms: 14911ms -> 4431ms

건별 getOrCreate를 bulk 조회/삽입 구조로 바꾸는 것만으로도, 전체 시간에서 10초 이상이 사라졌습니다.

여기서 확인한 사실은 명확했습니다.

병목의 핵심은 연산량보다 반복적인 DB 왕복 구조였다.


7. 4차 개선: 남은 병목인 StudentCourse INSERT를 JDBC batch로 전환

3차 개선 이후 로그를 보면 남은 병목은 거의 하나로 좁혀졌습니다.

insert_ms=3682
total_ms=4431

다른 구간이 모두 0.1초 수준으로 내려왔기 때문에, 이제는 INSERT만 줄이면 된다고 말할 수 있는 상태가 됐습니다.

이 지점이 중요했습니다.

처음에는 INSERT만 줄이면 해결된다는 가설이 틀렸습니다.
하지만 getOrCreate 병목을 먼저 걷어낸 뒤에는, 같은 가설이 맞는 가설로 바뀌었습니다.

최적화는 처음 떠오른 병목 하나를 바로 고치는 작업이라기보다,
계측을 통해 병목을 좁혀 가면서 우선순위를 계속 다시 잡는 과정에 가까웠습니다.

마지막 개선은 StudentCourseRepository.saveAll을 JDBC batch INSERT로 바꾸는 작업이었습니다.

핵심 아이디어는 단순합니다.

  • JPA 엔티티를 하나씩 persist하지 않는다
  • INSERT할 행을 StudentCourseBulkRow로 평탄화한다
  • JdbcTemplate.batchUpdate로 한 번에 보낸다

코드 예시는 아래 정도로 요약할 수 있습니다.

jdbcTemplate.batchUpdate(
        INSERT_SQL,
        new BatchPreparedStatementSetter() {
            @Override
            public void setValues(PreparedStatement ps, int i) throws SQLException {
                StudentCourseBulkRow row = rows.get(i);
                ps.setObject(1, row.studentId());
                ps.setLong(2, row.offeringId());
                ps.setString(3, row.gradeType() != null ? row.gradeType().getValue() : null);
                // ...
            }

            @Override
            public int getBatchSize() {
                return rows.size();
            }
        });

서비스 로직 입장에서는 무엇을 저장하느냐는 그대로이고,
어떻게 DB에 보내느냐만 바뀐 셈입니다.


8. 최종 결과: 16초에서 0.83초까지

최종 측정 결과는 아래와 같았습니다.

[PERF] portal.sync
academic_ms=275
professor_map_ms=90
course_map_ms=80
curriculum_merge_ms=23
course_get_or_create_ms=102
offering_fetch_ms=2
insert_ms=162
delete_ms=0
total_ms=832
ins_cnt=50
upd_cnt=0
del_cnt=0

최종 결과를 요약하면 아래와 같습니다.

  • 사용자 체감 기준: 약 16초 -> 0.83초
  • 교수/과목/개설강좌 준비: 약 10초 이상 -> 각 0.1초 수준
  • StudentCourse INSERT: 3.6초 -> 0.16초

단계별로 정리하면 아래 순서였습니다.

단계 total_ms 핵심 해석
Baseline 체감 약 16초 어디가 느린지 알 수 없었음
Iteration #1 14676 INSERT는 느리지만 전부는 아니었음
Iteration #2 14911 반복적인 getOrCreate가 주병목임을 확인
Iteration #3 4431 bulk 조회/삽입으로 대부분 해소
Iteration #4 832 남은 INSERT 병목까지 JDBC batch로 해결

처음에는 마지막 INSERT가 제일 눈에 띄었습니다.
실제로도 느렸습니다.
다만 계측을 더 잘게 쪼개 보기 전까지는, 그 구간이 가장 큰 병목인지는 알 수 없었습니다.

이번 케이스에서는 순서가 중요했습니다.

  • 먼저 병목을 분해해서 보고
  • 그다음 반복적인 DB 왕복을 줄이고
  • 마지막으로 남은 INSERT를 직접 batch 처리

이 순서로 갔기 때문에 16초 -> 0.83초까지 내려갈 수 있었습니다.


9. 정리: 이번 개선의 본질은 batch 도입보다 구조 변경에 있었다

이번 개선에서 중요했던 것은 단순히 batch를 썼다는 사실이 아니었습니다.

핵심은 아래 세 단계에 있었습니다.

  1. 먼저 로그로 병목을 잘게 분해했다
  2. 건별 DB 왕복 구조를 일괄 처리 구조로 바꿨다
  3. 마지막으로 남은 INSERT 구간에만 저수준 batch를 적용했다

결과적으로 쿼리 패턴도 크게 바뀌었습니다.

대상 변경 전 변경 후
교수 건별 조회/삽입 반복 일괄 조회 1회 + 누락분 저장 1회 수준
과목 건별 조회/삽입 반복 일괄 조회 1회 + 누락분 저장 1회 수준
개설강좌 건별 조회/삽입 반복 일괄 조회 1회 + 누락분 저장 1회 수준
StudentCourse INSERT 단건 INSERT 반복 batch INSERT

이번 작업으로 얻은 교훈도 분명했습니다.

  • 느린 구간을 추정만 하면 최적화 순서를 틀릴 수 있다
  • getOrCreate 패턴은 편하지만, 대량 데이터 처리에서는 쉽게 병목이 된다
  • JPA batch 옵션만으로 해결되지 않는 구간은 JDBC batch처럼 더 낮은 레벨 제어가 필요할 수 있다

이번 개선은 결국 batch 하나를 넣어서 빨라진 작업이 아니라,
병목을 계측으로 좁혀 가면서 쿼리 구조 자체를 바꾼 과정에 더 가까웠습니다.

'Project > 척척학사' 카테고리의 다른 글

[척척학사] EC2에서 Lambda로 옮기며 백엔드 실행 경계를 다시 설계한 과정  (0) 2026.08.04
[척척학사] Lambda 서버리스 전환 후 처리량 한계 분석  (0) 2026.08.04
트래픽 피크형 서비스의 운영 구조를 요청 기반 구조로 바꾼 과정  (0) 2026.08.04
AWS EC2 주기적 사망 원인 분석 및 JVM 메모리 최적화 (t3.micro → t3.small)  (0) 2026.08.04
DB 커넥션 23초 점유 해결: 비동기 상태 머신으로 가용성 100% 확보하기  (0) 2026.08.04
'Project/척척학사' 카테고리의 다른 글
  • [척척학사] EC2에서 Lambda로 옮기며 백엔드 실행 경계를 다시 설계한 과정
  • [척척학사] Lambda 서버리스 전환 후 처리량 한계 분석
  • 트래픽 피크형 서비스의 운영 구조를 요청 기반 구조로 바꾼 과정
  • AWS EC2 주기적 사망 원인 분석 및 JVM 메모리 최적화 (t3.micro → t3.small)
pp8817
pp8817
공부한 내용, 개발 관련 지식, 트러블 슈팅 등을 기록합니다. 이전 블로그: https://velog.io/@pp8817/posts
  • pp8817
    끄적이는 개발 log
    pp8817
  • 전체
    오늘
    어제
    • 분류 전체보기 (273)
      • Project (71)
        • 척척학사 (29)
        • Book (23)
        • Saynow (3)
        • YAPP 27기 (4)
        • 나의 작은 프로젝트 (10)
        • Landit (2)
      • Backend (88)
        • Spring (13)
        • Spring MVC (13)
        • Spring Security (4)
        • JPA (26)
        • Database (19)
        • HTTP·Web (13)
        • Architecture (0)
      • Language·CS (59)
        • Java·Kotlin (5)
        • CS Interview (17)
        • Backend Interview (5)
        • Concepts (32)
      • Algorithm (25)
        • Algorithm (24)
        • 소마 알고리즘 스터디 (1)
      • Infra (8)
      • Troubleshooting (11)
      • Retrospective (6)
      • Etc (5)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    jpa
    HTTP WEB 기본 지식
    BOJ
    나의 작은 프로젝트
    척척학사
    게시판
    CS Interview
    Algorithm
    트러블슈팅
    Spring
    Spring MVC
    java
    OS
    Book
    interview
    Project
    object
    Python
    개념 정리!
    http
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.6
pp8817
[척척학사] 포털 연동 과정 16초를 0.8초로 94.8% 개선한 과정
상단으로

티스토리툴바