임동욱 임동욱 08-21
feat: 시스템관리 가맹점 관리 (mock)
기획 MCH_001 목록 / MCH_002_p 등록 / MCH_003_p 수정. 관리자용 가맹점 API가 없어
(mngr 도메인에 컨트롤러가 없고 common/qrauth의 결제 조회용 VO는 5개 필드뿐)
Repository를 mock으로 둔다 — 화면·검증·액션은 실제 구조다.

목록은 업종·사용여부 필터와 가맹점명/사업자번호/주소 검색, 최근등록순 정렬,
엑셀다운로드(CSV)까지. 가맹점코드는 저장 시 서버가 만들고 수정에서는 읽기 전용이다.

시안에 있으나 정할 것이 남은 두 가지는 자리를 두고 표시해 둔다 — 주소 검색은 연동할
서비스가 없어 직접 입력으로 받고, 엑셀등록은 양식·검증 규칙이 없어 비활성이다.

Co-Authored-By: Claude Opus 5 
@6284fb74c5efdc7a4e81e2abb5b638b13e7213c1
 
app/(protected)/(basic)/system/merchants/_actions.ts (added)
+++ app/(protected)/(basic)/system/merchants/_actions.ts
@@ -0,0 +1,76 @@
+'use server';
+
+import { revalidatePath } from 'next/cache';
+import { verifySession } from '@/lib/auth/dal';
+import {
+  createMerchant,
+  deleteMerchant,
+  updateMerchant,
+} from '@/lib/data/repositories/merchant-repository';
+import {
+  validateMerchant,
+  type MerchantFormState,
+} from '@/lib/domain/merchant-form';
+import { MERCHANTS_PATH } from '@/lib/domain/merchant-query';
+
+const SAVE_FAILED_MESSAGE = '저장하지 못했습니다. 잠시 후 다시 시도해 주세요.';
+const DELETE_FAILED_MESSAGE = '삭제하지 못했습니다. 잠시 후 다시 시도해 주세요.';
+
+function readString(formData: FormData, key: string): string {
+  const value = formData.get(key);
+  return typeof value === 'string' ? value : '';
+}
+
+export async function saveMerchantAction(
+  _prevState: MerchantFormState,
+  formData: FormData
+): Promise<MerchantFormState> {
+  await verifySession();
+
+  const validation = validateMerchant({
+    id: readString(formData, 'id'),
+    brandName: readString(formData, 'brandName'),
+    name: readString(formData, 'name'),
+    categoryCode: readString(formData, 'categoryCode'),
+    businessNumber: readString(formData, 'businessNumber'),
+    ownerName: readString(formData, 'ownerName'),
+    phoneNumber: readString(formData, 'phoneNumber'),
+    address: readString(formData, 'address'),
+    addressDetail: readString(formData, 'addressDetail'),
+    latitude: readString(formData, 'latitude'),
+    longitude: readString(formData, 'longitude'),
+    isVisible: readString(formData, 'isVisible') === 'Y',
+    memo: readString(formData, 'memo'),
+  });
+  if (!validation.ok) {
+    return { status: 'error', errors: validation.errors };
+  }
+
+  try {
+    if (validation.values.id) {
+      await updateMerchant(validation.values);
+    } else {
+      await createMerchant(validation.values);
+    }
+  } catch {
+    return { status: 'error', message: SAVE_FAILED_MESSAGE };
+  }
+
+  revalidatePath(MERCHANTS_PATH);
+  return { status: 'success' };
+}
+
+export async function deleteMerchantAction(
+  id: string
+): Promise<{ status: 'success' } | { status: 'error'; message: string }> {
+  await verifySession();
+
+  try {
+    await deleteMerchant(id);
+  } catch {
+    return { status: 'error', message: DELETE_FAILED_MESSAGE };
+  }
+
+  revalidatePath(MERCHANTS_PATH);
+  return { status: 'success' };
+}
 
app/(protected)/(basic)/system/merchants/_components/merchant-filter.module.scss (added)
+++ app/(protected)/(basic)/system/merchants/_components/merchant-filter.module.scss
@@ -0,0 +1,12 @@
+@use "@fox/styles/abstracts" as fox;
+
+.filter {
+  display: flex;
+  flex-wrap: wrap;
+  align-items: center;
+  gap: fox.gap(3);
+}
+
+.select {
+  inline-size: 16rem;
+}
 
