Notice
Recent Posts
Recent Comments
반응형
관리 메뉴

HM소프트

웹·앱 공용 풀스택 시스템 개발기 (1) — 공용 백엔드 API 재설계 본문

외주 개발일지

웹·앱 공용 풀스택 시스템 개발기 (1) — 공용 백엔드 API 재설계

HM소프트 2026. 6. 21. 06:26
반응형

업무 규칙을 한 곳에 두는 API 전용 백엔드를 Next.js·Prisma·PostgreSQL·Redis로 설계한다. 웹 세션·관리자 JWT와 앱 JWT·기기 바인딩이라는 두 인증을 하나의 AuthContext로 추상화하고, next build의 모듈 평가 함정과 Redis graceful degrade, 단일 응답 봉투와 DomainError까지 다룬다.

시리즈를 시작하며 — 하나의 백엔드, 두 개의 클라이언트

한 대규모 사업장의 통합 업무 시스템을 v2.0으로 다시 설계했다. 인사·근태·회계·교육·KPI·안전·문서·영업까지 아홉 개 업무 영역을 한 시스템에서 다루는데, v1과 v2의 결정적 차이는 클라이언트가 둘로 늘었다는 점이다. 관리자는 웹(브라우저)을, 현장 직원은 모바일 앱을 쓴다. 그런데 둘은 같은 업무 데이터를 본다. 같은 직원, 같은 근태, 같은 휴가 잔여일.

이 시리즈는 그 시스템을 세 편으로 나눠 기록한다.

  1. (이 글) 공용 백엔드 API 재설계 — Next.js(API 전용) + Prisma + PostgreSQL + Redis, 그리고 "웹·앱 두 인증 경로"를 한 백엔드에 어떻게 얹었나
  2. 알림(alerts) 시스템 — 이상 감지 엔진과 서버사이드 필터·상태 이력·일괄 확인
  3. React Native 직원 앱 — 데모 모드로 먼저 만들고 운영 백엔드에 붙이기

회사명·장소·실명은 전부 일반화하고, 구조와 코드만 다룬다. 1편의 주제는 한 문장으로 요약된다. "업무 규칙은 백엔드 한 곳에만 두고, 웹과 앱은 그 위에 화면만 얹는다." 이게 왜 어렵고, 어떤 결정들이 필요했는지가 이 글의 내용이다.

왜 백엔드를 따로 떼어냈나

v1에서는 데이터 로직 상당수가 웹 쪽에 묻혀 있었다. 화면을 그리면서 규칙도 같이 짜는 구조다. 웹만 있을 때는 그럭저럭 굴러갔다. 문제는 모바일 앱이 추가되면서 드러났다.

앱이 같은 업무를 다루려면, 웹에 박힌 규칙(권한 판정, 입력 검증, KPI 계산)을 앱에서 다시 구현해야 한다. 같은 규칙이 두 곳에 생기는 순간, 둘은 반드시 어긋난다. 휴가 일수 계산 하나만 봐도 그렇다 — 웹은 주말을 빼고 앱은 안 빼면, 같은 신청이 두 화면에서 다른 숫자로 보인다. 사용자는 어느 쪽을 믿어야 할지 모른다.

그래서 v2.0에서는 API 전용 백엔드 서버를 독립시켰다. 화면을 가지지 않는 순수 API 서버다. 웹도 앱도 이 API만 호출하고, 업무 규칙은 백엔드 한 곳에만 존재한다.

핵심은 가운데 업무 규칙 박스가 하나뿐이라는 것이다. 새 클라이언트(예를 들어 키오스크나 사내 대시보드)를 붙여도, 그 클라이언트는 화면만 만들면 된다. 규칙은 이미 백엔드에 있으니까.

기술 스택과, "화면 없는 Next.js"라는 선택

스택은 다음과 같다.

  • 런타임: Node.js 22 LTS, 패키지 매니저는 pnpm
  • 프레임워크: Next.js 16 (App Router) — 단, API 전용. 페이지를 두지 않고 src/app/api/**/route.ts만 쓴다
  • ORM: Prisma 7
  • DB: PostgreSQL (진실의 원천)
  • 캐시/세션/잠금: Redis (ioredis)
  • 인증: 관리자 웹은 NextAuth(세션) + 관리자 JWT, 직원 앱은 자체 발급 JWT + 기기 바인딩

여기서 흔히 받는 질문이 "왜 Express나 NestJS가 아니라 화면 프레임워크인 Next.js를 API 서버로 쓰느냐"다. 이유는 2편의 웹 프런트도 Next.js이기 때문이다. 같은 언어(TypeScript), 같은 빌드 도구, 같은 타입 생태계를 백엔드와 프런트가 공유한다. Prisma가 생성하는 타입을 양쪽이 그대로 import할 수 있고, App Router의 route.ts 컨벤션이 디렉터리 구조 = URL 구조라 라우트가 폭증해도(현재 150개 안팎) 길을 잃지 않는다.

