협상 과정은 네 가지 움직임의 순수 함수이며, 이메일을 작성하는 모델은 한계점을 보지 못한다
요약
본 글은 협상 과정을 네 가지 움직임의 순수 함수로 모델링하여 설명하며, 이메일 작성 모델이 실제 인간의 복잡한 상호작용(주고받는 과정)을 완벽히 재현하기 어렵다는 점을 지적합니다. 특히, 결정적인 '무엇'은 네트워크나 데이터베이스가 아닌 단순한 TypeScript 코드 구조를 통해 구현됨을 강조합니다.
핵심 포인트
- 협상 과정을 네 가지 움직임의 순수 함수로 모델링함.
- 모델이 개입하는 시점에는 이미 숫자가 선택된 상태임.
- 결정적인 로직은 복잡한 인프라가 아닌 단순 코드 구조에 의존함.
- 금액 산정 시 인간적이고 논리적인 '반올림' 규칙을 적용해야 함.
저희 비즈니스 플랜에 따르면, Nakodo는 이메일을 통해 브랜드와 크리에이터에게 수수료를 책정할 수 있습니다. 브랜드는 각 크리에이터 사이즈별로 지불 가능한 최대 금액과 선택적으로 전체 캠페인 예산을 설정한 다음 업무로 돌아갑니다. 그러자 크리에이터가 첫 번째 이메일에서 제시된 금액보다 더 많은 것을 요구하며 답장을 보내고, 무엇인가가 결정해야 합니다.
그 '무엇'은 네트워크 연결도, 데이터베이스도, 모델도 없는 114줄의 순수 TypeScript 코드입니다. 모델은 한 단계 후에 개입하게 되며, 그때는 이미 숫자가 선택된 상태입니다. 이는 첫 수수료가 어디서 나오는지에 대한 이전 게시물과 관련된 내용입니다. 그 글은 오프닝 오퍼(opening offer)에 관한 것이었고, 이 글은 주고받는 과정(back and forth)에 관한 것입니다.
네 가지 움직임
export type Move =
| { type: "accept"; fee: number } // 동의: 그들의 요청 금액 또는 적게 요청했을 때 제시된 오퍼
| { type: "counter"; fee: number; final: boolean } // 대신 이 금액을 제안합니다
...
nextMove는 테이블 위에 있는 것, 그들이 요구한 것, 우리가 이 크리에이터에게 동의할 수 있는 최대 금액, 이미 몇 번의 반박(counter)이 있었는지, 그리고 마지막 것이 최종이었는지 여부를 받습니다. 그리고 네 가지 중 하나를 반환합니다. 이는 총 함수(total function)입니다: 아무 말도 할 수 없는 상태는 없습니다.
사다리의 첫 번째 줄이 제가 가장 좋아하는 부분입니다:
// 테이블 위에 있는 것보다 적게 요청하는 것은 '예'이다: 제시된 수수료가 유지된다.
if (asked <= offered) return { type: "accept", fee: offered };
£250을 제안받은 크리에이터가
그보다 더 나아가, 그 형태는 다음과 같습니다. 중간에 하나의 제안이 있고, 여전히 적합하다면 그들의 금액이 제시됩니다. 또는 그들이 요구하는 금액이 한도를 초과했다면, 명확하게 최종적인 제안으로서 그 한도 자체가 제시되거나; 아니면 바로 그보다 약간 넘어서서 브랜드가 결정하거나; 혹은 훨씬 많이 넘어서는 경우 정중한 거절을 합니다. 모든 분기는 협상을 종료시키거나 작은 고정된 횟수 중 하나를 소모하므로, 라운드에 대한 명확한 제한이 있습니다.
export const MAX_COUNTERS = 2;
제가 그 경계가 어디에 위치하는지를 설정하는 세 가지 비율이나 가격 확인의 승수(multiplier)는 분명한 이유로 공개하지 않습니다. 왜냐하면 자신이 요구하는 금액 중 첫 번째 반격이 어느 정도인지, 그리고 한도를 초과해도 여전히 제안을 받을 수 있는 거리가 얼마나 되는지 정확히 아는 크리에이터라면 사다리를 역방향으로 읽어 자신의 시작가를 적절하게 선택할 수 있기 때문입니다. 형태가 흥미로운 엔지니어링이며, 상수들은 그저 요율표(rate card)일 뿐입니다.
내려갈 수밖에 없는 반올림 (Rounding that can only go down)
아무도 영상에 대해 £318.40을 제안하지 않습니다. 금액은 사람이 말할 법한 단위로 맞춰집니다:
// 사람들이 제안하는 금액: 275, 1,250, 25,500. 내림하여 한도가 절대 초과되지 않게 합니다.
export function roundDown(n: number): number {
if (!Number.isFinite(n) || n <= 0) return 0;
...
단계는 숫자보다 한 자릿수 아래의 절반이므로, 금액에 따라 증가합니다. 279는 275가 되고, 1,234는 1,200이 되며, 25,640은 25,500이 되고, 73은 그대로 73입니다. 중요한 단어는 '내림(down)'입니다. 한도를 가장 보기 좋은 숫자로 반올림하면 때때로 올림될 수 있고, 그러면
세 가지 상한선(caps)이 있습니다: 이 브랜드가 이 규모의 크리에이터에게 지불할 수 있는 최대 금액, 캠페인 예산 중 남은 금액, 그리고 가격 검토를 거친 후 실제 이 크리에이터의 오디언스가 가치 있는 배수입니다. 마지막 항목이 존재하는 이유는 앞의 두 가지는 정적(static)이지만 오디언스는 그렇지 않기 때문입니다. 3,000명의 일반 시청자를 가진 크리에이터가 중간 수준의 요율을 요청한다고 해서 브랜드가 그 규모에 대해 높은 상한선을 가지고 있다는 이유만으로 그것을 받아서는 안 됩니다.
Math.max(o.offered, ...)는 한도가 우리가 이미 서면으로 기재한 것보다 낮아지지 않음을 의미합니다. 캠페인 도중에 예산이 소진되었다고 해서 크리에이터의 받은 편지함에 이미 들어간 제안을 철회할 수는 없습니다.
그리고 조기 반환(early return)은 입 밖으로 내뱉어야 할 부분입니다: 브랜드가 크리에이터 규모에 대한 상한선을 설정하지 않았다면, 한도는 null이며, null은 '브랜드에게 문의하라', 즉 아무것도 지불하지 않음을 의미합니다. 상한선이 없는 규모는 그것을 명시하는 문장과 함께 needs_you를 생성합니다. 저는 전에 null이 무제한을 의미했던 계획 기능에 대해 작성한 적이 있습니다 그리고 무료 플랜이 실수로 유료 플랜보다 더 많은 기능을 갖게 되었습니다. 한 번이면 충분했습니다.
예산 측면은 상수(constant)가 아니라 쿼리(query)가 필요합니다. 왜냐하면 남은 금액은 캠페인의 다른 모든 대화에 달려 있기 때문입니다:
const fee = o.deal?.agreed ?? o.fee;
도입된 각 스레드는 합의된 요율 또는 아직 아무것도 합의되지 않았다면 마지막으로 제안된 금액(캠페인 통화로 변환)을 기준으로 계산됩니다. 현재 스레드는 ID를 통해 제외되므로 크리에이터가 자기 자신과 경쟁하는 일이 없으며, 도입이 철회된 스레드는 더 이상 handed_off 되지 않으므로 스스로 카운트에서 빠집니다.
상태가 존재하는 곳, 그리고 우리가 작성하지 않은 마이그레이션
협상은 상태(state)를 가집니다: 그들이 요청한 것, 어떻게 작성했는지, 몇 개의 카운터가 만들어졌는지, 마지막 것이 최종이었는지, 마지막으로 결정했을 때의 한도는 무엇이었는지, 그들의 오디언스가 얼마의 가치를 가지는지, 그리고 그들이 합의한 것은 무엇인지. 이것이 아홉 개의 필드입니다.
이것은 테이블이 아닙니다. 아홉 개의 열도 아닙니다. 이것은 모든 스레드가 이미 가지고 있던 offer JSON 열 안에 있는 deal 객체입니다. 왜냐하면 오퍼(offer)가 디일(deal)을 수정하고, 이 둘은 항상 함께 읽히고 쓰이기 때문입니다. 마이그레이션(migrations)이 전혀 필요하지 않으며, 기능 이전의 스레드는 디일(deal)이 없는 스레드로 읽히는데, 이는 올바릅니다.
const before: Deal = offer.deal ?? {
firstFee: offer.fee, asked: null, askedText: null, counters: 0,
final: false, limit: null, fair: null, agreed: null, waiting: null,
...
firstFee는 유지됩니다. 왜냐하면 협상이 진행됨에 따라 오퍼(offer)의 수수료가 덮어쓰여지기 때문에, 브랜드 측은 시작점이 어디였는지 여전히 확인하고 싶어 하기 때문입니다.
모델에게 제공되는 것
움직임(move)이 존재하면, 이메일로 이를 작성해야 합니다. 작가(writer)가 받는 입력은 목록이며, 그 안의 모든 것을 읽을 수 있습니다:
Move: counter
Brand: Fernway
Creator's channel: Lift With Lena
...
여기에는 한계점이 없습니다. 예산(budget), 남은 예산(remaining budget), 크기의 최대치, 다른 크리에이터, 또는 다른 사람이 합의한 수수료가 없습니다. 모델이 한계점을 이메일에 누설하는 것은 불가능합니다. 왜냐하면 그 한계점 자체가 요청(request)에 없었기 때문이며, 지침(instructions)은 여기에 안전장치를 추가합니다: 예산, 한계점, 다른 크리에이터 또는 다른 사람이 받는 금액을 절대 언급하지 말라는 것입니다. 이 문장이 여전히 지침 안에 있는지 확인하는 테스트가 있는데, 프롬프트(prompt)를 다시 작성할 때까지는 어리석게 들립니다.
그러면 답장은 모든 자동화된 답변이 거치는 것과 동일한 가드(guard)를 통과합니다. 초안에 있는 모든 금액과 백분율은 추출되어 입력의 금액과 비교됩니다:
// Amounts in an answer that appear nowhere in what the AI was given: made
// up, so the answer isn't sent.
export function unknownAmounts(answer: string, input: string): string[]
이 코드는 £1,200, $1.5k, 250 GBP, 그리고 20 퍼센트를 처리하며, 백분율을 금액과 분리하여 10% 수수료와 £10 수수료가 같은 사실이 아님을 유지합니다. 창작자 자신의 요율을 반복하는 것은 괜찮습니다. 왜냐하면 그 요율은 입력에 있기 때문입니다. 배송비나 두 번째 할부금을 지어내는 것은 아닙니다. 임의로 금액을 만든 초안은 폐기되고 일반 버전이 대신 사용됩니다:
export function fallbackFeeReply(move: "counter" | "final" | "accept", fee: string, name: string | null, contact: string): string {
const hi = name ? `Hi ${name}, thanks` : "Thanks";
if (move === "accept") return `${hi}, ${fee} works for us. We've copied in ${contact} to confirm the details and send the brief.`;
...
이 세 문장은 평이하며, 창작자가 무엇을 작성했든 항상 사실입니다. 이것이 모델이 다운되었거나, 토큰에 지배당하거나, 상상 속의 숫자를 인용한 경우에도 중요한 유일한 속성입니다. 협상은 아예 모델 없이도 작동합니다. 그저 양식처럼 읽힐 뿐입니다.
공개 측면
이 코드가 보장하는 약속은 고객과 창작자 모두가 읽을 수 있는 곳에 적혀 있습니다: 약관에는 "Nakodo가 당신을 위해 동의하는 수수료는 당신이 설정한 한도를 절대 초과하지 않으며, 원칙적으로 합의된다"라고 되어 있고, 개인정보 보호정책에는 "설정된 한도 내에서 수수료에 동의한다"고 되어 있는데, 이 고지는 브랜드가 아닌 창작자에게 적용됩니다.
그리고 같은 산술이 아무것도 숨겨지지 않은 상태로 어떻게 보이는지 보고 싶다면, 스폰서십 비용 계산기는 백엔드 없이 브라우저에서 실행되며, 유튜버에게 지불해야 할 금액 가이드에서는 범위가 설명됩니다. 이 계산기는 범위를 게시하고 적정 가격을 게시하는 것을 거부하는데, 이는 이 글이 비율(ratios) 대신 사다리(ladder)를 게시하는 이유와 같은 이유입니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기