app/(protected)/(basic)/system/merchants/_components/merchant-form-modal.tsx (added)
+++ app/(protected)/(basic)/system/merchants/_components/merchant-form-modal.tsx
@@ -0,0 +1,308 @@
+'use client';
+
+import { useActionState, useEffect, useRef, useState } from 'react';
+import { FoxButton } from '@fox/core/components/fox-button';
+import { FoxFormLabel } from '@fox/core/components/fox-form-label';
+import { FoxHelperText } from '@fox/core/components/fox-helper-text';
+import { FoxInput } from '@fox/core/components/fox-input';
+import { FoxModal } from '@fox/core/components/fox-modal';
+import { FoxRadio } from '@fox/core/components/fox-radio';
+import { FoxRadioGroup } from '@fox/core/components/fox-radio-group';
+import { FoxSelect } from '@fox/core/components/fox-select';
+import { submitFormAction } from '@/app/_hooks/submit-form-action';
+import { useFeedback } from '@/app/_hooks/use-feedback';
+import {
+  MERCHANT_CATEGORY_OPTIONS,
+  type Merchant,
+} from '@/lib/domain/merchant';
+import {
+  ADDRESS_DETAIL_MAX_LENGTH,
+  BRAND_MAX_LENGTH,
+  INITIAL_MERCHANT_FORM_STATE,
+  MEMO_MAX_LENGTH,
+  NAME_MAX_LENGTH,
+  OWNER_MAX_LENGTH,
+} from '@/lib/domain/merchant-form';
+import { saveMerchantAction } from '../_actions';
+import styles from './merchant-form.module.scss';
+
+interface MerchantFormModalProps {
+  merchant?: Merchant;
+  onClose: () => void;
+}
+
+export function MerchantFormModal({ merchant, onClose }: MerchantFormModalProps) {
+  const isEdit = Boolean(merchant);
+  const { showToast } = useFeedback();
+  const formRef = useRef<HTMLFormElement>(null);
+  const [state, formAction, isPending] = useActionState(
+    saveMerchantAction,
+    INITIAL_MERCHANT_FORM_STATE
+  );
+
+  const [categoryCode, setCategoryCode] = useState(merchant?.categoryCode ?? '');
+  const [isVisible, setIsVisible] = useState(merchant?.isVisible ?? true);
+  // 주소·좌표는 검색 결과가 채운다(기획 ③ Read Only).
+  const [address, setAddress] = useState(merchant?.address ?? '');
+  const [latitude, setLatitude] = useState(merchant?.latitude ?? '');
+  const [longitude, setLongitude] = useState(merchant?.longitude ?? '');
+
+  useEffect(() => {
+    if (state.status === 'success') {
+      showToast({
+        variant: 'success',
+        message: isEdit ? '가맹점을 수정했습니다.' : '가맹점을 등록했습니다.',
+      });
+      onClose();
+    }
+  }, [state, showToast, onClose, isEdit]);
+
+  const errors = state.status === 'error' ? (state.errors ?? {}) : {};
+
+  // TODO(기획): 주소 검색 서비스가 정해지지 않았다. 연동 전까지 직접 입력으로 받는다.
+  function searchAddress() {
+    const picked = window.prompt('주소를 입력하세요.', address);
+    if (picked === null) {
+      return;
+    }
+    setAddress(picked.trim());
+    setLatitude('');
+    setLongitude('');
+  }
+
+  return (
+    <FoxModal
+      open
+      size="md"
+      title={isEdit ? '가맹점 수정' : '가맹점 등록'}
+      onClose={onClose}
+      actions={
+        <>
+          <FoxButton type="default" size="md" label="취소" onAction={onClose} />
+          <FoxButton
+            type="primary"
+            size="md"
+            label={isPending ? '저장 중...' : isEdit ? '수정' : '등록'}
+            disabled={isPending}
+            onAction={() => formRef.current?.requestSubmit()}
+          />
+        </>
+      }
+    >
+      <form
+        ref={formRef}
+        onSubmit={(event) => submitFormAction(event, formAction)}
+        className={styles.fields}
+      >
+        <input type="hidden" name="id" value={merchant?.id ?? ''} />
+        <input type="hidden" name="categoryCode" value={categoryCode} />
+        <input type="hidden" name="isVisible" value={isVisible ? 'Y' : 'N'} />
+        <input type="hidden" name="address" value={address} />
+        <input type="hidden" name="latitude" value={latitude} />
+        <input type="hidden" name="longitude" value={longitude} />
+
+        {/* 등록 시에는 코드가 아직 없다 — 저장할 때 서버가 만든다. */}
+        {isEdit && (
+          <div className={styles.field}>
+            <FoxInput
+              size="md"
+              label="가맹점코드"
+              value={merchant?.code ?? ''}
+              readOnly
+              state="view"
+            />
+          </div>
+        )}
+
+        <div className={styles.field}>
+          <FoxInput
+            size="md"
+            name="brandName"
+            label="브랜드명"
+            defaultValue={merchant?.brandName ?? ''}
+            placeholder="브랜드명을 입력하세요."
+            maxLength={BRAND_MAX_LENGTH}
+          />
+        </div>
+
+        <div className={styles.field}>
+          <FoxInput
+            size="md"
+            name="name"
+            label="가맹점명"
+            requirement="required"
+            defaultValue={merchant?.name ?? ''}
+            placeholder="가맹점명을 입력하세요."
+            maxLength={NAME_MAX_LENGTH}
+            invalid={Boolean(errors.name)}
+            message={errors.name}
+          />
+        </div>
+
+        <div className={styles.field}>
+          <FoxSelect
+            size="md"
+            label="업종"
+            requirement="required"
+            options={MERCHANT_CATEGORY_OPTIONS.map((option) => ({
+              value: option.value,
+              label: option.label,
+            }))}
+            value={categoryCode}
+            onValueChange={setCategoryCode}
+            placeholder="선택"
+            error={Boolean(errors.categoryCode)}
+            hint={errors.categoryCode}
+          />
+        </div>
+
+        <div className={styles.field}>
+          <div className={styles.pair}>
+            <FoxInput
+              size="md"
+              name="businessNumber"
+              label="사업자등록번호"
+              defaultValue={merchant?.businessNumber ?? ''}
+              placeholder="000-00-00000"
+              invalid={Boolean(errors.businessNumber)}
+              message={errors.businessNumber}
+            />
+            <FoxInput
+              size="md"
+              name="ownerName"
+              label="대표자명"
+              defaultValue={merchant?.ownerName ?? ''}
+              placeholder="대표자명을 입력하세요."
+              maxLength={OWNER_MAX_LENGTH}
+            />
+          </div>
+        </div>
+
+        <div className={styles.field}>
+          <FoxInput
+            size="md"
+            name="phoneNumber"
+            label="전화번호"
+            requirement="required"
+            defaultValue={merchant?.phoneNumber ?? ''}
+            placeholder="가게 전화번호를 입력하세요."
+            invalid={Boolean(errors.phoneNumber)}
+            message={errors.phoneNumber}
+          />
+        </div>
+
+        <div className={styles.field}>
+          <FoxFormLabel as="span" requirement="required">
+            주소
+          </FoxFormLabel>
+          <div className={styles.addressRow}>
+            <FoxInput
+              size="md"
+              aria-label="주소"
+              value={address}
+              readOnly
+              state="view"
+              placeholder="주소 검색을 진행해주세요."
+            />
+            <FoxButton
+              type="secondary"
+              size="md"
+              label="주소 검색"
+              onAction={searchAddress}
+            />
+          </div>
+          <FoxInput
+            size="md"
+            name="addressDetail"
+            aria-label="상세주소"
+            defaultValue={merchant?.addressDetail ?? ''}
+            placeholder="상세주소를 입력하세요."
+            maxLength={ADDRESS_DETAIL_MAX_LENGTH}
+          />
+          {errors.address && (
+            <FoxHelperText type="danger" message={errors.address} />
+          )}
+        </div>
+
+        <div className={styles.field}>
+          <FoxFormLabel as="span">위도 / 경도</FoxFormLabel>
+          <div className={styles.pair}>
+            <FoxInput
+              size="md"
+              aria-label="위도"
+              value={latitude}
+              readOnly
+              state="view"
+              placeholder="주소 검색 시 자동 입력"
+            />
+            <FoxInput
+              size="md"
+              aria-label="경도"
+              value={longitude}
+              readOnly
+              state="view"
+              placeholder="주소 검색 시 자동 입력"
+            />
+          </div>
+        </div>
+
+        <div className={styles.field}>
+          <FoxFormLabel as="span" requirement="required">
+            사용여부
+          </FoxFormLabel>
+          <FoxRadioGroup name="visibility" size="md" label="사용여부">
+            <FoxRadio
+              value="Y"
+              label="노출"
+              checked={isVisible}
+              onChange={() => setIsVisible(true)}
+            />
+            <FoxRadio
+              value="N"
+              label="미노출"
+              checked={!isVisible}
+              onChange={() => setIsVisible(false)}
+            />
+          </FoxRadioGroup>
+        </div>
+
+        <div className={styles.field}>
+          <FoxInput
+            size="md"
+            name="memo"
+            label="메모"
+            defaultValue={merchant?.memo ?? ''}
+            placeholder="관리자 메모 (사용자에게 노출되지 않음)"
+            maxLength={MEMO_MAX_LENGTH}
+          />
+        </div>
+
+        {isEdit && (
+          <div className={styles.field}>
+            <FoxFormLabel as="span">등록일 / 최종수정일</FoxFormLabel>
+            <div className={styles.pair}>
+              <FoxInput
+                size="md"
+                aria-label="등록일"
+                value={merchant?.createdAt ?? ''}
+                readOnly
+                state="view"
+              />
+              <FoxInput
+                size="md"
+                aria-label="최종수정일"
+                value={merchant?.updatedAt ?? ''}
+                readOnly
+                state="view"
+              />
+            </div>
+          </div>
+        )}
+
+        {state.status === 'error' && state.message && (
+          <FoxHelperText type="danger" message={state.message} />
+        )}
+      </form>
+    </FoxModal>
+  );
+}
 
