단순한 CRUD를 넘어: 똑똑한 관리자 대시보드

이동휘·2025년 7월 15일
0

매일매일 블로그

목록 보기
43/49

"잘 만든 서비스 뒤에는 반드시 훌륭한 관리 도구가 있다."

사용자가 만족하는 서비스를 만들기 위해 화려한 프론트엔드와 견고한 백엔드를 구축하는 것도 중요하지만, 서비스의 품질을 장기적으로 유지하고 발전시키는 힘은 결국 '운영'에서 나옵니다. 사용자의 문의에 신속하게 대응하고, 중요한 공지를 제때 전달하며, 데이터를 체계적으로 관리하는 것. 이 모든 것은 잘 만들어진 관리자 페이지가 있기에 가능합니다.

이전 글들에서 PostGIS와 Supabase RPC를 활용한 백엔드 최적화, 그리고 Zustand와 커스텀 훅을 통한 프론트엔드 경험 구축에 대해 다뤘습니다. 이번 글에서는 그 두 세계를 연결하며 서비스의 완성도를 높이는 운영 관리 시스템, 즉 관리자 대시보드를 어떻게 구축했는지 그 기술적 비결을 상세히 공유하고자 합니다.


1. 철통보안의 첫걸음: 미들웨어(Middleware)를 활용한 관리자 경로 보호

관리자 페이지는 서비스의 민감한 데이터와 핵심 기능이 모여있는 곳이기에, 무엇보다 철저한 접근 제어가 필수입니다. 만약 단순히 클라이언트 사이드(예: React useEffect)에서 사용자의 권한을 확인하고 리디렉션한다면 어떻게 될까요? 권한 없는 사용자가 관리자 페이지에 접근했을 때, 화면이 아주 잠깐 보였다가 로그인 페이지로 넘어가는 '화면 깜빡임(UI Flicker)' 현상이 발생합니다. 이는 사용자 경험을 해칠 뿐만 아니라, 잠재적인 보안 위협을 노출하는 것과 같습니다.

우리는 이 문제를 해결하기 위해 Next.js 미들웨어(Middleware)를 도입했습니다. 미들웨어는 페이지가 사용자에게 렌더링되기 전, 서버(Edge)에서 먼저 요청을 가로채어 처리합니다. 이를 통해 권한 없는 사용자는 관리자 페이지의 어떤 코드나 UI도 볼 수 없도록 원천적으로 차단하여, 완벽한 보안과 끊김 없는 사용자 경험을 동시에 달성했습니다.

구현 과정 A to Z

1단계: 미들웨어 설정 및 Supabase 서버 클라이언트 생성

먼저, 프로젝트 루트에 middleware.ts 파일을 생성합니다. 이 파일은 특정 경로의 모든 요청에 대해 가장 먼저 실행되는 관문 역할을 합니다. 미들웨어 내부에서는 서버 환경 전용 Supabase 클라이언트를 생성해야 합니다. 이 클라이언트는 사용자의 브라우저로부터 전달된 쿠키를 읽어, 서버에서도 사용자의 인증 상태를 안전하게 파악할 수 있게 해줍니다.

// middleware.ts
import { createServerClient } from '@supabase/ssr';
import { NextResponse, type NextRequest } from 'next/server';

export async function middleware(request: NextRequest) {
  let response = NextResponse.next({ request: { headers: request.headers } });

  // 쿠키를 읽고 쓸 수 있는 서버 클라이언트 생성
  const supabase = createServerClient(
    process.env.SUPABASE_URL!,
    process.env.SUPABASE_ANON_KEY!,
    {
      cookies: {
        get: (name: string) => request.cookies.get(name)?.value,
        set: (name: string, value: string, options: CookieOptions) => {
          request.cookies.set({ name, value, ...options });
          response = NextResponse.next({ request: { headers: request.headers } });
        },
        remove: (name: string, options: CookieOptions) => {
          request.cookies.set({ name, value: '', ...options });
          response = NextResponse.next({ request: { headers: request.headers } });
        },
      },
    }
  );

  // ... (이후 권한 확인 로직)

  return response;
}

export const config = {
  matcher: [
    // API, 정적 파일 등을 제외한 모든 페이지 경로에서 미들웨어 실행
    '/((?!api|_next/static|_next/image|favicon.ico).*)',
  ],
};

2단계: 경로 분석 및 DB 기반 권한 확인 로직

미들웨어의 핵심은 현재 요청 경로(pathname)를 분석하고, 경로의 특성에 따라 적절한 권한 확인 절차를 거치는 것입니다.

