데이터는 계속 들어오는데, 사용자가 보는 조회는 느려졌다. G2BMarket을 운영하면서 어려웠던 순간은 수집 자체가 멈췄을 때만이 아니었다. 데이터를 최신으로 만들기 위해 돌린 작업과 사용자의 조회가 같은 DB 자원을 쓰고 있었다.
처음에는 밀린 자료를 빨리 처리하는 데 신경을 썼다. 하지만 배치가 많은 건수를 처리하더라도, 그동안 서비스 요청이 시간 초과로 끝난다면 잘 돌아간다고 말하기 어려웠다.
이번 개선에서 잡은 기준은 하나였다. 서비스가 감당할 수 있는 속도로 수집하고, 그 선을 넘으면 멈추자. 배치 처리량과 동시성도 그 기준에서 다시 보게 됐다.
수집은 진행 중인데 상세 조회가 시간 초과로 끝났다
2026년 10월 1일 보강 수집 중 점검에서는 낙찰 상세 JSON 두 건이 각각 8초 제한 안에 응답하지 못했다. 반면 메인 HTML은 약 106ms에 정상 응답했다.
첫 화면이 열린다는 것과 그 안에서 필요한 데이터가 정상적으로 조회된다는 것은 달랐다. 메인이 빠르다는 이유만으로 서비스가 괜찮다고 판단하면, 실제 상세 조회의 문제를 놓칠 수 있었다.
당시 조사에서는 DB 읽기 I/O 대기도 관측했다. 수집 서버와 API 서버는 따로 있었지만 같은 PostgreSQL을 사용했다. 실행 서버를 나눈 것이 DB 자원까지 나눈 것은 아니었다.
수집만이 모든 지연의 유일한 원인이라고 단정할 근거는 없었다. 다만 사용자의 응답이 악화되는 동안에도 수집을 같은 속도로 계속하는 구조는 바꿀 필요가 있었다.
작업 시각을 나누어도, 오래 걸리면 겹쳤다
수집 작업은 서로 다른 시각에 시작했다. 그런데 앞선 작업이 오래 걸리면 다음 작업이 시작할 때까지 끝나지 않았다. 시작 시간을 나누는 것만으로 실행 중첩을 막을 수는 없었다.
기존의 작업별 파일 잠금은 같은 작업이 두 번 켜지는 것을 막았다. 낙찰 수집과 계약 수집처럼 서로 다른 작업이 동시에 DB를 쓰는 것까지 조정하지는 못했다.
그래서 공통 가드를 사용하는 낙찰·계약·고용 수집 세 작업에 하나의 실행 허가를 붙였다. DB의 세션 advisory lock으로 협력하는 무거운 수집 작업 중 하나만 들어가도록 했다. 업무 테이블이나 행을 잠그는 방식이 아니라, 애플리케이션끼리 실행 순서를 지키는 방식이다. PostgreSQL advisory lock 문서
범위는 이 가드를 사용하는 작업까지다. 다른 배치나 수동 프로그램의 모든 쓰기를 통제하는 전역 장치는 아니다. 먼저 실행한 작업이 오래 걸리면 다음 작업이 보류될 수도 있다. 동시 실행을 줄인 만큼, 밀린 수집이 모두 끝나는지는 별도로 확인해야 했다.
한 번에 저장하는 양도 고정하지 않았다
동시 실행을 막아도 한 작업의 SQL이 너무 오래 걸리면 부담은 남았다. 실제로 실행 허가를 적용한 뒤에도 계약과 고용 수집에서 60초 SQL 시간 초과가 발생했다.
고용 원천 수집에서는 외부 API가 준 한 페이지 1,000건을 저장하는 단계가 막혔다. 당시 입력은 기존 자료를 반복 갱신하는 것이 아니라 신규 저장 대상이었다. 동시성 제어만으로 개별 저장 비용까지 해결되는 것은 아니었다.
이 경로에서는 고정 500건 SQL 대신 100건에서 시작해 실행시간에 따라 25~500건 사이에서 묶음을 조절했다. 빠르게 끝나면 늘리고, 오래 걸리면 줄였다. 허용한 SQL 시간 초과만 제한된 횟수로 묶음을 축소해 재시도하고, 다른 오류를 무조건 반복하지는 않았다.
여기서 저장 묶음과 커밋 단위는 구분했다. SQL을 작게 나누더라도 원천 페이지와 재개 위치 기록은 함께 커밋해야 했다. 저장하지 못한 페이지를 완료로 표시하면 다음 실행이 그 자료를 건너뛸 수 있기 때문이다.
실패한 페이지는 롤백하고 이미 확정된 앞선 페이지는 남겼다. 낙찰 수집에는 저장이 오래 걸렸을 때 커밋과 DB 연결 종료 후 잠시 쉬는 감속도 추가했다. 열린 트랜잭션을 붙잡고 쉬는 방식은 피했다.
느려지면 더 밀어 넣지 않고 멈추게 했다
마지막으로 실제 서비스 응답을 수집의 시작·지속 조건으로 넣었다. 실행 전 대상 API를 검사해 지연이나 실패가 있으면 수집을 시작하지 않았다.
실행 중에도 정기적으로 검사하고, 연속 실패 기준을 충족하면 가드가 자신이 시작한 수집 작업만 중단하게 했다. 웹 서버나 다른 작업까지 함께 종료하는 방식은 아니었다.
중단할 때는 이미 커밋한 자료와 재개 기록을 보존했다. 실행하지 못했거나 중간에 멈춘 작업을 성공으로 바꾸지도 않았다. 보호 때문에 멈췄다는 사유를 남겨야 다음에 무엇을 확인하고 어디서 재개할지 알 수 있었다.
이 가드는 수집이 장애의 원인임을 증명하지 않는다. 서비스가 나빠진 상태에서 추가 부하를 계속 밀어 넣지 않도록 하는 보호 장치다. 수집 완료보다 서비스 응답을 먼저 지키겠다는 선택이었다.
확인한 효과는 빨라진 저장과 실제 보호 동작이었다
고용 원천 한 페이지 검증에서는 신규 1,000건 저장이 1.688초에 끝났다. 같은 응답을 다시 넣었을 때는 0.235초였고 변경된 행은 없었다. 값과 행 버전도 유지돼, 재실행이 불필요한 갱신으로 이어지지 않는 것을 확인했다.
이것을 앞선 60초 실패 대비 개선 배수로 계산하지는 않았다. 한 페이지 검증 당시의 부하와 캐시 조건이 달랐고, 신규 저장과 재입력도 서로 다른 작업이다. 전체 수집 완료시간이나 지속 처리량을 측정한 결과는 아니었다.
동시성 제어에서는 고용 수집이 계약 작업 뒤에 실행되는 것을 확인했다. 보호 가드도 대상 API가 시간 초과일 때 수집을 시작하지 않고 보류했다.
10월 12일 추가 확인: 실행 중에도 보호 조건에 따라 멈췄다
발행일 이후의 실행 기록을 추가로 남긴다. 10월 12일에는 고용 수집이 222페이지를 진행한 뒤, 낙찰 상세가 두 차례 연속 응답 제한을 넘자 가드가 자기 수집 작업을 중단했다. 앞서 커밋한 페이지는 남았지만 후속 집계에는 도달하지 않았다.
이 결과는 “수집과 집계가 모두 성공했다”가 아니다. 부분 저장은 보존하면서 서비스 보호 조건에 따라 실행을 멈추었다는 결과다. 수집 건수, DB 반영, 집계 완료, 보호 중단을 구분해서 봐야 했다.
처리량의 기준은 서비스를 지키며 확정한 양이었다
동시 실행을 줄이고 저장 묶음을 조절하면, 무조건 많은 작업자를 켰을 때보다 밀린 자료가 천천히 줄어들 수도 있다. 그래도 처리 도중 서비스와 수집이 함께 실패하는 것보다는, 확정한 데이터를 남기며 이어가는 쪽을 선택했다.
보호 장치가 동작했다고 조회 병목까지 해결된 것은 아니다. 수집 중단 후에도 실패한 계약 조회가 있었고, 10월 12일에도 상세 응답 지연이 보호 조건을 충족했다. 그 쿼리와 공유 I/O의 문제는 계속 좁혀봐야 한다.
이번에 바뀐 것은 “몇 건을 가져왔는가”만 보던 기준이었다. 사용자 응답을 지키며 얼마나 저장했고, 어디까지 완료했으며, 왜 멈췄는가를 함께 봐야 했다.
좋은 배치는 끝까지 무조건 달리는 작업이 아니었다. 서비스가 버틸 속도로 진행하고, 멈춰야 할 때 멈춘 뒤, 확정한 지점에서 다시 이어갈 수 있는 작업에 가까웠다.
이 글은 2026년 10월 1일의 제한 검증을 바탕으로 작성하고, 10월 12일 실행 기록을 추가했다. 현재 운영 상태, 전체 수집·집계 완료나 서비스 전반의 처리량 개선을 보증하는 결과는 아니다.