겉으로 보이는 동작은 그대로 두고, 지금 동작을 테스트로 먼저 묶은 다음 작은 단계로 나눠 정리했습니다. 코드에 안 보이는 정보는 「확인 필요」로 남겼습니다.
1. 하는 일 정리와 지금 동작을 잡아 두는 테스트
하는 일 (입력 → 출력)
입력: order 하나
| 값 | 지금 코드가 쓰는 방식 |
|---|
order.items | for...of로 한 줄씩 돕니다. 배열이 아니어도 반복할 수 있는 값(Set 등)이면 됩니다. 없으면 TypeError가 납니다 |
item.type | "food"와 ==로 비교합니다. 같으면 음식, 다르거나 없으면 '그 밖'입니다 |
item.price, item.qty | 곱해서 줄 금액을 냅니다. "1000" 같은 숫자 문자열도 계산됩니다. 없으면 NaN이 됩니다 |
order.coupon | "WELCOME"과 ==로 비교해 같을 때만 3,000원을 뺍니다 |
order.member | == true로 비교합니다. true, 1, "1"은 회원, "true", 2는 회원이 아닙니다 |
출력: 원 단위 정수(Math.round). 숫자가 아닌 값이 섞이면 NaN이 나옵니다.
계산 순서
- 줄마다 단가 × 수량을 구합니다. 수량이 10을 넘으면(11개부터) 음식은 × 0.9, 그 밖은 × 0.95. 반올림하지 않고 소수 그대로 둡니다.
- 줄 금액을 모두 더합니다.
- 쿠폰이
"WELCOME"이면 3,000원을 뺍니다.
- 0보다 작으면 0으로 맞춥니다(아래에서 「0원 바닥」이라고 부릅니다).
- 회원이고 금액이 50,000원을 넘으면 × 0.97. 이때 금액은 쿠폰까지 뺀 뒤 금액입니다.
- 반올림해서 돌려줍니다.
설명과 코드는 맞습니다("10개 넘게"는 > 10, "5만 원이 넘으면"은 > 50000). 다만 설명에 없는데 코드가 정해 버린 것이 세 가지 있습니다. 수량을 줄마다 따로 세고, 5만 원을 쿠폰 뺀 금액으로 보고, 반올림은 마지막에 한 번만 합니다.
테스트
Node 20에 들어 있는 node:test를 쓰므로 설치할 것이 없습니다. 기대값은 전부 지금 코드가 내는 값 그대로입니다.
확인 필요: calc가 들어 있는 파일 이름과 내보내는 방식이 코드에 안 보여서, calc.js에서 module.exports = { calc }로 내보낸다고 가정했습니다. ESM(import/export)을 쓰고 있으면 맨 위 require 세 줄만 import로 바꾸면 됩니다.
// calc.test.js
// 실행: node --test calc.test.js (Node 20에 들어 있는 테스트 도구라 설치할 것 없음)
const test = require("node:test");
const assert = require("node:assert/strict");
const { calc } = require("./calc"); // 확인 필요: 파일 이름과 내보내는 방식
// 시험용 상품 한 줄
const food = (price, qty) => ({ type: "food", price, qty });
const goods = (price, qty) => ({ type: "goods", price, qty }); // food가 아닌 종류(값은 아무거나)
test("대량 할인: 한 줄의 수량이 10을 넘을 때만 (음식 10%, 그 밖 5%)", () => {
assert.equal(calc({ items: [food(1000, 10)] }), 10000); // 10개는 할인 없음
assert.equal(calc({ items: [food(1000, 11)] }), 9900); // 11,000 × 0.9
assert.equal(calc({ items: [goods(1000, 11)] }), 10450); // 11,000 × 0.95
// 지금 동작이 맞는지 확인 필요: 줄마다 따로 세서, 6개씩 두 줄은 할인 없음
assert.equal(calc({ items: [food(1000, 6), food(1000, 6)] }), 12000);
});
test("경계값: 빈 목록과 0", () => {
assert.equal(calc({ items: [] }), 0);
assert.equal(calc({ items: [], coupon: "WELCOME" }), 0); // 0 - 3,000 → 0원
assert.equal(calc({ items: [food(1000, 0)] }), 0); // 수량 0
assert.equal(calc({ items: [goods(0, 20)] }), 0); // 가격 0
});
test("순서: 쿠폰 → 0원 바닥 → 회원 할인(50,000원 초과)", () => {
assert.equal(calc({ items: [food(1000, 10)], coupon: "WELCOME" }), 7000);
assert.equal(calc({ items: [food(1000, 2)], coupon: "WELCOME" }), 0); // -1,000 → 0원
assert.equal(calc({ items: [goods(50000, 1)], member: true }), 50000); // 딱 5만 원은 할인 없음
assert.equal(calc({ items: [goods(50001, 1)], member: true }), 48501); // 48,500.97 → 반올림
// 지금 동작이 맞는지 확인 필요: 쿠폰을 뺀 금액(50,000원)으로 따져서 회원 할인 없음
assert.equal(calc({ items: [goods(53000, 1)], coupon: "WELCOME", member: true }), 50000);
});
test("경계값: 없는 값", () => {
// 지금 동작이 맞는지 확인 필요: type이 없으면 '그 밖'으로 보고 5% 할인
assert.equal(calc({ items: [{ price: 1000, qty: 11 }] }), 10450);
// 지금 동작이 맞는지 확인 필요: qty가 없으면 정수가 아니라 NaN
assert.ok(Number.isNaN(calc({ items: [{ type: "food", price: 1000 }] })));
// 지금 동작이 맞는지 확인 필요: items가 없으면 TypeError로 멈춤
assert.throws(() => calc({}), TypeError);
});
test("리팩토링 중 바뀌기 쉬운 곳: 느슨한 비교(==)와 반올림 위치", () => {
// 지금 동작이 맞는지 확인 필요: 쿠폰 코드는 대소문자를 가림
assert.equal(calc({ items: [food(1000, 10)], coupon: "welcome" }), 10000);
// 지금 동작이 맞는지 확인 필요: member가 1이면 회원, 문자열 "true"면 회원 아님
assert.equal(calc({ items: [goods(60000, 1)], member: 1 }), 58200);
assert.equal(calc({ items: [goods(60000, 1)], member: "true" }), 60000);
// 줄 금액은 소수 그대로 더하고, 반올림은 마지막에 한 번
assert.equal(calc({ items: [food(15, 11)] }), 149); // 148.5 → 149
assert.equal(calc({ items: [food(15, 11), food(15, 11)] }), 297); // 줄마다 반올림했다면 298
});
실행: node --test calc.test.js
「지금 동작이 맞는지 확인 필요」로 표시한 곳 (7곳)
- 같은 상품이 두 줄로 나뉘면(6개 + 6개) 대량 할인이 없습니다.
- 회원 할인 5만 원을 쿠폰 뺀 금액으로 따집니다. 53,000원에 쿠폰을 쓰면 50,000원이 되어 회원 할인이 없습니다.
type이 없으면 음식이 아닌 것으로 보고 5% 할인합니다.
qty가 없으면 오류 없이 NaN을 돌려줍니다.
items가 없으면 TypeError로 멈춥니다.
- 쿠폰 코드는 대소문자를 가립니다(
"welcome"은 안 먹힙니다).
member가 1이면 회원, 문자열 "true"면 회원이 아닙니다.
2. 고칠 곳 목록
행 번호는 function calc(order) {를 1행으로 셉니다.
| 종류 | 위치 | 내용 |
|---|
| 긴 함수 | 1~22행 calc 전체 | 22줄이지만 줄 금액, 대량 할인, 쿠폰, 0원 바닥, 회원 할인, 반올림까지 여섯 가지 일을 한 함수가 합니다 |
| 같은 코드 반복 | 6·8·12·14행, 5·11행 | total = total + item.price * item.qty가 네 번, item.qty > 10이 두 번 나옵니다. 8행과 14행은 글자까지 같고, 음식과 그 밖은 곱하는 수(0.9, 0.95)만 다릅니다 |
| 깊은 중첩 | 3~16행 | for 안에 if(종류), 그 안에 if(수량)로 3단입니다 |
| 뜻이 안 보이는 이름 | 1행 calc, 2행 total | calc는 무엇을 계산하는지 안 보이지만 바꾸면 안 되는 이름이라 주석으로 보완합니다. total 하나가 상품 합계, 쿠폰 뺀 금액, 최종 금액을 번갈아 맡습니다 |
| 박힌 숫자·문자열 | 4행 "food", 5·11행 10, 6행 0.9, 12행 0.95, 18행 "WELCOME"·3000, 20행 50000·0.97 | 할인 정책이 식 곳곳에 흩어져 있어, 정책을 바꿀 때 한 곳을 놓치기 쉽습니다 |
| 이번엔 안 고침 | 4·18·20행 == | ===로 바꾸면 결과가 달라지는 입력이 있습니다(예: member: 1). 「따로 할 일」로 뺍니다 |
3. 단계별 리팩토링 계획
단계마다 테스트를 돌리고 커밋해 두면, 깨졌을 때 바로 앞 단계로 돌아갈 수 있습니다.
| 단계 | 바꾸는 것 | 테스트 |
|---|
| 0 | 위 테스트 파일을 추가하고 지금 코드에서 돌립니다 | 5개 통과 (기준선) |
| 1 | 박힌 숫자·문자열 8개를 값은 그대로 둔 채 이름 있는 상수로 바꿔 파일 맨 위에 모읍니다 | 통과 |
| 2 | 종류별 배율을 고르는 bulkMultiplier(type)을 만들어 음식/그 밖 두 갈래를 하나로 합칩니다 | 통과 |
| 3 | 줄 금액 계산을 lineAmount(item)으로 빼고, 반복문은 subtotal에 더하기만 하게 합니다 | 통과 |
| 4 | 쿠폰 적용을 applyCoupon(total, coupon)으로 뺍니다 | 통과 |
| 5 | 회원 할인을 applyMemberDiscount(total, member)로 뺍니다 | 통과 |
| 6 | calc 위에 하는 일과 계산 순서를 주석으로 적습니다 | 통과 |
일부러 안 바꾼 것
== 비교: 결과가 달라질 수 있어 그대로 두고 「따로 할 일」로 뺐습니다.
for...of를 reduce로 바꾸지 않았습니다. reduce는 배열에만 있어서 Set 같은 값이 오면 오류가 나고, 빈 칸이 있는 배열은 지금은 오류가 나는데 reduce는 빈 칸을 건너뛰고 계산해 버립니다.
- 계산 순서(쿠폰 → 0원 바닥 → 회원 할인)와 반올림 위치(마지막 한 번).
- 값을 읽는 순서(
type → qty → price). 그래서 lineAmount가 수량을 보기 전에 배율부터 고릅니다. 덕분에 items: [null]처럼 잘못된 값이 와도 오류 문구까지 원래와 같습니다.
주의: 상수는 파일 맨 위에 둡니다. const는 선언한 줄보다 먼저 쓰면 오류가 나서, 같은 파일 위쪽에서 calc를 바로 부르는 코드가 있으면 깨집니다.
4. 최종 코드(전체)
// 할인 정책
// 주의: == 비교는 원래 동작을 지키려고 일부러 그대로 둡니다. (=== 로 바꾸는 일은 '따로 할 일' 참고)
// 대량 할인: 한 줄의 수량이 이 값을 '넘으면' 적용 (10개는 할인 없음, 11개부터)
const BULK_QTY_THRESHOLD = 10;
const FOOD_TYPE = "food";
const FOOD_BULK_MULTIPLIER = 0.9; // 음식 10% 할인
const OTHER_BULK_MULTIPLIER = 0.95; // 그 밖의 상품 5% 할인
// 쿠폰
const WELCOME_COUPON_CODE = "WELCOME";
const WELCOME_COUPON_AMOUNT = 3000; // 원
// 회원 할인: 쿠폰까지 뺀 금액이 이 값을 '넘으면' 적용
const MEMBER_DISCOUNT_THRESHOLD = 50000; // 원
const MEMBER_MULTIPLIER = 0.97; // 3% 할인
// 상품 종류에 맞는 대량 할인 배율
function bulkMultiplier(type) {
return type == FOOD_TYPE ? FOOD_BULK_MULTIPLIER : OTHER_BULK_MULTIPLIER;
}
// 상품 한 줄의 금액 (대량 할인 포함, 반올림 전)
function lineAmount(item) {
const multiplier = bulkMultiplier(item.type);
if (item.qty > BULK_QTY_THRESHOLD) {
return item.price * item.qty * multiplier;
}
return item.price * item.qty;
}
function applyCoupon(total, coupon) {
if (coupon == WELCOME_COUPON_CODE) return total - WELCOME_COUPON_AMOUNT;
return total;
}
function applyMemberDiscount(total, member) {
if (member == true && total > MEMBER_DISCOUNT_THRESHOLD) {
return total * MEMBER_MULTIPLIER;
}
return total;
}
/**
* 장바구니 주문 금액을 원 단위 정수로 돌려줍니다.
* 순서: 줄 금액 합계 → 쿠폰 → 0원 바닥 → 회원 할인 → 반올림
*/
function calc(order) {
let subtotal = 0;
for (const item of order.items) {
subtotal += lineAmount(item);
}
let total = applyCoupon(subtotal, order.coupon);
if (total < 0) total = 0; // 0원 밑으로는 내려가지 않음
total = applyMemberDiscount(total, order.member);
return Math.round(total); // 반올림은 마지막에 한 번
}
module.exports = { calc }; // 확인 필요: 지금 내보내는 방식에 맞추기 (도우미 함수와 상수는 내보내지 않음)
5. 바뀐 점 표
| 전 | 후 | 이유 |
|---|
0.9, 0.95, 10, "food", "WELCOME", 3000, 50000, 0.97이 식 안에 박혀 있음 | 이름 있는 상수 8개를 파일 맨 위에 모음 | 할인 정책을 한곳에서 읽고 고칠 수 있게 |
| 음식/그 밖 두 갈래에 거의 같은 식 4줄(6·8·12·14행) | bulkMultiplier가 곱할 수만 고르고, 식은 lineAmount에 한 번 | 반복을 없애고, 종류가 늘어도 한 곳만 고치게 |
for → if → if 3단 중첩 | 반복문 안은 subtotal += lineAmount(item) 한 줄 | 중첩을 없애고, 반복문은 더하기만 하게 |
쿠폰·회원 할인 조건이 calc 안에 한 줄씩 | applyCoupon, applyMemberDiscount | calc가 계산 순서만 보여 주게 |
total 하나가 단계마다 다른 금액을 맡음 | 상품 합계는 subtotal, 그 뒤는 total | 지금 값이 어느 단계 금액인지 이름으로 보이게 |
calc 이름만으로는 하는 일을 알 수 없음 | 이름은 그대로, 위에 설명 주석 | 이름을 바꾸면 안 되므로 |
| 내보내기가 코드에 안 보임 | module.exports = { calc } (확인 필요) | 테스트에서 불러오려고. 도우미 함수와 상수는 내보내지 않아 밖에 공개된 것은 calc 하나 그대로 |
6. 따로 할 일과 직접 확인할 것
리팩토링과 섞지 말고, 리팩토링을 끝낸 뒤 하나씩 따로 고치세요. 고칠 때는 해당 테스트의 기대값을 먼저 바꿔 실패하는 것을 본 다음 코드를 고치면 됩니다.
따로 할 일: 버그로 보이는 곳
- 없는 값이 NaN으로 나갑니다.
qty나 price가 없거나 숫자가 아니면 오류 없이 NaN을 돌려줘서 "원 단위 정수" 약속이 깨집니다. 그런데 qty: null이면 0원으로 계산돼, 같은 '없음'인데 결과가 다릅니다. 입력 검사를 넣되, 오류를 던질지 0으로 볼지는 정책부터 정해야 합니다.
items가 없으면 TypeError로 멈춥니다. 빈 주문을 0원으로 볼지, 지금처럼 멈추는 게 맞는지 정해야 합니다.
member == true가 애매합니다. 1, "1"은 회원, "true", 2는 회원이 아닙니다. === true로 바꾸면 1로 오는 데이터가 할인을 잃고, if (order.member)로 바꾸면 문자열 "false"도 회원이 됩니다. 실제 데이터 모양을 보고 고쳐야 합니다.
- 표기가 조금만 달라도 다른 값이 됩니다.
"Food", " food"는 음식이 아니라 5% 할인이고, "welcome"은 쿠폰이 안 먹힙니다.
- 음수 수량·가격이 합계를 깎습니다. 음식 1,000원 × 20개(18,000원)에 1,000원 × -5개 줄이 같이 오면 13,000원이 됩니다. 0원 바닥은 쿠폰 뒤에 한 번만 걸립니다. 막을지 정해야 합니다.
따로 할 일: 성능
- 손볼 곳이 보이지 않습니다. 상품 수만큼 한 번 도는 구조이고, 리팩토링으로 늘어난 함수 호출은 차이가 거의 없습니다.
직접 확인할 것
- 실제 주문 데이터의 값 모양:
member가 true/false인지 1/0인지, qty·price가 숫자인지 문자열인지, type에 어떤 값이 오는지. 위 1·3·4번을 어떻게 고칠지가 여기서 정해집니다.
- 할인 정책 세 가지: 회원 할인 5만 원을 쿠폰 전 금액으로 보는지 뒤 금액으로 보는지, 같은 상품이 두 줄로 나뉘어 들어올 수 있는지, 반올림을 마지막에 한 번 하는지 줄마다 하는지(15원 × 11개 음식 두 줄: 지금 297원, 줄마다 반올림하면 298원).
- calc를 부르는 쪽: 지금 어떻게 내보내는지(CommonJS인지 ESM인지), NaN이나 TypeError를 받아 처리하는 코드가 있는지. 있다면 따로 할 일의 수정이 그쪽 동작까지 바꿉니다.
실제 주문 데이터 두세 건(특히 member·qty·price 값)과 calc를 부르는 코드 한 군데를 보여 주시면 「확인 필요」 자리를 확정하고 버그 수정안까지 이어서 써 드릴 수 있는데, 알려 주실 수 있을까요?