FrontEnd Develop/Project : Team Nova MJ Search

MJS : 학사일정 컴포넌트 및 자유게시판 HOT 게시물 컴포넌트 연동 문서

Frisbeen 2025. 8. 19. 04:37

대상: 신입/합류 개발자

목표: 두 위젯을 빠르게 이해하고, 팀의 코드/디자인 규칙에 맞춰 안정적으로 개발·확장할 수 있도록 한다.

위젯

  1. 학사일정 텍스트 전용 패널(달력 그리드 없음)
  2. 자유게시판 HOT 게시물 리스트(로그인 게이팅)


0. 스택 & 전제

  • Next.js/React 18, TypeScript (strict), Tailwind CSS, Zustand(전역 인증), react-icons(아이콘)
  • 디자인 토큰: mju-primary, grey-40 등 (Tailwind theme.extend.colors에 등록되어 있어야 함)
  • API 모듈: getAcademicEvents, getBoards (서버 응답이 케이스별로 달라질 수 있음 → 안전 파서 필요)
// tailwind.config.{js,ts} 예시
export default {
  theme: {
    extend: {
      colors: {
        mju: { primary: '#2B6CB0' }, // 실제 브랜드 컬러로 교체
        'grey-40': '#6B7280',        // 예시: slate-500 정도 톤
      },
      boxShadow: { card: '0 8px 24px rgba(0,0,0,0.06)' },
      borderRadius: { xl2: '1rem' },
    },
  },
};

1. 팀 공통 규칙 요약

  • any 금지, unknown + 타입 가드 사용.
  • API 응답은 안전 파서로만 소비 (shape 변동/래핑 대응).
  • useEffect 비동기 처리 시 정리(clean-up) 철저(마운트 여부/AbortController).
  • 파생값은 useMemo, 이벤트 핸들러는 useCallback(필요 시)로 렌더 최소화.
  • 접근성(A11y): 버튼 aria-label, 섹션 aria-label, 포커스 링 유지.
  • 디자인 시스템 클래스 우선: 컬러/간격/둥근 모서리/섀도우 일관.
  • 빈 상태/로딩/에러 3상태 명확히 표현.

2. 공통 유틸 (복붙 가능)

2.1 안전 파서

function isRecord(v: unknown): v is Record<string, unknown> {
  return typeof v === 'object' && v !== null;
}

export function safePickArray<T>(payload: unknown, keyPath: (string | number)[]): T[] {
  let cur: unknown = payload;
  for (const key of keyPath) {
    if (isRecord(cur) && key in cur) cur = (cur as Record<string, unknown>)[key];
    else return [];
  }
  return Array.isArray(cur) ? (cur as T[]) : [];
}

export function getErrorMessage(e: unknown, fallback = '요청 중 오류가 발생했습니다.') {
  return e instanceof Error ? e.message : fallback;
}

2.2 날짜 유틸

export function toDate(iso: string) {
  const [y, m, d] = iso.split('-').map(Number);
  return new Date(y, m - 1, d);
}
export const mmdd = (dt: Date) => `${String(dt.getMonth() + 1).padStart(2, '0')}.${String(dt.getDate()).padStart(2, '0')}`;
export const fmtRange = (a: Date, b: Date) => `${mmdd(a)} ~ ${mmdd(b)}`;
export const isOngoing = (s: string, e: string, today = new Date()) => {
  const S = toDate(s), E = toDate(e);
  const T = new Date(today.getFullYear(), today.getMonth(), today.getDate());
  return S <= T && T <= E;
};
export const overlapsMonth = (s: string, e: string, y: number, m0to11: number) => {
  const S = toDate(s), E = toDate(e);
  const sk = S.getFullYear() * 12 + S.getMonth();
  const ek = E.getFullYear() * 12 + E.getMonth();
  const tk = y * 12 + m0to11;
  return sk <= tk && tk <= ek;
};

팁: 서버가 YYYY-MM-DD를 보낸다는 전제가 깨질 수 있으므로, 필요 시 zod 같은 스키마 검증을 붙여도 좋음.


3. 컴포넌트 A — 학사일정 텍스트 전용 패널

3.1 요구사항 요약 (디자인 기준)

  • 달력 그리드 없음. 상단에 월 네비게이션(좌/우 화살표) + 중앙에 YYYY년 M월.
  • 아래는 텍스트 리스트. 각 항목은 점선 경계 + 라운드 카드.
  • 진행 중 일정은 연한 mju-primary/10 배경, 휴일/방학 키워드는 날짜만 빨간색.
  • 로딩 시 스켈레톤(점선 경계박스, pulse), 빈 상태/에러 문구 제공.