app/(protected)/(basic)/system/merchants/_components/merchant-form.module.scss (added)
+++ app/(protected)/(basic)/system/merchants/_components/merchant-form.module.scss
@@ -0,0 +1,39 @@
+@use "@fox/styles/abstracts" as fox;
+
+.fields {
+  display: flex;
+  inline-size: 100%;
+  flex-direction: column;
+}
+
+.field {
+  display: flex;
+  inline-size: 100%;
+  flex-direction: column;
+  gap: fox.gap(3);
+  padding-block: fox.padding(5);
+  border-block-end: fox.border(1) solid fox.color(border-neutral-subtler);
+}
+
+// 사업자등록번호/대표자명, 위도/경도, 등록일/최종수정일처럼 두 칸이 나란히 서는 줄.
+.pair {
+  display: flex;
+  gap: fox.gap(4);
+
+  > * {
+    flex: 1 1 0;
+    min-inline-size: 0;
+  }
+}
+
+// 주소 입력 + [주소 검색] 버튼.
+.addressRow {
+  display: flex;
+  gap: fox.gap(3);
+  align-items: flex-end;
+
+  > :first-child {
+    flex: 1 1 0;
+    min-inline-size: 0;
+  }
+}
 
app/(protected)/(basic)/system/merchants/_components/merchant-list.tsx (added)
+++ app/(protected)/(basic)/system/merchants/_components/merchant-list.tsx
@@ -0,0 +1,257 @@
+'use client';
+
+import { useRouter } from 'next/navigation';
+import { useState } from 'react';
+import { FoxButton } from '@fox/core/components/fox-button';
+import {
+  FoxListContainer,
+  type FoxListColumn,
+} from '@fox/core/components/fox-list-container';
+import { FoxSelect } from '@fox/core/components/fox-select';
+import { FoxSelectText } from '@fox/core/components/fox-select-text';
+import { FoxStatusIndicator } from '@fox/core/components/fox-status-indicator';
+import {
+  MERCHANT_CATEGORY_OPTIONS,
+  formatAddressSummary,
+  formatCategoryLabel,
+  formatOptionalValue,
+  formatVisibilityLabel,
+  type Merchant,
+} from '@/lib/domain/merchant';
+import {
+  MERCHANTS_EXCEL_PATH,
+  MERCHANTS_PATH,
+  MERCHANT_PAGE_SIZE_OPTIONS,
+  MERCHANT_SEARCH_FIELD_OPTIONS,
+  MERCHANT_VISIBILITY_OPTIONS,
+  buildMerchantHref,
+  type MerchantPageSize,
+  type MerchantQuery,
+  type MerchantSearchField,
+} from '@/lib/domain/merchant-query';
+import { MerchantFormModal } from './merchant-form-modal';
+import { MerchantRowActions } from './merchant-row-actions';
+import styles from './merchant-filter.module.scss';
+
+interface MerchantListProps {
+  items: Merchant[];
+  query: MerchantQuery;
+  currentPage: number;
+  totalPages: number;
+  totalCount: number;
+}
+
+/** 기획 MCH_001 ④ — 정렬은 최근등록순 하나뿐이라 선택지가 아니라 표시다. */
+const SORT_OPTIONS = [{ value: 'recent', label: '최근등록순' }];
+
+export function MerchantList({
+  items,
+  query,
+  currentPage,
+  totalPages,
+  totalCount,
+}: MerchantListProps) {
+  const router = useRouter();
+  const [isCreateOpen, setIsCreateOpen] = useState(false);
+
+  function go(patch: Partial<MerchantQuery>) {
+    router.replace(buildMerchantHref(query, { ...patch, page: 1 }));
+  }
+
+  const columns: FoxListColumn<Merchant>[] = [
+    {
+      key: 'no',
+      header: '번호',
+      width: 80,
+      render: (_row, index) => (currentPage - 1) * query.pageSize + index + 1,
+    },
+    { key: 'code', header: '가맹점코드', width: 140 },
+    {
+      key: 'brandName',
+      header: '브랜드명',
+      width: 120,
+      render: (row) => formatOptionalValue(row.brandName),
+    },
+    { key: 'name', header: '가맹점명', width: 240, emphasis: true },
+    {
+      key: 'categoryCode',
+      header: '업종',
+      width: 100,
+      render: (row) => formatCategoryLabel(row.categoryCode),
+    },
+    {
+      key: 'phoneNumber',
+      header: '전화번호',
+      width: 160,
+      render: (row) => formatOptionalValue(row.phoneNumber),
+    },
+    {
+      key: 'address',
+      header: '주소',
+      width: 280,
+      render: formatAddressSummary,
+    },
+    {
+      key: 'isVisible',
+      header: '사용여부',
+      width: 120,
+      render: (row) => (
+        <FoxStatusIndicator
+          type={row.isVisible ? 'success' : 'danger'}
+          label={formatVisibilityLabel(row.isVisible)}
+        />
+      ),
+    },
+    {
+      key: 'createdAt',
+      header: '등록일',
+      width: 140,
+      render: (row) => formatOptionalValue(row.createdAt),
+    },
+    {
+      key: 'actions',
+      header: '관리',
+      width: 120,
+      render: (row) => <MerchantRowActions merchant={row} />,
+    },
+  ];
+
+  return (
+    <>
+      <FoxListContainer<Merchant>
+        title="가맹점 목록"
+        breadcrumb={[
+          { label: '홈', href: '/' },
+          { label: '시스템관리' },
+          { label: '가맹점 관리' },
+        ]}
+        caption="가맹점 목록"
+        columns={columns}
+        rows={items}
+        rowKey={(row) => row.id}
+        totalCount={totalCount}
+        emptyMessage="검색 결과가 없습니다."
+        sorts={
+          <>
+            <FoxSelectText
+              size="sm"
+              ariaLabel="정렬"
+              options={SORT_OPTIONS}
+              value="recent"
+            />
+            <FoxSelectText
+              size="sm"
+              ariaLabel="페이지 크기"
+              options={MERCHANT_PAGE_SIZE_OPTIONS.map((size) => ({
+                value: String(size),
+                label: `${size}개씩`,
+              }))}
+              value={String(query.pageSize)}
+              onValueChange={(value) =>
+                go({ pageSize: Number(value) as MerchantPageSize })
+              }
+            />
+          </>
+        }
+        filter={
+          <div className={styles.filter}>
+            <div className={styles.select}>
+              <FoxSelect
+                size="md"
+                ariaLabel="업종"
+                options={[
+                  { value: '', label: '업종: 전체' },
+                  ...MERCHANT_CATEGORY_OPTIONS.map((option) => ({
+                    value: option.value,
+                    label: option.label,
+                  })),
+                ]}
+                value={query.category}
+                onValueChange={(value) => go({ category: value })}
+              />
+            </div>
+            <div className={styles.select}>
+              <FoxSelect
+                size="md"
+                ariaLabel="사용여부"
+                options={[
+                  { value: '', label: '사용여부: 전체' },
+                  ...MERCHANT_VISIBILITY_OPTIONS.map((option) => ({
+                    value: option.value,
+                    label: option.label,
+                  })),
+                ]}
+                value={query.visibility}
+                onValueChange={(value) => go({ visibility: value })}
+              />
+            </div>
+          </div>
+        }
+        searchFields={MERCHANT_SEARCH_FIELD_OPTIONS.map((option) => ({
+          value: option.value,
+          label: option.label,
+        }))}
+        searchField={query.searchField}
+        keyword={query.keyword}
+        searchPlaceholder="검색어를 입력하세요."
+        toolbarActions={
+          <>
+            {/* TODO(기획): 엑셀등록 양식·검증 규칙이 정해지지 않았다. 규칙이 나오면 금칙어
+                화면처럼 업로드 모달을 붙인다. */}
+            <FoxButton
+              type="secondary"
+              size="md"
+              leadingIcon={
+                <i className="fox-ico fox-ico-UploadSimple" aria-hidden="true" />
+              }
+              label="엑셀등록"
+              disabled
+            />
+            {/* 파일 응답이라 라우터가 아니라 네이티브 GET 폼으로 보낸다. */}
+            <form action={MERCHANTS_EXCEL_PATH} method="get">
+              <FoxButton
+                type="default"
+                size="md"
+                htmlType="submit"
+                label="엑셀다운로드"
+              />
+            </form>
+            <FoxButton
+              type="primary"
+              size="md"
+              leadingIcon={
+                <i className="fox-ico fox-ico-Plus" aria-hidden="true" />
+              }
+              label="등록"
+              onAction={() => setIsCreateOpen(true)}
+            />
+            <FoxButton
+              type="default"
+              size="md"
+              label="초기화"
+              onAction={() => router.replace(MERCHANTS_PATH)}
+            />
+          </>
+        }
+        page={currentPage}
+        totalPages={totalPages}
+        buildHref={(patch) =>
+          buildMerchantHref(query, { page: Number(patch.page ?? 1) })
+        }
+        onQueryChange={(patch) => {
+          if (patch.keyword !== undefined || patch.searchField !== undefined) {
+            go({
+              keyword: String(patch.keyword ?? ''),
+              searchField: (patch.searchField ??
+                query.searchField) as MerchantSearchField,
+            });
+          }
+        }}
+      />
+
+      {isCreateOpen && (
+        <MerchantFormModal onClose={() => setIsCreateOpen(false)} />
+      )}
+    </>
+  );
+}
 
