임동욱 임동욱 08-10
feat: 학생 회원 도메인 타입과 mock Repository 추가
@6ce47c37fbdbb7a7dbff67d2cdb3cc382488b632
 
lib/data/repositories/student-member-repository.ts (added)
+++ lib/data/repositories/student-member-repository.ts
@@ -0,0 +1,208 @@
+import 'server-only';
+import type { StudentMember } from '@/lib/domain/student-member';
+import type {
+  StudentMemberQuery,
+  StudentMemberSearchField,
+} from '@/lib/domain/student-member-query';
+
+/**
+ * mock 학생 회원 Repository.
+ *
+ * 데이터 출처는 외부 시스템 「알콩」이며, 백엔드(edupay-backend)에 학생 회원 조회 API가 아직
+ * 없어 mock으로 구현한다. 공개 시그니처(도메인 타입만 주고받음)는 백엔드 연동 후에도 유지한다 —
+ * 연동 시점에는 이 파일의 내부 구현만 실제 API 호출로 교체하고, 각 함수에 캐시 전략
+ * (`fetchStudentMembers`/`fetchStudentMemberById`는 `cache: 'no-store'` — 개인정보를 담은
+ * 목록이고 검색 조건이 매 요청 달라지며 사용여부가 자주 바뀌므로 재사용 캐시를 두지 않는다)을
+ * 명시적으로 추가해야 한다. 지금은 실제 `fetch()` 호출이 없어(in-memory mock) 옵션을 걸 대상이
+ * 없다는 점에 유의 — 위 주석은 연동 시점에 적용할 의도를 남겨두는 것이다.
+ *
+ * mock 데이터는 결정적(deterministic)으로 생성한다 — `Math.random()`을 쓰지 않고 인덱스 기반
+ * 순환으로 이름·학교·학년 등을 만들어 매 요청 동일한 128건을 반환한다. 모듈이 처음 로드될 때
+ * 한 번만 생성해(module-level 배열) 이후 요청 간 같은 데이터를 유지하고,
+ * `updateStudentMemberActiveStatus`가 반영한 변경도 프로세스 생존 동안 유지된다(재시작 시
+ * 초기화 — 실제 영속 저장소가 아님에 유의).
+ */
+
+const TOTAL_MOCK_COUNT = 128;
+const MEMBER_CODE_PREFIX = 'ST';
+
+const SURNAMES = ['김', '이', '박', '최', '정', '강', '조', '윤', '장', '임'];
+const GIVEN_NAMES = [
+  '민준',
+  '서연',
+  '도윤',
+  '지우',
+  '하은',
+  '주원',
+  '수아',
+  '지호',
+  '예은',
+  '건우',
+  '서윤',
+  '연우',
+  '다은',
+];
+const GUARDIAN_GIVEN_NAMES = ['영수', '순자', '동현', '미경', '재훈', '은영'];
+
+const SCHOOL_NAMES = [
+  '한빛초등학교',
+  '늘푸른초등학교',
+  '서울중학교',
+  '대한중학교',
+  '한강고등학교',
+  '동산고등학교',
+  '중앙초등학교',
+  '푸른중학교',
+];
+
+/** 모듈 로드 시점에 1회만 고정한다 — 이후 모든 날짜 계산이 이 값 기준 오프셋이라 같은
+ *  프로세스에서는 매 요청 동일한 결과를 반환한다(Math.random 없이 결정적). */
+const GENERATED_AT = new Date();
+
+function formatDate(date: Date): string {
+  const year = date.getFullYear();
+  const month = String(date.getMonth() + 1).padStart(2, '0');
+  const day = String(date.getDate()).padStart(2, '0');
+  return `${year}-${month}-${day}`;
+}
+
+function subtractDays(base: Date, days: number): Date {
+  const result = new Date(base);
+  result.setDate(result.getDate() - days);
+  return result;
+}
+
+function padDigits(value: number, length: number): string {
+  const max = 10 ** length;
+  const normalized = ((value % max) + max) % max;
+  return String(normalized).padStart(length, '0');
+}
+
+function buildPhoneNumber(seed: number): string {
+  const middle = padDigits(1000 + seed * 137, 4);
+  const last = padDigits(2000 + seed * 271, 4);
+  return `010-${middle}-${last}`;
+}
+
+/**
+ * 인덱스 하나로부터 학생 회원 1건을 결정적으로 만든다. 회원코드는 ST00001~ST00128을
+ * 생성 순서(index) 그대로 부여하고, 가입일은 "최근 날짜부터 역순"(index가 커질수록 과거)으로
+ * 채운다 — 즉 index 0(ST00001)이 가장 최근 가입자, index 127(ST00128)이 가장 오래된
+ * 가입자다.
+ */
+function buildStudentMember(index: number): StudentMember {
+  const sequenceNumber = index + 1;
+  const memberCode = `${MEMBER_CODE_PREFIX}${padDigits(sequenceNumber, 5)}`;
+
+  const surname = SURNAMES[index % SURNAMES.length];
+  const givenName =
+    GIVEN_NAMES[Math.floor(index / SURNAMES.length) % GIVEN_NAMES.length];
+  const name = `${surname}${givenName}`;
+
+  const loginId = `stu${padDigits(sequenceNumber, 4)}`;
+  const grade = (index % 6) + 1;
+  const classNumber = (index % 10) + 1;
+  const studentNumber = (index % 30) + 1;
+
+  const guardianGivenName =
+    GUARDIAN_GIVEN_NAMES[index % GUARDIAN_GIVEN_NAMES.length];
+
+  const joinedAt = formatDate(subtractDays(GENERATED_AT, index));
+  const birthYear = GENERATED_AT.getFullYear() - (grade + 6);
+  const birthDate = `${birthYear}-${padDigits((index % 12) + 1, 2)}-${padDigits(
+    (index % 28) + 1,
+    2
+  )}`;
+
+  return {
+    id: memberCode,
+    memberCode,
+    name,
+    loginId,
+    phoneNumber: buildPhoneNumber(index),
+    email: `${loginId}@example.com`,
+    role: '학생',
+    schoolName: SCHOOL_NAMES[index % SCHOOL_NAMES.length],
+    grade,
+    classNumber,
+    studentNumber,
+    guardianName: `${surname}${guardianGivenName}`,
+    guardianPhoneNumber: buildPhoneNumber(index + 500),
+    joinedAt,
+    birthDate,
+    // 11명 중 1명 꼴로 비활성 — 조회 팝업의 사용여부 라디오가 실제로 두 상태 모두를
+    // 반영하는지 검증할 수 있도록 결정적으로 소수를 비활성화한다.
+    isActive: index % 11 !== 0,
+  };
+}
+
+let mockStudentMembers: StudentMember[] = Array.from(
+  { length: TOTAL_MOCK_COUNT },
+  (_, index) => buildStudentMember(index)
+);
+
+function matchesKeyword(
+  member: StudentMember,
+  field: StudentMemberSearchField,
+  keyword: string
+): boolean {
+  return member[field].toLowerCase().includes(keyword);
+}
+
+/**
+ * 검색·정렬·페이징이 적용된 학생 회원 목록을 조회한다.
+ * 캐시 전략: `no-store` 상당 — searchParams 기반이라 조건이 매 요청 달라지고 개인정보를
+ * 포함하므로 재사용 캐시를 두지 않는다.
+ */
+export async function fetchStudentMembers(
+  query: StudentMemberQuery
+): Promise<{ items: StudentMember[]; totalCount: number }> {
+  const keyword = query.keyword.trim().toLowerCase();
+
+  const filtered = keyword
+    ? mockStudentMembers.filter((member) =>
+        matchesKeyword(member, query.searchField, keyword)
+      )
+    : mockStudentMembers;
+
+  const sorted = [...filtered].sort((a, b) =>
+    query.sort === 'name'
+      ? a.name.localeCompare(b.name, 'ko')
+      : b.joinedAt.localeCompare(a.joinedAt)
+  );
+
+  const totalCount = sorted.length;
+  const start = (query.page - 1) * query.pageSize;
+  const items = sorted.slice(start, start + query.pageSize);
+
+  return { items, totalCount };
+}
+
+/**
+ * 단건 조회 — Server Action의 입력 검증(존재 여부 확인)에 사용한다.
+ * 캐시 전략: `no-store` 상당(위와 동일한 이유).
+ */
+export async function fetchStudentMemberById(
+  id: string
+): Promise<StudentMember | null> {
+  return mockStudentMembers.find((member) => member.id === id) ?? null;
+}
+
+/**
+ * 사용여부만 갱신한다(조회 팝업에서 유일하게 수정 가능한 필드). mock 한정 — 모듈 레벨 배열을
+ * 불변 갱신(map으로 새 배열 생성)하고 프로세스 생존 동안 유지한다. 쓰기 함수라 캐시 대상이
+ * 아니다 — 호출부(Server Action)가 `revalidatePath('/students')`로 목록 화면을 재검증한다.
+ */
+export async function updateStudentMemberActiveStatus(
+  id: string,
+  isActive: boolean
+): Promise<void> {
+  const exists = mockStudentMembers.some((member) => member.id === id);
+  if (!exists) {
+    throw new Error(`존재하지 않는 학생 회원입니다: ${id}`);
+  }
+
+  mockStudentMembers = mockStudentMembers.map((member) =>
+    member.id === id ? { ...member, isActive } : member
+  );
+}
 
