Notice
Recent Posts
Recent Comments
반응형
관리 메뉴

HM소프트

취향 큐레이션 커머스 개발기 (1) — Laravel 모놀리스로 멀티롤 쇼핑몰 골격 잡기 본문

외주 개발일지

취향 큐레이션 커머스 개발기 (1) — Laravel 모놀리스로 멀티롤 쇼핑몰 골격 잡기

HM소프트 2026. 6. 23. 08:08
반응형

Laravel 모놀리스로 취향 큐레이션 커머스의 골격을 잡은 기록. 구매자·큐레이터·관리자 멀티롤 권한을 enum과 미들웨어로 나누고, 퀴즈 답을 무드 프로필로 환산하는 추천 엔진, 결제 위젯 v2 키 함정, 금액 위·변조 차단과 정산 어댑터 설계를 다룬다.

시리즈를 시작하며

취향과 감성을 입력받아 상품을 추천하고, 사람 큐레이터가 직접 상품을 골라 코멘트를 붙이는 — 그런 취향 큐레이션 커머스를 만들었다. 같은 제품 컨셉을 다른 스택으로 구현해 본 적도 있는데, 이번 시리즈는 Laravel + Blade 모놀리스 구현 관점의 기록이다. SPA 프레임워크를 따로 두지 않고, 서버 사이드 렌더링 한 코드베이스에서 구매자·큐레이터·관리자 세 화면을 전부 처리했다.

특정 서비스명·브랜드명·결제사·정산사 실명은 전부 일반화했다. 코드의 골격과 그 골격을 그렇게 잡은 이유에만 집중한다. 두 편으로 나눈다.

  1. (이 글) Laravel 골격 — 멀티롤 권한 구조, 취향 매칭 추천 엔진, 결제 위젯 v2, 정산 어댑터, 동적 옵션 폼
  2. 품질 단계 — S3 전환, 피처 테스트 49개, Playwright E2E, 사흘간의 버그 사냥

이 1편은 "기능을 어떻게 있게 만들었나"의 기록이고, 2편은 "그 기능을 어떻게 믿을 수 있게 만들었나"의 기록이다.

전체 그림 — 하나의 모놀리스, 세 개의 영역

먼저 시스템 전체를 한 장으로 본다. 도메인 자체는 평범한 이커머스(상품·장바구니·주문·결제·정산·리뷰)지만, 거기에 취향 매칭 추천사람 큐레이터라는 두 축이 얹혀 있는 게 이 서비스의 성격이다.

마이크로서비스로 쪼갤 이유가 처음부터 없었다. 트래픽 규모도, 팀 규모도 모놀리스가 정답인 상황이었다. 대신 한 코드베이스 안에서 역할별 경계를 분명히 긋는 것을 골격의 1번 원칙으로 삼았다. 컨트롤러 네임스페이스를 Front / Curator / Admin으로 갈라 두면, 폴더만 봐도 "이건 누구의 화면인가"가 보인다. 라우트도 미들웨어로 영역을 묶어, 권한 검사를 컨트롤러 안이 아니라 라우트 경계에서 한 번에 끝낸다.

역할 셋, 권한은 enum 하나로

권한 모델은 단순하게 갔다. 사용자 역할은 user / curator / admin 세 가지뿐이고, 이걸 문자열로 흩어 두지 않고 백드(backed) enum으로 묶었다. 라벨(화면 표기)과 권한 판정 로직을 enum 안에 넣어 둔 게 핵심이다.

여기서 의도한 건 권한 판정 로직을 한 곳에 모으는 것이다. "관리자는 큐레이터가 할 수 있는 건 다 할 수 있다"는 규칙이 isCuratorOrAbove() 한 메서드에 박혀 있어서, 큐레이터 영역 미들웨어가 이걸 그대로 호출한다. 만약 이 규칙을 컨트롤러마다 if ($role === 'curator' || $role === 'admin')로 흩뿌렸다면, 나중에 "에디터" 같은 역할이 하나 추가될 때 수십 군데를 고쳐야 한다.

미들웨어는 이 enum 위에 얇게 얹힌다. 큐레이터 영역 진입을 막는 가드는 이렇게 생겼다.