3.2 상태 설계

  • 원시 상태: year, month(0~11), rows(전체 연도 데이터), loading, err.
  • 파생 상태: monthItems = rows.filter(overlapsMonth).slice(0, maxItems).
  • 전이: prevMonth/nextMonth → 월 경계에서 연도 증감.

3.3 데이터 패턴

  • 연도 단위로 한번 크게 가져오기(size 넉넉히) → 월 이동은 클라이언트 필터.
  • 응답 래핑 케이스(data.content, content, 배열 자체)를 모두 처리하는 안전 파서 사용.

3.4 코드 스니펫 (핵심만)

'use client';
import { useEffect, useMemo, useState } from 'react';
import { getAcademicEvents } from '@/api/calendar';
import { fmtRange, isOngoing, overlapsMonth, toDate } from '@/shared/date';
import { getErrorMessage, safePickArray } from '@/shared/safe';

type Item = { year: number; startDate: string; endDate: string; description: string };

export default function AcademicSchedulePanel({ initialYear, initialMonth, maxItems = 6 }: { initialYear?: number; initialMonth?: number; maxItems?: number; }) {
  const now = new Date();
  const [year, setYear] = useState(initialYear ?? now.getFullYear());
  const [month, setMonth] = useState(initialMonth ?? now.getMonth());
  const [rows, setRows] = useState<Item[]>([]);
  const [loading, setLoading] = useState(true);
  const [err, setErr] = useState<string | null>(null);

  useEffect(() => {
    let alive = true;
    (async () => {
      try {
        setLoading(true); setErr(null);
        const resp = await getAcademicEvents({ year, page: 0, size: 1000, sortBy: 'startDate', sortDir: 'asc' });
        // data.content → content → 배열 본문 순으로 안전 추출
        const content = safePickArray<Item>(resp, ['data', 'content'])
          .concat(safePickArray<Item>(resp, ['content']))
          .concat(Array.isArray(resp) ? (resp as Item[]) : [])
          .filter(Boolean);
        const onlyYear = content.filter((it) => toDate(it.startDate).getFullYear() === year || toDate(it.endDate).getFullYear() === year)
          .sort((a, b) => +toDate(a.startDate) - +toDate(b.startDate) || +toDate(a.endDate) - +toDate(b.endDate) || a.description.localeCompare(b.description));
        if (!alive) return; setRows(onlyYear);
      } catch (e) {
        if (!alive) return; setErr(getErrorMessage(e, '학사일정을 불러오지 못했습니다.')); setRows([]);
      } finally { if (alive) setLoading(false); }
    })();
    return () => { alive = false; };
  }, [year]);

  const monthItems = useMemo(() => rows.filter((ev) => overlapsMonth(ev.startDate, ev.endDate, year, month)).slice(0, maxItems), [rows, year, month, maxItems]);

  const title = `${year}년 ${month + 1}월`;
  const prevMonth = () => setMonth((m) => (m === 0 ? (setYear((y) => y - 1), 11) : m - 1));
  const nextMonth = () => setMonth((m) => (m === 11 ? (setYear((y) => y + 1), 0) : m + 1));

  return (
    <section className="w-full rounded-2xl border border-grey-40 bg-white shadow-sm p-5" aria-label="학사일정">
      <h3 className="text-base font-semibold text-grey-40 mb-5"> 학사일정</h3>
      <div className="rounded-xl border-grey-40 px-4 py-3">
        <div className="flex items-center justify-between">
          <button onClick={prevMonth} aria-label="이전 달" className="h-9 w-9 rounded-full text-mju-primary hover:bg-mju-primary/10 focus:ring-2 focus:ring-mju-primary/30">‹</button>
          <h2 className="text-xl md:text-2xl font-extrabold text-gray-900">{title}</h2>
          <button onClick={nextMonth} aria-label="다음 달" className="h-9 w-9 rounded-full text-mju-primary hover:bg-mju-primary/10 focus:ring-2 focus:ring-mju-primary/30">›</button>
        </div>
      </div>

      <div className="mt-4 space-y-3">
        {loading && (
          <ul className="space-y-3">
            {Array.from({ length: maxItems }).map((_, i) => (
              <li key={i} className="h-16 rounded-xl border-2 border-dashed border-mju-primary/40 bg-gray-50 animate-pulse" />
            ))}
          </ul>
        )}
        {!loading && err && <p className="text-sm text-red-600">{err}</p>}
        {!loading && !err && monthItems.length === 0 && <p className="text-sm text-gray-500">해당 월의 일정이 없습니다.</p>}
        {!loading && !err && monthItems.length > 0 && (
          <ol className="space-y-3">
            {monthItems.map((ev, idx) => (
              <li key={`${ev.startDate}-${ev.endDate}-${idx}`}>
                <div className={`rounded-xl border-2 border-dashed border-mju-primary/40 px-4 py-3 ${isOngoing(ev.startDate, ev.endDate) ? 'bg-mju-primary/10' : 'bg-white'}`}>
                  <div className="text-[15px] md:text-base font-semibold text-gray-900">{ev.description}</div>
                  <div className="mt-1 text-sm">
                    <span className={/휴일|공휴일|휴무|방학/i.test(ev.description) ? 'text-error font-medium' : 'text-gray-500'}>
                      {fmtRange(toDate(ev.startDate), toDate(ev.endDate))}
                    </span>
                    {isOngoing(ev.startDate, ev.endDate) && (
                      <span className="ml-2 inline-block rounded-md border border-mju-primary/30 bg-mju-primary/10 px-2 py-0.5 text-xs font-semibold text-mju-primary">현재 진행 중</span>
                    )}
                  </div>
                </div>
              </li>
            ))}
          </ol>
        )}
      </div>
    </section>
  );
}

