본문 바로가기

일기

팀을 옮긴 후 2년 반을 돌아보기 (2) - 이동 후 처음으로 한 일

이전 글 - 팀을 옮긴 후 2년 반을 돌아보기 (1) - 이동 과정

 2021년 5월부터 2022년 말까지 내용이다. 이전 팀에서 3년 2개월을 일했고, 어느덧 현재 팀에서 2년 반을 지나고 있는데 그 중 절반에 해당되는 내용이다.

현 팀의 목표

 두 가지를 꼽으라고 한다면 개인적으로 이렇게 생각한다.

  • 중요도가 높은 특정 사내 서비스들의 구조를 개선한다.
  • 그 과정에서 기술을 선도하고 경험들을 사내에 공유한다.

 이전 팀의 정도는 아니지만 이 서비스도 오래된 기술을 사용하고 있었고, 특히 서비스가 급격하게 성장하면서 그에 따른 데이터베이스의 확장에 대한 고려가 제 때 이루어지지 못했기 때문에 해당 문제를 해결하고자 프로젝트가 띄워졌다. 그 과정에서 여러 구성원 분들이 새로 채용되기도, 나와 같이 사내에서 이동하기도 한 것이다.

 데이터베이스에 대한 부연 설명을 하자면, 연관된 여러 서비스가 단일 데이터베이스를 공유하는 상태로 운영이 되고 있던 상태였다. 그나마 내가 이동하기 전 먼저 진행된 프로젝트들에서 떼어낸 것들도 있었지만 아직 많은 과정이 남아있었다. 이전 팀에서 물리 데이터베이스만 몇 개를 나누어 사용하던 것을 경험한 후 여기와서 보니 "이게 가능한가?" 라는 생각이 들었다. 그 데이터베이스는 바로 오라클이었는데 새삼 유료로 내놓고 파는 이유가 있구나, 대단하다는 생각이 들었다. 처음 뿐만 아니라 지금까지도 "이게 돌아간다고?" 하는 부분들이 있다.

이동 후 한 일들

1) 정산 재개발

 판매자에게 지급할 금액을 계산할, 정산이라는 프로젝트로 이동 후 투입되었다. 세부적으로 개별 단위, 일 단위, 월 단위 등으로 구분이 되는데, 이 중에서 개별 단위에 대한 처리를 담당하게 되었다. 내부적으로는 '건별 정산' 이라고 부르고 있고 기존에는 1시간 주기로 배치가 돌면서 직전 1시간 동안 누적된 건들을 내부 처리 단위로 변환하는 작업을 했다. 기존에 가지고 있던 한계를 나열해보면 다음과 같다.

  • 의존하는 건별 정산 데이터가 공유 데이터베이스를 보고 있다.
    • 건별 정산 데이터는 내부, 외부 가맹점, 혜택, ... 등이 있다.
  • 직전 1시간의 판매수가 폭발적으로 늘어난다면, 배치가 1시간 내에 끝나지 못하고 지연될 수 있다.
    • 자정 즈음이라면 일 단위 처리까지 영향이 갈 수도 있었다.
  • 로직이 전부 쿼리에 있다. (애플리케이션 로직이 존재하지 않았다.)

  구조 개선 중 중요한 것이 하나의 거대한 데이터베이스를 여러 데이터베이스로 나누는 것인데, 정산이 기존과 같은 상태로 유지된다면 뗴어낼 수 없었다. 그래서 내가 이동하기 전, 개별 데이터를 kafka 로 발행하여 정산에서 컨슈밍하여 처리하는 방식으로 협의가 된 상태였다. 내가 진행할 것은 기존 건별 정산 처리에 대한 이해, 쿼리 레벨 -> 애플리케이션 레벨로 재개발카프카 컨슈밍 환경, 정책 구성, 검증 및 전환 등이 있었다.