// app/Http/Middleware/EnsureCurator.php — 영역 진입 가드
public function handle(Request $request, Closure $next): Response
{
    $user = $request->user();

    if (! $user) {
        abort(403, 'Unauthorized.');
    }

    if (! $user->isCurator()) {
        abort(403, 'Unauthorized. Curator or admin access required.');
    }

    return $next($request);
}

User::isCurator()는 내부적으로 UserRole::isCuratorOrAbove()를 부른다. 즉 관리자도 이 가드를 통과한다 — 의도된 동작이다. 라우트 정의 쪽에서는 이 미들웨어를 그룹에 한 번 걸면 끝이다. 공개 영역은 SEO·세션 추적 미들웨어만, 회원 영역은 auth·verified까지, 큐레이터/관리자 영역은 거기에 역할 가드를 더하는 식으로 레이어를 쌓아 올린다.

// routes/web.php — 영역별로 미들웨어를 누적
Route::middleware(['seo.defaults', 'mood.track'])->group(function () {
    // 공개 화면: 홈, 스토어, 큐레이터 목록, 무드 퀴즈 ...
});

Route::middleware(['auth', 'verified', 'seo.defaults'])->group(function () {
    // 회원 화면: 장바구니, 체크아웃, 주문, 위시리스트 ...
});

권한을 라우트 경계에서 끝내 두면 컨트롤러 본문은 "이 사람이 들어와도 되는가"를 다시 묻지 않아도 된다. 컨트롤러는 비즈니스 로직에만 집중한다. 이게 멀티롤 모놀리스를 어지럽지 않게 유지하는 가장 큰 장치였다.

추천 엔진 — 퀴즈 답을 점수 벡터로

이 서비스의 색깔을 결정하는 건 추천 엔진이다. 사용자는 무드 퀴즈(스와이프 또는 단계형)에 답하고, 그 답이 무드 태그별 가중 점수로 환산되어 상품과 매칭된다. 데이터 모델은 단순하다. 무드 카테고리(5개) 아래 무드 태그(15개)가 있고, 퀴즈 선택지마다 "이 선택지는 어떤 태그에 얼마의 가중치로 기여하는가"가 피벗 테이블에 들어 있다. 상품도 자기 무드 태그를 갖고, 그 관계에는 relevance_score(이 상품이 이 태그를 얼마나 잘 대표하는가)가 붙는다.

핵심은 두 단계의 점수 계산이다. 1단계는 퀴즈 답을 사용자의 "무드 프로필"로 바꾸는 것. 선택지마다 붙은 태그 가중치를 다 더한 뒤, 합이 1.0이 되도록 정규화한다. 정규화를 하는 이유는, 사용자가 답을 많이 했든 적게 했든 프로필끼리 비교 가능한 스케일로 맞추기 위해서다.

2단계는 이 프로필로 상품을 고르는 것. 무드 프로필에 들어 있는 태그를 하나라도 가진 후보 상품을 추려낸 뒤, 각 상품에 대해 적합도 = Σ(프로필의 태그 점수 × 그 상품의 태그 relevance)를 계산한다. 사용자가 강하게 원하는 태그를, 그 태그를 잘 대표하는 상품이 가졌을 때 점수가 가장 높게 나오는 구조다.

// MoodMatcher.php — 무드 프로필로 상품 적합도 산출
public function recommendProducts(array $moodProfile, int $limit = 12): Collection
{
    // 프로필이 없으면(퀴즈 미응답) 추천 대신 추천상품 노출로 폴백
    if (empty($moodProfile)) {
        return Product::active()->featured()
            ->with(['curator', 'moodTags'])->limit($limit)->get();
    }

    $tagIds = array_keys($moodProfile);

    $products = Product::active()
        ->whereHas('moodTags', fn ($q) => $q->whereIn('mood_tags.id', $tagIds))
        ->with(['curator', 'moodTags'])
        ->get();

    $scored = $products->map(function ($product) use ($moodProfile) {
        $score = 0;
        foreach ($product->moodTags as $tag) {
            if (isset($moodProfile[$tag->id])) {
                $relevance = (float) ($tag->pivot->relevance_score ?? 0.5);
                $score += $moodProfile[$tag->id] * $relevance;
            }
        }
        $product->relevance_score = $score;
        return $product;
    });

    return $scored->sortByDesc('relevance_score')->take($limit)->values();
}

