| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | |||
| 5 | 6 | 7 | 8 | 9 | 10 | 11 |
| 12 | 13 | 14 | 15 | 16 | 17 | 18 |
| 19 | 20 | 21 | 22 | 23 | 24 | 25 |
| 26 | 27 | 28 | 29 | 30 | 31 |
- FastAPI
- 인증
- 백엔드
- 프론트엔드
- 온디바이스AI
- React
- next.js
- 아키텍처
- 보안
- javascript
- typescript
- 배포
- Kotlin
- github actions
- 마이그레이션
- 메일자동화
- 크롤링
- JWT
- REST API
- playwright
- php
- electron
- 소셜로그인
- Vite
- Android
- 상태관리
- sqlite
- python
- contextBridge
- 도메인설계
- Today
- Total
HM소프트
취향 큐레이션 커머스 개발기 (2) — 49개 피처 테스트와 사흘간의 버그 사냥 본문

취향 큐레이션 커머스를 피처 테스트 49개와 Playwright E2E로 검증하고 사흘간 버그 23개를 잡은 기록. S3 전환의 숨은 경로 가정, 한국어 UI와 역할 기반 셀렉터 충돌, 정산 집계의 상태 조건, 환불을 8단계 상태 머신으로 설계한 과정을 담았다.
기능은 다 만들었다, 이제 믿을 수 있게 만들자
1편에서 Laravel + Blade 모놀리스로 취향 큐레이션 커머스의 골격을 잡았다. 멀티롤 권한, 추천 엔진, 결제 위젯 v2, 정산 어댑터까지 — "기능이 있는" 상태는 됐다. 그런데 기능이 "있는 것"과 "동작하는 것"은 다르다. 이 차이를 테스트를 붙이고 나서야 숫자로 확인했다. 이번 편은 골격을 믿을 수 있게 만든 과정의 기록이다. 미디어 스토리지를 S3로 옮기고, 피처 테스트 49개로 회귀의 그물을 친 뒤, Playwright로 브라우저까지 끌고 들어가, 사흘간 버그를 23개 잡아냈다.
먼저 이번 편의 검증 구조를 한 장으로 본다.

요지는 두 겹의 그물이다. 피처 테스트는 HTTP 요청 레벨에서 비즈니스 규칙이 맞는지 빠르게 검증하고, E2E는 실제 브라우저로 화면이 정말 도는지 확인한다. 둘 다 깔아 두니, 버그 하나를 고칠 때마다 "이 수정이 다른 걸 깨뜨렸는지"를 몇 분 안에 알 수 있었다. 사흘간의 버그 사냥이 무모하지 않았던 건 이 그물 덕분이다.
미디어 스토리지를 로컬에서 S3로
가장 먼저 손본 인프라 작업은 미디어 저장소다. 상품 이미지와 스토리 미디어를 처음엔 로컬 디스크에 저장하고 있었는데, 배포 환경에서 서버 인스턴스가 교체되면 그동안 올라간 파일이 통째로 날아가는 구조였다. 그래서 S3 호환 오브젝트 스토리지로 전환했다. Laravel은 파일시스템을 "디스크"라는 추상화 뒤에 두기 때문에, 애플리케이션 코드는 디스크 이름만 알면 된다 — 그 디스크가 로컬이든 S3든 호출부는 동일하다.
// 미디어 업로드 — 코드는 디스크 이름만 안다
// 'media' 디스크가 로컬을 가리키든 S3를 가리키든 호출은 그대로
Storage::disk('media')->putFileAs("products/{$id}", $file, $name);
$url = Storage::disk('media')->url($path);
전환 자체는 설정 변경에 가까웠지만, 옮기면서 두 가지가 같이 깨졌다. 하나는 로컬 경로를 가정하고 있던 테스트들 — storage/app 아래 실제 파일을 들여다보던 단언들이 S3에서는 의미가 없어졌다. 다른 하나는 업로드 후 리다이렉트하던 관리자 페이지 라우트 하나가 누락돼 있던 것(스토리지 전환과 무관하게 잠복해 있던 버그가 이 김에 드러났다). 테스트는 Storage::fake('media')로 가짜 디스크를 물려 고치고, 누락 라우트는 리소스 라우트를 보강해 복구했다.
// 테스트에서는 실제 S3 대신 가짜 디스크를 물린다
Storage::fake('media');
// ... 업로드 동작 실행 ...
Storage::disk('media')->assertExists("products/{$id}/{$name}");
S3 전환에서 하나 더 신경 쓴 건 권한(ACL) 정책이다. 요즘 버킷은 객체별 ACL을 끄고 버킷 소유자 권한으로 통일하는 설정(BucketOwnerEnforced)을 쓰는 경우가 많은데, 이 환경에서 업로드 시 객체 ACL을 지정하면 거꾸로 에러가 난다. 그래서 디스크 설정에서 ACL 지정을 빼야 했다. "S3 전환은 설정 한 줄"이라고들 하지만, 실제 작업의 본체는 로컬 경로 가정이 코드 어디에 숨어 있는지 찾아내는 것과 권한 정책을 환경에 맞추는 것이었다.
피처 테스트 49개 — 회귀의 그물을 깔다
기능 구현을 8개 단계(무드 테마, 스와이프 퀴즈, 홈 개인화, 영상 랜딩, 스토리 뷰어, 큐레이터 코멘트, 대시보드 차트, 정산 어댑터)로 나눠 진행했었는데, 각 단계를 커버하는 피처 테스트 49개를 한 번에 작성했다. 회원가입부터 장바구니·주문·정산·큐레이터 승인까지 HTTP 레벨에서 도는 통합 테스트다. 최종적으로 프로젝트 전체 테스트는 217개까지 늘었다.
피처 테스트를 짤 때 의식적으로 둘로 나눴다. 하나는 시드 데이터 무결성 — 추천 엔진이 기댓값대로 동작하려면 시드 데이터의 구조 자체가 맞아야 한다. 무드 카테고리 5개, 카테고리당 태그 3개(총 15개), 질문 5개에 선택지 4개씩, 모든 선택지에 태그 매핑이 존재하는지 같은 것들을 못 박았다.