> 기존 건별 정산 처리에 대한 이해

 기존 로직은 쿼리에 있었고 쿼리에 대한 이해가 필요했다. 쿼리에 로직이 많이 들어가 있으면 이해가 어려운데, 더군다나 이제 막 이동한 상태여서 도메인 지식이 없던 상태에다 조건 (WHERE) 절에 들어간 데이터가 추상화된 상태여서 직관적으로 이해하기 어려웠다. 쿼리 수가 30개 가량은 되었던 것 같은데 2~3주 간은 쿼리를 이해하는 데에 시간을 쏟았던 것 같다. (쿼리 수는 정확하지 않다.)

 정산 자체는 사용자, 특히 구매자에게 직접적으로 드러나지 않는 서비스이기에 보통은 외부로 드러나는 스펙을 그렇게 알아야 할 필요는 없다고 생각한다. 하지만 건별 정산은 결제완료로 인한 대금 지급, 취소/교환/반품 등으로 부터 일어나는 지급 회수나 추가 배송비 관련 정산 등 구매자의 흐름에 어느정도 연관되어 있는 면이 있다. 지나고 생각해보면 사용자와 밀접한 서비스 개발 경험이 있어서 이 쪽 프로젝트로 투입되었던게 아닐까 싶다.

> 쿼리 레벨 -> 애플리케이션 레벨로 재개발

 쿼리 중복 제거를 비롯한 여러 과정을 거쳐 30개 정도의 쿼리를 10개 내의 단위로 나누었다. 이 과정에서도 여러 가지 문제점들이 있었는데, 먼저 쿼리에 숨어 있는 로직 이었다. 만약 쿠폰 등을 사용해서 최종 결제 금액이 0원이라면, 해당 건은 정산 처리에서 제외해야 할까? 우리는 포함해야 했다. 정산에서 바라보는 데이터가 데이터베이스 -> kafka 로 가면서 바라보는 세부 항목이 조금 변했는데, 변경한 데이터에서는 0원에 대한 데이터가 존재하지 않아 누락된 것이다. 이외에도 다양하게 숨어 있는 로직들이 있었다.

 그 다음으로는 계산 문제가 있었다. 수수료율 계산 시 소수점 자릿수와 올림/버림 문제는 중요하다. 우선 첫번째 맞닥뜨린 문제로, 오라클에서 나눗셈을 처리할 때 기본적으로 소수점 몇 번째 자리까지를 두고 계산하는지 당시에는 아무리 찾아도 나오는 결과가 없었다. 그래서 초기에 비교적 적은 자리수로 설정했으나 검증 과정에서 금액이 틀어지는 이슈가 발견되어 자리수를 늘렸다. 둘째로는 연산 순서에 관한 문제이다. 1 / 3 * 3 의 연산이 있을 때 결과는 1 이다. 그런데 여기서 소수점을 버리는 연산인 TRUNC 가 추가되면 결과가 어떻게 될까? 오라클에서 TRUNC(1 / 3 * 3) 의 결과는 0 으로 나온다. 1 / 3 * 3 = 0.333333 * 3 = 0.999999 으로 보고 여기서 소수점을 버리면 0 이 된다. 하지만 연산 순서를 조정하여 곱연산을 앞으로 가져오면 TRUNC(3 * 1 / 3) 의 결과는 1 이다.

 우리는 애플리케이션 레벨에서 BigDecimal 을 사용하여 계산했는데 여기서도 연산 순서의 문제는 재현되었다. 보통의 계산은 TRUNC(1 / 3 * 3) 과 같이 내부 값을 전부 계산한 후 소수점 올림/버림을 수행하고 있었는데, 여기서 곱연산은 교환/결합 법칙이 성립된다는 것에 착안하여 분자, 분모끼리 묶어서 곱을 수행한 뒤 최종 값끼리 나누었다. 1 / 3 * 4 / 5 * 30 = (1 * 4 * 30) / (3 * 5) 인 셈이다. 다행히 우리는 코틀린을 사용했으므로 언어에서 제공해주는 기능들을 활용하여 다음과 같이 구현했다.

  • 연산 전체를 한 단계 감싼 객체 정의 (LazyEvalBigDecimal)
  • BigDecimal -> LazyEvalBigDecimal extension 추가 (fun BigDecimal?.toLazyEvalBigDecimal())
  • operator overloading (plus, minus, ...)
  • 소수점 올림/버림 (round(), down())

