
우리는 매일 아침 출근을 한다. 근태관리에서 출퇴근은 결국 돈과 관련되기 때문에 고용주/고용인 양쪽 입장에서 모두 중요하며 민감하게 생각한다.
과거에는 종이에 구멍을 뚫어(punch) 출퇴근 관리를 하였다. 언어란 신기하게도 현대 사회에서는 더 이상 사용하게 되지 않은 사물이나 행위를 나타내는 용어를 관습적으로 사용하는 경우가 많다. 여기서 얘기하는 펀치(punch)도 유사하게 출퇴근을 나타내는 용어로 여전히 사용 중이다. 펀치 인은 출근, 펀치 아웃은 퇴근. 아마 일부 외국 e-HR 시스템에서는 여전히 이러한 용어들을 사용하지 않을까?
요즘 출퇴근 하는 방식은 과거와 사뭇 다르다. 보안업체에서 제공하는 지문, 출입 패스 카드를 찍었을 때의 기록으로 출퇴근 기록을 만들 수 있다. 보안업체는 단순히 누가, 언제, 어디를 통과했는지에 대한 정보만 기록하기 때문에 직원 개개인의 근태현황을 관리자 입장에서 확인하기란 쉽지 않다. 이러한 근태관리 서비스를 제공하는 측은 우리 서비스이다. 고객의 입장에선 보안업체의 출입기록을 근태관리 서비스에 연동해서 활용하고자 하는 니즈가 있고 몇몇의 e-HR 서비스는 이러한 연동기능을 제공한다.
최근 이 일을 맡고 있는 우리 팀원이 관련 기능의 PR 리뷰를 요청했고 거기서 내가 했던 "이벤트는 어디에 활용될 수 있을까?"에 대한 고민을 공유해보려고 한다. 이 제안이 실제로 받아들여지지 않아 현재의 아키텍처와는 다른데, 서드 파티(보안업체)의 레거시 시스템을 연동하고자 할 때 어떻게 할까에 대한 아이디어로 이런 게 있다 정도로만 생각하면 좋을 것 같다.
서드 파티와의 연동
위에서도 언급했지만, 출퇴근 기록은 서드 파티의 출/퇴근 기록을 우리 서비스로 넘겨받아서 활용하는 형태이다. 출퇴근/보안 설루션의 대표적인 서드 파티로 세콤과 캡스가 있다. 우리 측에서 API를 열거나, 상대측에서 API를 여는 통신방식이 있을 수 있지만 그러한 서비스를 제공하지 않을 수 있다. 예를 들어, 고객사가 직접 호스팅을 해서 고객사 내부 DB에 직접 적재하고 이를 우리가 열어준 스테이징 DB로 전달해 주는 방식만 제공이 가능할 수 있다.

이런 상황에서 우리가 할 수 있는 방식은 (1) 서드 파티로부터 저장된 데이터를 (2) 근태 서비스에 스케줄러를 붙여 주기적으로 스테이징 DB로부터 폴링 해오고, (3) 우리 근태 도메인에 맞게 변환/검증을 하여 근태 DB에 저장하는 것이다.
B2B 서비스를 하면 각 고객의 니즈를 직접적으로 충족시켜야 하는 순간들이 온다. 예를 들어, 세콤/캡스 카드를 찍는 순간 즉각적으로 근태 서비스에 업데이트되어 확인할 수 있기를 바랄 수 있다. 배치결과에 따라 결국 일관성이 맞춰지겠지만 실시간성이 더 좋은 서비스를 제공받고 싶다면 우리는 어떻게 해야 할까? 배치 주기를 더 짧게 조절할 수 있다. 1s가 기존의 주기였다면 500ms 낮춰 해결할 수 있다.
당시에는 몰랐는데, 이 방식이 부족해 보여도 고객회사와 사용자가 적은 경우에는 탁월한 선택이였다. 비즈니스의 성공여부가 불투명한데 아키텍처에 대한 고민으로 피쳐 개발이 늦어져야 할까? 처음에는 피쳐 개발이 중요하다. 하지만, 다음 수정 단계에서는 어느 정도 확장성을 열어둔 방식으로 설계를 진화시켜야 하지 않을까 싶다.
서비스가 성장하면 어떻게 될까?
시간이 지나 서비스가 성장해서 여러 고객 회사들이 서비스를 구독하게 되었고, 세콤/캡스 이외의 여러 서드 파티로 부터 출퇴근 기록을 연동받아야 한다면 어떻게 해야 할까? 또한 각각의 고객회사가 서로 다른 실시간성을 바란다면? 서드 파티마다 서로 다른 스키마로 기록을 하고 있다면? 서드 파티의 장애로 출퇴근 기록이 스테이징 DB로 넘어오지 않는다면? 그래서 출퇴근 기록을 확인할 수 없다면?
마지막 문제는 아마 서드 파티로 연락하지 않고, 우리 서비스로 고객이 직접 연락할 확률이 매우 높다.