이 무결성 테스트가 의외로 값을 했다. 추천이 이상하게 나올 때 "엔진 로직이 틀렸나, 데이터가 틀렸나"를 가르는 첫 분기점이 되어 줬기 때문이다. 데이터 무결성 테스트가 통과하는데 추천이 이상하면, 원인은 엔진에 있다 — 이렇게 원인 후보를 절반으로 줄여 주는 테스트가 디버깅 속도를 결정한다.
다른 하나는 비즈니스 규칙 검증이다. 대표적인 게 재고 검증. "재고보다 많이 담을 수 없다"는 규칙은 1편의 장바구니 코드에 들어 있는데, 이게 HTTP 레벨에서 실제로 막히는지를 테스트로 못 박았다.

테스트를 짜다 만난 함정 하나는 DB 격리였다. 시드 데이터에 의존하는 테스트와 그렇지 않은 테스트가 섞이면서, 어떤 테스트는 시드가 있다고 가정하고 어떤 테스트는 빈 DB를 가정해 서로의 상태에 오염됐다. 각 테스트 클래스에 protected $seed = true;를 명시해 모든 테스트가 같은 시드 상태에서 출발하도록 통일하고 나서야 217개가 안정적으로 초록불이 됐다. 테스트의 신뢰도는 단언의 정교함보다 출발 상태가 결정적인가에 더 크게 달려 있다는 걸 다시 배웠다.
Playwright E2E와 한국어 라벨의 충돌
HTTP 레벨 위에 Playwright로 브라우저 검증을 한 겹 더 얹었다. 공개 화면 로드, 로그인/로그아웃, 역할별 화면 접근, 무드 퀴즈 같은 실제 사용자 동선을 진짜 브라우저로 도는 테스트다.
// tests/e2e/site.spec.ts — 공개 화면 스모크 테스트
test('스토어 페이지 — 상품 목록 표시', async ({ page }) => {
await page.goto('/store');
const products = page.locator('[class*="product"], .grid a[href*="/store/"]');
await expect(products.first()).toBeVisible();
});
test('무드 퀴즈 질문 1 표시', async ({ page }) => {
await page.goto('/mood/question/1');
await expect(page.locator('body')).toContainText('질문');
const options = page.locator('button, a, label')
.filter({ hasText: /에너지|차분|졸리|의욕/ });
expect(await options.count()).toBeGreaterThanOrEqual(1);
});
그런데 E2E를 한국어 UI에 붙이면서 예상 못 한 문제를 두 번 만났다. 첫째, 테스트가 처음엔 영문 라벨을 가정하고 있었다(프로필, 비밀번호 변경, 계정 삭제 등이 영어로 적혀 있었다). 화면은 한국어인데 테스트는 영어를 찾으니 줄줄이 실패했다. UI를 한국어로 통일하면서 테스트 단언도 한국어로 맞췄다.
둘째가 더 까다로웠다. 라벨을 맞춘 뒤에도 빵부스러기(breadcrumb) 셀렉터가 상단 내비게이션의 한국어 라벨과 충돌했다. 같은 텍스트(예: "주문 내역")가 GNB에도, breadcrumb에도 있으니, 텍스트만으로 잡는 getByText가 두 요소를 동시에 매칭해 테스트가 "strict mode" 위반으로 깨졌다. 해법은 역할(role) 기반 셀렉터로 범위를 좁히는 것이었다.