3.5 엣지 케이스 체크리스트

  • 장기간(전월/익월) 걸친 일정 필터가 정확한가?
  • 오늘 날짜 경계(자정) 기준 진행 중 판단 정확한가?
  • YYYY-MM-DD 포맷이 아닌 경우 예외 처리 / 무시 정책은?
  • 스크롤 영역 높이 제한이 필요한가(페이지 레이아웃에 맞게)?

3.6 A11y 체크

  • 네비 버튼 aria-label 지정.
  • 포커스 링 제거 금지(focus:outline-none만 쓰지 말고 focus:ring-* 추가).
  • 로딩 동안 aria-busy 고려(선택) / 스켈레톤에 aria-hidden.

4. 컴포넌트 B — 자유게시판 HOT 리스트(로그인 게이팅)

4.1 요구사항 요약

  • 상단 헤더: "HOT 게시물" + 우측 더보기 링크.
  • 비로그인: API 호출 스킵, 안내 카드 + 로그인 버튼 노출.
  • 로그인: 로딩 스켈레톤 → 성공 시 리스트, 실패 시 에러 문구.
  • 항목: HOT 뱃지 + 제목 + (좋아요/댓글) 아이콘 카운트.

4.2 상태 & 데이터 흐름

  • 전역: useAuthStore((s) => s.isLoggedIn).
  • 로컬: status: 'loading'|'success'|'error', items: BoardItem[], errorMsg.
  • 로그인 전에는 status='success'로 두고 빈 상태 UI 표시(불필요한 API 호출 차단).

4.3 코드 스니펫 (핵심)

'use client';
import { useEffect, useState } from 'react';
import { getBoards } from '@/api/board';
import type { BoardItem } from '@/api/board';
import { useAuthStore } from '@/store/useAuthStore';
import { FaRegThumbsUp, FaRegCommentDots } from 'react-icons/fa';
import { getErrorMessage, safePickArray } from '@/shared/safe';

type Status = 'loading' | 'success' | 'error';

export default function HotBoardList({ limit = 5, hrefBuilder = (uuid) => `/boards/${uuid}`, loginHref = '/login', onLoginClick }: { limit?: number; hrefBuilder?: (uuid: string) => string; loginHref?: string; onLoginClick?: () => void; }) {
  const isLoggedIn = useAuthStore((s) => s.isLoggedIn);
  const [status, setStatus] = useState<Status>('loading');
  const [items, setItems] = useState<BoardItem[]>([]);
  const [errorMsg, setErrorMsg] = useState('');

  useEffect(() => {
    let mounted = true;
    if (!isLoggedIn) {
      setStatus('success'); setItems([]); setErrorMsg('');
      return () => { mounted = false; };
    }
    (async () => {
      try {
        setStatus('loading'); setErrorMsg('');
        const resp = await getBoards(0, 20, 'likeCount', 'DESC');
        const rows = safePickArray<BoardItem>(resp, ['content'])
          .concat(safePickArray<BoardItem>(resp, ['data', 'content']))
          .concat(Array.isArray(resp) ? (resp as BoardItem[]) : []);
        const hot = rows.filter((r) => r.popular === true).slice(0, limit);
        if (!mounted) return; setItems(hot); setStatus('success');
      } catch (e) {
        if (!mounted) return; setStatus('error'); setErrorMsg(getErrorMessage(e, '자유게시판 HOT 목록을 불러오지 못했어요.'));
      }
    })();
    return () => { mounted = false; };
  }, [limit, isLoggedIn]);

  return (
    <section className="rounded-xl border b