실시간 요구사항이 다르다면 각각의 기준을 받아 근태 서비스의 스케줄링 로직 각각 적용해야한다. 근태 서비스가 스케일 아웃을 한다면 중복 동작하지 않도록 스케줄러 락을 걸거나 멱등성 처리를 반드시 해야 한다. 서로 다른 스키마를 폴링 해서 각각의 맵퍼를 만들어 변환해야 한다. 서드 파티로부터 발생된 장애도 확인하기 위해서 모니터링이나 로깅 처리를 근태 서비스에 붙여야 한다.
근태서비스는 서드 파티를 알아야할까?
결국 서드 파티로부터 전달된 데이터를 처리하고 우리 것으로 받아들이기 위해서 수많은 작업들이 필요하고 여러 기능들이 융합되어 근태 서비스에 존재하게 된다. 하지만, 근태 서비스의 원래 역할들이 존재한다:
- 근로 규칙 계산(연장/야간/휴일 판정, 소정근로 초과 등)
- 일/월 집계 결정 및 저장
- 도메인 상태 변경 정책 등등
데이터를 주기적으로 조회하고 스키마를 통일하는 역할이 근태서비스에서 이뤄질 필요가 있을까? 주기가 빨라짐에 따라 근태 서비스의 리소스에 부담이 가는 게 아닐까? 꼭 주기적으로 조회를 해야 할까? 출퇴근 기록은 9시와 18시에 피크 타임인데 굳이 그 외의 시간까지 동일한 주기를 가질 필요가 있을까? 데이터가 변경되면 알아서 푸시해줄 수 없는 걸까?
폴링 어댑터를 도입하기
차라리 폴링기능을 별도의 서비스로 분리하는 게 어떨까? 하는 생각을 갖게 되었다.

근태 서비스로부터 분리된 폴링 어댑터는 스케줄러를 통해 주기적으로 스테이징 DB로부터 출퇴근 기록들을 받아오게 된다. 근태서비스에 주기적으로 가해지던 스케줄 부하를 분리할 수 있다. 근태서비스는 이제 근로 규칙에 따른 유효성 검증과 우리 서비스 정책에 맞는 일/월 집계 처리와 같은 역할만 담당하면 된다.
반대로, 폴링 어댑터는 회사마다 필요한 실시간성을 보장하기 위해 주기를 자유롭게 커스텀할 수 있고, 각 벤더에 맞는 포맷을 변환해 주는 어댑터 역할을 담당할 수 있게 해 준다. 폴링과 포맷에 대한 어댑터 역할을 갖게 되었다. 각각의 역할로 분리되면서 얻을 수 있는 또 다른 이점으로는 각각 독립적으로 스케일아웃할 수 있다는 점이다. 어댑터의 부하가 크면 어댑터의 서버만 늘리면 되고, 근태 서비스의 부하가 커지면 근태 서버만 늘리면 된다.
근태 서비스와 폴링 어댑터는 어떻게 통신해야 할까?
다음으로 고민해야 할 점은 통신 방법이다. 직접 DB 공유하는 방식은 앞에서 제외를 했다. 그렇다면 근태 서비스가 주기적으로 API를 쏴서 폴링 어댑터와 동기적 통신을 하는 방법은 어떨까? 이 방법은 규모가 작은 경우에는 유효하다고 생각한다. 쉽고, 서비스 간의 계약이 명확하다. 다만 지금처럼 규모가 커지면 API는 곧 병목이 된다. 호출 피크 (일반적으로 09:00 - 18:00), 특정 서비스의 다운 타임이 전파되는 등의 복잡성이 증가할 수 있다.