lib/domain/student-member-query.ts (added)
+++ lib/domain/student-member-query.ts
@@ -0,0 +1,155 @@
+/**
+ * 학생 회원 목록의 검색·정렬·페이징 조건 — 순수 규칙(허용 값·기본값·URL 직렬화)만 담는다.
+ * next/react 의존이 없다(`URLSearchParams`는 서버·브라우저 양쪽에서 쓸 수 있는 표준 Web API).
+ *
+ * `app/(protected)/(basic)/students/page.tsx`가 `searchParams`를 `parseStudentMemberQuery`로
+ * 정규화하는 지점이자 단일 진실원천이며, 검색바·툴바·페이지네이션은 모두 `buildStudentMemberHref`로
+ * 같은 규칙에 따라 URL을 만들어 파라미터 이름·기본값이 여러 파일에 흩어져 드리프트하는 것을 막는다.
+ */
+
+/** 라우트 경로 — 이 파일 안에서만 하드코딩하고 나머지는 이 상수를 참조한다. */
+export const STUDENT_MEMBERS_PATH = '/students';
+
+export type StudentMemberSearchField =
+  | 'name'
+  | 'loginId'
+  | 'phoneNumber'
+  | 'schoolName'
+  | 'memberCode';
+
+export const STUDENT_MEMBER_SEARCH_FIELD_OPTIONS: ReadonlyArray<{
+  value: StudentMemberSearchField;
+  label: string;
+}> = [
+  { value: 'name', label: '회원명' },
+  { value: 'loginId', label: 'ID' },
+  { value: 'phoneNumber', label: '휴대전화번호' },
+  { value: 'schoolName', label: '학교명' },
+  { value: 'memberCode', label: '회원코드' },
+];
+
+export type StudentMemberSortOption = 'joinedAt' | 'name';
+
+export const STUDENT_MEMBER_SORT_OPTIONS: ReadonlyArray<{
+  value: StudentMemberSortOption;
+  label: string;
+}> = [
+  { value: 'joinedAt', label: '가입일순' },
+  { value: 'name', label: '이름순' },
+];
+
+export const STUDENT_MEMBER_PAGE_SIZE_OPTIONS = [10, 30, 50] as const;
+export type StudentMemberPageSize =
+  (typeof STUDENT_MEMBER_PAGE_SIZE_OPTIONS)[number];
+
+export const DEFAULT_STUDENT_MEMBER_SEARCH_FIELD: StudentMemberSearchField =
+  'name';
+export const DEFAULT_STUDENT_MEMBER_SORT: StudentMemberSortOption = 'joinedAt';
+export const DEFAULT_STUDENT_MEMBER_PAGE_SIZE: StudentMemberPageSize = 10;
+const DEFAULT_PAGE = 1;
+const MAX_KEYWORD_LENGTH = 100;
+
+export type StudentMemberQuery = {
+  searchField: StudentMemberSearchField;
+  keyword: string;
+  sort: StudentMemberSortOption;
+  page: number;
+  pageSize: StudentMemberPageSize;
+};
+
+/** Next.js `page.tsx`의 `searchParams`가 리졸브하는 값 형태를 그대로 옮긴 구조 타입 —
+ *  next 패키지를 import하지 않고도 같은 shape을 표현해 domain 계층의 무의존 규칙을 지킨다. */
+type RawSearchParams = Record<string, string | string[] | undefined>;
+
+function readParam(params: RawSearchParams, key: string): string | undefined {
+  const value = params[key];
+  return Array.isArray(value) ? value[0] : value;
+}
+
+function isStudentMemberSearchField(
+  value: string | undefined
+): value is StudentMemberSearchField {
+  return (
+    value !== undefined &&
+    STUDENT_MEMBER_SEARCH_FIELD_OPTIONS.some((option) => option.value === value)
+  );
+}
+
+function isStudentMemberSortOption(
+  value: string | undefined
+): value is StudentMemberSortOption {
+  return (
+    value !== undefined &&
+    STUDENT_MEMBER_SORT_OPTIONS.some((option) => option.value === value)
+  );
+}
+
+function isStudentMemberPageSize(
+  value: number
+): value is StudentMemberPageSize {
+  return (STUDENT_MEMBER_PAGE_SIZE_OPTIONS as readonly number[]).includes(
+    value
+  );
+}
+
+/**
+ * URL의 searchParams를 검증된 `StudentMemberQuery`로 정규화한다. 값이 없거나 허용 목록을
+ * 벗어나면 기본값으로 fallback한다 — searchParams는 사용자가 임의로 조작 가능한 값이라
+ * 신뢰하지 않는다.
+ */
+export function parseStudentMemberQuery(
+  searchParams: RawSearchParams
+): StudentMemberQuery {
+  const searchFieldRaw = readParam(searchParams, 'searchField');
+  const sortRaw = readParam(searchParams, 'sort');
+  const keywordRaw = readParam(searchParams, 'keyword');
+  const pageRaw = Number(readParam(searchParams, 'page'));
+  const pageSizeRaw = Number(readParam(searchParams, 'pageSize'));
+
+  return {
+    searchField: isStudentMemberSearchField(searchFieldRaw)
+      ? searchFieldRaw
+      : DEFAULT_STUDENT_MEMBER_SEARCH_FIELD,
+    keyword: (keywordRaw ?? '').trim().slice(0, MAX_KEYWORD_LENGTH),
+    sort: isStudentMemberSortOption(sortRaw)
+      ? sortRaw
+      : DEFAULT_STUDENT_MEMBER_SORT,
+    page: Number.isInteger(pageRaw) && pageRaw > 0 ? pageRaw : DEFAULT_PAGE,
+    pageSize: isStudentMemberPageSize(pageSizeRaw)
+      ? pageSizeRaw
+      : DEFAULT_STUDENT_MEMBER_PAGE_SIZE,
+  };
+}
+
+/**
+ * `StudentMemberQuery`(+ 부분 override)를 `/students` 링크로 직렬화한다. `parseStudentMemberQuery`의
+ * 역연산이며, 기본값과 같은 필드는 URL에서 생략해 링크를 짧게 유지한다.
+ */
+export function buildStudentMemberHref(
+  query: StudentMemberQuery,
+  overrides: Partial<StudentMemberQuery> = {}
+): string {
+  const merged = { ...query, ...overrides };
+  const params = new URLSearchParams();
+
+  if (merged.searchField !== DEFAULT_STUDENT_MEMBER_SEARCH_FIELD) {
+    params.set('searchField', merged.searchField);
+  }
+  if (merged.keyword) {
+    params.set('keyword', merged.keyword);
+  }
+  if (merged.sort !== DEFAULT_STUDENT_MEMBER_SORT) {
+    params.set('sort', merged.sort);
+  }
+  if (merged.pageSize !== DEFAULT_STUDENT_MEMBER_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
+    ? `${STUDENT_MEMBERS_PATH}?${queryString}`
+    : STUDENT_MEMBERS_PATH;
+}
 
lib/domain/student-member.ts (added)
+++ lib/domain/student-member.ts
@@ -0,0 +1,35 @@
+/**
+ * 학생 회원 도메인 타입 — 순수 데이터 표현, 외부 의존 없음.
+ *
+ * 데이터 출처는 외부 시스템 「알콩」이다. 백엔드(edupay-backend)에 학생 회원 조회 API가 아직
+ * 없는 구간이라 `student-member-repository.ts`가 결정적 mock 데이터로 이 타입을 채워 제공한다
+ * — 백엔드 연동 후에도 이 타입 자체는 그대로 유지되고 Repository 내부 구현만 교체된다.
+ *
+ * 조회 전용 화면(등록/수정/삭제 없음)의 데이터라 `isActive`를 제외한 모든 필드는 읽기 전용으로
+ * 취급한다(수정 가능 여부는 UI 계층의 책임이지 타입 자체의 제약은 아니다).
+ */
+export type StudentMember = {
+  /** 내부 식별자. mock 단계에서는 memberCode와 동일한 값을 쓰지만, 실제 백엔드 연동 시에는
+   *  별도의 PK일 수 있어 memberCode와 분리된 필드로 둔다. */
+  id: string;
+  /** 화면에 노출되는 회원코드 (예: ST00001). */
+  memberCode: string;
+  name: string;
+  loginId: string;
+  phoneNumber: string;
+  email: string;
+  /** 회원 역할 — 본 화면(학생 회원 목록)에서는 항상 '학생'이다. */
+  role: string;
+  schoolName: string;
+  grade: number;
+  classNumber: number;
+  studentNumber: number;
+  guardianName: string;
+  guardianPhoneNumber: string;
+  /** ISO 형식(YYYY-MM-DD) 문자열. */
+  joinedAt: string;
+  /** ISO 형식(YYYY-MM-DD) 문자열. */
+  birthDate: string;
+  /** 조회 팝업에서 유일하게 수정 가능한 필드(사용여부). */
+  isActive: boolean;
+};
Add a comment
List