대신 "화면 프레임워크를 API로만 쓰는" 데서 오는 함정도 있었다. 가장 대표적인 게 빌드 단계다. Next.js는 next build 때 모든 라우트 모듈을 한 번씩 평가한다. 그래서 Redis 같은 외부 의존성을 모듈 로드 시점에 강제하면, 운영에 Redis가 멀쩡히 있어도 빌드가 죽는다. 이 함정은 뒤에서 다시 나온다.

Prisma — 스키마가 곧 공유 타입

업무 영역이 아홉 개라 엔티티가 많다. 직원·부서·근태·휴가·급여·교육·평가·KPI·안전점검·문서·클레임·알림설정·감사로그… 마이그레이션만 스무 개가 넘는다. 이걸 손으로 타입 맞춰 가며 짜는 건 불가능에 가깝다. Prisma로 스키마를 한 곳에 정의하면, 그 스키마가 곧 타입이 된다.

조직도 한 화면을 그리는 데 세 개의 쿼리가 들어간다. 부서 트리, 부서별 현원, 부서별 자격 상태(QA) 집계. 이걸 Promise.all로 병렬 실행하고, 메모리에서 Map으로 합쳐 트리 노드를 만든다. 여기서 중요한 건 groupBy·_count 같은 집계가 전부 타입으로 검증된다는 점이다. qaStatus라는 컬럼이 없으면 컴파일이 안 된다. 그리고 이 응답 타입을 웹(2편)과 앱(3편)이 그대로 받아 쓰니, "백엔드가 보내는 모양"과 "클라이언트가 기대하는 모양"이 자동으로 일치한다. 사람이 맞추는 게 아니라 타입이 맞춰 준다.

Prisma 클라이언트 자체는 개발 환경에서 핫 리로드 때마다 연결이 새로 생기지 않도록 전역 싱글턴으로 잡았다. 작지만 개발 중 "too many connections" 에러를 막는 표준 패턴이다.

// src/lib/prisma.ts — 개발 핫리로드 대비 싱글턴
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient | undefined };

export const prisma =
  globalForPrisma.prisma ??
  new PrismaClient({
    log: process.env.NODE_ENV === "development" ? ["query", "error", "warn"] : ["error"],
  });

if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;

Redis — "없으면 죽는다"와 "없어도 돈다" 사이

Redis는 세 가지에 쓴다. Rate Limit, JWT 블랙리스트(로그아웃 토큰 무효화), 계정 잠금(로그인 5회 실패). 그리고 앞서 본 인증 검증 결과의 단기 캐시도 Redis다.

문제는 환경마다 Redis 유무가 다르다는 것이다. 운영에는 반드시 있어야 하지만, 로컬 개발에서는 Redis 없이도 서버가 떠야 편하다. 그리고 앞서 말한 대로 next build는 모듈을 미리 평가한다. 이 세 가지 요구를 한꺼번에 만족시키는 게 Redis 초기화 코드의 까다로운 지점이었다.

규칙을 정리하면 이렇다. 운영(빌드 아님) + URL 없음 → 즉시 throw(설정 누락을 조용히 넘기면 보안 기능이 통째로 꺼진 채 서비스가 뜬다). 빌드 단계 → 우회(빌드는 Redis 없이도 돼야 하니까). 개발 + URL 없음 → 경고 후 null 반환(개발 편의). 그리고 null을 받은 호출자들은 전부 "Redis 없으면 통과"로 graceful degrade하도록 짰다. 예를 들어 Rate Limit은 Redis가 없으면 항상 허용한다.

// src/lib/rate-limit.ts — fixed window 카운터, 없으면 통과
export async function consumeRateLimit(key: string, max: number, windowSec: number) {
  if (!redis) return { allowed: true, remaining: max, limit: max, resetIn: windowSec };
  const k = `rl:${key}`;
  const count = await redis.incr(k);
  if (count === 1) await redis.expire(k, windowSec); // 첫 요청에만 만료 설정
  const ttl = await redis.ttl(k);
  return { allowed: count <= max, remaining: Math.max(0, max - count), limit: max, resetIn: ttl > 0 ? ttl : windowSec };
}

INCR 후 첫 요청에만 EXPIRE를 거는, 흔한 fixed-window 카운터다. 슬라이딩 윈도우는 정확하지만 무겁고, 여기서는 "분당 N회"만 막으면 충분해서 단순한 쪽을 택했다. 정확도보다 단순함을 고른 의도적 트레이드오프다.

공용 API의 진짜 난제 — 두 종류의 인증을 한 백엔드에서

여기가 1편에서 가장 공들인 부분이다. 같은 백엔드를 웹과 앱이 서로 다른 방식으로 인증한다.

  • 관리자 웹: 두 가지 경로가 있다. 서버 렌더 시 NextAuth 세션, 그리고 정적 SPA가 직접 호출할 때 쓰는 관리자 JWT(access 15분 + refresh 7일). 기기 바인딩은 없다.
  • 직원 앱: 자체 발급 앱 JWT(24시간) + 기기 바인딩. 한 직원 계정은 한 기기에만 묶인다.

