분모와 분자가 모두 사람 손에 있는 지표
출처: 우아한형제들 기술블로그 — ‘대략’이 아닌 ‘정확한’ 데이터로!: 생산성 측정 지표 개발기
사장님 입점 요청을 검수하는 승인 작업자들의 생산성을, 엑셀 수기 관리에서 자동 집계 지표로 옮긴 과정을 담은 글이다. 기존에 쌓이던 다른 목적의 데이터를 골라 쓰고, 배치와 스케줄러를 섞어 집계하고, 조회를 청크로 나눠 병렬화했다. 원문을 읽고 정리한 내용과 내 생각을 함께 적는다.
문제
SUPER2 어드민에는 사장님이 올린 입점 신청과 정보 수정 요청을 사람이 검수하고 승인하는 절차가 있다. 요청 종류가 다르면 봐야 할 항목도 달라져서 작업자는 종류별 업무 그룹에 나눠 배정된다. 최초 입점 신청을 보는 A 업무그룹에 30명, 정산계좌 변경을 보는 B 업무그룹에 10명, 광고서비스 추가를 보는 C 업무그룹에 5명 하는 식이다.
관리자가 해야 할 일은 이 배분을 계속 고쳐 나가는 것이다. 어느 그룹에 요청이 몰리고 어느 그룹이 여유가 있는지 보고 사람을 옮겨야 한다. 그런데 볼 수 있는 숫자가 없었다. 처리 건수도, 소요 시간도, 출퇴근 기록도 전부 엑셀에 손으로 적고 있었다. 원문의 표현대로 “정확한 데이터 없이 어림짐작으로 산출한 예상치를 기반으로 인력을 투입”하는 상태가 반복됐다.
이 글은 성능 최적화 이야기처럼 보이는 자리마다 실제로는 다른 이야기를 하고 있다. 관측을 염두에 두지 않고 만들어진 시스템에 나중에 관측을 얹는 이야기이고, 관측 대상이 서버가 아니라 사람인 이야기다. 이 두 가지가 글 전체를 관통한다. 원본 테이블 어디에도 “업무 그룹”이라는 축이 없었던 건 첫 번째 성격에서 나오고, 뒤에서 볼 오차의 무게가 달라지는 이유는 두 번째에 있다.
분석
최종 지표의 분자만 추정치로 남았다
작업요청 테이블에서 처리 완료 건을, 작업자 변경이력 테이블에서 출퇴근과 휴식을, 작업 요청 할당 변경이력 테이블에서 할당 건을 가져온다. 집계에 쓴 원천이 이 세 개인데, 어느 테이블에도 업무 그룹이라는 단위가 없었다.
처방은 갈렸다. 이력 테이블 두 개는 코드를 고쳐, 출퇴근·휴식 상태가 바뀌는 시점과 요청이 할당되는 시점마다 그때 배정돼 있던 업무 그룹을 함께 저장하게 했다. 일어난 사실을 그대로 남긴 것이다. 그런데 작업요청 테이블은 손대지 않았다. 대신 “요청의 처리 일시를 기준으로, 요청의 처리가 어느 업무그룹에서 처리 완료되었을지를 추정”했다.
최종 지표가 무엇이었는지 다시 보자. 생산성 = 처리요청 ÷ 투입인력이고, 방금 추정으로 채운 그 값이 분자다. 제목은 ‘대략’이 아닌 ‘정확한’ 데이터인데 정작 최종 지표의 분자가 대략으로 남았다.
왜 그랬을지는 원문이 반복해 말하는 원칙에서 짐작이 간다. 기존 운영 로직을 최대한 건드리지 않고 작은 작업 범위에서 가성비 높게. 승인 완료 경로는 이 시스템의 심장이니 손대기가 부담스러웠을 것이다. 어디까지나 추측이다. 다만 비용 배분이 거꾸로 보인다. 이력 테이블 두 개는 쓰기 시점에 컬럼을 추가하는 수정을 감수했으면서 정확도가 가장 중요한 하나에는 같은 수정을 하지 않았다. 승인 완료 시점에 그룹 컬럼 하나를 더 남기는 일이 이 프로젝트에서 가장 값싼 정확도 향상이었을 가능성이 높다.
오차가 어떻게 생기는지는 구체적으로 그려볼 수 있다. 작업자가 A그룹에서 요청을 할당받아 검수를 진행하다가 B그룹으로 옮겨간 뒤 완료 버튼을 누른다. 할당은 A에, 처리는 B에 잡힌다. A의 요청처리율은 실제보다 낮게 잡히고 B의 생산성은 높게 나온다. 이 오차는 랜덤하지 않다. 그룹 이동이 잦을수록 한 방향으로 쏠린다.
그리고 원문이 성과 사례로 든 것이 정확히 그룹 이동이다. 9월 12일에 A 작업자가 1번 업무그룹에서 생산성 77%를 기록했는데 2번 업무그룹으로 옮긴 뒤 144%가 나왔다는 사례. 하필 추정 오차가 가장 크게 벌어지는 시나리오가 자랑 사례로 올라와 있다. 이동 직전 1번 그룹에서 대부분 검수해 둔 건이 이동 후에 완료되면 그 실적은 2번 그룹으로 간다. 이게 실제 개선인지 회계상의 이동인지, 원문이 제시한 데이터로는 가릴 수 없다. 실제 개선일 수도 있다. 어느 쪽인지 가릴 방법이 없다.
분모도 사람이 버튼으로 입력한다
투입인력은 근무한 시간을 8시간으로 나눈 값이고, 그 근무 시간은 작업자가 ‘작업 시작’과 ‘작업 종료’ 버튼을 누른 기록에서 나온다. 측정 대상이 자기 분모를 직접 입력하는 구조다.
원문의 자동 퇴근 배치는 이 취약성을 시스템이 스스로 인정한 장치다. 저녁 9시에 퇴근 버튼을 누르지 않은 작업자를 자동 처리한다. 좋은 대응인데 보정의 방향이 한쪽으로 치우친다. 퇴근을 찍지 않은 사람은 9시까지 일한 것으로 기록되니 분모가 부풀고 생산성이 떨어진다. ‘자동 퇴근’ 표시가 붙어서 관리자가 알아볼 수는 있다. 그런데 기간별 총합 집계와 최대 1년치 CSV에는 그 값이 그대로 섞여 들어간다. 표시는 행 단위에 있고 합계는 그 표시를 지운다.
휴식 종류가 넷인 것도 눈에 걸린다. 식사, 교육, 휴식, 그리고 검수. 검수가 휴식으로 분류돼 순 근무시간에서 빠진다면 검수를 많이 한 작업자는 분모가 줄어 생산성이 올라간다. 원문은 이 네 가지의 정의를 설명하지 않고 투입인력이 총 근무시간 기준인지 순 근무시간 기준인지도 명확히 하지 않는다. 지표를 어딘가에 쓰지 않는다면 넘어갈 수 있는 빈칸이다. 그런데 원문은 이 지표를 업무 레벨 평가 기준을 세우는 데 쓰고 연간 계약 시 적정 인원 산출에도 쓸 예정이라고 밝혀뒀다. 그 용도라면 이 한 줄의 정의가 결과를 바꾼다.
측정이 평가가 되는 순간 사람은 지표를 향해 행동을 바꾼다. 굿하트의 법칙이 말하는 그 지점이고 여기서는 그 통로가 특히 짧다. 분모와 분자가 모두 작업자가 누르는 버튼 위에 올라가 있으니까.
잘한 것: 쓰기 시점에 맥락을 남긴 판단
업무 그룹을 이력 테이블 두 개에 함께 저장하기로 했는데, 이 글에서 가장 재사용 가치가 높은 대목이 여기다.
업무 그룹 배정은 시간에 따라 변하는 값이다. 현재 상태를 담은 테이블을 조인해서는 과거를 복원할 수 없다. 오늘 A그룹인 작업자가 3주 전에도 A그룹이었다는 보장이 없고 그 사실을 사후에 알아낼 방법이 사실상 없다. 그러니 이벤트가 일어나는 그 순간에 그때의 맥락을 함께 박아 넣는 것 말고는 답이 없다.
정규화 관점에서는 중복이지만 이력 테이블에서는 그게 정답이다. 회계 장부가 거래 금액 옆에 그날의 환율을 같이 적어두는 것과 같은 성격이다. 나중에 환율 테이블을 조인해서 복원하겠다는 계획은 환율이 한 번이라도 바뀌는 순간 무너진다.
그래서 앞의 지적이 더 아프다. 이 원칙을 세 테이블 중 두 개에만 적용했다.
PK 레인지 스캔으로 인덱스를 대신했다
작업 요청 할당 변경이력 테이블에는 createdAt 인덱스가 없었다. 이 테이블의 PK가 snowflake 방식이라 앞자리에 타임스탬프가 들어 있다. 그래서 인덱스를 새로 추가하는 대신 PK 기준 range 검색을 하고 그 결과를 createdAt으로 필터링했다. 이력 테이블은 계속 누적되니 여기에 인덱스를 걸었다면 DDL 비용에 이후의 쓰기 비용까지 따라붙었을 텐데, 그걸 통째로 아꼈다.
여기에 하나 덧붙이면, snowflake id에 박힌 타임스탬프는 id를 채번한 서버의 시각이지 createdAt과 같은 값이 아니다. 트랜잭션 커밋 시점이나 시계 오차만큼 어긋날 수 있다. 그래서 범위를 넉넉히 잡고 결과를 다시 걸러야 하는데, 원문이 쓴 문장이 정확히 “PK 기준의 range 검색 이후 createdAt 필터링”이다. 알고 한 조치로 보인다.
한편 다른 이력 테이블인 작업자 변경이력에는 modifiedAt 인덱스를 새로 추가했다. 같은 “인덱스가 없다”는 문제에 두 테이블이 다른 처방을 받았다. 한쪽은 PK 구조가 대안을 줬고 다른 쪽은 아니었다.
배치와 스케줄러를 섞은 이유
집계 방식을 고를 때 원문이 세운 기준이 “내가 짠 모든 코드가 잘못되었다면?”이다. 이 기준으로 보면 스케줄러는 주기 호출에 유리하지만 배포 이후에 과거 데이터를 다시 계산할 수 없고 배치는 재집계가 되는 대신 자주 돌리기엔 리소스가 크다. 원문은 서버 인스턴스가 워커 10대에 비해 한정적이라는 사정도 적어뒀다.
그래서 둘을 섞었다. 오전 6시부터 오후 10시까지는 5분 주기 스케줄러가 돌고 새벽 1시에 배치가 최종 집계를 한다. 하루 집계 함수 호출이 288회에서 193회로 줄어 리소스를 1/3 절약했다고 한다.
칭찬할 지점은 절약이 아니라 순서다. 집계 시스템에서 가장 중요한 속성은 같은 입력으로 다시 돌렸을 때 같은 답이 나오고 과거를 덮어쓸 수 있느냐다. 빠르기는 그다음이다. 재집계 가능성을 성능보다 앞에 놓고 방식을 골랐다.
절약 쪽 수치는 조금 다르게 읽힌다. 288에서 193으로 줄어든 건 최적화라기보다 데이터가 쌓이지 않는 새벽에 안 돌게 한 것이다. 6시부터 22시까지 5분 간격이면 192회, 거기에 배치 1회를 더하면 193이다. 안 하던 일을 안 하게 됐을 뿐 하던 일이 싸지지는 않았다.
더 중요한 건 원문이 언급하지 않은 쪽이다. 새벽 배치가 낮의 스케줄러 결과를 다시 계산한다는 건, 관리자가 낮에 보고 사람을 옮긴 그 숫자가 최종본이 아닐 수 있다는 뜻이다. 원문이 “1개의 집계 로직”으로 두 데이터를 만든다고 썼으니 두 경로가 같은 코드를 공유할 것이고 그렇다면 값이 달라질 이유는 진행 중인 하루의 미완결 데이터 정도로 좁혀진다. 그래도 실시간 화면의 숫자와 확정 숫자가 다를 수 있다는 사실 자체는 화면이 말해줘야 한다. 오늘의 77%와 어제의 77%는 같은 종류의 숫자가 아니다.
병렬 청크 조회는 총 작업량을 줄이지 않는다
조회 쪽은 단순 페이지네이션이 아니다. 집계 데이터를 메모리에 올려 일자 기준으로 다시 sum하고 평균을 내니, 얼마를 한 번에 올릴지가 곧 DB 부하 문제가 된다.
여기서 세운 기준이 좋다. “데이터양이 지금보다 5배가 많아진다면?” 절대 수치 대신 성장 배수로 잡았고 근거도 구체적이다. 작업자 수가 늘 수 있고 현재 한 명이 평균 1.5개 업무그룹에서 일하는 것이 3~4개로 바뀔 수 있다. 그러면 3배가 아니라 5배 이상이 된다. 이 계산이 있으니 조회 목표 1초라는 숫자가 근거를 갖는다.
목표를 맞추기 위해 기간을 청크로 나눠 병렬 호출했다. 원문은 이걸 “DB 리소스에 부하를 줄일 수 있는 조회 방식”이라고 표현했는데, 실제로 일어난 일은 부하의 이동이다. 병렬화는 총 DB 작업량을 줄이지 않는다. 벽시계 시간을 줄이는 대신 같은 순간의 동시 부하를 올린다. 관리자 여러 명이 동시에 1년치 CSV를 뽑으면 커넥션과 부하가 청크 수 × 동시 사용자만큼 곱해진다.
메모리도 마찬가지인데, 원문 스스로 1 row가 약 150바이트이고 수십만 건이면 45~75MB라고 계산해뒀다. 요청 하나가 힙에 수십 MB를 올리는데 청크 병렬은 그걸 순차로 흘려보내는 대신 동시에 다 올린다. sum과 평균이라면 SQL의 GROUP BY로 내려보내는 선택지도 있었을 텐데 원문에는 그 검토가 남아 있지 않다. 근무 상태가 바뀔 때마다 구간을 만드는 로직이 SQL로 옮기기 까다로웠으리라는 짐작은 해볼 수 있다. 구간 계산은 확실히 애플리케이션 코드가 편한 종류의 일이다.
해결
만들어진 건 지면 두 개다. 생산성 조회 지면은 다섯 개 지표를 보여준다. 할당요청, 처리요청, 요청처리율, 투입인력, 생산성. 집계는 작업자가 아닌 업무 그룹 단위로 한다. 인력 조정 판단이 그룹 단위로 이뤄지기 때문이고 한 작업자가 여러 그룹에서 일했다면 그룹별로 나뉘어 잡힌다. 관리자 한 명이 여러 그룹을 맡는 경우가 많아 다중 선택 필터가 붙었다. 최대 1년치를 CSV로 내려받을 수 있고, 검색 기간이 하루를 넘으면 일자별 대신 기간 총합을 보여준다.
근무 관리 지면은 수기 시트를 대체한다. ON/OFF, 총 근무시간, 순 근무시간, 휴식시간 네 종류를 기록하고 여러 그룹에서 일한 작업자는 작업자 단위로 묶어 보여준다. 반차나 조퇴 같은 변수는 카테고리 태그가 달린 메모로 남긴다. 앞서 본 자동 퇴근 배치도 여기 있다.
기술 쪽은 데이터 선택, 가공, 집계, 조회의 네 단계로 정리돼 있다. 다만 이 프로젝트에서 코드보다 중요했던 건 정의다.
가장 잘 쓰인 정의가 처리요청이다. “작업자가 담당 작업자로 적용된 상태에서 처리 완료한 요청의 수. 작업자가 직접 승인하거나 취소한 건만 카운팅하고, 요청을 올린 사장님이나 영업담당자가 취소 처리한 경우는 제외.” 이 한 문장이 지표 신뢰도의 상당 부분을 결정한다. 남이 취소한 건을 내 실적에서 빼는 건 기술 문제가 아니라 정의 문제이고 정의를 명시적으로 적어 문서에 남긴 것이 좋다. 앞에서 지적한 휴식 네 종류와 투입인력의 기준이 같은 수준으로 적히지 않았다는 게 그래서 더 눈에 띈다. 한쪽은 할 수 있는 만큼 정확했고 다른 쪽은 빈칸이다.
사전 검증의 순서도 짚어둘 만하다. 실시간 호출이 아니라 5분에 1회 집계이므로 전체 소요를 10초로 잡았다. DB 원천 조회는 5초 안에 끝나면 되고 앞으로의 증가를 감안해 지금은 2초 이내여야 쓸 수 있다고 기준을 먼저 세웠다. 그다음 재봤다. 222ms와 930ms. 통과. 거기서 멈추지 않고 평균이 3~5초로 오르면 파티셔닝이나 1년 지난 데이터 파기로 대응한다는 다음 수까지 정해뒀다. 기준을 먼저 정하고 재는 순서를 지켰다.
결과
숫자는 이렇다. 집계 1회 8초 이내, 조회 200ms 이내, 최대 집계 소요 950ms. 생산성 집계 자동화로 연간 약 5명, 근무 기록 자동화로 연간 약 1명의 인력 비용 절감. 그리고 앞서 본 77% → 144% 사례.
절감한 5명과 1명이 어떻게 계산된 숫자인지는 원문에 없다. 수기 집계에 쓰던 시간을 인력으로 환산한 것으로 짐작되지만 근거는 제시되지 않았다. 사내 보고 자료에는 산출 방식이 있었을 텐데 글에는 결론만 실렸다.
더 눈에 띄는 건 성능 수치와 정확도 수치의 대비다. 950ms, 200ms, 8초, 222ms, 930ms. 성능은 이렇게 자세히 잰다. 그런데 지표의 정확도에 관한 수치는 하나도 없다. 추정으로 채운 처리 건수가 실제와 얼마나 어긋나는지, 그룹 이동이 하루에 몇 번 일어나는지, 자동 퇴근으로 처리된 건이 전체의 몇 %인지. 성능은 쟀고 정확도는 재지 않았다. 제목은 정확한 데이터다.
그렇다고 이 프로젝트의 값어치가 깎이지는 않는다. 아무 숫자도 없던 자리에서 수기 엑셀을 걷어내고 매일 자동으로 갱신되는 숫자를 만든 것은 그 자체로 큰 이동이다. 정확도를 논할 수 있는 것도 숫자가 생긴 뒤의 일이다. 없는 숫자에는 오차를 물을 수도 없다.
인상 깊었던 점
이 글이 다른 성능 글과 다른 자리는 관측 대상이 서버가 아니라는 데 있다.
APM이 재는 대상은 자기가 측정되는 걸 모른다. 응답 시간을 잘 보이게 하려고 애쓰는 서버는 없다. 사람은 다르다. 자기가 측정되고 있다는 걸 알고 그 숫자가 업무 레벨 평가에 쓰인다는 것도 알게 된다. 그래서 지표 설계의 무게중심이 정확도에서 공정성으로 옮겨간다. 오차 몇 %는 서버 모니터링에서 노이즈지만 여기서는 누군가의 평가다.
그렇다면 화면에 “이 값은 추정”이라는 표시가 있었어야 한다. 사실로 기록된 할당 건수와 처리 일시로 미뤄 짐작한 처리 건수가 같은 표에 같은 서체로 나란히 놓인다. 읽는 사람에게는 둘을 구분할 단서가 없다. 관리자는 두 숫자를 같은 무게로 믿고 사람을 옮긴다.
이 블로그에 캐시를 비우는 비용은 캐시 크기가 아니라 Redis 전체 크기다를 쓰면서 비용이 몇 자릿수씩 차이 나는 두 연산을 코드가 같은 크기로 그려 보인다는 이야기를 했다. 여기서 같은 일이 한 층 위에서 벌어진다. 그때는 코드가 두 연산을 같은 크기로 그렸고 여기서는 화면이 사실과 추정을 같은 크기로 그린다. 읽는 사람이 차이를 알아낼 방법이 없다는 것도 같다.
없던 지표를 만드는 일의 어려움은 대부분 집계 성능에 있지 않다. 무엇을 세고 무엇을 빼는지 정하는 데 있고, 정할 수 없어서 추정으로 메운 자리를 어떻게 드러내느냐에 있다. 추정을 남기는 건 죄가 아니다. 시스템을 다시 만들지 않고서는 알 수 없는 것들이 있다. 문제는 추정을 사실처럼 보이게 두는 쪽이다.