안녕하세요, 고위드의 카드 스쿼드의 Backend Engineer 임서영입니다.
고위드에서는 법인의 금융 데이터를 수집하기 위해 자회사의 스크래핑 서비스를 활용하고 있어요. 은행 계좌 수집, 저축/증권 계좌 수집, 2개년 데이터 수집 등 다양한 유형의 스크래핑이 여러 서버에 걸쳐 비동기로 동작합니다.
기존에는 스크래핑의 진행사항을 명확하게 추적하기 어려웠어요. 히스토리가 정상적으로 기록되지 않는 케이스가 있었거든요. 또한 동일 법인에 대해 스크래핑이 처리되는 중에 새로운 요청이 들어오면, "진행중인 작업이 있습니다."라는 에러와 함께 두 번째 요청이 누락되는 문제가 매일 1~2건씩 발생했고, 이를 직접 수기로 대응해야 했습니다.
이런 문제들이 반복되다 보니, 근본적인 해결을 위해 구조 자체를 개선해야겠다는 생각이 들었어요. 동시 요청을 어떻게 제어할지, 히스토리는 어떻게 남길지 차근차근 구조를 다시 잡아봤고, 이 글에서 그 과정을 공유해 보려고 합니다.
기존 스크래핑, 무엇이 문제였나요?
스크래핑 히스토리 종료 시점 누락
기존에도 스크래핑 요청 히스토리를 쌓고 있었지만, 히스토리 저장과 스크래핑 API 호출이 같은 트랜잭션 안에 있었어요. 히스토리를 저장하는 로직은 스크래핑 API 호출보다 먼저 실행되지만, 트랜잭션이 커밋되기 전까지는 DB에 반영되지 않았어요.
스크래핑이 빠르게 완료되어 콜백이 먼저 도착하면 어떻게 될까요? 히스토리를 저장한 트랜잭션이 아직 커밋되지 않았기 때문에, 콜백 핸들러가 히스토리를 조회하지 못해 종료 시점을 기록할 수 없었고, 따라서 요청이 언제 끝났는지 추적할 수 없는 상황이었습니다.
동시 요청으로 인한 스크래핑 누락
자회사의 스크래핑 서비스는 동일 법인에 대한 한 번에 하나의 요청만 처리할 수 있어요. 외부 금융 기관과의 연동 특성상, 동일 대상에 대한 요청은 순차적으로 처리되어야 하거든요.
먼저 들어온 요청이 처리 중이면 이후 요청은 거부되는데, 이를 재처리하는 로직이 없어서 그대로 유실되었습니다. 전체 흐름을 보면 왜 그런지 이해할 수 있는데요.