이걸 라우트마다 if-else로 처리하면 지옥이 된다. 그래서 인증 컨텍스트를 해석하는 가드 함수 계층을 만들고, 라우트는 자기에게 맞는 가드 하나만 부르게 했다.

세 가드(getWebAuth, getAdminJwt, getAppAuth)가 각각 다른 출처를 해석하지만, 반환 타입은 모두 같은 AuthContext다. userId, role, employeeId, 그리고 source: "web" | "app". 라우트 핸들러는 인증이 세션에서 왔는지 토큰에서 왔는지 신경 쓰지 않고, AuthContext만 들고 권한을 판단한다.

앱 인증이 특히 까다로운데, JWT 시그니처만 맞으면 끝이 아니기 때문이다. 토큰이 발급된 뒤에 계정이 정지될 수도, 기기가 초기화될 수도 있다. 그래서 매 요청마다 DB 상태를 함께 확인한다 — 단, 매번 DB를 때리면 비싸니 60초 Redis 캐시를 끼웠다.

검증 항목이 네 개다. 사용자 활성 여부, 직원 재직 여부, 기기 바인딩 존재 + 초기화되지 않음(resetAt이 null), 그리고 토큰의 deviceId와 바인딩된 deviceId 일치. 하나라도 깨지면 401이다. 관리자가 직원의 기기를 초기화하면 resetAt이 채워지고, 그 직원의 기존 토큰은 — 캐시가 만료되는 최대 60초 안에 — 전부 무효가 된다. (기기 바인딩이 로그인 단계에서 어떻게 걸리는지는 3편에서 다룬다.)

권한 판정도 작은 헬퍼로 일원화했다. isAdmin(ctx), isAdminOrLeader(ctx), hasRole(ctx, ...roles). 라우트는 가드로 AuthContext를 얻고, 이 헬퍼로 한 줄 검사한 뒤 통과/거부한다. 권한 규칙이 코드 곳곳에 흩어지지 않고 한 파일에 모인다.

응답을 하나의 봉투로 — envelope과 DomainError

웹·앱이 같은 백엔드를 쓰니, 응답 형태도 통일돼야 한다. 클라이언트마다 다른 모양을 파싱하게 두면 또 규칙이 두 곳에 생긴다. 그래서 모든 응답을 { success, data } 또는 { success, error, message }라는 단일 봉투(envelope)로 감쌌다.

DomainError는 작지만 유용한 패턴이다. 트랜잭션 내부 깊은 곳에서 규칙을 어겼을 때(예: "이미 처리된 알림", "기기 불일치"), throw new DomainError(...) 한 줄로 트랜잭션을 롤백하고 함수를 빠져나온다. 바깥 catch에서 handleDomainError가 이걸 적절한 HTTP 상태로 매핑한다. 그리고 ApiErrors 객체에 unauthorized·forbidden·deviceMismatch·locked·rateLimited 같은 표준 에러를 코드 상수와 함께 모아 뒀다. 클라이언트는 error 필드의 코드 문자열(DEVICE_MISMATCH 등)로 분기하고, message는 사용자에게 그대로 보여준다. 에러의 "기계용 코드"와 "사람용 문구"를 분리한 것이다.

ok/fail이 호출될 때마다 logResponse가 미들웨어가 발급한 x-request-id와 시작 시각을 읽어 상태 + 소요시간을 한 줄 JSON 로그로 남긴다. await하지 않는 fire-and-forget이라 응답 지연에 영향이 없다.

정리하며 — 1편의 교훈

  • v2.0의 출발점은 API 전용 백엔드 분리다. 업무 규칙(권한·검증·계산)을 한 곳에 두면, 새 클라이언트는 화면만 만들면 되고 웹·앱의 불일치가 구조적으로 사라진다.
  • 화면 프레임워크(Next.js)를 API 서버로만 쓰면 타입·생태계 공유라는 이점이 크지만, next build가 모듈을 미리 평가하는 함정을 NEXT_PHASE로 우회해야 했다.
  • 가장 어려웠던 건 두 종류의 인증(웹 세션/관리자 JWT vs 앱 JWT+기기바인딩)을 한 백엔드에 얹는 일이었다. 출처가 다른 인증을 같은 AuthContext로 추상화해 라우트가 출처를 모르게 했다.
  • 앱 토큰은 시그니처만이 아니라 DB 상태(활성·재직·기기일치)까지 매 요청 검증하되, 60초 Redis 캐시로 비용을 눌렀다.
  • Redis는 "운영엔 필수, 개발엔 선택"을 빌드 단계까지 고려해 초기화하고, 호출자는 graceful degrade한다.
  • 응답을 단일 봉투 + DomainError로 통일해, 클라이언트가 "기계용 코드"와 "사람용 문구"를 일관되게 다루게 했다.

다음 2편에서는 이 백엔드 위에서 가장 공들인 기능, 알림(alerts) 시스템을 다룬다. 이상을 자동으로 감지하는 detector 엔진, 그리고 그걸 소비하는 관리자 웹의 서버사이드 필터·상태 이력·일괄 확인이다.

반응형