> 카프카 컨슈밍 환경, 정책 구성

 우리가 원 데이터를 수집하기 위해 바라보아야 하는 토픽은 3개였으며 (추후 기능 추가로 더 늘어났다), 그 중 별개의 카프카 브로커로 구성된 것도 있었다. 여기서 중점적으로 본 것은 메시지를 컨슈밍하여 사용하는 방식, 트랜잭션 정책, 실패 및 재시도 정책 & 최종 실패 처리 등이 있다. 자세히 들어가기 전에 우리는 spring-kafka 를 이용하여 컨슈머들을 구현했다. 재시도 정책, DLQ 발행 등 유용한 기능들이 이미 제공되어 있었다.

 메시지를 처음 받아들이는 단계에서는 String, BigDecimal, Int 등 최대한 raw 타입으로 정의하고, 그 다음 단계로 실제 내부에서 사용할 객체 타입으로 변환하는 과정을 거쳤다. 정산만을 위해서 발행하는 토픽이 아닌 공용으로 사용하는 토픽이었으며, 우리 쪽에서는 필요하지 않은 필드들과 enum 값들이 포함되어 있었다. 만약 받아들이는 객체 내에 enum 필드가 정의되어 있다면, 발행 주체에서 우리가 모르는 값을 발행한 경우 deserialize 하는 단계에서 오류가 발생할 수 있었다. 보통 새로 추가된 값들은 협의가 되지 않은 이상 기존 사용처에서는 필요하지 않은 값일 경우가 많다.

 그 다음으로는 트랜잭션 정책이다. 특정 토픽으로 발행되는 하나의 메시지는 정산 내에서 여러 단위로 나누어졌다. 상품 결제 금액, 배송비, 할인 금액, ... 등 정산 내에서 처리하는 단위가 나누어져 있었다. 상품 결제 금액 -> 배송비 순으로 처리하다가 배송비 처리 중 오류가 발생하면 상품 결제 금액은 어떻게 해야하는가에 대한 고민이 생겼다. 결론으로는 하나의 메시지 전체를 하나의 트랜잭션으로 보았다. 메시지 하나 당 처리 시간이 그렇게 오래 걸리지도 않았고, 개별 처리 단위마다 트랜잭션을 잡기에는 실패 시 복구 처리 등이 너무 복잡하다고 생각했기 때문이다.

 실패는 로직 내의 오류, 일시적인 DB 오류, 외부 API 호출 실패, 메시지 변환 실패 등 여러 가지가 있다. 이를 재시도 관점에서 보면 재시도 할 오류, 재시도 하지 않을 오류, 그 외 일반 오류로 분류해볼 수 있다. 먼저 우리는 외부 DB 로 부터 정산 DB 로 CDC 로 복제해오는 몇 테이블들이 있었다. 정산 처리 과정에서 필요한 데이터였는데 이 시점에 정산 DB 로 복제가 되어 있지 않으면 처리가 불가능했다. 만약 복제 지연이 발생하면 기다렸다가 재시도 할 수 있도록 오류 타입을 구분했다. 재시도하지 않고 넘어갈 오류도 비슷하게 추가하여 구분했고, 그 외 일반적인 오류는 1초 간 대기 후 1번의 재시도를 거쳤다.

 만약 정한 재시도 횟수만큼이 지나도 실패한다면 해당 메시지는 어떻게 해야할까? 우리는 DLQ (Dead Letter Queue) 로 보내고 다음에 다시 처리했다. DLQ 까지 가는 메시지는 보통 내부 로직의 오류가 있고 수정 후 재처리해야 하는 메시지들이었다. DLQ 로 보냄으로써 해당 메시지는 무시하고 후속 메시지들은 계속 처리할 수 있는 환경을 구성했다. DLQ 에 들어간 메시지는 별도의 api 를 추가하여 해당 토픽으로 들어간 메시지들을 읽어들여 수정한 로직을 다시 수행해주었다.