// middleware.ts (계속)
  // 1. 현재 사용자 세션 가져오기
  const { data: { user } } = await supabase.auth.getUser();
  const { pathname } = request.nextUrl;

  // 2. 관리자만 접근 가능한 경로 배열
  const adminRequiredPaths = ['/admin'];

  // 3. /admin으로 시작하는 경로에 대한 접근 제어 시작
  if (adminRequiredPaths.some((path) => pathname.startsWith(path))) {
    // 3-1. 비로그인 사용자는 로그인 페이지로 리디렉션
    if (!user) {
      const redirectUrl = new URL('/login', request.url);
      redirectUrl.searchParams.set('next', pathname); // 원래 가려던 경로를 쿼리 파라미터로 추가
      return NextResponse.redirect(redirectUrl);
    }

    // 3-2. 로그인 사용자 대상 DB 기반 관리자 권한 확인
    const { data: profile } = await supabase
      .from('profiles')
      .select('role, is_admin')
      .eq('id', user.id)
      .single();

    const isAdmin = profile?.is_admin === true || profile?.role === 'admin';

    // 3-3. 관리자가 아니면 '권한 없음' 페이지로 리디렉션
    if (!isAdmin) {
      return NextResponse.redirect(new URL('/unauthorized', request.url));
    }
  }

  // 모든 검사를 통과하면 원래 요청을 그대로 진행
  return response;

이 로직의 흐름은 매우 명확하고 안전합니다.

  • 1차 차단 (비로그인): 관리자 페이지에 접근하려는 사용자가 로그아웃 상태라면, 원래 가려던 경로(next 파라미터) 정보를 포함하여 로그인 페이지로 즉시 보냅니다.
  • 2차 확인 (DB 조회): 로그인된 사용자라면, profiles 테이블을 직접 조회하여 is_admin 플래그나 role이 'admin'인지 서버에서 직접 확인합니다. 이는 클라이언트에서 조작할 수 없는 가장 확실한 방법입니다.
  • 2차 차단 (권한 부족): 로그인했지만 관리자가 아니라면, "권한이 없다"는 것을 명확히 알려주는 /unauthorized 페이지로 보냅니다.

이처럼 서버 단에서 요청을 먼저 검사하고 처리하는 미들웨어 방식 덕분에, 관리자 페이지는 외부 접근으로부터 철저히 보호되며 사용자에게는 혼란 없는 깔끔한 경험을 제공할 수 있게 되었습니다.

🤔 꼬리 질문: 미들웨어에서 DB를 조회하는 것은 성능에 영향을 줄 수 있습니다. 만약 관리자 권한 확인이 매우 빈번하게 일어난다면, 이 DB 조회 성능을 어떻게 최적화할 수 있을까요? (힌트: JWT 클레임, 세션 데이터 캐싱 등)


2. 효율적인 콘텐츠 관리: '공지사항' CRUD 기능 구현기

서비스 운영의 핵심 중 하나는 사용자와의 원활한 소통입니다. 저희는 '공지사항' 기능을 통해 이 역할을 수행합니다. 하지만 단순한 글쓰기(CRUD) 기능만으로는 실제 운영 환경의 다양한 요구사항("이벤트 공지를 미리 작성해두고 원하는 날짜에 게시하고 싶다", "긴급 점검 내용을 최상단에 고정하고 싶다")을 충족하기 어렵습니다.

이를 위해 저희는 단순한 CRUD를 넘어, '상태(State)'를 관리하는 관점에서 공지사항 기능을 설계했습니다.

설계의 핵심: 로직과 UI의 분리 (커스텀 훅)

관리자 페이지의 코드가 비대해지는 것을 막기 위해, 저희는 React의 커스텀 훅(Custom Hook)을 적극적으로 활용하여 데이터 관련 로직과 UI를 철저히 분리했습니다.

  • 데이터 조회 전담 (useAnnouncements): 공지사항 목록을 불러오고, 페이지네이션, 로딩, 에러 상태를 관리하는 모든 로직을 이 훅 안에 캡슐화했습니다.
  • 데이터 변경 전담 (useAnnouncementMutations): 공지사항을 생성(Create), 수정(Update), 삭제(Delete)하는 API 호출과 관련된 로직을 이 훅으로 분리했습니다.

이러한 관심사 분리(Separation of Concerns)는 코드의 복잡도를 낮추고, 향후 새로운 기능이 추가되더라도 유연하게 확장할 수 있는 구조적 토대를 마련해주었습니다.

직관적인 관리 UI 구축

관리자가 쉽고 빠르게 콘텐츠를 관리할 수 있도록, shadcn/ui의 컴포넌트들을 조합하여 직관적인 UI를 구축했습니다.

  • 한눈에 보는 목록: Table 컴포넌트를 사용해 공지사항 목록을 표시하되, Badge 컴포넌트로 '중요' 또는 '임시저장'과 같은 상태를 시각적으로 명확하게 구분했습니다.
  • 간편한 액션: 각 공지사항 항목 옆의 DropdownMenu를 통해 '보기', '수정', '삭제' 등의 작업을 즉시 수행할 수 있도록 하여, 불필요한 페이지 이동을 최소화하고 작업 효율을 높였습니다.

'임시저장'과 '게시'의 유연한 상태 관리

