Frontend
설계는 맞았지만, 전제가 바뀌어 있었다
김*한··수정됨 2026.09.05
같은 화면에서 같은 버튼을 눌렀는데, 어느 경로로 들어왔는지에 따라 견적 결과가 달랐습니다.
LED 전광판 견적 시스템은 오랫동안 두 개의 견적 화면을 운영해왔습니다. 용도와 예산만으로 서버가 구성을 추천하는 간편 견적, 제품과 컨트롤러를 직접 지정하는 전문 견적. 각각 POST /recommend/smart와 POST /calculate라는 서로 다른 API 위에 서 있었고, 사용자가 사양을 아는지 모르는지가 그 분리의 기준이었습니다.
두 화면을 하나로 합치기 전까지는, 이 구분에 문제를 느낄 이유가 없었습니다.
고른 값이 견적에 반영되지 않았다
통합된 화면에서 컨트롤러로 X20을 선택하고 견적을 뽑았더니, 견적서에는 X12가 찍혀 있었습니다.
에러는 없었습니다. 요청은 200으로 성공했고, 응답에는 컨트롤러 정보가 정상적으로 들어 있었고, 금액도 납득할 만한 범위였습니다. 다만 제가 고른 모델이 아니었을 뿐입니다.
계산 요청부를 열어보니 이런 분기가 있었습니다.
if (isProductLinkedQuote) {
// 제품 상세 페이지에서 넘어온 경우
const result = await calculateEstimate({
productId, pixelPitch, width, height,
deviceSelectionMode: "fixed",
selected_devices: formData.selectedDevices, // 사용자가 고른 컨트롤러
});
return;
}
// 견적 페이지로 직접 들어온 경우
const result = await getSmartRecommendation({
productId, pixelPitch, width, height,
budget, purpose, viewingDistance, // 컨트롤러 관련 필드 없음
});
같은 화면, 같은 버튼인데 진입 경로에 따라 다른 API를 타고 있었습니다. 그리고 두 번째 경로의 요청 본문에는 컨트롤러를 지정하는 필드가 아예 없습니다.
빠뜨린 게 아니었습니다. 서버 쪽 핸들러를 확인해보니 /recommend/smart는 그 값을 받는 스펙 자체가 아니었습니다. 컨트롤러는 화면 해상도를 계산해서 서버가 결정하는 값이었습니다.
// 필요 픽셀 수를 계산해 감당 가능한 모델 중 가장 작은 것을 고른다
const totalPixels = (actualWidth / pitch) * (actualHeight / pitch);
const device = await selectDeviceForPixels(totalPixels);
사용자가 무엇을 고르든 그 선택은 요청에 실리지 못했고, 서버는 자기 기준으로 컨트롤러를 골라 응답에 넣었습니다.
설계가 틀린 게 아니라, 전제가 사라졌다
여기서 짚고 싶은 건 이 분기가 처음에는 옳았다는 점입니다.
/recommend/smart는 "사용자가 사양을 모른다"는 전제 위에 만들어진 API입니다. 사양을 모르니 서버가 대신 골라주는 게 이 API의 존재 이유고, 그렇기 때문에 컨트롤러 지정 파라미터가 없는 것도 결함이 아니라 의도된 설계였습니다. 간편 견적 화면이 이 API를 쓰는 것도 당연했습니다.
전제가 깨진 건 화면을 합치던 시점입니다. 통합된 흐름에서는 사용자가 제품과 피치를 직접 고르고, 크기를 입력하고, 컨트롤러까지 선택한 뒤에 계산 단계에 도달합니다. "사용자가 사양을 모른다"는 전제는 이때 이미 성립하지 않았습니다.
그런데 코드에는 그 전제가 조건문 하나로 남아 있었습니다.
if (isProductLinkedQuote) { ... }
이 조건이 원래 구분하던 것은 "사양을 아는 사용자인가"였는데, 화면을 합친 뒤로는 단지 "어느 링크를 타고 들어왔는가"만 구분하고 있었습니다. 조건은 그대로인데 그것이 의미하던 바가 바뀐 것입니다.
그리고 이 어긋남은 UI를 실제로 얹어보기 전까지는 드러나지 않았습니다. 컨트롤러 선택 단계를 통합 흐름에 노출하고 나서야, 그 아래 API가 그 값을 받을 수 없다는 사실이 보였습니다.
왜 늦게 발견됐나
이 버그가 오래 살아남은 이유는 실패처럼 보이지 않았기 때문입니다.
선택이 무시됐다는 신호가 어디에도 없었습니다. 서버가 값을 못 받았을 때 400을 돌려줬다면 즉시 발견됐을 겁니다. 하지만 이 API에게 그건 오류 상황이 아니었습니다. 애초에 안 받는 값이니 없는 게 정상이고, 그래서 자기 로직대로 컨트롤러를 골라 응답을 채웠습니다.
두 경로로 같은 조건을 넣고 실제 요청과 응답을 나란히 비교하고 나서야 확실해졌습니다.
[제품 상세 → 견적] X20 지정 → 요청에 selected_devices 포함 → 응답 deviceModel "X20" ✓
[견적 페이지 직접] X20 지정 → 요청에 필드 없음 → 응답 deviceModel "X12" ✗
사용자 입력이 API 계약에 없으면, 시스템은 에러 대신 그럴듯한 값을 돌려줍니다. 빈 값이나 예외보다 이쪽이 훨씬 오래 숨어 있습니다.
해결은 분기를 없애는 것이었습니다. 통합 흐름에서는 계산 단계에 도달할 때 제품이 이미 확정돼 있으므로, 추천 API를 쓸 이유가 없어졌기 때문입니다.
// 모든 진입 경로가 같은 계산 API를 쓴다.
// 제품은 이 시점에 이미 확정돼 있으므로 추천이 필요 없다.
const result = await calculateEstimate({
productId: Number(selectedProduct.id),
pixelPitch: `P${selectedProduct.model}`,
width, height,
// 직접 고르면 그 컨트롤러를 강제하고, 안 고르면 견적에서 제외한다
deviceSelectionMode: formData.deviceSelectionMode === "fixed" ? "fixed" : "none",
selected_devices: formData.selectedDevices || [],
includeGob, includeFrame, includeAluminumCase,
exchangeRate,
});
컨트롤러 미선택 시의 동작도 함께 정리했습니다. 이전에는 안 고르면 서버가 자동으로 골라줬는데, 이제는 none 모드로 견적에서 제외합니다. 고르지 않은 것을 임의로 채워 넣는 것 자체가 앞서 겪은 문제의 축소판이기 때문입니다.
분기를 걷어내자 부수적으로 정리된 것들이 있었습니다. 두 API의 응답 스키마가 달라서 — 한쪽은 data.overfit.costs, 다른 쪽은 recommendation.calculation.costs — 화면에서 각각 변환하던 코드가 하나로 줄었고, 도달할 수 없게 된 140여 줄이 삭제됐습니다. 한쪽 경로에만 붙어 있던 견적 이력 저장도 자연스럽게 양쪽에 적용됐습니다.
정리
- 설계 시점에 옳았던 분기도 전제가 사라지면 부채가 된다
- 화면 통합과 데이터 흐름 통합은 별개다
- API 계약에 없는 입력은 에러가 아니라 그럴듯한 값으로 대체된다
화면을 합칠 때 저희가 던진 질문은 "이 컴포넌트들을 어떻게 하나로 만들까"였습니다. "이 화면들은 왜 나뉘어 있었을까"를 먼저 물었다면 API까지 함께 봤을 겁니다.
읽기 도구
약 7분 읽기
이 글이 도움이 되었나요?
Work with GIWorks
프로젝트에 GIWorks의 경험이 필요하신가요?
전시·콘텐츠·엔지니어링 프로젝트의 아이디어와 고민을 들려주세요. 필요한 기술과 실행 방법을 함께 찾겠습니다.
문의하기