E2E를 안정화하면서 마지막으로 손본 건 로드 시간 임계값이다. 처음엔 메인 페이지 로드를 3초 안으로 단언했는데, CI 환경은 로컬보다 네트워크·CPU가 느려서 거짓 경보가 자꾸 울렸다. 임계값을 10초로 현실에 맞게 완화했다. 성능 단언을 이상적인 수치로 박으면, 그 테스트는 성능을 지키는 게 아니라 CI를 빨갛게 만드는 알람으로 전락한다. 성능은 별도 벤치마크로 측정하고, E2E의 시간 단언은 "멈춤(hang)을 잡는" 정도의 헐거운 안전망으로 두는 게 맞았다.
사흘간의 버그 사냥 — 절반은 디테일이었다
그물을 다 깔고 나서 본격적으로 버그를 잡았다. 하루에 8개, 5개, 5개씩 묶음으로 처리하며 사흘을 보냈다. 인상적이었던 건, 버그의 절반이 기능 부재가 아니라 기본값·상태 보존·검증 누락 같은 디테일이었다는 점이다. 기억에 남는 것들을 추리면:
- 장바구니 재고 검증 누락 — 품절·재고부족 상품이 검증 없이 주문까지 흘러갔다. 담기와 수량변경 양쪽에 재고 검증을 박았다.
- 로그인 후 리다이렉트 — 비로그인 상태로 상품 페이지에 들어가 로그인하면, 원래 보려던 페이지가 아니라 홈으로 떨어졌다. 진입 시 의도한 URL을 저장했다가 로그인 후 그곳으로 돌려보내도록 고쳤다.
- 위시리스트 상태 불일치 — 이미 담았는데 하트 아이콘은 빈 하트로 남아 "담기"처럼 보였다. 서버의 위시 상태에 따라 채운/빈 하트를 렌더하도록 했다.
is_active기본값 불일치 — 상품 생성과 수정에서 활성화 기본값 처리가 달라, 어떤 경로로 만든 상품은 전부 비노출이었다. 생성·수정 양쪽의 기본값을 일치시켰다.- API rate limit 부재 — 주소 검색·무드 매칭 같은 공개 API에 제한이 없어, 폼을 연타하면 외부 호출이 폭주할 수 있었다.
- 카테고리 필터·가격 정렬 — 부모 카테고리 필터가 자식 카테고리 상품을 빠뜨렸고, 가격 정렬은 옵션가가 있는 상품에서 어긋났다. 그리고 쿼리스트링이 페이지네이션 링크에 보존되지 않아 2페이지부터 필터가 풀렸다.
이 중 두 개를 코드로 들여다본다. 먼저 API rate limit. 공개 API 그룹에 분당 호출 제한을 걸되, 결제 콜백(웹훅)만은 제한에서 제외했다. 웹훅은 결제사가 보내는 신호라, 정산 폭주 시점에 우리 쪽 throttle에 막히면 결제 상태 동기화를 통째로 놓치기 때문이다.


"제한을 건다"는 평범한 작업이지만, 무엇을 제외할지가 진짜 설계다. 외부에서 들어오는 신뢰된 콜백과 사용자 폼 연타를 같은 잣대로 다루면 안 된다. 일괄로 throttle을 걸어 놓고 웹훅을 빼는 한 줄을 빠뜨렸다면, 정산이 몰리는 시점에 결제 상태가 어긋나는 — 추적하기 가장 괴로운 종류의 버그가 났을 것이다.
다른 하나는 큐레이터 대시보드의 출금 가능 금액 쿼리 버그다. 큐레이터에게 보여주는 "정산 가능 금액"이, 단순히 결제완료(confirmed) 주문을 합산하고 있었다. 하지만 결제가 됐다고 바로 출금 가능한 게 아니다 — 배송완료(delivered)되어 정산 항목으로 처리됐지만, 아직 미지급(unpaid)인 금액만 출금 대상이다. 상태 조건을 잘못 잡으면 큐레이터에게 실제보다 부풀려진 금액을 보여주게 된다(돈이 걸린 화면에서 가장 위험한 종류의 오류다).