운영의 편의성을 위해, 데이터베이스 announcements 테이블에 is_published(게시 여부)와 is_important(중요 여부)라는 두 개의 boolean 필드를 두었습니다.

  • 공지사항 생성/수정 시:
    • 관리자가 '게시하기' 버튼을 누르면 is_publishedtrue로, published_at(게시일)은 현재 시간으로 설정되어 API에 전송됩니다.
    • '임시저장' 버튼을 누르면 is_publishedfalse로 설정되어, 사용자에게는 노출되지 않는 상태로 안전하게 저장됩니다.
  • API 처리:
    • GET 요청(목록 조회) 시, API는 요청자가 관리자일 때만 is_publishedfalse인 공지사항까지 포함하여 반환합니다. 일반 사용자는 오직 is_publishedtrue인 공지사항만 볼 수 있습니다.

이처럼 프론트엔드의 직관적인 UI와 백엔드의 명확한 상태 관리 로직이 결합하여, 관리자는 복잡한 운영 요구사항을 손쉽게 처리할 수 있게 됩니다.


3. 사용자와의 소통 창구: '문의하기' 기능과 상태 관리

사용자와의 소통은 서비스의 생명선과도 같습니다. '가게 등록 요청', '서비스 개선 제안', '오류 신고' 등 사용자의 목소리는 서비스가 성장하는 가장 중요한 자양분입니다. 저희는 이러한 소통을 체계적으로 관리하고 신속하게 대응하기 위해, 단순한 이메일 수신함을 넘어선 상태 기반 문의 관리 시스템을 구축했습니다.

체계적인 관리를 위한 데이터 모델링

효율적인 관리는 잘 설계된 데이터 구조에서 시작됩니다. 저희는 Supabase contacts 테이블을 설계할 때, 문의 내용뿐만 아니라 문의의 유형(type)처리 상태(status)를 명확히 구분하는 데 중점을 두었습니다.

// types/supabase.ts -> public.Tables.contacts.Row (가독성을 위해 재구성)
interface Contact {
  id: number;
  // 문의 유형: '가게 등록', '일반 문의', '피드백' 중 하나
  type: "store_registration" | "inquiry" | "feedback";
  // 처리 상태: '대기중', '처리중', '완료', '종료' 중 하나
  status: "pending" | "in_progress" | "completed" | "closed";
  // ... 기타 필드
}
  • type 필드: 사용자가 문의 목적을 명확히 선택하게 함으로써, 관리자는 문의의 성격을 빠르게 파악하고 우선순위를 정할 수 있습니다.
  • status 필드: 모든 문의는 '대기중(pending)' 상태로 시작하여, 관리자의 처리에 따라 상태가 변경됩니다. 이를 통해 어떤 문의가 처리되었고 어떤 문의가 대기 중인지 명확하게 추적하여, 누락되는 문의가 없도록 방지합니다.

운영 효율을 극대화하는 관리자 UI/UX

반복적인 관리 업무는 최대한 효율적이어야 합니다. 저희는 관리자의 작업 흐름을 고려하여 문의 관리 페이지를 설계했습니다.

  • 필터링 기능: '상태별' 또는 '유형별'로 문의를 필터링하여, 관리자가 '아직 처리하지 않은 가게 등록 요청'만 모아보는 등 원하는 데이터에 빠르게 접근할 수 있도록 했습니다.
  • 직관적인 상태 변경: Select 컴포넌트를 통해 드롭다운 메뉴에서 바로 상태를 변경할 수 있습니다. 관리자가 상태를 선택하면, 별도의 저장 버튼 없이 즉시 데이터베이스에 반영되고 UI가 업데이트되어 매우 빠른 피드백을 경험합니다.
  • 효율적인 2단 레이아웃 (데스크톱): 화면 왼쪽에는 문의 목록이, 오른쪽에는 선택된 문의의 상세 내용이 표시되는 2단 레이아웃을 채택했습니다. 이를 통해 관리자는 불필요한 페이지 이동 없이 여러 문의를 신속하게 확인하고 처리할 수 있어 작업의 피로도를 크게 줄였습니다.

🤔 꼬리 질문: 관리자가 문의에 대한 답변을 작성하고 사용자에게 이메일로 알림을 보내는 기능을 추가한다면, 이 로직은 프론트엔드, 백엔드 API, 데이터베이스 트리거 중 어디에서 처리하는 것이 가장 적절할까요? 각 방식의 장단점을 논의해 볼 수 있을까요?


결론: 보이지 않는 곳의 노력이 서비스의 완성도를 만든다

훌륭한 관리자 페이지는 단순히 데이터를 나열하는 내부 도구가 아닙니다. 이것은 서비스의 품질을 유지하고, 사용자와 소통하며, 비즈니스의 방향을 결정하는 데이터가 모이는 서비스의 또 다른 심장입니다.

Next.js의 강력한 서버 기능(미들웨어, Route Handlers)과 Supabase의 유연한 데이터베이스 및 인증 기능을 유기적으로 결합함으로써, 사용자에게 보이는 서비스뿐만 아니라 보이지 않는 곳의 운영 효율성까지 함께 고려한 완성도 높은 프로젝트를 만들 수 있었습니다.

이 글이 단순히 기능을 만드는 것을 넘어, 서비스 전체를 지탱하는 견고한 시스템을 구축하고자 하는 개발자분들께 좋은 영감이 되기를 바랍니다.

0개의 댓글