배경 및 목표
프로그래머스와 일부 플로우에선 동기 방식으로 연동하고 있어, 외부 플랫폼 장애 발생 시 자사 서비스에 영향을 주었습니다. 또한 신규 채용 플랫폼을 추가할 때마다 기존 코드를 수정해야 했습니다.
또한 잡플래닛과의 연동이 단방향(자사에서 잡플래닛)이어서, 잡플래닛에서 자체적으로 공고 상태를 변경해도 감지할 수 없었습니다. 예를 들어 잡플래닛에서 공고를 마감 처리했는데 자사 시스템에서는 여전히 게시 중으로 표시되는 등, 양쪽 공고 상태가 불일치하는 상황이 지속될 위험이 있었습니다.
목표
- 외부 플랫폼 장애가 자사 서비스로 전파되지 않도록 연동을 격리합니다.
- 신규 플랫폼을 기존 코드 수정 없이 토픽 구독만으로 확장합니다.
- 단방향 연동으로 감지 못하던 외부 데이터 변경을 주기적으로 반영합니다.
- 정합성 오류를 조기에 발견해 빠르게 대응합니다.
해결 방법과 해결 후보군
후보군 비교 (아웃바운드 연동)
| 방식 | 설명 | 한계 |
|---|---|---|
| 동기 호출 유지 | 외부 API 직접 호출 | 외부 장애 전파, 신규 플랫폼 추가 시 코드 수정 |
| 동기 + 재시도 래핑 | try-catch 재시도 | 장애 시 응답 지연 여전, 결합 유지 |
| 이벤트 드리븐 (채택) | Kafka 이벤트 발행 | 장애 격리, 토픽 구독만으로 플랫폼 확장 |
후보군 비교 (역방향 정합성)
| 방식 | 설명 | 한계 |
|---|---|---|
| 실시간 양방향 동기화 | 변경 즉시 반영 | 외부 협조 필수, 구현, 유지보수 비용 큼 |
| 조회 시마다 외부 확인 | 조회 때 외부 대조 | 매 조회 외부 호출로 지연, 부하 |
| 주기 배치 + 이벤트 동기화 (채택) | 배치가 회사별 이벤트 발행, 컨슈머가 대조, 업데이트 | 준실시간이나 외부 결합 없이 자사만으로 구현 가능 |
1. 이벤트 드리븐 아웃바운드 연동 + 장애 격리
Kafka 이벤트 드리븐 아키텍처 전환
동기 호출을 제거하고, 자사 서비스는 Kafka 토픽에 이벤트만 발행하는 구조로 변경했습니다. 외부 플랫폼과의 직접 의존이 사라지므로, 외부 장애가 자사 서비스에 전파되지 않습니다.
// 자사 서비스: 이벤트만 발행
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
fun onEntityCreated(event: EntityCreatedEvent) {
kafkaTemplate.send("event.domain.created", event.toMessage())
}플랫폼별 컨슈머 그룹 분리
각 외부 플랫폼에 독립적인 컨슈머 그룹을 부여했습니다. 잡플래닛 컨슈머에 문제가 생겨도 다른 플랫폼 컨슈머는 정상 동작합니다. 신규 플랫폼 추가 시에는 새 컨슈머 그룹이 토픽을 구독하기만 하면 되므로, 기존 코드 수정이 불필요합니다.
실패 기록 + 재시도 API
네트워크 순단 등으로 일부 플랫폼 처리가 실패할 수 있으므로, 실패를 감지하면 실패 건을 별도로 기록하고, 미리 만들어 둔 재시도 API로 해당 건만 재처리합니다.
sequenceDiagram participant K as Kafka participant C as 플랫폼 컨슈머 participant DB as 실패 기록 participant Op as 운영자 participant R as 재시도 API K->>C: 이벤트 전달 C->>C: ❌ 처리 실패 C->>DB: 실패 건 기록 Op->>R: 재시도 API 호출 R->>C: 해당 건 재처리
이 이벤트 드리븐 연동 패턴이 이후 ORP, 프로그래머스 등 모든 외부 연동의 표준이 되었습니다.
2. 역방향 정합성 배치
앞의 아웃바운드 연동은 자사에서 잡플래닛으로 향하는 단방향이므로, 잡플래닛이 자체적으로 변경한 상태는 역방향으로 감지해 정합성을 완결합니다.
주기적 정합성 배치 + 이벤트 기반 동기화 구축
배치 스케줄러가 주기적으로 SyncUp 배치를 실행하고, 배치는 회사별로 동기화 이벤트를 Kafka에 발행합니다. 컨슈머가 이벤트를 수신해 잡플래닛 API에서 전체 데이터를 조회하고, 자사 DB의 공고 상태와 비교하는 구조로 구축했습니다.
// 배치: 회사별 동기화 이벤트 발행
@Component
class SyncUpBatch(
private val companyRepository: CompanyRepository,
private val eventPublisher: SyncEventPublisher
) {
fun run() {
companyRepository.findAllLinked().forEach { company ->
eventPublisher.publish(CompanySyncRequested(company.id))
}
}
}
// 컨슈머: 외부 조회 → 대조 → 업데이트 → 로깅
@Component
class CompanySyncConsumer(
private val externalClient: ExternalPlatformClient,
private val entityRepository: EntityRepository
) {
fun consume(event: CompanySyncRequested) {
val externalEntities = externalClient.fetchAll(event.companyId)
val internalEntities = entityRepository.findAllLinked(event.companyId)
externalEntities.forEach { external ->
val internal = internalEntities.find { it.externalId == external.id }
if (internal != null && internal.status != external.status) {
internal.updateStatus(external.status)
log.info("상태 불일치 감지: ${internal.title} (자사: ${internal.status} → 외부: ${external.status})")
}
}
}
}컨슈머가 잡플래닛 측의 공고 상태(게시 중, 마감, 삭제 등)와 자사 시스템의 공고 상태를 대조하여, 불일치가 발견되면 자사 DB를 잡플래닛 상태에 맞게 업데이트하고 변경 이력을 로깅합니다.
정합성 동기화 흐름
sequenceDiagram participant Sch as 배치 스케줄러 participant Batch as SyncUp 배치 participant K as Kafka 이벤트 큐 participant C as 동기화 컨슈머 participant Ext as 잡플래닛 API participant DB as 자사 DB Sch->>Batch: 주기적 실행 loop 회사별 동기화 Batch->>K: 회사별 동기화 이벤트 발행 K->>C: 이벤트 수신 C->>Ext: 공고 데이터 조회 Ext-->>C: 공고 목록 + 상태 C->>DB: 자사 공고 상태 조회 C->>C: 양측 공고 상태 비교 alt 상태 불일치 발견 C->>DB: 자사 공고 상태 업데이트 C->>C: 변경 이력 로깅 end end
결과
| 지표 | 결과 |
|---|---|
| 장애 격리 | 100% |
| 신규 플랫폼 확장 | 토픽 구독만으로 |
| 부분 실패 | 허용 + 재시도 |
| 응답 시간 | 외부 API 대기에서 즉시로 |
| 테스트 | 컨슈머별 독립 단위 테스트 |
| 상태 정합성 | 양측 불일치 해소 |
| 사용자 영향 | 잘못된 공고 상태 노출 방지 |
모니터링
- 플랫폼별 컨슈머 그룹의 랙, 실패율과 재시도 API 호출 건수를 관측합니다.
- 외부 장애 발생 시 자사 서비스 영향도(격리 여부)를 관측합니다.
- 회사별 동기화 이벤트 처리 시 불일치 감지 건수와 업데이트 건수를 관측합니다.