영차 BE에서 게이트웨이와 모듈 사이의 데이터를 동기화하는 방법
게이트웨이를 데이터 조립기로 만들지 않고, 데이터 소유 서비스와 이벤트 기반 읽기 복제본으로 모듈을 연결하는 구조를 정리했다. 중복 이벤트와 퇴사 데이터 보존, 발행 유실을 줄이는 방법도 함께 다룬다.
배경 — 게이트웨이가 데이터를 알아서는 안 된다
영차는 사업장이 필요한 기능만 골라 쓰는 모듈형 ERP를 목표로 한다. 1차 인사 모듈도 하나의 큰 서버가 아니라 다음 경계를 가진다.
- 계정·조직: 사용자·사업장·지점·소속
- 근태: 출퇴근·근무일정·정정·위치 확인
- 휴가: 휴가 종류·부여·신청·승인·잔여
- 알림: 푸시·인앱 알림·발송용 토큰
- 감사: 변경 이력과 접근 기록 보관
이때 화면 하나에 직원 이름, 지점, 근태, 휴가가 함께 보인다고 해서 게이트웨이가 여러 서비스를 호출해 응답을 조립하게 만들면 경계가 무너진다. 게이트웨이는 단일 진입점, CORS, 호출 한도, 추적 ID 전파와 라우팅만 맡는다. 어떤 데이터가 누구의 것인지 판단하거나 도메인 응답을 합치는 역할은 하지 않는다.
데이터를 직접 소유하는 서비스도 하나로 정했다. 예를 들어 소속 정보는 계정·조직 모듈이 정본이다. 근태와 휴가가 직원 이름이나 등급을 필요로 할 때 계정·조직 모듈을 매번 동기 호출하지 않는다. 필요한 읽기 값만 각자의 데이터베이스에 복제한다.
선택 — 동기 호출과 이벤트 복제 사이
처음에는 필요한 순간에 원본 서비스에 물어보는 방법이 가장 간단해 보인다.
근태 → 계정·조직 → 소속 조회
휴가 → 계정·조직 → 소속 조회
하지만 출퇴근처럼 자주 호출되는 경로에 다른 서비스의 가용성을 묶으면 한쪽 장애가 전체 기능으로 번진다. 트랜잭션 경계도 서비스 밖으로 늘어난다. 반대로 각 서비스가 상대방의 테이블을 직접 읽으면 데이터 소유권과 변경 규칙이 사라진다.
그래서 원본은 하나로 두고, 읽기에 필요한 값만 이벤트로 복제하는 방식을 선택했다.
계정·조직
│ 소속 변경 이벤트
├──────────────→ 근태 읽기 사본
├──────────────→ 휴가 읽기 사본
├──────────────→ 알림 읽기 사본
└──────────────→ 감사 읽기 사본
방향은 공통 계정·조직 모듈에서 업무 모듈로 흐르게 고정한다. 반대 방향 이벤트는 만들지 않는다. 게이트웨이와 모바일 BFF는 인증과 진입 경계를 담당할 뿐, 이 복제 흐름에 들어오지 않는다.
적용 — 원본과 읽기 복제본을 분리하기
소속 변경은 하나의 통합 이벤트로 보낸다
소속 생성, 등급 변경, 재직 상태 변경은 각각 별도 이벤트로 쪼개지 않고 하나의 소속 변경 이벤트로 통합한다. 복제본이 필요한 값을 한 이벤트에 담으면 소비자는 같은 행을 upsert할 수 있다.
{
"schemaVersion": 1,
"occurredAt": "2026-09-07T01:00:00Z",
"traceId": "trace-id",
"membership": {
"user": "user-id",
"company": "company-id",
"branch": "branch-id",
"grade": "OWNER",
"employmentStatus": "ACTIVE"
}
}
각 서비스의 읽기 사본은 원본의 대체 테이블이 아니다. 해당 서비스가 조회와 권한 범위 확인에 필요한 읽기 전용 사본이다. 활성 사업장도 로컬 사본으로 확인한다. 그러면 근태 조회가 계정·조직 모듈의 응답을 기다리지 않는다.
값의 소유자 단위로 이벤트를 만든다
복제 대상이 늘어날 때 직원 이벤트에 모든 값을 얹지는 않았다. 값이 어느 대상의 것인지에 따라 이벤트를 나눈다.
| 원본 값 | 변경 단위 | 복제 대상 | 목적 |
|---|---|---|---|
| 소속 정보 | 직원·소속 | 근태·휴가·알림·감사 | 조회와 권한 범위 확인 |
| 근무 형태 | 근무 형태 | 휴가 | 승인 화면의 표시 정보 보강 |
| 사용자 연락처 | 사용자 | 알림 | 안내 발송의 보조 수단 |
| 위치정보 동의 | 사용자 | 근태 | 좌표 저장 여부 판단 |
| 사업장 설정 | 사업장 | 근태 | 휴게 입력 단위 판단 |
사업장 설정을 바꿀 때 직원마다 소속 이벤트를 다시 보내면 한 번의 변경이 직원 수만큼 퍼진다. 값의 소유자가 사업장이므로 사업장 단위 이벤트 하나를 보내고, 근태 모듈이 사업장 기준으로 받는다. 근무 형태 이름도 소속 이벤트에 넣지 않고 근무 형태 자체의 이벤트로 보낸다. 변경 단위와 이벤트 단위를 맞추면 불필요한 재발행을 막을 수 있다.
복제본은 최신 값으로 덮어쓴다
Kafka 소비는 중복될 수 있다. 그렇다고 모든 이벤트에 별도의 처리 이력 테이블을 만들지는 않는다.
현재 상태를 보관하는 읽기 사본은 같은 키를 upsert한다. 같은 이벤트를 두 번 받아도 결과가 같아야 한다. 순서가 뒤집힐 수 있는 경우에는 발생 시각 또는 파티션 순번을 비교해 최신 값만 반영한다. 같은 대상을 같은 파티션 키로 보내면 한 대상의 변경 순서도 유지할 수 있다.
반대로 감사 기록처럼 사건을 누적하는 저장소는 다르다. 같은 사건을 두 번 받으면 기록이 두 줄 남는다. 이런 이벤트에는 고유 이벤트 ID를 넣고 소비자가 처리한 식별자를 기준으로 중복을 제거한다. 알림 발송 이벤트도 같은 이유로 고유 이벤트 ID가 필요하다.
게이트웨이는 조립하지 않는다
화면에 여러 영역의 값이 필요하더라도 게이트웨이에 조립 로직을 넣지 않는다.
- 단일 영역 조회는 해당 영역의 API가 반환한다.
- 다른 영역의 값이 필요하지만 실시간성이 중요하지 않으면 읽기 복제본을 사용한다.
- 서로 다른 원본의 최신 응답을 동시에 합쳐야 하는 화면은 앱에서 여러 API를 호출한다.
- 인증 토큰과 활성 사업장 정보는 게이트웨이를 거쳐 전달하지만, 업무 데이터의 의미는 각 영역이 해석한다.
이 규칙 덕분에 게이트웨이는 모듈이 늘어도 도메인 조립 서비스로 커지지 않는다.
실수한 것들
모든 이벤트에 고유 ID와 처리 이력을 강제할 뻔했다
처음에는 중복 수신이 걱정돼 모든 이벤트에 고유 ID와 처리 이력 테이블이 필요하다고 보기 쉬웠다. 하지만 현재 상태를 덮어쓰는 복제 이벤트는 같은 값을 다시 저장해도 결과가 바뀌지 않는다.
누적형 이벤트만 고유 ID를 통한 중복 제거 대상으로 분리했다. 중복을 막는 비용도 이벤트가 실제로 만들어 내는 부작용에 따라 정해야 한다.
퇴사 이벤트를 일반적인 소속 변경으로만 처리했다
퇴사도 소속 상태 중 하나이므로 처음에는 일반 덮어쓰기로 충분해 보였다. 그러나 퇴사는 뒤에 자동으로 새로운 변경이 오지 않을 수 있는 종착 상태다. 더 중요한 점은 각 서비스의 사본 처리 목적이 다르다는 것이다.
- 알림과 감사: 해당 회차의 읽기 사본을 삭제한다.
- 근태와 휴가: 법정 보존이 필요한 기록을 보관 영역으로 옮긴다.
- 근태·휴가 영역: 열린 근태를 마감하고 대기 중인 휴가를 취소한다.
따라서 소비자는 모든 상태 변경을 단순 덮어쓰지 않는다. 퇴사라는 상태에 대한 별도 분기가 필요하다.
유실돼도 다음 이벤트가 오면 된다고 생각했다
일반적인 프로필 속성은 다음 변경 때 다시 복제될 수 있다. 하지만 퇴사 이벤트가 유실되면 사본이 계속 재직 상태로 남고, 이후 이벤트가 오지 않을 수 있다. 사본 파기와 근태 마감도 실행되지 않는다.
그래서 소속 변경 이벤트는 전체 발행 경로를 Outbox로 승격했다. 원본 변경과 발행할 이벤트를 같은 로컬 트랜잭션에 기록하고, 릴레이가 Kafka로 보낸다. 소비자는 여전히 멱등하게 처리한다. Outbox는 중복을 없애는 장치가 아니라 발행 유실을 줄이는 장치다.
결과
| 항목 | 동기 호출 중심 | 이벤트 기반 읽기 복제 |
|---|---|---|
| 게이트웨이 역할 | 응답 조립 가능성이 커짐 | 라우팅·인증 경계에 집중 |
| 데이터 소유권 | 호출 관계에 섞이기 쉬움 | 원본 서비스가 정본, 소비자는 읽기 사본 |
| 출퇴근 조회 | 계정·조직 상태에 직접 의존 | 근태 데이터베이스에서 로컬 조회 |
| 서비스 간 방향 | 양방향 호출로 늘어날 수 있음 | 공통 모듈에서 업무 모듈로 단방향 이벤트 |
| 중복 수신 | 호출자마다 처리 | 상태 사본은 최신 값 반영, 누적 이벤트는 고유 ID 중복 제거 |
| 퇴사 처리 | 일반 상태 변경으로 오해 가능 | 삭제·보존·부수효과를 소비자별로 분리 |
남는 것
이 구조에서 “동기화가 끝났다”는 말은 모든 서비스의 데이터가 항상 같은 순간에 바뀐다는 뜻이 아니다. 원본 변경이 먼저 커밋되고, 소비자가 이벤트를 받아 읽기 사본을 갱신하는 동안에는 짧은 지연이 있다. 대신 각 서비스는 다른 서비스가 잠시 멈춰도 자기 데이터로 조회와 권한 범위를 판단할 수 있다.
앞으로 이벤트를 추가할 때 지킬 기준은 세 가지다.
- 그 값을 실제로 소유하는 서비스가 발행한다.
- 소비자는 필요한 읽기 값만 로컬에 복제한다.
- 중복·순서 뒤섞임·발행 유실을 이벤트의 성격에 맞게 처리한다.
게이트웨이는 모든 것을 아는 중앙 서버가 될 필요가 없다. 데이터의 정본과 복제 규칙을 분명히 하면, 게이트웨이는 얇게 유지하면서도 모듈은 서로 독립적으로 확장할 수 있다.