여기서 두 가지 설계 결정이 있었다. 첫째, 점수 계산을 SQL이 아니라 PHP 컬렉션에서 했다. 무드 태그 가중 합을 SQL 조인·집계로 짤 수도 있지만, 후보 상품 수가 수백 단위인 규모에서는 메모리에서 도는 편이 훨씬 읽기 쉽고 디버깅하기 쉽다. 추천 로직은 자주 바뀌는 영역이라 가독성을 택했다 — 데이터가 수만 건으로 커지면 그때 후보를 SQL로 1차 거르고 점수만 PHP에서 매기는 식으로 단계를 나누면 된다. 둘째, 빈 프로필일 때 폴백을 명시했다. 퀴즈를 안 푼 방문자에게도 홈은 비어 보이면 안 되므로, 프로필이 없으면 추천 대신 "추천 상품(featured)"을 보여준다. 추천 엔진이 0개를 반환하는 사고를 코드 첫 줄에서 막는다.

같은 점수를 카테고리 단위로 묶어 "당신은 차분(calm) 38%, 집중(focus) 25%…" 같은 결과 화면도 만든다. 같은 입력으로 상품 추천과 결과 시각화를 동시에 뽑는 구조라, 엔진 하나가 두 화면을 먹인다.

결제 위젯 v2 마이그레이션, 그리고 키 두 종류의 함정

결제는 국내 한 PG사의 결제 위젯을 붙였다. 개발 도중 PG SDK가 v1에서 v2로 넘어가면서 체크아웃이 통째로 깨졌고, 이걸 마이그레이션하는 데 커밋 두 개를 연달아 썼다. v2는 초기화 방식 자체가 달라서, 단순히 버전 숫자만 바꿔서 될 일이 아니었다.

// v2 — 위젯 전용 클라이언트 키로 초기화한다
const widgets = PaymentSDK.widgets({ customerKey });
await widgets.setAmount({ currency: "KRW", value: total });
await widgets.renderPaymentMethods({ selector: "#payment-method" });
await widgets.renderAgreement({ selector: "#agreement" });

마이그레이션 자체보다 발목을 잡은 건 키 종류였다. 이 PG는 일반 API용 클라이언트 키와 위젯 전용 클라이언트 키를 따로 발급하는데, 일반 키로 위젯을 초기화하면 명확한 에러 없이 렌더링만 조용히 실패했다. 콘솔에도 친절한 메시지가 없어서 한참 헤맸다. 문서를 다시 읽고 위젯 전용 키(접두사가 다른)로 바꾸자 즉시 동작했다. 서버 쪽은 두 키를 모두 들고 있다가, 위젯에는 위젯 전용 키를, 없으면 일반 키로 폴백하도록 했다.

// 결제 서비스 — 위젯에 넘길 결제 요청 데이터 구성
public function requestPayment(Order $order): array
{
    $orderName = $order->items()->count() > 1
        ? $order->items()->first()?->product_name . ' 외 ' . ($order->items()->count() - 1) . '건'
        : ($order->items()->first()?->product_name ?? '주문');

    return [
        // 위젯 전용 키 우선, 없으면 일반 키로 폴백
        'clientKey'     => $this->widgetClientKey ?: $this->clientKey,
        'orderId'       => $order->order_number,
        'amount'        => $order->payable_amount,
        'orderName'     => $orderName,
        'successUrl'    => route('checkout.success'),
        'failUrl'       => route('checkout.fail'),
        'customerName'  => $order->user?->name,
        'customerEmail' => $order->user?->email,
    ];
}

여기서 배운 건 분명하다. 결제 연동에서 화면이 "그냥 안 뜰" 때 의심 순서는 코드 → 키 → 계약 상태다. 코드가 멀쩡한데 안 되면 십중팔구 키를 잘못 쓰고 있다. 그리고 결제 SDK는 버전이 메이저로 바뀌면 키 정책까지 바뀌는 일이 잦으니, 마이그레이션 노트의 "키" 섹션을 가장 먼저 읽어야 한다.

결제 승인 — 금액 위·변조를 서버에서 잠그다