app/(protected)/(basic)/system/merchants/_components/merchant-row-actions.tsx (added)
+++ app/(protected)/(basic)/system/merchants/_components/merchant-row-actions.tsx
@@ -0,0 +1,73 @@
+'use client';
+
+import { useState, useTransition } from 'react';
+import { FoxButton } from '@fox/core/components/fox-button';
+import { FoxButtonGroup } from '@fox/core/components/fox-button-group';
+import { FoxIconButton } from '@fox/core/components/fox-icon-button';
+import { useFeedback } from '@/app/_hooks/use-feedback';
+import type { Merchant } from '@/lib/domain/merchant';
+import { deleteMerchantAction } from '../_actions';
+import { MerchantFormModal } from './merchant-form-modal';
+
+interface MerchantRowActionsProps {
+  merchant: Merchant;
+}
+
+export function MerchantRowActions({ merchant }: MerchantRowActionsProps) {
+  const { showAlert, hideAlert, showToast } = useFeedback();
+  const [isEditOpen, setIsEditOpen] = useState(false);
+  const [isDeleting, startDeleting] = useTransition();
+
+  function runDelete() {
+    hideAlert();
+    startDeleting(async () => {
+      const result = await deleteMerchantAction(merchant.id);
+      if (result.status === 'error') {
+        showToast({ variant: 'danger', message: result.message });
+        return;
+      }
+      showToast({ variant: 'success', message: '가맹점을 삭제했습니다.' });
+    });
+  }
+
+  function confirmDelete() {
+    showAlert({
+      variant: 'danger',
+      title: '가맹점을 삭제하시겠습니까?',
+      message: `"${merchant.name}"을(를) 삭제합니다. 삭제 후에는 되돌릴 수 없습니다.`,
+      actions: (
+        <FoxButtonGroup size="md">
+          <FoxButton type="default" size="md" label="취소" onAction={hideAlert} />
+          <FoxButton type="primary" size="md" label="삭제" onAction={runDelete} />
+        </FoxButtonGroup>
+      ),
+    });
+  }
+
+  return (
+    <FoxButtonGroup size="sm" align="center">
+      <FoxIconButton
+        type="default"
+        size="sm"
+        icon={<i className="fox-ico fox-ico-PencilSimple" aria-hidden="true" />}
+        label="수정"
+        onAction={() => setIsEditOpen(true)}
+      />
+      <FoxIconButton
+        type="default"
+        size="sm"
+        icon={<i className="fox-ico fox-ico-Trash" aria-hidden="true" />}
+        label={isDeleting ? '삭제 중' : '삭제'}
+        disabled={isDeleting}
+        onAction={confirmDelete}
+      />
+
+      {isEditOpen && (
+        <MerchantFormModal
+          merchant={merchant}
+          onClose={() => setIsEditOpen(false)}
+        />
+      )}
+    </FoxButtonGroup>
+  );
+}
 
