안녕하세요, 이중혁입니다

배우고 경험한 기술들을 하나씩 정리하는 공간

유저 관리

NestJS Admin API를 활용한 유저 관리 시스템

Portfolio-Server의 Admin API를 활용한 유저 관리 시스템입니다. NestJSTypeORM으로 구축된 백엔드 API와 연동합니다.

주요 기능

  • JWT 인증: JwtAuthGuard 기반 보안
  • 동적 헤더: selected-user-id 헤더로 유저 컨텍스트 전환
  • 고급 필터: FilterDto 기반 다양한 필터 타입 지원
  • 페이지네이션: PaginationInDto/OutDto 구조

Portfolio-Server API

  • User: 유저 기본 정보 조회
  • UserDetail: 게임 재화 및 설정
  • Character: 보유 캐릭터 목록
  • Mission: 미션 진행 상태
  • Arena: 최고 점수 및 히스토리

현재 상태

포트폴리오에서는 먼저 인증 및 필터/페이지네이션 표준화를 구축하고, 유저·상세·캐릭터·미션·점수 API를 단계적으로 연동했습니다. 프런트는 표준 필터 UI와 JWT 기반 접근 제어를 사용해 운영 편의성과 보안을 함께 확보했습니다.

로그인
유저 목록

인증 정보

NestJS Admin API에 접근하기 위해서는 먼저 로그인이 필요합니다.

  • 이메일: 아무 이메일 형식으로 넣어주세요
  • 패스워드: 소문자, 대문자, 숫자, 특수문자, 10글자 이상
  • • 로그인 성공 시 Access Token이 자동으로 저장됩니다

로그인

import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import { plainToInstance } from 'class-transformer';
import { PaginationInDto } from '@libs/common/dto/pagination-in.dto';

// 쿼리 파라미터를 PaginationInDto로 자동 변환하는 데코레이터
// - page, size, order, filter를 자동으로 파싱
// - filter는 JSON 문자열을 객체로 변환
export const QueryFilter = createParamDecorator(
  (data: unknown, ctx: ExecutionContext) => {
    const request = ctx.switchToHttp().getRequest();
    const value = request.query;
    // eslint-disable-next-line prefer-const
    let { page, size, order, ...filters } = value;

    // 기본값 설정
    page = page || 1;
    size = size || 20;
    order = order || 'DESC';

    // 필터 객체 파싱 (JSON 문자열 → 객체)
    const filterObj: any = {};
    for (const key of Object.keys(filters)) {
      try {
        filterObj[key] = JSON.parse(filters[key]);
      } catch {
        // JSON 파싱 실패 시 무시
      }
    }

    // PaginationInDto 인스턴스로 변환
    return plainToInstance(PaginationInDto, {
      page,
      size,
      order,
      filter: filterObj,
    });
  },
);

Admin Tool (Ruby on Rails ActiveAdmin)

Ruby on Rails ActiveAdmin을 활용한 운영 관리 도구

장점

  • 빠른 개발: 모델만 정의하면 CRUD 인터페이스 자동 생성
  • 고급 필터: Ransack 기반 검색/정렬/필터링 자동 생성
  • 데이터 익스포트: CSV/JSON/XML 다운로드 기능 내장
  • 로컬라이징: I18n.t 사용으로 다국어 지원
  • DSL 기반: 간단한 코드로 복잡한 관리 기능 구현
  • Rails 통합: Devise(인증), Pundit(권한), ActiveJob(백그라운드) 자동 연동
  • 권한 관리: 역할 기반 세밀한 접근 제어
  • 일관된 UI: 통일된 관리 인터페이스

단점

  • DB 직접 연결: 트랜잭션 없이 직접 수정, 롤백 불가
  • 성능 제약: N+1 쿼리, 대용량 데이터 처리 어려움
  • 학습 곡선: ActiveAdmin만의 DSL 문법 학습 필요
  • 커스터마이징: 복잡한 프론트엔드 로직 구현 제한
  • 유지보수: Rails 업그레이드 시 호환성 문제
  • UI 제약: 기본 테마에서 벗어나기 어려움

개선점

  • Index 페이지 활용: 빠른 개발, 고급 필터, 데이터 익스포트 장점 극대화
  • DB 읽기 전용 권한: 운영툴 DB 연결은 SELECT만 가능, 직접 수정/삭제 차단
  • NestJS REST API: 모든 CUD(Create/Update/Delete) 작업은 REST API를 통해서만 수행
  • 보안 강화: API 레벨에서 검증 및 권한 제어, 트랜잭션 보장
  • 휴먼 에러 방지: 서비스 레이어에서 비즈니스 로직 검증, 롤백 가능
class ApplicationController < ActionController::Base
  # CSRF 공격 방어 - 예외 발생 시 세션 리셋
  protect_from_forgery with: :exception
  
  # 모든 요청 전에 IP 화이트리스트 검증
  before_action :verify_ip_address
  
  # 모든 요청 전에 세션 만료 확인
  before_action :check_session_expiry

  private

  # 세션 만료 시간 체크
  # - Devise timeout 설정 기반으로 세션 유효성 검증
  # - 마지막 활동 시간(last_seen)과 현재 시간 차이로 만료 여부 판단
  def check_session_expiry
    # 세션에서 마지막 활동 시간 조회 (없으면 현재 시간)
    last_seen = session[:last_seen] ? Time.parse(session[:last_seen]) : Time.current
    
    # Devise 설정의 타임아웃 시간 조회 (초 단위)
    session_expiry_duration = Devise.timeout_in.to_i

    # 마지막 활동 이후 경과 시간이 타임아웃보다 크면 세션 만료
    if (Time.current - last_seen).to_i > session_expiry_duration
      # 현재 관리자 사용자 로그아웃
      sign_out current_admin_user
      
      # 관리자 루트로 리다이렉트 + 경고 메시지
      redirect_to admin_root_path, alert: "Your session has expired. Please log in again."
    else
      # 세션 유효 시 마지막 활동 시간 갱신
      session[:last_seen] = Time.current.to_s
    end
  end

  # IP 화이트리스트 검증
  # - X-Forwarded-For 헤더에서 실제 클라이언트 IP 추출
  # - 화이트리스트에 등록된 IP만 접근 허용
  def verify_ip_address
    # 프록시를 거친 경우 X-Forwarded-For 헤더에서 원본 IP 추출
    forwarded_ips = request.env['HTTP_X_FORWARDED_FOR']
    remote_ip = forwarded_ips ? forwarded_ips.split(',').first.strip : request.remote_ip
    
    # 화이트리스트에 없고 + 프로덕션 환경 + health 체크 아니면 401 Unauthorized 반환
    head :unauthorized if Whitelist.find_by(ip_address: remote_ip).nil? && !Rails.env.development? && request.path != '/health'
  end
end