
크롤러 같은 멀티 프로세스 / 멀티 인스턴스 환경에서는 “지금 누가 작업 중인지”, “데이터가 준비되었는지” 같은 상태를 안전하게 관리해야 한다. 이를 제대로 안 하면, 여러 봇이 동시에 같은 타깃을 갱신하거나, 아직 준비되지 않은 데이터를 읽어가면서 꼬일 수 있다.
이번 글에서는 인턴 시절 첫 프로젝트였던 '무중단 분산 크롤링 봇'에서 MongoDB Document 필드만으로 동시성과 상태를 관리하도록 설계했던 방법을 소개하려고 한다.
상태 플래그
Document에는 두 가지 핵심 플래그가 있다.
- isReady: 크롤러가 대상(seeds)을 사용해도 되는지 여부
- isEditing: 현재 다른 프로세스가 갱신 작업을 하고 있는지 여부
이 두 가지 조합으로 현재 상태를 판별한다.
상태 의미
| isReady=true, isEditing=false | 사용 가능 |
| isReady=false, isEditing=true | 갱신 중 |
| isReady=false, isEditing=false | 초기 상태 / 갱신 필요 |
| isReady=true, isEditing=true | 잘못된 상태 (에러 복구 필요) |
Lock 개념
여러 인스턴스가 동시에 같은 Document를 수정하지 않도록, 원자적 update를 이용해 isEditing을 true로 만든다.
성공한 단 하나의 프로세스만이 실제 업데이트를 진행할 수 있다.
const result = await collection.updateOne(
{ _id: botId, isEditing: false },
{ $set: { isEditing: true, lastUpdateStartedAt: new Date() } }
);
if (result.modifiedCount === 1) {
// 내가 락을 얻음 → 업데이트 담당
} else {
// 다른 프로세스가 이미 편집 중
}
상태 전환 규칙
이 봇은 무중단으로 24시간, 365일 동작해야 하기 때문에 자동 복구 규칙이 꼭 필요하다.
- isEditing=true인데 10분 넘게 진행 중 → 비정상으로 판단하고 강제 초기화
- isReady=true인데 seeds가 비어 있음 → 하루 이상 지났으면 강제 업데이트
- isReady=true, isEditing=false인데 seeds가 존재 → 그냥 사용 가능
이렇게 시간 기반 조건을 넣어주면, 크롤러가 비정상 종료되거나 Document가 꼬였을 때도 스스로 복구할 수 있다.
waitSeedReady() 패턴
모든 크롤러 시작점에는 waitSeedReady()라는 공통 루틴을 둔다.
이 함수는 다음 중 하나를 반환한다.
- true → seed 사용 가능 → 크롤링 시작
- false → seed 갱신 필요 → Lock 획득 후 업데이트 진행
즉, 모든 탐색 루프 전에 항상 상태 확인 → 필요한 경우 갱신 과정을 거치도록 강제한다.
간소화된 코드 예시
async function waitSeedReady(botId, db, checkInterval = DEFAULT_CHECK_INTERVAL) {
const startTime = new Date();
while (true) {
try {
// ..... //
// ..... //
// === 분기 시작 ===
// (A) 정상 사용 가능: ready && !editing
if (ready && !editing) {
// A-1) seeds가 비어 있는 경우 → forceUnlock → lock 시도 → 성공 시 갱신
if (seeds.length === 0) {
await forceUnlockIfStuck(botId, db);
const editLockAcquired = await lockEdit(botId, db);
if (editLockAcquired) return false; // 갱신 필요
await delay(checkInterval);
continue;
}
// A-2) 날짜 변경(오늘과 last가 다름) → lock → notReady → 갱신
if (last && today !== last.toISOString().split('T')[0]) {
const editLockAcquired = await lockEdit(botId, db);
if (editLockAcquired) {
await markAsNotReady(botId, db);
return false; // 갱신 필요
}
await delay(checkInterval);
continue;
}
// A-3) 정상적으로 사용 가능
return true;
}
// (B) 갱신 대기: !ready && editing
if (!ready && editing) {
// B-1) waitSeedReady 시작 시점 기준 10분 초과 → 강제 해제 후 lock → 갱신
if (elapsedMinutes > TIMEOUT_MINUTES) {
await forceUnlockIfStuck(botId, db);
const editLockAcquired = await lockEdit(botId, db);
if (editLockAcquired) return false;
await delay(checkInterval);
continue;
}
// B-2) lastSeedUpdateStartedAt 기준 12분(10+2) 초과 → 강제 해제 후 lock → 갱신
if (lastUpdateElapsedMinutes > TIMEOUT_MINUTES + 2) {
await forceUnlockIfStuck(botId, db);
const editLockAcquired = await lockEdit(botId, db);
if (editLockAcquired) return false;
await delay(checkInterval);
continue;
}
// B-3) 정상 대기
await delay(checkInterval);
continue;
}
// (C) 초기/유휴: !ready && !editing → lock 시도 → 갱신
if (!ready && !editing) {
const editLockAcquired = await lockEdit(botId, db);
if (editLockAcquired) return false; // 갱신 필요
await delay(checkInterval);
continue;
}
// (D) 모순 상태: ready && editing → 강제 해제 → lock → 갱신
if (ready && editing) {
await forceUnlockIfStuck(botId, db);
const editLockAcquired = await lockEdit(botId, db);
if (editLockAcquired) return false; // 갱신 필요
await delay(checkInterval);
continue;
}
// (E) 그 외(플래그 누락 등 비정상) → 문서 삭제 후 루프 지속
await db.collection.deleteOne({ _id: botId });
await delay(checkInterval);
} catch (error) {
// 실제 클래스는 logger.error 후 throw → 여기서는 그대로 에러 전파
throw error;
}
}
}
동시성 보장 포인트
- 원자적 updateOne으로 락 획득 → 단일 프로세스만 업데이트 가능
- isReady + isEditing 조합으로 읽기 안전성 보장 → 준비된 데이터만 안전하게 사용
- 타임아웃 기반 초기화 → 비정상 종료 복구
- 간단한 패턴 반복 → 무한 루프 속에서도 안정적으로 self-healing
개선 포인트
이 프로젝트는 첫 인턴 시절, 온보딩을 막 끝내고 설계부터 운영까지 전부 맡아 진행한 첫 프로젝트라서, 돌이켜보면 개선해야 할 부분이 정말 많이 보인다.
- 상태 표현 확장
- 현재는 단순히 true/false만 반환하지만, READY, WAITING, UPDATING, ERROR 같은 enum 기반 상태 머신으로 확장하면 관리가 훨씬 명확해진다.
- 스케일링 고려
- 단일 Document 기반 락은 인스턴스 수가 늘어날수록 경합이 생기기 때문에, Redis 같은 외부 분산 락 시스템을 함께 고려하면 확장성에 유리하다.
- 예외 처리 강화
- 단순 타임아웃 초기화 외에 retry, exponential backoff, 모니터링 알람을 붙이면 운영 안정성이 높아진다.
- MongoDB 기능 활용
- findOneAndUpdate, Change Stream 등을 활용하면 더 직관적이고 정교한 락 및 이벤트 기반 처리가 가능하다.
- 무한 루프 위험
- while(true) 루프 구조 자체는 self-healing을 위한 의도된 설계지만, 설계가 꼼꼼하지 못하면 잘못된 무한 루프에 빠져버릴 위험은 항상 존재한다.
다른 프로젝트들을 수행하면서도 느낀 거지만, 무한 루프의 탈출 조건은 정말 면밀히 검토하여 구성해야 한다.
- while(true) 루프 구조 자체는 self-healing을 위한 의도된 설계지만, 설계가 꼼꼼하지 못하면 잘못된 무한 루프에 빠져버릴 위험은 항상 존재한다.
마치며
이 프로젝트를 하면서 느낀 건, 분산 시스템이라고 꼭 거창할 필요는 없다는 것이다.
보통 분산이라고 하면 Hadoop, Kafka 같은 대규모 시스템을 떠올리지만,
적당한 규모에서 단순한 작업을 반복하는 경우라면 MongoDB Document만으로도 충분히 분산 구조를 만들 수 있다.
이렇게 단순한 구조로도 구현이 가능했던 건, 분산 구조이지만 인스턴스가 많아야 10개 내외이고, seed는 어차피 휘발성 임시 데이터였기 때문에, 예외 상황이 발생해도 그냥 지우고 다시 불러와도 문제가 없다는 프로젝트 특성의 영향도 컸던 것 같다.
'Learnings' 카테고리의 다른 글
| GraphQL Federation 직접 부딪히며 배우기 (1) | 2025.09.23 |
|---|---|
| 오픈서치(엘라스틱서치) 인덱스와 매핑, 그리고 리인덱싱 (0) | 2025.09.05 |