-반장노트 구인 도메인 조회 성능 개선기-
1. 들어가며
반장노트의 구인 도메인은 화면 하나를 그릴 때 여러 API를 함께 호출합니다. 기능이 추가되면 요청과 쿼리도 늘어날 수 있습니다. 이번 분기에는 테스트 담당자가 lab 환경(약 270만 건 seed, 파드 2 vCPU/2Gi, 2 replicas)에서 조회 중심 부하 테스트를 진행했습니다. 저는 테스트 결과를 바탕으로 구인 도메인의 조회 성능을 개선했습니다.
부하 테스트를 직접 진행한 것은 아닙니다. 그래서 이 글에서는 테스트 방법보다는 리포트에서 어떤 문제를 확인했고, 실제 코드에서 무엇을 바꿨는지 설명하려고 합니다. 내용은 테스트 리포트의 수치와 관련 커밋을 기준으로 정리했습니다.
2. 테스트 결과에서 읽은 것
전체 테스트 결과는 양호했습니다. 약 110 req/s에서 HTTP 평균 응답 시간은 80ms, p95는 331ms였고 실패율은 0%였습니다. CPU 사용량에도 여유가 있었습니다. 다만 화면과 API별로 나눠 보니 응답 시간 차이가 눈에 띄는 구간이 있었습니다.
|
화면 |
횟수 |
평균(ms) |
p95(ms) |
|---|---|---|---|
|
반장 홈 진입 |
1,461 |
514 |
632 |
|
팀 배정 |
524 |
352 |
413 |
|
지원 현황 |
522 |
347 |
408 |
|
구인 목록/상세 |
1,001 |
195 |
452 |
|
구인 관리 목록 (팀장) |
482 |
85 |
164 |
표 1. 화면별 응답 시간 일부 (출처: 1차 성능 테스트 리포트)
API별로 확인해 보니 내 지원 목록 조회의 평균 응답 시간이 345ms로 가장 길었습니다. 두 번째로 느린 API의 평균 응답 시간은 134ms로, 약 2.6배 차이가 났습니다. 이 API는 반장 홈, 구인 목록/상세, 지원 현황, 내 팀 화면에서 공통으로 사용하고 있었습니다. 호출 수 합계도 2,901회로 화면별 합계와 일치했습니다. 여러 화면이 함께 사용하는 API였기 때문에 이 부분을 개선하면 여러 화면에 영향을 줄 수 있다고 판단했습니다. 테스트 리포트에서도 우선 개선 대상으로 꼽았습니다.
다만 테스트 전에 제가 먼저 살펴보고 있던 곳은 구인 관리 목록이었습니다. 실제로 어떤 부분을 수정했는지부터 설명하겠습니다.
3. 테스트 전에 먼저 고친 것: 구인 관리 목록
테스트 전에 담당자가 공유한 시나리오에는 구인 관리 목록에서 “구인 글 10개면 요청이 12번 나가고, 서버도 구인 글마다 지원서를 전부 읽는다”고 적혀 있었습니다. 내용을 보고 관련 코드가 떠올라 테스트 결과를 기다리지 않고 먼저 수정했습니다. 화면에서 구인 글마다 건수 API를 호출하는 문제와, 서버에서 구인 글마다 지원서를 조회하는 문제가 함께 있었습니다.
서버 — 구인 글마다 지원서 전체 조회를 현재 페이지 기준 group by 1회로
// Before
jobPostings.map(p -> new JobPostingLdrRdo(p, jobApplicationLogic.findByJobPostingId(p.getId())));
// After
Map<String, Map<JobApplicationStatus, Long>> countsByPosting =
countApplicationsByPostingIds(jobPostings.getContent().stream().map(JobPosting::getId).toList());
jobPostings.map(p -> new JobPostingLdrRdo(p, countsByPosting.getOrDefault(p.getId(), Map.of())));
화면 — 구인 글 수만큼 건수 API를 부르던 useQueries 삭제
// Before
const applicantCountQueries = useQueries({
queries: jobPosts.map((job) =>
findJobApplicationCountsByJobPostingLdrOptions({ jobPostingId: job.jobPostId })),
});
// After — 목록 응답이 철회 제외 지원자 수를 함께 내려준다
applicantCount: job.activeApplicationCount ?? job.totalApplicationCount ?? 0,
이와 함께 정렬에 사용하는 컬럼 인덱스 4개를 Flyway로 추가하고, 구인 카드에 붙이는 팀 이름(팀 목록) 조회는 스크롤할 때마다 반복되던 것을 30초 동안 재사용하도록 했습니다. 첫 화면에서 발생하던 요청은 12번에서 2번으로 줄었습니다. 테스트 결과 구인 관리 목록의 평균 응답 시간은 85ms로, 주요 병목으로 보이지는 않았습니다. 다만 수정 전 측정값이 없어 개선율을 계산할 수는 없습니다. 같은 시기에 구인 상세와 지원서 제출 화면에서 지원 여부만 확인하려고 내 지원 목록 전체를 조회하던 부분도 상세 응답의 applied 값으로 대체했습니다.
4. 테스트 후 개선: 내 지원 목록과 반장 홈
4-1. 왜 느렸는가
내 지원 목록을 조회할 때는 반장에 해당하는 Worker마다 지원서 엔티티 전체를 읽고 있었습니다. 지원서에는 지원 당시의 내 정보와 같은 큰 JSON 컬럼도 포함되어 있습니다. 하지만 이 화면에서 주로 필요한 값은 지원 상태와 구인 글 ID였습니다. 필요한 정보보다 훨씬 많은 데이터를 읽고 있었던 셈입니다. 이 목록을 사용하는 다른 화면에서도 필요한 값 하나를 얻기 위해 전체 목록을 가져온 뒤 화면에서 걸러 쓰는 방식이 반복되고 있었습니다.
-
반장 홈: 이미 지원한 구인 글을 빼려고 내 지원 목록 전체를 호출
-
지원 현황, 지원 내역, 구인 목록: 카드마다 구인 글 상세 API를 따로 호출
4-2. 반장 홈: 호출 하나를 통째로 없애다
반장 홈에서 필요한 정보는 지원 목록 전체가 아니라 각 구인 글에 지원했는지를 나타내는 값이었습니다. 구인 글 목록 응답에 applied 필드를 추가하고, 서버에서 구인 글 ID만 조회해 값을 계산하도록 바꿨습니다. 지원을 철회한 뒤에는 다시 지원할 수 있으므로, 철회한 지원은 지원한 구인 글로 보지 않는 기존 기준을 유지했습니다.
서버 — 구인 글 id만 읽어 applied 계산
Set<String> appliedIds = findMyActiveAppliedPostingIds();
Page<JobPostingRdo> jobPostingRdos = jobPostings.map(jobPosting -> {
JobPostingRdo rdo = new JobPostingRdo(jobPosting, bookmarkedIds.contains(jobPosting.getId()));
rdo.setApplied(appliedIds.contains(jobPosting.getId()));
return rdo;
});
화면 — 홈에서 내 지원 목록 호출 제거
// Before
const { myJobApplicationRdoList: applications } = useFindMyJobApplicationsWkr({});
const appliedJobPostingIds = new Set(applications.map((a) => a.jobPostingId));
... .filter((job) => job.jobPostingStatus === Open && !appliedJobPostingIds.has(job.jobPostingId))
// After — 지원 여부는 목록 응답의 applied로 본다
... .filter((job) => job.jobPostingStatus === JobPostingStatus.Open && !job.applied)
홈 화면에서 내 지원 목록을 별도로 조회하지 않도록 바꾸면서, 홈이 해당 API의 응답을 기다려야 하는 과정도 없어졌습니다.
4-3. 지원 목록: 구인 글 요약을 함께 내려주기
지원 현황과 지원 내역 화면에서는 지원 건마다 구인 글 상세 API를 호출하고 있었습니다. 이를 줄이기 위해 지원 목록 응답에 카드에 필요한 구인 글 요약 정보를 함께 담도록 서버를 수정했고, 화면에서 반복 호출하던 부분을 제거했습니다. 구인 글 상세 API를 호출하면 열람 기록이 남는 구조이므로, 목록에 요약 정보를 제공하는 경로는 열람 기록을 남기지 않도록 별도로 구현했습니다. 구인 목록도 같은 방식으로 응답에 포함된 정보를 사용하도록 바꿨습니다.
화면 — 지원 건마다 구인 글 상세를 부르던 useQueries 삭제
// Before
const detailQueries = useQueries({
queries: applications.map((a) => findJobPostingDetailWkrOptions({ jobPostingId: a.jobPostingId })),
});
// After — 구인 글 요약은 지원 목록 응답에 함께 온다
const detail = application.jobPosting;
4-4. 읽는 양과 쿼리 수 줄이기
서버에서는 JPA 엔티티 전체를 조회하는 대신 필요한 컬럼만 가져오는 요약 조회를 추가했습니다. 이 방식은 지원서 본문 JSON을 읽지 않아도 되므로 지원 등록 시 제한 여부를 확인하는 로직, 팀장 홈의 미검토 지원자 카드, 지원 건수 칩에 적용했습니다. 특히 열린 구인 글마다 실행하던 미검토 지원자 조회를 한 번으로 줄였습니다. Worker별 조회도 IN 조건을 사용해 한 번에 처리하도록 변경했습니다.
지원서 요약 조회 — 본문 JSON을 제외한 컬럼만
queryFactory
.select(jobApplicationJpo.id, jobApplicationJpo.jobPostingId, jobApplicationJpo.workerId,
jobApplicationJpo.status, jobApplicationJpo.registeredOn)
.from(jobApplicationJpo)
.where(jobApplicationJpo.jobPostingId.in(jobPostingIds))
.fetch();
반장 구인 글 목록은 무한 스크롤 방식인데, 기존에는 페이지를 조회할 때마다 전체 건수를 확인하는 count 쿼리도 실행하고 있었습니다. 다음 페이지가 있는지만 확인하면 되는 상황이어서 limit + 1건을 조회하고, 결과가 한 건 더 있는지를 기준으로 다음 페이지 여부를 판단하도록 바꿨습니다.
5. 개선 후 확인
개선 이후, 테스트 담당자가 로컬 환경에서 비슷한 방식으로 부하 테스트를 다시 진행했습니다. 다만 첫 번째 테스트는 lab에서 진행했고, 재측정은 로컬 환경에서 시나리오 구성도 달랐습니다. 따라서 두 결과의 응답 시간을 그대로 비교하기는 어렵습니다. 대신 각 테스트 실행 안에서 다른 API와 비교했을 때 내 지원 목록의 위치가 어떻게 달라졌는지 확인했습니다.
|
API |
평균(ms) 1차 lab |
평균(ms) 재측정 로컬 |
|---|---|---|
|
내 지원 목록 |
345 |
6.6 |
|
홈 카드 조회 (비교 기준) |
100 |
13.6 |
|
구인 글 목록 (비교 기준) |
45 |
7.1 |
표 2. 같은 실행 안에서 비교할 API의 평균 응답 시간
첫 번째 테스트에서는 내 지원 목록의 평균 응답 시간이 홈 카드 조회의 약 3.5배였습니다. 재측정에서는 약 0.5배로 나타났습니다. 첫 번째 테스트에서 상대적으로 오래 걸리던 API가 재측정에서는 주요 병목으로 보이지 않았습니다. 반장 홈의 평균 응답 시간도 35.5ms로 다른 화면과 비슷한 수준이었습니다. 재측정에서 평균 응답 시간이 가장 길었던 API는 매칭 추천 구인 글 조회로, 평균 32ms였습니다. 반장 홈이 빨라졌다는 사용자 반응도 확인했지만, 이를 수치만으로 개선 효과의 증거라고 단정하지는 않았습니다.
다만 이 결과만으로 개선 전후의 성능 차이를 정확하게 증명할 수는 없습니다. 같은 환경과 조건에서 수정 전 코드를 측정한 결과가 없고, 재측정에는 Hikari 같은 자원 지표도 포함되지 않았기 때문입니다. 따라서 이 글에서는 특정 배수만큼 빨라졌다고 단정하지 않고, 각 테스트 안에서 병목의 상대적인 위치가 달라졌다는 점을 중심으로 정리했습니다.
6. 마치며
-
측정 결과를 보면 처음에 의심했던 부분이 실제 병목과 다를 수 있습니다. 테스트 시나리오에서 눈여겨봤던 건수 API의 응답 시간은 27ms였고, 우선적으로 개선할 곳은 내 지원 목록 조회였습니다. 테스트 결과가 우선순위를 정하는 데 도움이 됐습니다. 한편 화면에서 요청이 몇 번 발생하는지 확인하는 것만으로도 N+1 문제를 찾는 데 도움이 됩니다.
-
여러 화면이 같은 API를 사용한다면 한 곳을 개선하는 것만으로도 여러 화면에 영향을 줄 수 있습니다. 이번에는 내 지원 목록 조회가 네 화면에서 공통으로 사용되고 있어, 이 API를 개선 대상으로 삼았습니다.
-
N+1 문제는 쿼리 수만의 문제가 아니라 응답 설계와도 관련이 있습니다. 화면에서 필요한 값이 응답에 없어 추가 API를 호출하고 있다면, 응답에 필요한 정보를 함께 제공하는 방법을 검토할 수 있습니다. 다만 구인 글 상세 API처럼 호출 자체에 열람 기록이 남는 경우에는 부수 효과를 고려해야 하고, 전체 건수 대신 근사값을 제공하는 경우에도 그 차이를 명확히 알려야 합니다.
-
필요한 컬럼만 조회하는 것도 중요합니다. 특히 큰 JSON 컬럼이 포함된 테이블에서는 화면에 필요하지 않은 데이터까지 읽지 않도록 요약 조회나 projection을 활용하는 것이 도움이 됩니다.
앞으로는 lab 환경에서 같은 조건으로 개선 전후를 다시 측정해 차이를 확인할 필요가 있습니다. 재측정에서 새롭게 가장 느린 API로 나타난 매칭 추천 구인 글 조회도 추가로 살펴볼 예정입니다.
zero