다음 방법으로는 API 푸시를 유지하되, 메시지화 원칙을 이용하는 방법이 있다. API만으로도 "메시지처럼" 운용할 수 있다. (1) 폴링 어댑터가 스케줄 폴링을 해서 출퇴근 기록을 수집한다. (2) 수집된 값이 일정 크기를 넘어서면 혹은 주기적으로 API를 요청한다. (3) 요청받은 근태서비스는 요청 받은 내역을 비동기 큐에 넘겨주고 응답을 한다. (4) 다른 스레드가 작업을 실행한다.
이전 "트랜잭션에서 이벤트로 -Sync / Async / Redis 성능 비교와 TTV 분석"의 테스트 결과를 보면 JVM 내부에서 사용하는 큐도 한계점이 있어서 특정 RPS를 넘어서면 이벤트가 유실되는 현상을 볼 수 있었다. 이를 대비해서 큐 자체를 아래처럼 외부에 두는 방법이 있는데, 메시지 큐 혹은 카프카와 같은 시스템을 이용하는 방법이 있다. 메시지큐와 카프카의 차이점은 카프카는 메시지큐 운영이 가능하지만 메시지큐는 카프카와 같은 운영이 불가능하다는 점이다. 소비를 해도 사라지지 않고, 로그처럼 쌓으며, 읽는 위치도 변경이 가능하다. 읽어오는 역할, 중간에 보관하는 역할, 보내는 역할 세 가지의 축을 각각 운영해야 해서 복잡도가 높아지는 단점이 있다.