> 검증 및 전환

 새로 만든 정산 데이터가 기존에 만들고 있던 데이터와 일치하는지 검증이 필요했다. 판매자에게 지급하는 금액이 동일해야 하기 때문이다. 그래서 일정 기간 동안 기존 테이블 구조와 동일하면서 특정 prefix 를 붙인 검증용 테이블에 새로 만든 데이터들을 넣어주고, 이전 데이터와 일치하는지 비교하는 검증기를 추가로 개발하였다. 모든 필드 값이 동일한지, 덜 생성된건 없는지, 더 생성된건 없는지가 검증의 포인트였다.

 검증이 끝나고 다음은 전환 단계였다. 여러 방안들이 있었지만 개인적으로 우리가 채택한 방식은 약간 독특하다고 생각한다. 먼저 우리는 검증을 위해 운영 환경 컨슈머 그룹을 생성했고 해당 컨슈머 그룹명으로 오프셋이 관리되고 있는 상태였다. 그리고 중복 방지 로직을 넣어두었기 때문에 중복에 대한 걱정도 없었다. 운영 컨슈머를 검증용 테이블에서 실제 테이블에 insert 하도록 반영해주면 되었다. 기존 처리 배치는 1시간 단위이고 오후 2시 ~ 3시 데이터는 3시 15분에 읽어들여서 처리했다. 그리하여 다음과 같은 시간 순서로 전환을 수행했다.

 시간  수행
 14:45  운영 컨슈머 중지 (검증 테이블 insert)
 15:15  기존 배치 수행
 15:25  기존 배치 수행 확인, 검증 테이블 -> 실제 테이블 insert 로 수정
 15:30  운영 컨슈머 재배포 (실제 테이블 insert)

 여기서 운영 컨슈머 중지를 조금 일찍한 이유는 앞서 언급한 듯이 중복 방지 처리도 해두었고, 실제 데이터베이스에 데이터가 들어간 시간과 토픽으로 발행된 시간의 차이가 있을 수 있기 때문이다.

> 아쉬운 점

 지나고보면 아쉬운 점들이 몇 가지 있다. 코틀린을 실 서비스에 처음 적용해보기도 했고 그 당시 리팩토링이나 클린 아키텍처 등의 공부를 하지 않은 상태로 구현에 들어갔기 때문에, 지금보면 낯 부끄러운 코드들이 아직 남아있다.

 그리고 하나의 큰 서비스를 갖는 구조로 개발했는데, 지금 생각해보면 내부에서 다시 여러 토픽으로 나누어서 처리해볼 법 했던 것 같다. 이후 기능이 확장될 때 조금 더 유리한 구조가 될 것 같다. 그 당시 spring-kafka 도 처음 사용하는 수준이었기에 당시의 최선은 다했지만, spring-cloud-stream 도 사용해본 현재에 와서는 조금 더 과감한 시도도 했을 것 같다.

 마지막으로 기술적으로 '코멘트를 해줄, 브레이크를 잡아줄 사람' 에 대한 필요를 느껴서 이동했는데 여기서도 그런 부분에 대해 직접적으로 만족할 수 없는, 내가 주도적으로 찾아서 해야하는 분위기였다. 어느 정도 참고할 수 있는 코드 베이스도 있고 질문했을 때 답변을 해줄 수 있는 분들은 계셨기에 괜찮긴했다.

2부 마무리

원래 2부로 마무리하려고 했으나 내용이 길어져서 3부로 끝내려고 한다. 여기까지에서 느낀 것들은

  • 초기 이동 후에 너무 긴장을 했던 것인지, 혹은 비대면이어서 그랬던 것인지 몇 달의 기억이 잘 없다. 회의도 이해와 집중을 잘 못했었다
  • 오라클은 정말 잘 만든 데이터베이스다. 그만큼 물리적인 비용이 들어가지만.
  • 이전에는 READ 성능이 중요했다면, 여기는 WRITE 에 대한 신뢰성이 중요하다.

다음 글 - 7년차에 접어든 현재와 미래