whereDoesntHave('settlementItems')가 핵심이다. "배송은 완료됐지만 아직 정산 묶음에 들어가지 않은" 항목만 합산해야, 이미 정산이 시작된 금액을 중복으로 세지 않는다. 돈을 다루는 집계 쿼리는 상태 조건을 한 줄씩 소리 내어 읽으며 검증해야 한다는 걸 절감했다.
환불은 상태 머신으로 — 마지막 기능 묶음
버그 사냥과 함께, 누락돼 있던 마지막 기능 6개(환불 플로우, 주문 이메일 알림, 큐레이터 지원 양식, 관리자 차트, 리뷰 이미지 첨부, 재고 부족 알림)를 채워 개발 구간을 마무리했다. 이 중 환불은 단순 플래그가 아니라 상태 머신으로 설계했다. 주문 상태는 8개(결제대기→결제완료→상품준비중→배송중→배송완료, 그리고 주문취소·환불요청·환불완료)이고, 환불은 이 상태 위에서 "요청→승인→환불완료"로 흐른다.

// app/Enums/OrderStatus.php — 주문 상태 머신
enum OrderStatus: string
{
case PENDING = 'pending'; // 결제대기
case PAID = 'paid'; // 결제완료
case PREPARING = 'preparing'; // 상품준비중
case SHIPPING = 'shipping'; // 배송중
case DELIVERED = 'delivered'; // 배송완료
case CANCELLED = 'cancelled'; // 주문취소
case REFUND_REQUESTED = 'refund_requested'; // 환불요청
case REFUNDED = 'refunded'; // 환불완료
}
환불 요청은 아무 주문에서나 되면 안 되므로, 환불 가능 상태인지 먼저 검사한 뒤에만 상태를 환불요청으로 넘긴다. 실제 PG 취소(결제사 cancel 호출)와 재고 복원은 관리자가 승인하는 단계에서 일어난다 — 1편에서 본 결제 서비스의 cancelPayment()가 여기서 호출된다.

환불을 상태 머신으로 둔 덕분에, "환불요청 상태인 주문만 관리자 화면에 모아 보여주기", "환불완료된 주문은 다시 환불 못 하게 막기" 같은 규칙이 전부 상태 비교 한 줄로 떨어졌다. 불리언 플래그(is_refunded 같은) 여러 개로 흩었다면, "요청은 했는데 아직 승인 안 됨" 같은 중간 상태를 표현하지 못해 결국 플래그 조합 지옥에 빠졌을 것이다.
정리하며
- S3 전환의 본체는 "설정 한 줄"이 아니라 로컬 경로 가정 찾아내기 + 권한 정책(ACL) 맞추기였다. 테스트는
Storage::fake()로 가짜 디스크를 물려 고친다. - 피처 테스트 49개(전체 217개)를 시드 무결성과 비즈니스 규칙으로 나눠 깔았다. 테스트 신뢰도는 단언의 정교함보다 출발 상태의 결정성(
$seed = true)이 좌우했다. - 한국어 UI의 E2E는 텍스트 셀렉터 대신 역할 기반 셀렉터가 답이었고, 성능 단언은 CI 현실에 맞게 헐겁게 둬야 거짓 경보를 막는다.
- 버그의 절반은 기능 부재가 아니라 기본값 불일치·상태 보존 누락·검증 누락 같은 디테일이었다. throttle은 "무엇을 제외하느냐"가, 정산 집계는 "상태 조건"이 진짜 설계였다.
- 환불은 8단계 상태 머신 위의 "요청→승인→완료" 흐름으로 설계해, 규칙을 상태 비교 한 줄로 떨어뜨렸다.
Laravel 모놀리스로 취향 큐레이션 커머스를 만들고 검증한 기록은 여기까지다. 같은 컨셉을 다른 스택으로 구현한 기록과 나란히 읽으면, 프레임워크가 바뀔 때 무엇이 그대로 남고 무엇이 달라지는지 — 권한 모델, 결제 검증, 정산 격리, 상태 머신 같은 도메인 본질은 스택을 가리지 않는다는 게 보일 것이다.
'외주 개발일지' 카테고리의 다른 글
| 공간 대여 매칭 플랫폼 개발기 (1) — PostGIS 위치 검색과 모바일 MVP (0) | 2026.07.04 |
|---|---|
| 쇼핑몰 고객 상담 챗봇 개발기 (1) — 규칙 기반 대화 엔진과 CI 구축 (0) | 2026.07.04 |
| 취향 큐레이션 커머스 개발기 (1) — Laravel 모놀리스로 멀티롤 쇼핑몰 골격 잡기 (0) | 2026.06.23 |
| 전화 금융사기 탐지 앱 개발기 (1) — 온디바이스 Whisper·LLM 파이프라인 (0) | 2026.06.23 |
| 엔지니어링 기업 홈페이지 개발기 (1) — Next.js 정적 사이트와 이중 언어 지원 (0) | 2026.06.23 |