폴링 어댑터에서 "출퇴근" 메시지를 보내는 법
카프카에서는 파티션이라는 개념이 있는데 토픽을 물리적으로 나눈 단위이다. e-HR처럼 B2B 서비스를 하는 경우 회사단위로 계약을 하는데 서비스 내에서는 테넌트로 구분한다. 테넌트가 지불하는 금액에 따라 티어(베이식, 프리미엄)를 나누는 등으로 활용할 수 있다. 티어에 따라 테넌트가 온보딩 할 때 리소스 프로비저닝을 자동화해 둬서 사일로와 같은 형태로 별도의 리소스를 제공할 수도 있고 저렴한 티어를 사용하는 경우 공통 풀에 리소스를 다 같이 쓰는 방법이 있다.
공통 풀에 리소스를 같이 사용하는 경우 위 그림처럼 출퇴근 기록을 테넌트 별로 처리하게끔 하는 방법을 사용할 수 있는데, 카프카에서 파티션 키를 프로듀서 쪽에서 설정하면 리스닝을 하는 쪽에서 파티션 키에 따라 메시지가 구분되어 처리된다. 즉, 특정 회사만 순서대로 처리하도록 구현을 할 수 있고, 집계 처리를 하는 경우 "테넌트 ID : 집계일 : 구성원 ID" 등의 키를 사용하면 순서가 보장되도록 할 수 있다.
이전 "DELETE-INSERT 패턴에서 발생하는 InnoDB Deadlock 분석" 글에서 집계 처리관련된 데드락이 발생하는 내용을 다뤘었는데, 여기서도 순서를 보장함으로써 데드락을 피하는 방법을 사용할 수 있고, 실제로 실무에서도 키를 설정해서 데드락을 해결하였다.
근태서비스에서 제일 중요한 출퇴근은 절대로 데이터가 누락돼선 안된다. 즉, 폴링 어댑터가 데이터를 전달할 때 '적어도 한번(at least once)'를 만족하도록 보장해야 한다. RDB의 트랜잭션과 함께 사용되는 경우 메시지 발행은 근본적으로 동일한 트랜잭션 처리가 불가능하다. 트랜잭션이 완료되고 메시지를 보내는 과정에서 무시할 수 없는 갭이 생기기 때문에 정확히 한 번(Exactly once)을 보장하기 어렵기 때문에 '적어도 한 번'을 보장하기 위한 추가적인 방법이 필요하다. 유실은 치명적이기 때문에 중복을 허용하는 방향이 현실적인 전략이다.
이때 나오는 개념이 "아웃 박스 패턴"인데, 아웃 박스는 보내는 함을 나타낸다. 원리는 매우 간단하다. 보낼 메시지들을 보내는 함(DB 테이블)에 저장하는 것이다. 그리고 다른 서비스가 주기적으로 읽고 보내지 않은 메시지를 보낸다. 메시지 발송이 성공하면 보냈다고 표시한다. 이 과정에서 적어도 한 번 보내도록 보장을 하지만, 중복 발송이라는 또 다른 경우의 수가 발생한다.
근태서비스에서 "출퇴근" 메시지를 받는 법
중복 발송은 불가피하기 때문에 멱등적으로 처리하거나 멱등성 키를 이용해서 중복 요청을 무시하는 방법을 택한다. 여기서도 인박스(받는 함) 패턴이라는 개념이 나온다. 해당 요청을 처리하기 전에 미리 받아 저장해둔다. 출퇴근 기록이 변환되어 자체적으로 멱등성 키를 만들어 근태 서비스에서 멱등적으로 처리되게 하거나, 아니면 인박스를 이용해서 요청 자체를 받아들일지 말지를 결정할 수 있다.
오프셋과 커밋
보낸 메시지들은 순서대로 쌓이기 때문에 어디까지 읽었는지를 저장하고 표시하는 방법이 필요하다. 카프카에서는 오프셋과 커밋의 개념을 묶어서 학습하면 좋은데, 오프셋은 각 파티션에서 "메시지가 몇 번째까지 읽혔는가"를 나타내는 번호이다. 커밋은 "나는 offset=123까지 정상적으로 처리했음"을 중개인에 저장하는 행위이다. 이 값은 카프카 내부 토픽에 기록되고, 이후 컨슈머가 재시작하거나 리밸런스 되면 이 값부터 이어서 읽게 된다.

커밋을 하는 방법은 수동커밋, 자동커밋 두 가지가 있는데 자동 커밋은 중개인으로부터 메시지를 포루하는 순간 자동으로 커밋된다. 반면에 수동커밋은 결국 애플리케이션 코드에서 메시지를 처리한 뒤, 오프셋을 직접 중개인에 저장하는 방식을 취한다. 주의할 점은 프로듀서의 ack (ack=all)과 다른 개념이다. 프로듀서의 ack는 브로커에 안전하게 적혔다를 보장하는거지 컨슈머의 처리와 커밋과는 무관하다. 컨슈머의 ack는 어디까지 읽었는지를 브로커에 저장하는 의미라서 서로를 혼동해서 사용하면 안 된다.
출퇴근에서의 컨슈머의 커밋은 설정은 중요해 보인다. 데이터가 유실되지 않아야 하기 때문에 확실히 처리되면 수동커밋을 해서 여기까지 읽었어를 표시해 주는 방식을 채택해야 한다. 그런 경우 멱등 처리에 대한 애플리케이션의 대안이 필요하다.
마무리
시스템이 커지면서 결국에는 도달하는 결론은 '이벤트' 방식의 통신이었다. 하지만, 서비스의 성숙도나 비즈니스의 단계에 따라 적절한 구현 방법이 있고 이를 시기 적절하게 채택해야한다는 사실은 변함이 없다. 그 과정에서 다음 단계로 더 유연하게 넘어가기 위한 설계를 어떻게 할 수 있을지 고민하고 적용할 수 있는 능력을 갖추는게 필요하지 않을까 생각된다.
단순히 기술적인것에 넘어 비즈니스/도메인에 맞게 선택해야한다.