공수 산정 (1) 예산에 맞춰 범위를 줄였는데, 그 범위도 예산에 안 맞았습니다
요구 명세는 이미 나와 있었고, 예산도 이미 정해져 있었습니다. 제가 정한 게 아니라 제게 주어진 숫자였습니다. 그래서 제가 할 일은 하나였습니다. 이 예산으로 이 명세를 할 수 있는지 따져 보는 것.
제품 성격만 추상화해 두면 이렇습니다. 여러 채널에서 데이터를 모아 정제하고 점수화해 화면에 반영하는 파이프라인이고, 멀티테넌트 SaaS이며, 다국어를 지원해야 합니다. 기능 목록은 쉰 개가 넘었고, 그중 1차 대상으로 좁힌 것만 서른 개 남짓이었습니다.
따져 보니 안 됐습니다. 전체를 다 하기에는 한참 모자랐어요. 그런데 예산을 더 달라고 할 수 있는 상황도 아니었습니다. 그러면 남는 선택지는 둘입니다. 그냥 못 하겠다고 하거나, "이 예산이면 여기까지는 됩니다"를 계산해서 보여주거나.
저는 후자를 택했습니다. 거절을 숫자로 하는 셈입니다. 예산을 제가 절대 밑돌지 않으려는 시급 하한으로 나눠 쓸 수 있는 시간을 구했더니 107시간이 나왔습니다. 그 다음 기능 목록을 펼쳐 놓고 107시간에 맞을 때까지 항목을 하나씩 지웠습니다. 남은 목록이 곧 답이 됩니다. 이 돈으로 살 수 있는 건 여기까지입니다, 하고.
여기까지는 지금도 옳은 접근이었다고 생각합니다. 예산이 고정돼 있고 올릴 수 없다면, 그 예산이 무엇을 살 수 있는지 정직하게 계산해서 내미는 게 맞습니다.
문제는 그 다음에 드러났습니다. 나중에 같은 범위를 예산과 무관하게 처음부터 다시 재 봤더니 137시간이었습니다. 107이 아니라 137. 28% 컸습니다. 그러니까 "이 예산이면 여기까지는 됩니다"라고 말하려던 그 축소된 범위조차, 실은 그 예산에 들어가지 않고 있었습니다.
접근은 옳았는데 숫자가 틀렸습니다. 이 글은 그 28%가 어디서 왔는지에 대한 기록입니다.
예산에서 시간을 구하는 것까지는 맞습니다
먼저 억울한 부분부터 정리해 두고 싶습니다. 예산을 시급 하한으로 나눠 107시간을 구한 것 자체는 잘못이 아닙니다. 예산이 고정된 일에서 내가 쓸 수 있는 시간의 상한을 아는 건 반드시 필요합니다. 그걸 모르면 협상 자체가 성립하지 않습니다.
범위를 줄인 것도 잘못이 아닙니다. 예산이 전체를 감당하지 못하면 감당 가능한 부분집합을 제안하는 게 정직한 대응입니다.
제가 틀린 곳은 훨씬 좁습니다. 그 107시간이 "이 범위는 몇 시간짜리인가"의 답 자리까지 차지한 것입니다. 107은 예산에서 나온 숫자지 일에서 나온 숫자가 아닙니다. 그런데 저는 그것을 작업량인 것처럼 다뤘습니다.
문제는 자르다 멈추는 지점을 무엇이 정했는가입니다
범위를 줄일 때 어디서 그만 자를지를 정하는 방법은 두 가지가 있습니다. 하는 사람 입장에서는 똑같아 보이는데 성질이 완전히 다릅니다.
남은 범위를 따로 재서 멈추기. 지울 만큼 지운 뒤, 남은 목록이 정말 그 시간 안에 들어가는지를 예산을 잊고 다시 계산한다.
내 예상 합계가 목표에 닿으면 멈추기. 항목을 지워 가며 예상 시간을 더하다가, 합계가 목표 숫자에 닿는 순간 멈춘다.
저는 두 번째를 했습니다. 그리고 두 번째 방법의 문제는 언제나 성공한다는 것입니다. 합계는 반드시 107에 닿습니다. 107에 닿을 때까지 지우는 게 규칙이니까요. 그러니 그 숫자는 "이 범위가 107시간에 들어간다"를 한 번도 확인해 준 적이 없습니다. 제가 그렇게 되도록 멈춘 지점일 뿐입니다.
가방에 비유하면 이렇습니다. 기내용 가방 하나로 가겠다고 정하는 건 합리적이고, 짐을 줄이는 것도 맞습니다. 문제는 짐을 실제로 넣어 보지 않고 눈대중으로 "이 정도면 들어가겠다" 하고 덮는 것입니다. 가방 크기는 정확했습니다. 틀린 건 거기 들어갈 양에 대한 제 눈대중이었고, 그건 공항에 가서야 드러납니다.
이렇게 목표를 먼저 정해 두고 거기에 맞춰 추정이 끌려가는 것을 estimate-to-fit이라고 부릅니다. 답을 정해 놓고 거꾸로 계산한다는 뜻에서 이 글에서는 역산이라고 부르겠습니다. 정확히 해 두고 싶은 것은, 제가 역산한 것이 범위가 아니라는 점입니다. 예산에 맞춰 범위를 줄인 건 정당합니다. 제가 역산한 것은 그 범위가 몇 시간짜리인가에 대한 제 판단이었습니다.
그래서 곤란해집니다. 공수 추정은 원래 "이 계획으로는 안 된다"를 미리 말해 주는 장치입니다. 그런데 그 추정이 목표에서 나오면 그 숫자는 언제나 목표와 맞아떨어집니다. 맞아떨어지지 않을 수가 없어요. 애초에 거기서 나왔으니까요. 계획이 안 맞는다고 알려줄 수 있는 유일한 장치를 제 손으로 꺼 놓은 셈입니다.
빠진 한 단계 — 제약과 추정은 따로 구해야 합니다
제가 헷갈린 지점은 분명합니다. 예산 제약과 공수 추정은 묻는 것도 다르고 성격도 다른데, 답은 둘 다 "시간"으로 나옵니다. 그래서 결과만 놓고 보면 구분이 되지 않습니다.
항목 | 공수 추정 | 예산 제약 |
|---|---|---|
묻는 것 | 이 범위는 몇 시간이 드나 | 이 예산이면 몇 시간을 쓸 수 있나 |
답의 성격 | 관측 | 제약 |
답의 단위 | 시간 | 시간 |
나오는 곳 | 일의 내용 | 예산과 단가 |
둘 다 필요합니다. 예산이 정해진 일에서 "몇 시간까지 쓸 수 있나"를 먼저 구하는 건 당연한 순서고, 저는 그걸 했습니다. 다만 두 숫자는 서로를 보지 않고 따로 구해야 비교할 의미가 생깁니다. 한쪽을 다른 쪽에서 유도하면 둘은 언제나 일치하고, 언제나 일치하는 비교는 아무것도 알려주지 않습니다.
예산이 먼저 정해진 상황에서 옳은 순서
예산 → 쓸 수 있는 시간 계산 (제약) → 107시간
→ 그 시간에 맞게 범위 축소
→ 축소된 범위를 예산과 무관하게 다시 추정 (관측) → 137시간 ← 빠뜨린 단계
→ 두 숫자를 비교 → 28% 초과 확인
제가 밟은 순서
예산 → 쓸 수 있는 시간 계산 → 107시간
→ 예상 합계가 107에 닿을 때까지 범위 축소 → 끝제가 빠뜨린 건 한 단계였습니다. 범위를 다 줄인 뒤에, 그 줄인 범위가 몇 시간짜리인지를 예산을 잊고 다시 재 보는 것. 그 한 단계만 있었으면 28%는 그 자리에서 드러났을 겁니다. 어려운 계산이어서 빠진 게 아니라 순서에서 빠져 있었을 뿐입니다. 실제로 나중에 이 단계를 밟았을 때는 재사용할 수 있는 코드까지 실사해서 반영했는데도 137시간이 나왔습니다. 그 재추정이 왜 하필 위로 벌어졌는지는 4편에 따로 적었습니다.
역산이 닿을 수 있는 자리는 최선의 경우뿐입니다
그렇게 덜어내는 동안 제가 실제로 걷어내고 있던 것은 버퍼였습니다. 목표에 맞을 때까지 깎으면 여유가 남을 자리가 없습니다. 남아 있다면 아직 덜 깎은 것이니까요. 그래서 역산으로 나온 숫자는 필연적으로 모든 것이 잘 풀렸을 때의 값이 됩니다. 계획이 아니라 최선의 경우입니다.
여기에도 이름이 있습니다. 계획 오류입니다. 사람은 자기 일이 얼마나 걸릴지를 일관되게 낙관적으로 잡습니다. 같은 종류의 일에서 지난번에 얼마나 늦었는지를 뻔히 알면서도 이번엔 안 그럴 거라고 잡습니다. 흥미로운 것은 남의 일정을 봐 줄 때는 이 편향이 훨씬 약해진다는 점입니다. 낙관은 지식의 문제가 아니라 위치의 문제인 셈입니다.
역산이 곤란한 이유는 이 낙관을 우연히가 아니라 구조적으로 만들어 낸다는 데 있습니다. 낙관하려고 애쓸 필요조차 없습니다. 버퍼를 걷어내는 일이 곧 목표에 다가가는 일이라서, 성실하게 깎기만 해도 저절로 최선의 경우에 도착합니다.
다시 산정한 결과는 하나의 값이 아니라 구간으로 나왔고, 그 구간의 낙관 하한에는 "이건 '모든 것이 잘 풀림'이지 계획이 아니다"라는 단서가 붙어 있었습니다. 그리고 그 하한이 제 107시간과 거의 같았습니다. 거의 같다는 점 자체는 상당 부분 우연입니다 — 두 숫자는 서로를 모른 채 각각 예산 나누기와 항목별 합산에서 나왔으니까요. 다만 우연이 아닌 것은, 역산이 닿을 수 있는 자리가 애초에 그 근처밖에 없다는 사실입니다.
룰은 판정에 쓰고 추정에는 쓰지 마세요
뼈아팠던 것은 제 시급 하한이라는 룰 자체에는 잘못이 없었다는 점입니다. 같은 작업 안에서 그 룰은 두 번 작동했습니다.
한 번은 옳게 작동했습니다. "이 예산이면 몇 시간까지 쓸 수 있나"를 구하는 데 썼을 때입니다. 그건 그 룰이 있어야 할 자리입니다.
한 번은 그르게 작동했습니다. 그렇게 나온 107시간이 "이 범위는 몇 시간짜리인가"의 답까지 대신하게 된 순간입니다. 그때 제약이 조용히 추정으로 둔갑했습니다. 숫자는 같고 자리만 옮겼을 뿐인데요.
이 둘의 차이를 가장 짧게 말한 사람은 인류학자 메릴린 스트래선입니다. 흔히 굿하트의 법칙이라고 불리는 문장입니다.
어떤 측정이 목표가 되면, 그것은 더 이상 좋은 측정이 아니게 된다.
보통 이 법칙은 조직 이야기로 인용됩니다. 통화 시간을 평가 지표로 삼으면 상담원이 전화를 서둘러 끊고, 코드 줄 수로 성과를 재면 코드가 길어지는 식으로요. 제가 놓친 것은 이 법칙에 조직이 필요하지 않다는 점이었습니다. 지표를 걸어 둔 사람과 지표를 맞추는 사람이 동일인이어도 똑같이 작동합니다.
제 시급 하한은 재는 자였습니다. "이 조건에서 이 일을 맡을 것인가"를 재라고 만든 자입니다. 거기서 나온 시간을 예산 한도로 쓰는 데까지는 아무 문제가 없습니다. 그런데 그 시간이 작업량의 답 자리에까지 앉는 순간, 그것은 더 이상 재는 자가 아니라 맞춰야 할 목표가 됐습니다. 그리고 목표가 되자마자, 재려던 대상인 작업량이 그 기준에 맞춰 휘기 시작했습니다. 작정하고 숫자를 만진 게 아닙니다. 기준을 목표로 걸어 두면 판단이 저절로 그쪽으로 쏠리고, 저는 그게 쏠리고 있다는 걸 알아차리지 못했을 뿐입니다.
그래서 여기서 나온 결론은 "룰을 없애라"가 아닙니다. 룰은 판정에 쓰고 추정에는 쓰지 말라는 것입니다. 같은 기준이라도 어느 단계에 놓느냐에 따라 저를 지켜 주기도 하고, 반대로 제 판단을 조용히 끌어당기기도 합니다.
제가 만든 규칙이 저를 돕지 않고 방해하는 구조는 얼마 전에도 다른 형태로 마주친 적이 있습니다. 그때는 한때 옳았던 규칙이 시간이 지나 어긋난 경우였고, 이번엔 지금도 옳은 규칙이 제자리를 벗어난 경우였습니다. 어느 쪽이든 규칙은 만드는 것보다 두는 자리가 어렵다는 생각이 들었습니다.
이 오류를 잡아준 신호는 나눗셈 하나였습니다
이 오류가 고약한 이유는 항목을 아무리 다시 검토해도 잡히지 않는다는 데 있습니다. 제가 내린 개별 판단은 전부 검증 가능한 사실이었습니다. 그 기능은 정말로 1차에 없어도 되고, 저 기능은 정말로 2차로 미룰 수 있습니다. 하나씩 떼어 놓고 물으면 어느 것도 틀리지 않았습니다.
문제는 그 문장들이 아니라 그 문장들을 어디서 멈췄는가입니다. 저는 정확히 107에서 멈췄습니다. 조금 위도 조금 아래도 아니었습니다. 그 정지 지점을 정한 것은 작업의 성질이 아니라 예산이었습니다.
그러니 이 오류를 잡으려면 항목이 아니라 총합이 목표와 우연히 맞아떨어지는지를 봐야 합니다. 돌이켜 생각해보면 저에게 주어진 탐지 신호는 그것 하나뿐이었습니다. 계약 금액을 제 산정 시간으로 나누면 제 시급 하한이 소수점까지 나온다는 것.
실제로 그 지적은 제 숫자를 검토한 쪽에서 나왔습니다. 요지는 한 문장이었습니다.
이 숫자는 작업량 추정이 아니라 시급 하한 역산값으로 보인다. 계약 금액을 이 숫자로 나누면 시급 하한과 소수점까지 일치한다. 이 일치는 우연이라기엔 지나치다.
반박할 거리가 없었습니다. 그게 정확히 제가 한 일이었으니까요. 다만 저 지적이 대단한 통찰이어서 나온 것은 아니라는 점을 말해 두고 싶습니다. 나눗셈 한 번이면 됩니다. 누구라도 할 수 있고, 저도 할 수 있었습니다. 다만 그 나눗셈을 해 볼 생각은 그 숫자에 아무 지분이 없는 쪽에서만 납니다. 저에게는 지분이 있었습니다. 그 숫자는 제가 만든 것이었고, 저는 그것이 맞기를 바라고 있었습니다.
그래서 이 검증에 필요한 것은 영리함이 아니라 조건 하나라는 생각이 들었습니다. 제 결론에 아무 애착이 없을 것. 그 조건만 갖추면 사람이든 도구든 상관없습니다.
정리하며 — 그리고 검증할 데이터가 없었습니다
절차만 다시 적으면 이렇습니다. 예산이 먼저 정해진 일이라면 쓸 수 있는 시간을 구하는 것까지는 그대로 하되, 범위를 줄이고 난 뒤에 그 범위를 예산과 무관하게 한 번 더 잽니다. 제약과 추정을 각각 따로 구해서 비교합니다. 그리고 두 숫자가 우연히 일치하면, 한쪽이 다른 쪽에서 나온 게 아닌지 의심합니다.
다만 이 목록을 아무리 잘 외워도 부족하다는 생각이 들었습니다. 저는 이 원칙들을 몰라서 어긴 게 아닙니다. 어기고 있다는 것을 몰라서 어겼습니다. 역산도 계획 오류도 굿하트의 법칙도 저는 전부 알고 있었습니다. 이름까지 알고 있었습니다. 아는 것은 아무 도움이 되지 않았습니다. 이 오류들은 몰라서 저지르는 종류가 아니라, 저지르는 동안 자각되지 않는 종류이기 때문입니다.
그리고 하나가 더 남습니다. 저는 제 숫자를 검증하려던 게 아니라 확인받으려고 했습니다. 기대한 답은 "대체로 맞다"였고, 그 답이 나왔다면 저는 만족하고 창을 닫았을 겁니다. 겉으로는 분명히 자기 점검이었습니다. 시간과 노력도 실제로 썼습니다. 다만 제가 찾고 있던 것은 반례가 아니라 동의였습니다. 검증 절차를 성실하게 밟으면서 그것을 검증이 아닌 것으로 만들어 놓는 일이 가능하다는 걸 이때 알았습니다.
이 일을 겪고 나서 자연스럽게 든 질문은 "내 추정은 원래 얼마나 정확했나"였습니다. 확인해 보려고 했고, 확인할 수 없다는 것을 알았습니다. 견적 대비 실제 소요가 얼마나 벌어졌는지를 남겨 둔 종결 사례가 하나도 없었습니다. 견적은 매번 냈고 일은 매번 끝났는데, 그 둘을 나란히 놓고 본 기록이 없었습니다.
곁다리로 발견한 것이었지만 어쩌면 이 글에서 가장 오래 남을 부분일지도 모르겠다는 생각이 들었습니다. 계획 오류가 편향이라고 불리는 이유는 틀리기 때문이 아니라 한쪽으로 틀리기 때문입니다. 그런데 제 추정이 어느 쪽으로 치우쳐 있는지 저는 지금 알 수 없습니다. 이번의 +28%가 한 번의 사고인지 평소의 습관인지도 모릅니다. 판단할 데이터가 없으니까요. 그 공백을 확인한 것 자체가 이번의 소득이었습니다.
이 소동 이후로, 이번에는 범위를 좁히기 전의 전체 범위를 대상으로 서로 입력이 겹치지 않는 네 가지 방법을 다시 돌렸습니다. 앞의 107시간·137시간과는 재는 대상 자체가 다르니 숫자를 바로 견주시면 안 됩니다. 예전에 제 블로그 프로젝트를 기능점수와 COCOMO, 시장 시세로 나란히 견적해 본 적이 있는데, 그때 방법을 겹쳐 본 이유도 같은 것이었습니다. 한 가지 방법의 결과는 그 방법의 편향을 그대로 물려받으니까요. 그 이야기를 다음 편에서 이어 쓰겠습니다.
관련 글
소프트웨어 개발 비용, 어떻게 산정할까? - 4가지 방법론과 실제 적용 사례
개인 블로그 프로젝트(42,000줄)를 4가지 비용 산정 방법론으로 분석해봤습니다. 기능점수(FP), COCOMO, 시장 시세, 유사 프로젝트 비교까지 - 각 방법의 원리와 실제 적용 결과를 공유합니다.
Cloudflare R2 presigned 멀티파트 업로드 — mock 160개가 못 잡은 버그 2개
바이트가 서버를 통과하지 않는 순간, 서버는 파일 크기도 업로드 완료 여부도 스토리지에 남은 것도 잃습니다. R2 presigned 멀티파트를 붙이며 그 셋을 되찾은 과정과, mock 160개가 초록불인 채로 실물 레인 하나가 잡아낸 버그 두 개를 기록했습니다.
맥북 디스크 정리 — 크기순이 아니라 지웠을 때의 대가순으로 봤습니다
닷새 전에 82GB까지 비운 맥북 디스크가 다시 20GB가 됐습니다. 지우는 일은 명령 한 줄이지만 어려운 건 분류였습니다. 크기순이 아니라 틀렸을 때 치르는 대가순으로 다섯 등급을 가른 기준과, 표준 정리 명령이 로컬 DB를 날릴 뻔한 연쇄 폭파를 정리했습니다.