Scraping Server가 외부 스크래핑 API를 호출하면 바로 OK 응답을 반환합니다. 실제 스크래핑은 외부에서 비동기로 진행되고, 완료 시 콜백을 보내는 구조예요.
문제는 해당 법인에 대해 이미 진행 중인 작업이 있는 경우에도 OK 응답을 반환한 뒤 콜백에서 "진행중인 작업이 있습니다."라는 실패 응답을 보내온다는 점이에요.
즉, 첫 번째 요청이 외부에서 처리되는 동안 두 번째 요청을 보내도 OK 응답을 받지만, 콜백으로 실패가 돌아왔습니다. 이 실패한 요청을 재처리 하는 로직이 없어 그대로 유실되었어요.
이 제약 때문에 두 가지 문제가 발생했어요.
- 금융기관 연동 누락
- 동시 요청 제약으로 인해 일부 스크래핑 유형을 순차적으로 요청할 수 없어, 특정 조건의 법인에서 금융기관 데이터가 완전하게 수집되지 않는 케이스가 존재했어요.
- 한도 산출 지연
- 동시 요청이 발생해 스크래핑 에러가 나면 한도 산출 자체가 중단되었고, Slack 알림을 확인한 뒤 직접 수기로 재시도해야 했습니다.
이렇게 바꿔보았어요!
개선 작업에 앞서, 위 문제들을 두 가지 핵심 목표로 정리했어요.
- 히스토리 종료일이 정상적으로 저장되도록 할 것
- 누락되는 스크래핑 없이 모두 실행할 것
콜백이 먼저 오면 어쩌죠? → 히스토리 트랜잭션 분리 !
기존에는 스크래핑 요청과 히스토리 저장이 같은 트랜잭션에 묶여 있었어요. 외부 호출 특성상 요청을 보내고 히스토리가 커밋되기 전에 콜백이 먼저 도착하면, 종료일을 업데이트할 히스토리 데이터가 아직 DB에 없어 종료일이 누락되었습니다.
우선 외부 API 호출을 트랜잭션 밖으로 분리했습니다. 트랜잭션 안에서 외부 호출이 일어나면 응답 지연이나 타임아웃으로 DB 커넥션을 불필요하게 점유하게 되고, 호출 이후 예외가 발생하면 DB 작업은 롤백되지만 외부 요청은 이미 나간 상태라 양쪽 상태가 불일치할 수 있어요. 이번 케이스처럼 커밋 전에 콜백이 도착하는 타이밍 이슈가 생길 수도 있고요.
다만 히스토리 저장은 상위 메서드에 이미 트랜잭션이 걸려 있어서, 단순히 메서드를 분리하는 것만으로는 별도 커밋이 되지 않았어요. 그래서 히스토리 저장 메서드에
REQUIRES_NEW 를 적용해 기존 트랜잭션과 무관하게 즉시 커밋되도록 했어요.@Service
@RequiredArgsConstructor
public class ScrapingRequestHistoryService {
private final ScrapingRequestHistoryRepository scrapingRequestHistoryRepository;
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void createHistory(Long corpId, ScrapingType scrapeType) {
ScrapingRequestHistory history = ScrapingRequestHistory.create(corpId, scrapeType)
scrapingRequestHistoryRepository.save(history);
}
}스크래핑을 요청하는 쪽에서는 히스토리 저장 메서드를 먼저 호출해 히스토리를 커밋한 뒤, Kafka로 스크래핑 이벤트를 발행합니다.
public void publishScrape(Long corpId, String registrationNumber, ScrapingType scrapingType) {
// 스크래핑 히스토리 테이블 저장 (별도 트랜잭션으로 즉시 커밋)
scrapingRequestHistoryService.createHistory(corpId, scrapingType);
ScrapeEvent event = new ScrapeEvent(
corpId,
registrationNumber,
scrapingType
);
kafkaTemplate.send(KafkaConstant.SCRAPE_REQUEST, registrationNumber, event);
}이렇게 하면 콜백이 아무리 빨리 도착하더라도 히스토리 데이터가 이미 DB에 존재하기 때문에, 종료일이 정상적으로 기록됩니다.
물론 Kafka 발행이 실패하면 히스토리만 남고 스크래핑은 실행되지 않을 수 있어요. 다만 발생 가능성이 극히 낮고, 일정 시간 내에 종료되지 않은 히스토리를 감지하는 모니터링을 별도로 두어 대응하고 있어요.
동일 법인 순차 처리
대부분의 스크래핑 실패 케이스는 진행 중인 작업이 있을 때 동일 사업자번호로 요청이 들어가면서 발생했어요. 즉, 같은 법인에 대한 요청은 순차적으로 처리되어야 했습니다.
기존에는 동기 API 호출로 스크래핑을 요청하고 있었는데, 이 구조에서는 요청 순서를 제어하기 어려웠어요. 그래서 요청 자체를 메시지 큐 기반으로 전환하면서 Kafka를 도입했습니다. Kafka는 키를 기준으로 같은 파티션에 라우팅해 주기 때문에, 사업자번호를 키로 사용하면 동일 법인의 요청이 하나의 파티션에서 순서대로 소비되는 구조를 만들 수 있어요.
하지만 Kafka 파티셔닝만으로 충분할까요? Consumer 입장에서는 스크래핑 서버가 OK를 반환하면 처리가 끝난 것으로 판단해 바로 다음 메시지를 소비하게 됩니다.
Consumer Thread:
Message 1 (계좌 수집, 사업자번호 A) → 스크래핑 서버 → 메시지 처리 ✅
Message 2 (증권 수집, 사업자번호 A) → 스크래핑 서버 → 외부 스크래핑 API에서는 아직 1번 처리 중 ❌외부 스크래핑 API 입장에서는 아직 첫 번째 스크래핑이 진행 중인데 두 번째 요청이 들어오니, 바로 거부 응답을 내려주게 됩니다. 따라서 스크래핑이 끝날 때까지 같은 사업자번호의 다음 요청을 보내지 않는다는 것을 보장해야 했습니다.
그래서 Redis Lock을 도입했어요. 스크래핑 요청 시 락을 획득하고, 콜백이 올 때까지 유지하면서, 그 사이에 들어오는 동일 사업자번호의 요청은 대기열에 쌓아두는 방식이에요.
전체 흐름을 정리하면 아래와 같아요.
- 스크래핑 요청이 들어오면 해당 사업자번호로 Redis Lock(SETNX) 획득을 시도합니다.
- 락을 획득하면 스크래핑 API를 호출하고, 획득하지 못하면 Redis 대기열에 저장해 둡니다.
- 외부 스크래핑 API가 스크래핑을 완료하고 콜백을 보내면, 콜백을 받은 서버에서 Kafka 완료 토픽으로 스크래핑 완료 메시지를 발행합니다.
- Scraping Server가 완료 신호를 소비하면 락을 해제하고, 대기열에 다음 요청이 있으면 꺼내서 Kafka로 재발행합니다.
즉, Redis Lock + 대기 Queue + Kafka 완료 신호의 조합으로 같은 사업자번호에 대해 완전한 순차 처리를 보장할 수 있게 됐어요.