결제에서 진짜 중요한 코드는 위젯이 아니라 승인(confirm) 단계다. 위젯은 브라우저에서 도는 클라이언트 코드이고, 클라이언트가 보내는 값은 무엇이든 조작될 수 있다고 가정해야 한다. 그래서 승인 직전에 서버가 들고 있는 주문 금액과 클라이언트가 보낸 결제 금액이 일치하는지를 반드시 검증한다.

payable_amount는 서버가 장바구니·쿠폰·배송비를 계산해 주문에 저장해 둔 값이다. 클라이언트가 "1,000원만 결제하겠다"고 위조해도 서버 값과 다르면 승인 자체를 거부한다. 이 한 줄이 결제 위·변조를 막는 마지막 빗장이다. 또 하나, $response->throw()PG의 오류 응답을 조용한 실패가 아니라 예외로 터지게 했다. 결제 같은 도메인에서 가장 위험한 건 "실패했는데 성공한 줄 아는" 상태인데, 예외로 끊어 두면 상위 컨트롤러가 반드시 그 실패를 처리하게 강제된다.

주문은 트랜잭션 한 덩어리, 실패하면 재고를 되돌린다

체크아웃 컨트롤러에서 주문을 만드는 과정은 여러 테이블을 동시에 건드린다. 주문 생성, 주문 항목 복사, 상품 재고 차감, 판매 수량 증가, 쿠폰 사용 기록, 장바구니 비우기 — 이 중 하나라도 중간에 실패하면 데이터가 어긋난다(재고는 깠는데 주문은 안 생기는 식). 그래서 이 묶음 전체를 DB 트랜잭션으로 감쌌다.

여기서 한 가지 의도적으로 다르게 처리한 부분이 있다. 주문 생성 트랜잭션에서 재고를 먼저 깐 뒤 PG 승인을 시도하는데, 승인이 실패하면 깐 재고를 다시 되돌린다. 결제가 실패한 주문 때문에 재고가 묶여 있으면 멀쩡한 손님이 "품절"을 보게 되기 때문이다.

// 결제 승인 실패 시 — 차감했던 재고를 원복하고 주문을 취소 처리
foreach ($order->items as $item) {
    $item->product()->increment('stock', $item->quantity);
    $item->product()->decrement('sold_count', $item->quantity);
}
$order->update(['status' => OrderStatus::CANCELLED, 'cancelled_at' => now()]);

주문 항목을 만들 때 product_name, price 같은 값을 상품에서 복사해 박아 넣는 것도 의도적이다. 주문은 "그 시점의 거래 기록"이라, 나중에 상품명이나 가격이 바뀌어도 과거 주문서는 그대로 남아야 한다. 상품 테이블을 조인해서 보여주면 가격이 바뀌는 순간 옛날 주문서의 금액이 흔들린다. 스냅샷으로 박제하는 것이 회계상으로도, 분쟁 대응으로도 안전하다.

정산은 어댑터 뒤로 — 외부 의존성을 갈아끼울 수 있게

큐레이터에게 판매 대금을 정산하는 부분은 처음부터 외부 정산사를 나중에 교체할 수 있다는 가정으로 설계했다. 초기엔 내부 정산(우리가 직접 상태를 관리)으로 시작하지만, 사업이 커지면 전문 정산 대행사를 붙일 가능성이 높았다. 그래서 정산의 다섯 가지 동작(생성·처리·상태조회·취소·동기화)을 인터페이스로 못 박고, 구현체를 갈아끼우는 전략(Strategy) 패턴으로 갔다.

서비스 클래스는 이 인터페이스에만 의존하고, 어떤 구현체를 쓸지는 설정값 하나로 결정한다. 내부 정산이 기본이고, 외부 정산사를 붙일 땐 설정만 바꾸면 된다.

인터페이스에 syncStatus()를 일부러 넣어 둔 게 포인트다. 내부 정산은 우리가 상태를 직접 쓰니 항상 동기 상태라 이 메서드가 사실상 빈 동작이지만, 외부 정산사는 상태가 비동기로 바뀌므로 "외부에서 우리 쪽으로 상태를 끌어오는" 동작이 반드시 필요하다. 미래에 필요할 메서드를 인터페이스 단계에서 미리 자리만 잡아 두면, 나중에 외부 구현체를 붙일 때 인터페이스를 깨지 않아도 된다. 내부 구현체에서는 그냥 입력을 그대로 반환하면 그만이다.