app/(protected)/(basic)/system/merchants/excel/route.ts (added)
+++ app/(protected)/(basic)/system/merchants/excel/route.ts
@@ -0,0 +1,66 @@
+import { verifySession } from '@/lib/auth/dal';
+import { fetchAllMerchants } from '@/lib/data/repositories/merchant-repository';
+import { formatCategoryLabel, formatVisibilityLabel } from '@/lib/domain/merchant';
+
+/**
+ * 가맹점 목록 엑셀 다운로드(기획 MCH_001 ②) — 검색 조건과 무관하게 전체다.
+ *
+ * TODO(백엔드): 서버가 xlsx를 만들어 주면 학생 목록처럼 스트림 중계로 바꾼다. 지금은 백엔드
+ * 자체가 없어 여기서 CSV를 만든다.
+ */
+
+const HEADERS = [
+  '번호',
+  '가맹점코드',
+  '브랜드명',
+  '가맹점명',
+  '업종',
+  '사업자등록번호',
+  '대표자명',
+  '전화번호',
+  '주소',
+  '상세주소',
+  '사용여부',
+  '등록일',
+];
+
+/** 쉼표·따옴표·줄바꿈이 든 값은 따옴표로 감싸고 내부 따옴표는 두 번 쓴다(RFC 4180). */
+function toCsvCell(value: string): string {
+  return /[",\n]/.test(value) ? `"${value.replace(/"/g, '""')}"` : value;
+}
+
+export async function GET() {
+  await verifySession();
+
+  const merchants = await fetchAllMerchants();
+
+  const rows = merchants.map((merchant, index) =>
+    [
+      String(index + 1),
+      merchant.code,
+      merchant.brandName,
+      merchant.name,
+      formatCategoryLabel(merchant.categoryCode),
+      merchant.businessNumber,
+      merchant.ownerName,
+      merchant.phoneNumber,
+      merchant.address,
+      merchant.addressDetail,
+      formatVisibilityLabel(merchant.isVisible),
+      merchant.createdAt,
+    ]
+      .map(toCsvCell)
+      .join(',')
+  );
+
+  // 엑셀이 UTF-8을 알아보게 BOM을 앞에 붙인다 — 없으면 한글이 깨진다.
+  const csv = `${[HEADERS.join(','), ...rows].join('\r\n')}`;
+
+  return new Response(csv, {
+    status: 200,
+    headers: {
+      'Content-Type': 'text/csv; charset=utf-8',
+      'Content-Disposition': 'attachment; filename="merchants.csv"',
+    },
+  });
+}
 
app/(protected)/(basic)/system/merchants/page.tsx (added)
+++ app/(protected)/(basic)/system/merchants/page.tsx
@@ -0,0 +1,35 @@
+import type { Metadata } from 'next';
+import { verifySession } from '@/lib/auth/dal';
+import { fetchMerchants } from '@/lib/data/repositories/merchant-repository';
+import { parseMerchantQuery } from '@/lib/domain/merchant-query';
+import { MerchantList } from './_components/merchant-list';
+
+export const metadata: Metadata = {
+  title: '가맹점 관리',
+};
+
+export const dynamic = 'force-dynamic';
+
+interface PageProps {
+  searchParams: Promise<Record<string, string | string[] | undefined>>;
+}
+
+export default async function Page({ searchParams }: PageProps) {
+  await verifySession();
+
+  const query = parseMerchantQuery(await searchParams);
+  const { items, totalCount } = await fetchMerchants(query);
+
+  const totalPages = Math.max(1, Math.ceil(totalCount / query.pageSize));
+  const currentPage = Math.min(query.page, totalPages);
+
+  return (
+    <MerchantList
+      items={items}
+      query={query}
+      currentPage={currentPage}
+      totalPages={totalPages}
+      totalCount={totalCount}
+    />
+  );
+}
 
lib/data/repositories/merchant-repository.ts (added)
+++ lib/data/repositories/merchant-repository.ts
@@ -0,0 +1,162 @@
+import 'server-only';
+import type { Merchant } from '@/lib/domain/merchant';
+import { MERCHANT_CATEGORY_OPTIONS } from '@/lib/domain/merchant';
+import type { MerchantQuery } from '@/lib/domain/merchant-query';
+import type { MerchantValues } from '@/lib/domain/merchant-form';
+
+/**
+ * 가맹점 Repository — 기획 MCH_001/002_p/003_p.
+ *
+ * TODO(백엔드): 관리자용 가맹점 API가 아직 없다(`mngr` 도메인에 해당 컨트롤러가 없고,
+ * `common/qrauth`의 `CmmPaymentMerchantVo`는 결제 조회용이라 id·name·type·addr·telNo뿐이다).
+ * 아래 mock 저장소를 지우고 다른 Repository처럼 backendFetch + getSessionAccessToken으로
+ * 교체하면 호출부는 그대로 둘 수 있다.
+ *
+ * 이슈: 저장소가 모듈 메모리라 서버를 다시 띄우면 등록·수정·삭제가 초기값으로 되돌아간다.
+ */
+
+const TOTAL = 128;
+const BRANDS = ['GS25', 'CU', '', '스타벅스', '', '교보문고'];
+const CATEGORY_CODES = MERCHANT_CATEGORY_OPTIONS.map((option) => option.value);
+const CITIES = [
+  '경기도 평택시 송탄1로',
+  '경상남도 양산시 배움터길',
+  '경기도 용인시 기흥구 죽전로',
+  '서울특별시 마포구 양화로',
+  '부산광역시 해운대구 센텀중앙로',
+];
+const BASE_DATE = Date.UTC(2026, 0, 5);
+const DAY_MS = 24 * 60 * 60 * 1000;
+
+function dateAt(offsetDays: number): string {
+  return new Date(BASE_DATE + offsetDays * DAY_MS).toISOString().slice(0, 10);
+}
+
+/** 인덱스로만 만든다 — 난수를 쓰면 새로고침마다 목록이 흔들린다. */
+function buildMerchant(index: number): Merchant {
+  const serial = index + 1;
+  const brand = BRANDS[index % BRANDS.length];
+  const city = CITIES[index % CITIES.length];
+
+  return {
+    id: `MCH-${String(serial).padStart(6, '0')}`,
+    code: `MCH-${String(serial).padStart(6, '0')}`,
+    brandName: brand,
+    name: brand ? `${brand} ${city.split(' ').at(-1)}점` : `${city.split(' ').at(-1)}점`,
+    categoryCode: CATEGORY_CODES[index % CATEGORY_CODES.length],
+    businessNumber: `${100 + (index % 800)}-${10 + (index % 80)}-${String(10000 + index).slice(0, 5)}`,
+    ownerName: `대표${serial}`,
+    phoneNumber: `0${2 + (index % 6)}${String(10000000 + index * 7).slice(0, 8)}`,
+    address: `${city} ${(index % 90) + 1}`,
+    addressDetail: brand ? `1층 ${brand}` : '',
+    latitude: (37.0987 + (index % 50) * 0.01).toFixed(4),
+    longitude: (127.0736 + (index % 50) * 0.01).toFixed(4),
+    // 몇 곳은 미노출로 둬 필터가 실제로 걸리는지 보이게 한다.
+    isVisible: index % 7 !== 0,
+    memo: '',
+    createdAt: dateAt(index % 60),
+    updatedAt: dateAt((index % 60) + 15),
+  };
+}
+
+let MERCHANTS: Merchant[] = Array.from({ length: TOTAL }, (_, index) =>
+  buildMerchant(index)
+);
+
+const SEARCH_VALUE: Record<string, (merchant: Merchant) => string> = {
+  name: (merchant) => merchant.name,
+  businessNumber: (merchant) => merchant.businessNumber,
+  address: (merchant) => `${merchant.address} ${merchant.addressDetail}`,
+};
+
+function matchesQuery(merchant: Merchant, query: MerchantQuery): boolean {
+  if (query.category && merchant.categoryCode !== query.category) {
+    return false;
+  }
+  if (query.visibility === 'visible' && !merchant.isVisible) {
+    return false;
+  }
+  if (query.visibility === 'hidden' && merchant.isVisible) {
+    return false;
+  }
+
+  const keyword = query.keyword.trim().toLowerCase();
+  if (!keyword) {
+    return true;
+  }
+  const read = SEARCH_VALUE[query.searchField];
+  return read ? read(merchant).toLowerCase().includes(keyword) : true;
+}
+
+/** 기획 MCH_001 ④ — 최근등록순 정렬. */
+function byNewestFirst(a: Merchant, b: Merchant): number {
+  return Number(b.code.slice(4)) - Number(a.code.slice(4));
+}
+
+export async function fetchMerchants(
+  query: MerchantQuery
+): Promise<{ items: Merchant[]; totalCount: number }> {
+  const matched = MERCHANTS.filter((merchant) => matchesQuery(merchant, query));
+  matched.sort(byNewestFirst);
+
+  const start = (query.page - 1) * query.pageSize;
+  return {
+    items: matched.slice(start, start + query.pageSize),
+    totalCount: matched.length,
+  };
+}
+
+/** 엑셀 다운로드는 검색 조건과 무관하게 전체다(기획 MCH_001 ②). */
+export async function fetchAllMerchants(): Promise<Merchant[]> {
+  return [...MERCHANTS].sort(byNewestFirst);
+}
+
+function nowDate(): string {
+  return new Date().toISOString().slice(0, 10);
+}
+
+/** 가맹점코드는 저장 시 자동 생성한다(기획 MCH_002_p ⑤). */
+export async function createMerchant(values: MerchantValues): Promise<void> {
+  const nextSerial =
+    MERCHANTS.reduce(
+      (max, merchant) => Math.max(max, Number(merchant.code.slice(4)) || 0),
+      0
+    ) + 1;
+  const code = `MCH-${String(nextSerial).padStart(6, '0')}`;
+
+  MERCHANTS = [
+    ...MERCHANTS,
+    {
+      ...values,
+      id: code,
+      code,
+      createdAt: nowDate(),
+      updatedAt: nowDate(),
+    },
+  ];
+}
+
+export async function updateMerchant(values: MerchantValues): Promise<void> {
+  const index = MERCHANTS.findIndex((merchant) => merchant.id === values.id);
+  if (index < 0) {
+    throw new Error('가맹점을 찾을 수 없습니다.');
+  }
+
+  MERCHANTS[index] = {
+    ...MERCHANTS[index],
+    ...values,
+    // 코드·등록일은 바뀌지 않는다.
+    id: MERCHANTS[index].id,
+    code: MERCHANTS[index].code,
+    createdAt: MERCHANTS[index].createdAt,
+    updatedAt: nowDate(),
+  };
+}
+
+export async function deleteMerchant(id: string): Promise<void> {
+  const next = MERCHANTS.filter((merchant) => merchant.id !== id);
+  if (next.length === MERCHANTS.length) {
+    throw new Error('가맹점을 찾을 수 없습니다.');
+  }
+  MERCHANTS = next;
+}
lib/data/repositories/sidebar-menu-repository.ts
--- lib/data/repositories/sidebar-menu-repository.ts
+++ lib/data/repositories/sidebar-menu-repository.ts
@@ -100,6 +100,7 @@
             { id: 'codes', label: '코드관리', href: '/system/codes' },
             { id: 'menus', label: '메뉴관리', href: '/system/menus' },
             { id: 'banned-words', label: '금칙어관리', href: '/system/banned-words' },
+            { id: 'merchants', label: '가맹점 관리', href: '/system/merchants' },
           ],
         },
       ],
 
lib/domain/merchant-form.ts (added)
+++ lib/domain/merchant-form.ts
@@ -0,0 +1,91 @@
+export const NAME_MAX_LENGTH = 100;
+export const BRAND_MAX_LENGTH = 100;
+export const OWNER_MAX_LENGTH = 50;
+export const ADDRESS_DETAIL_MAX_LENGTH = 200;
+export const MEMO_MAX_LENGTH = 500;
+
+export interface MerchantValues {
+  id: string;
+  brandName: string;
+  name: string;
+  categoryCode: string;
+  businessNumber: string;
+  ownerName: string;
+  phoneNumber: string;
+  address: string;
+  addressDetail: string;
+  latitude: string;
+  longitude: string;
+  isVisible: boolean;
+  memo: string;
+}
+
+export type MerchantFormErrors = Partial<
+  Record<'name' | 'categoryCode' | 'phoneNumber' | 'address' | 'businessNumber', string>
+>;
+
+export type MerchantFormState =
+  | { status: 'idle' }
+  | { status: 'error'; message?: string; errors?: MerchantFormErrors }
+  | { status: 'success' };
+
+export const INITIAL_MERCHANT_FORM_STATE: MerchantFormState = { status: 'idle' };
+
+export const REQUIRED_MESSAGE = '필수입력 사항을 입력하세요.';
+
+/** 사업자등록번호는 선택이지만, 넣었다면 형태를 지킨다(기획 예시 `000-00-00000`). */
+const BUSINESS_NUMBER_PATTERN = /^\d{3}-?\d{2}-?\d{5}$/;
+
+type ValidationResult =
+  | { ok: true; values: MerchantValues }
+  | { ok: false; errors: MerchantFormErrors };
+
+export function validateMerchant(values: MerchantValues): ValidationResult {
+  const errors: MerchantFormErrors = {};
+
+  const name = values.name.trim();
+  if (!name) {
+    errors.name = REQUIRED_MESSAGE;
+  } else if (name.length > NAME_MAX_LENGTH) {
+    errors.name = `가맹점명은 ${NAME_MAX_LENGTH}자 이내로 입력해 주세요.`;
+  }
+
+  if (!values.categoryCode.trim()) {
+    errors.categoryCode = REQUIRED_MESSAGE;
+  }
+
+  const phoneNumber = values.phoneNumber.trim();
+  if (!phoneNumber) {
+    errors.phoneNumber = REQUIRED_MESSAGE;
+  } else if (!/^[\d-]+$/.test(phoneNumber)) {
+    errors.phoneNumber = '전화번호는 숫자와 -만 입력할 수 있습니다.';
+  }
+
+  if (!values.address.trim()) {
+    errors.address = '주소 검색을 진행해 주세요.';
+  }
+
+  const businessNumber = values.businessNumber.trim();
+  if (businessNumber && !BUSINESS_NUMBER_PATTERN.test(businessNumber)) {
+    errors.businessNumber = '사업자등록번호는 000-00-00000 형식으로 입력해 주세요.';
+  }
+
+  if (Object.keys(errors).length > 0) {
+    return { ok: false, errors };
+  }
+
+  return {
+    ok: true,
+    values: {
+      ...values,
+      name,
+      brandName: values.brandName.trim().slice(0, BRAND_MAX_LENGTH),
+      ownerName: values.ownerName.trim().slice(0, OWNER_MAX_LENGTH),
+      phoneNumber,
+      businessNumber,
+      address: values.address.trim(),
+      addressDetail: values.addressDetail.trim().slice(0, ADDRESS_DETAIL_MAX_LENGTH),
+      memo: values.memo.trim().slice(0, MEMO_MAX_LENGTH),
+    },
+  };
+}
 
lib/domain/merchant-query.ts (added)
+++ lib/domain/merchant-query.ts
@@ -0,0 +1,116 @@
+import { MERCHANT_CATEGORY_OPTIONS } from '@/lib/domain/merchant';
+
+export const MERCHANTS_PATH = '/system/merchants';
+/** 엑셀 다운로드 — 파일 응답이라 라우트 핸들러가 받는다(기획 MCH_001 ②). */
+export const MERCHANTS_EXCEL_PATH = `${MERCHANTS_PATH}/excel`;
+
+/** 필터 셀렉트의 "전체" 값 — URL에서는 생략된다. */
+export const FILTER_ALL = '';
+
+export type MerchantSearchField = 'name' | 'businessNumber' | 'address';
+
+export const MERCHANT_SEARCH_FIELD_OPTIONS: ReadonlyArray<{
+  value: MerchantSearchField;
+  label: string;
+}> = [
+  { value: 'name', label: '가맹점명' },
+  { value: 'businessNumber', label: '사업자번호' },
+  { value: 'address', label: '주소' },
+];
+
+export const MERCHANT_VISIBILITY_OPTIONS = [
+  { value: 'visible', label: '노출' },
+  { value: 'hidden', label: '미노출' },
+] as const;
+
+export const MERCHANT_PAGE_SIZE_OPTIONS = [10, 30, 50] as const;
+export type MerchantPageSize = (typeof MERCHANT_PAGE_SIZE_OPTIONS)[number];
+
+export const DEFAULT_MERCHANT_SEARCH_FIELD: MerchantSearchField = 'name';
+export const DEFAULT_MERCHANT_PAGE_SIZE: MerchantPageSize = 10;
+const DEFAULT_PAGE = 1;
+const MAX_KEYWORD_LENGTH = 100;
+
+export type MerchantQuery = {
+  category: string;
+  visibility: string;
+  searchField: MerchantSearchField;
+  keyword: string;
+  page: number;
+  pageSize: MerchantPageSize;
+};
+
+type RawSearchParams = Record<string, string | string[] | undefined>;
+
+function readParam(params: RawSearchParams, key: string): string {
+  const value = params[key];
+  return (Array.isArray(value) ? value[0] : value) ?? '';
+}
+
+function readOneOf(
+  params: RawSearchParams,
+  key: string,
+  allowed: readonly { value: string }[]
+): string {
+  const raw = readParam(params, key);
+  return allowed.some((option) => option.value === raw) ? raw : FILTER_ALL;
+}
+
+function isSearchField(value: string): value is MerchantSearchField {
+  return MERCHANT_SEARCH_FIELD_OPTIONS.some((option) => option.value === value);
+}
+
+function isPageSize(value: number): value is MerchantPageSize {
+  return (MERCHANT_PAGE_SIZE_OPTIONS as readonly number[]).includes(value);
+}
+
+export function parseMerchantQuery(searchParams: RawSearchParams): MerchantQuery {
+  const searchField = readParam(searchParams, 'searchField');
+  const page = Number(readParam(searchParams, 'page'));
+  const pageSize = Number(readParam(searchParams, 'pageSize'));
+
+  return {
+    category: readOneOf(searchParams, 'category', MERCHANT_CATEGORY_OPTIONS),
+    visibility: readOneOf(
+      searchParams,
+      'visibility',
+      MERCHANT_VISIBILITY_OPTIONS
+    ),
+    searchField: isSearchField(searchField)
+      ? searchField
+      : DEFAULT_MERCHANT_SEARCH_FIELD,
+    keyword: readParam(searchParams, 'keyword').trim().slice(0, MAX_KEYWORD_LENGTH),
+    page: Number.isInteger(page) && page > 0 ? page : DEFAULT_PAGE,
+    pageSize: isPageSize(pageSize) ? pageSize : DEFAULT_MERCHANT_PAGE_SIZE,
+  };
+}
+
+export function buildMerchantHref(
+  query: MerchantQuery,
+  overrides: Partial<MerchantQuery> = {}
+): string {
+  const merged = { ...query, ...overrides };
+  const params = new URLSearchParams();
+
+  if (merged.category) {
+    params.set('category', merged.category);
+  }
+  if (merged.visibility) {
+    params.set('visibility', merged.visibility);
+  }
+  if (merged.searchField !== DEFAULT_MERCHANT_SEARCH_FIELD) {
+    params.set('searchField', merged.searchField);
+  }
+  if (merged.keyword) {
+    params.set('keyword', merged.keyword);
+  }
+  if (merged.pageSize !== DEFAULT_MERCHANT_PAGE_SIZE) {
+    params.set('pageSize', String(merged.pageSize));
+  }
+  if (merged.page !== DEFAULT_PAGE) {
+    params.set('page', String(merged.page));
+  }
+
+  const queryString = params.toString();
+  return queryString ? `${MERCHANTS_PATH}?${queryString}` : MERCHANTS_PATH;
+}
 
lib/domain/merchant.ts (added)
+++ lib/domain/merchant.ts
@@ -0,0 +1,66 @@
+export interface Merchant {
+  id: string;
+  /** 등록 시 자동 생성 — 수정 불가(기획 MCH_003_p ①). */
+  code: string;
+  brandName: string;
+  name: string;
+  categoryCode: string;
+  businessNumber: string;
+  ownerName: string;
+  phoneNumber: string;
+  address: string;
+  addressDetail: string;
+  latitude: string;
+  longitude: string;
+  isVisible: boolean;
+  memo: string;
+  createdAt: string;
+  updatedAt: string;
+}
+
+export const EMPTY_FIELD_PLACEHOLDER = '-';
+
+export function formatOptionalValue(
+  value: string | number | null | undefined
+): string {
+  return value === null || value === undefined || value === ''
+    ? EMPTY_FIELD_PLACEHOLDER
+    : String(value);
+}
+
+/**
+ * 업종 — 기획 MCH_002_p ②.
+ *
+ * TODO(기획): "제공받을 수 없는 데이터라면 추후 삭제 필요"로 적혀 있다. 코드표가 정해지면
+ * 공통코드 조회로 바꾼다.
+ */
+export const MERCHANT_CATEGORY_OPTIONS: ReadonlyArray<{
+  value: string;
+  label: string;
+}> = [
+  { value: 'CONVENIENCE', label: '편의점' },
+  { value: 'CAFE', label: '카페' },
+  { value: 'RESTAURANT', label: '음식점' },
+  { value: 'BOOKSTORE', label: '서점' },
+  { value: 'STATIONERY', label: '문구점' },
+  { value: 'ETC', label: '기타' },
+];
+
+export function formatCategoryLabel(code: string): string {
+  if (!code) {
+    return EMPTY_FIELD_PLACEHOLDER;
+  }
+  return (
+    MERCHANT_CATEGORY_OPTIONS.find((option) => option.value === code)?.label ??
+    code
+  );
+}
+
+export function formatVisibilityLabel(isVisible: boolean): string {
+  return isVisible ? '노출' : '미노출';
+}
+
+/** 목록의 주소 칸 — 상세주소까지 붙이면 한 줄을 넘겨 표가 밀린다. */
+export function formatAddressSummary(merchant: Merchant): string {
+  return formatOptionalValue(merchant.address);
+}
Add a comment
List