그런데 외부 스크래핑이 오류로 콜백이 끝내 도착하지 않으면 어떻게 될까요? 락이 영원히 해제되지 않아 해당 법인의 모든 후속 요청이 막히게 됩니다. 이를 방지하기 위해 락의 TTL을 35분으로 설정했어요.
보통 스크래핑은 10분 내외로 완료되지만, 드물게 2개년 스크래핑이 30분 가까이 걸리는 케이스가 있어서 이를 충분히 커버하면서도 비정상 상황에서는 자동으로 락이 풀리도록 여유를 둔 값이에요.
이번 작업으로 개선된 점!
항목 | Before | After |
요청 방식 | 동기 Feign 호출 | Kafka 비동기 발행 |
동시성 제어 | 없음 (동시 요청 그대로 전달) | Redis Lock + 대기 Queue + Kafka 완료 신호 |
히스토리 관리 | 요청과 같은 트랜잭션이라 종료 시점 누락 가능 | REQUIRES_NEW로 별도 트랜잭션 즉시 커밋 |
완료 신호 | 없음 (콜백만 처리) | 완료 메시지 Kafka로 발행 → Scraping Server에서 소비 |
확장성 | 새 스크래핑 유형 추가 시 동일 이슈 재발 | Lock이 사업자번호 단위라 유형 추가해도 안전 |
운영 안정성 향상
- 가장 큰 변화는 수기 대응이 사라진 점이에요. 기존에는 동시 요청으로 스크래핑이 누락되면 Slack 알림을 확인하고 직접 재시도해야 했는데, 순차 처리가 보장되면서 동시 요청으로 인한 스크래핑 실패는 0건이 되었습니다.
- 히스토리 트랜잭션 분리로 스크래핑의 시작과 끝이 빠짐없이 기록되어, 특정 법인의 스크래핑이 언제 요청되고 언제 완료되었는지 추적할 수 있게 되었어요.
고객 영향 개선
- 순차 처리가 보장되면서 저축/증권 계좌 스크래핑까지 안전하게 수행할 수 있게 되었어요. 기존에는 동시 요청 제약으로 스크래핑 범위가 제한적이었는데, 이번 개선으로 더 넓은 범위의 금융 데이터를 수집할 수 있게 되어 고객에게 제공하는 데이터의 완전성이 높아졌습니다.
- 한도 산출 과정에서 스크래핑 오류로 산출이 중단되어 지연되던 케이스도 해소되었습니다.
확장성과 유지보수성 향상
- 여러 서비스에 흩어져 있던 스크래핑 로직을 통합하면서 관리 포인트가 줄었어요. 수정 시 영향 범위를 파악하기 쉬워졌어요.
- 새로운 스크래핑 유형이 추가되더라도 동시 요청 이슈 없이 안전하게 확장할 수 있는 구조가 되었습니다.
앞으로 더 개선을 해본다면…!
이번 개선에서 아직 풀지 못한 숙제가 하나 남아 있어요. 바로 콜백 응답을 특정 요청에 정확히 매칭할 수 없다는 점이에요.
현재 구조에서는 스크래핑 요청마다 히스토리를 DB에 쌓고 있는데, 외부 스크래핑 API의 콜백에는 "이 콜백이 어떤 요청에 대한 응답인지" 식별할 수 있는 값이 없어요. 같은 사업자번호로 같은 유형의 스크래핑을 여러 번 요청하면, 돌아온 콜백이 어떤 히스토리에 대한 응답인지 100% 확신할 수 없는 구조인 거죠.
그래서 지금은 순차 처리를 통해 이 문제를 풀어내고 있어요. 동일 사업자번호에 대해 항상 하나의 요청만 진행되도록 보장하면, 콜백이 돌아왔을 때 "지금 진행 중인 그 요청의 응답이겠구나"라고 순서로 매칭할 수 있으니까요.
순차 처리가 단순히 동시 요청 실패를 막는 것만이 아니라, 콜백과 히스토리를 안전하게 연결하기 위한 전제 조건이기도 한 셈이에요.
하지만 이 방식에는 한계가 있습니다. 순서에 의존하는 매칭이다 보니, 만에 하나 요청 순서가 밀리는 엣지 케이스가 생기면 매칭의 정확성도 함께 흔들릴 수 있거든요.
이 문제를 근본적으로 해결하려면 멱등 키(Idempotency Key)를 도입해야 합니다. 요청마다 고유한 키를 부여하고, 외부 스크래핑 API 호출 시 이 키를 함께 전달한 뒤, 콜백에도 같은 키가 포함되어 돌아오는 구조를 만드는 거예요.
멱등 키가 도입되면 다음과 같은 개선이 가능해져요.
- 콜백 매칭의 정확성
- 콜백이 돌아왔을 때 멱등 키로 히스토리를 직접 조회할 수 있어, 순서에 의존하지 않고도 어떤 요청에 대한 응답인지 정확하게 추적할 수 있어요.
- 중복 요청 방지
- 동일한 멱등 키로 재요청하더라도 중복 처리 없이 같은 결과를 반환받을 수 있어요.
- 장애 추적 용이
요청 → Kafka 발행 → 외부 스크래핑 API 호출 → 콜백전체 흐름을 하나의 키로 추적할 수 있어, 장애 발생 시 어느 단계에서 멈췄는지 빠르게 특정할 수 있어요.
현재는 외부 스크래핑 API 쪽의 의존성이 엮여 있어 당장 적용하기 어려운 상황이지만 추후 이 부분까지 개선되면, 지금 구조에서 발생할 수 있는 만에 하나의 엣지 케이스까지 해소할 수 있을 거라 기대하고 있습니다.
마무리
이번 작업은 단순한 내부 운영 효율화가 아니었어요. 스크래핑 오류로 한도 산출이 멈추거나 금융기관 연동이 실패하면, 그 영향은 결국 고객에게 돌아가거든요. 고객이 마주하는 오류를 줄이고, 보유한 모든 계좌의 금융 데이터를 정상적으로 수집할 수 있도록 만드는 것이 이번 개선의 핵심 목표였습니다.
운영 부담이 줄었고, 더 다양한 금융 데이터를 제공할 수 있게 되었어요. 분산 시스템에서 순서 보장이라는 문제를 풀어내는 과정에서 함께 고민하고 리뷰해 준 동료들 덕분에 더 단단한 구조로 완성할 수 있었습니다. (호준님 감사해요 ^_^)
앞으로도 운영에서 느끼는 불편함을 그냥 지나치지 않고, 고객이 더 안정적으로 서비스를 이용할 수 있도록 개선해 나가겠습니다. 읽어주셔서 감사합니다!
에디터 🔗링크드인 이 궁금하시다면?
Share article