이렇게 해 두니 정산 로직을 쓰는 쪽(큐레이터 대시보드, 관리자 정산 화면)은 "정산사가 누구인지" 전혀 모른 채 SettlementService만 부른다. 외부 의존성을 인터페이스 한 겹으로 격리하는 이 작업은, 당장은 오버엔지니어링처럼 보여도 결제·정산처럼 외부 계약이 언젠가 바뀌는 영역에서는 가장 회수율 높은 투자라고 생각한다.

동적 옵션 폼 — 인덱스 꼬임과 입력 복원

마지막은 관리/큐레이터 화면의 단골 골칫거리, 동적 옵션 폼이다. 상품에는 색상·사이즈 같은 옵션이 붙고, 옵션은 "행 추가" 버튼으로 개수를 늘리는 구조다. 초기 구현엔 두 가지 문제가 있었다. 하나는 추가한 행의 배열 인덱스가 꼬여 마지막 행만 저장되던 버그, 다른 하나는 유효성 검증에 걸려 폼이 되돌아올 때 동적으로 추가했던 행이 통째로 사라지는 문제.

전자는 인덱스를 서버 렌더 시점이 아니라 클라이언트에서 단조 증가시키도록 바꿔 해결했다. 후자는 Laravel의 old() 헬퍼로 실패한 입력을 행 단위로 복원해서 풀었다. 폼이 검증 실패로 되돌아와도, 사용자가 입력했던 옵션 행들이 그대로 다시 그려진다.

{{-- 검증 실패 시에도 입력했던 옵션 행을 복원해 다시 그린다 --}}
@php $options = old('options', $product->options->toArray() ?? [[]]); @endphp
@foreach ($options as $i => $opt)
  <div class="option-row" data-index="{{ $i }}">
    <input name="options[{{ $i }}][name]"  value="{{ $opt['name'] ?? '' }}">
    <input name="options[{{ $i }}][price]" value="{{ $opt['price_adjustment'] ?? '' }}">
  </div>
@endforeach

핵심은 name="options[{{ $i }}][...]"처럼 명시적 인덱스를 키로 박는 것이다. options[]처럼 빈 대괄호로 두면 행 순서·삭제에 따라 서버가 받는 인덱스가 흔들린다. 인덱스를 명시하면 행을 추가·삭제해도 각 행이 자기 정체성을 유지하고, old() 복원도 정확히 같은 자리에 들어간다. 동적 폼에서 데이터가 새는 사고는 거의 다 "인덱스를 누가 관리하느냐"의 문제로 수렴한다.

정리하며

  • 역할 셋(구매자·큐레이터·관리자)을 컨트롤러 네임스페이스 분리 + 미들웨어 누적 + enum 권한 판정으로 한 모놀리스에 깔끔히 담았다. 권한 규칙은 enum 한 곳에 모은다.
  • 추천 엔진은 퀴즈 답을 정규화된 무드 프로필로 바꾸고, 상품 적합도를 Σ(프로필점수 × relevance)로 매긴다. 빈 프로필 폴백을 첫 줄에서 보장했다.
  • 결제 위젯 v2 마이그레이션의 진짜 함정은 위젯 전용 키 vs 일반 키 구분이었다. 화면이 조용히 안 뜨면 코드보다 키를 먼저 의심한다.
  • 결제 승인은 서버 보유 금액과 클라이언트 금액 일치 검증 + PG 오류의 예외화로 위·변조와 무음 실패를 막았다. 주문은 트랜잭션으로 묶고, 결제 실패 시 재고를 원복한다.
  • 정산은 인터페이스 + 전략 패턴 + 설정 스위치로 외부 의존성을 격리했다. syncStatus()처럼 미래에 필요할 메서드를 미리 자리잡아 둔다.
  • 동적 옵션 폼은 명시적 인덱스old() 행 단위 복원이 핵심이다.

다음 2편에서는 이 골격을 어떻게 검증했는지를 다룬다. 로컬 디스크에서 S3로의 미디어 전환, 피처 테스트 49개로 친 회귀의 그물, Playwright E2E와 한국어 UI의 충돌, 그리고 사흘에 걸친 버그 사냥의 기록이다.

반응형