정산 데이터 전처리 시스템은 겉으로 보면 엑셀을 업로드하고 ERP 입력 파일을 만드는 업무 도구처럼만 보입니다. 하지만 실제로 만들어보면 여러 거래처의 정산서 포맷, 회사 내부 코드 체계, 계약과 상품 정보, 권한과 상태 관리가 한 화면 안에서 계속 맞물립니다.

이 복잡도는 독립 백엔드 서버를 두지 않고 Next.js 풀스택 애플리케이션 안에서 다뤘습니다. UI는 React와 Next.js의 흐름을 따르고, 백엔드 성격의 업무 로직은 Hexagonal Architecture와 제가 직접 만든 타입-안전 IoC 컨테이너인 inversify-typesafe로 Core 계층에 분리했습니다(Node.js 세상에서 타입-안전한 의존성 주입이 가능하다면: inversify-typesafe 라이브러리 소개).

이 글은 이전에 정리했던 '중소규모 애플리케이션을 위한 Next.js 풀스택 아키텍처 & 서버리스 백엔드 아키텍처 제안'을 실제 업무 시스템에 적용한 사례를 다룹니다. 콘텐츠 회사의 정산 데이터 전처리 시스템을 만들면서 Next.js 풀스택 구조와 서버리스 인프라, Drizzle, PlanetScale Postgres, 그리고 inversify-typesafe를 어떻게 조합했는지 정리했습니다.

문제 정의 link
toc

프로젝트 배경: 정산 데이터가 병목이 되는 콘텐츠 비즈니스 link
toc

이번 프로젝트는 연 1,400억 원 규모의 매출을 올리는 국내 콘텐츠 회사의 정산 데이터 전처리 시스템을 만드는 일이었습니다. 제가 주로 작업한 영역은 영상 유통 정산, 라이선스 정산, 이커머스 정산 변환이었습니다.

첫 번째는 영상 유통사에 콘텐츠를 제공하고 받는 콘텐츠 수수료입니다. 유튜브, OTT, IPTV, 케이블, 해외 플랫폼처럼 여러 유통 채널에서 콘텐츠가 소비되고, 각 유통사는 매달 정산서를 보내옵니다.

두 번째는 콘텐츠 IP를 활용한 굿즈 라이선스 사업입니다. 라이선시가 콘텐츠 캐릭터나 브랜드를 사용해 상품을 만들고 판매하면, 회사는 계약 조건에 따라 로열티를 받습니다. 이 과정에서 증지 신청서와 판매내역서가 매달 발생합니다. 증지는 정품임을 증명하는 작은 스티커이고, 라이선스 상품을 관리하는 데 중요한 업무 데이터입니다.

세 번째는 회사가 직접 운영하거나 관리하는 이커머스 채널의 정산 변환입니다. 오픈마켓과 제휴몰마다 정산서 포맷이 다르고, 상품명이나 옵션명 같은 외부 값을 ERP 코드와 프로젝트 코드로 연결해야 했습니다.

문제는 이 모든 데이터가 최종적으로 ERP에 입력되어야 한다는 점입니다. ERP는 회사 내부의 기준 코드와 입력 포맷을 요구하지만, 외부에서 들어오는 정산서는 거래처마다 형식이 다릅니다. 같은 의미의 값도 파일마다 열 이름이 다르고, 외부 거래처가 쓰는 상품명, 콘텐츠명, 옵션명은 ERP가 요구하는 내부 코드와 바로 맞지 않습니다.

그래서 이 프로젝트의 핵심은 단순한 CRUD가 아니었습니다. 여러 경로에서 들어오는 정산 데이터를 회사 내부의 기준 데이터와 연결하고, ERP에 넣을 수 있는 형태로 정규화하며, 사람이 검토할 수 있는 UI를 제공하는 업무 시스템을 만드는 일이었습니다.

해결해야 했던 업무 문제 link
toc

제가 작업한 업무는 세 갈래로 나뉘었습니다.

첫 번째는 영상 유통 정산입니다. 수십 개의 영상 유통사가 매달 정산서를 보내오고, 그 데이터를 회사 내부의 거래처, 콘텐츠명, WBS(사내에서 사용하는 내부 ID), 장르, 정산월 같은 기준 정보와 연결해야 했습니다. 사람이 엑셀에서 하던 작업은 대부분 "이 이름이 내부 기준으로는 어떤 콘텐츠인가", "이 매출은 어떤 WBS로 들어가야 하는가"를 찾는 일이었습니다.

두 번째는 라이선스 정산입니다. 수백 개의 라이선스 계약에서 증지 신청서와 판매내역서가 발생합니다. 계약 상태, 라이선시, 콘텐츠명, 상품 정보, 증지 수량, 판매 수량, 정산 포함 여부를 관리하고, 정산서 생성 후에는 다운로드 이력과 상태 변경도 남겨야 했습니다.

세 번째는 이커머스 정산 변환입니다. 오픈마켓이나 제휴몰에서 내려받은 정산서를 ERP 입력에 필요한 코드 체계로 바꾸기 위해, 채널별 고유값과 ERP 코드, 프로젝트 코드를 연결하는 매핑 테이블이 필요했습니다. 같은 상품이라도 채널마다 표현이 다르기 때문에, 기준 매핑을 관리하고 변환 결과를 검토할 수 있는 UI가 중요했습니다.

이 프로젝트의 사용자는 회사 내부 구성원과 일부 라이선시입니다. 최대 동시접속자는 약 200명 정도를 고려하면 됐기 때문에 대규모 트래픽을 처리하는 서비스는 아니었습니다. 사용자가 엑셀에서 가져온 여러 행의 데이터를 화면에서 검토하고, 회사 내부 기준 코드와 맞춰 수정한 뒤 ERP 입력 파일로 내보내야 했습니다.

따라서 성능 요구사항은 일반적인 웹 서비스의 "초당 요청 수"보다 "업무 담당자가 엑셀에서 가져온 데이터를 얼마나 빠르게 검토하고 저장할 수 있는가"에 가까웠습니다. UI에서는 표 형태의 대량 편집 경험이 필요했고, 서버에서는 검증과 매핑 조회, 데이터베이스에서는 트랜잭션과 중복 검사를 안정적으로 처리해야 했습니다.

풀스택 아키텍처 선택 link
toc

왜 Next.js 풀스택 애플리케이션이 적합했는가 link
toc

이 프로젝트는 백엔드와 프론트엔드를 별도로 나누기보다 Next.js 하나로 풀스택 애플리케이션을 구성하기에 적합했습니다.

가장 큰 이유는 외부 API 제공이 목적이 아니었기 때문입니다. 이 시스템은 모바일 앱이나 외부 파트너에게 API를 제공하는 서비스가 아니라, 사용자가 브라우저에서 업무를 처리하는 내부 시스템입니다. 사용자는 화면을 보고, 엑셀 파일을 올리고, 정산 결과를 검토하고, ERP에 넣을 파일을 내려받습니다. API 자체가 제품인 시스템이 아니라 UI가 제품인 시스템에 가까웠습니다.

이런 경우 백엔드와 프론트엔드를 물리적으로 분리하면 생각보다 많은 비용이 생깁니다. API Spec을 정의해야 하고, 프론트엔드와 백엔드가 DTO를 맞춰야 하고, 배포도 두 번 해야 합니다. 한쪽에서 필드를 바꾸면 다른 쪽의 호환성을 신경 써야 합니다. 물론 이 비용은 외부 API를 제공하거나 백엔드가 독립적으로 커져야 하는 서비스에서는 필요한 비용입니다. 하지만 이 프로젝트에서는 그 비용이 얻는 이점보다 커 보였습니다.

Next.js App Router를 사용하면 페이지를 렌더링할 때 서버에서 데이터를 조회할 수 있고, 사용자 액션이 발생하거나 비동기로 데이터를 조회해야 하는 곳(=AJAX Call)에서는 Server Functions를 사용할 수 있습니다. 프론트엔드와 백엔드가 같은 저장소, 같은 타입스크립트 프로젝트, 같은 배포 단위 안에 있으므로 화면과 서버 로직이 훨씬 짧은 거리에서 연결됩니다.

다만 Next.js 하나로 모든 것을 만들 수 있다는 말이 프론트엔드와 백엔드의 경계를 흐려도 된다는 뜻은 아닙니다. 풀스택 애플리케이션을 작성해보면 프로젝트 크기가 아주 크지 않아도 코드가 쉽게 복잡해지는 것을 경험할 수 있습니다. 프론트엔드와 백엔드가 하나의 저장소 안에 같이 들어있기 때문에, 이 둘을 담을 수 있는 적절한 아키텍처를 고수하지 않으면 UI 코드와 업무 로직이 금방 뒤섞여 프로젝트의 복잡도가 급격히 높아집니다. Server Functions 안에 정산 규칙과 권한 검증, 로그 기록, 데이터 저장 흐름을 모두 넣는 식으로 만들면 처음에는 빠르게 개발할 수 있지만 프로젝트가 금방 지저분해집니다. 이 프로젝트에서는 Next.js의 풀스택 개발 경험은 유지하되, 업무 로직은 inversify-typesafe와 Hexagonal Architecture로 구성한 Core 계층에 모았습니다.

배포는 단순했습니다. Vercel에 Next.js 애플리케이션을 배포했기 때문에 별도의 백엔드 서버 인프라를 구성할 필요가 없었습니다. SSR과 Server Functions에 필요한 서버 실행 환경도 Vercel 안에서 함께 처리할 수 있었습니다.

데이터베이스는 PlanetScale에서 제공하는 Postgres를 사용했습니다. Drizzle로 스키마와 DDL 마이그레이션을 TypeScript 코드와 함께 관리했기 때문에, 애플리케이션 코드와 데이터베이스 구조를 같은 저장소 안에서 일관되게 다룰 수 있었습니다. 배포 파이프라인과 데이터베이스 세팅은 약 1시간 만에 끝났습니다.

개발 경험은 예전 JSP나 PHP 같은 풀스택 애플리케이션의 장점과 닮아 있었습니다. 화면과 서버 로직이 한 프로젝트에 있고, 배포도 한 번만 하면 됩니다. 다만 JSP나 PHP와 다른 점은 React, TypeScript, Server Components, Server Functions, 서버리스 배포, 타입 안전한 쿼리 빌더 같은 현대적인 도구를 함께 사용할 수 있다는 점입니다.

전체 아키텍처: 하나의 Next.js 앱, 분리 가능한 Core link
toc

애플리케이션은 하나의 Next.js 프로젝트로 배포되지만, 내부 코드는 프레임워크 계층과 업무 로직 계층을 분리했습니다. 대략적인 구조는 다음과 같습니다.

app/
  (dashboard)/
    dashboard/
      ... page, component, Server Functions

core/
  common/
    config/
    log/
    email/
  video-distribution/
    clients/
    settlements/
    result-for-erp/
    config/
  license/
    contracts/
    licensees/
    stickers/
    sales/
    settlements/
    config/
  ecommerce/
    erp-code-mappings/
    allocation-ratios/
    config/

lib/db/
  schema.ts
  drizzle.ts
  migrations/

아래 다이어그램은 이 구조를 실행 흐름까지 포함해 조금 더 자세히 표현한 것입니다. 페이지를 서버에서 렌더링할 때는 Server Component가 ApplicationContext를 통해 Core의 조회 UseCase를 호출하고, 사용자의 저장/수정 같은 동작은 Server Functions가 받아 Core의 명령 UseCase로 넘깁니다. 두 흐름 모두 inversify-typesafe로 구성한 ApplicationContext와 BeanConfig를 지나 Core에 접근합니다.

app/은 Next.js의 세계입니다. 페이지, 레이아웃, 클라이언트 컴포넌트, Server Functions가 여기에 있습니다. 프론트엔드는 전형적인 React와 Next.js의 best practices를 따르도록 했고, 인증 확인, 권한 확인, revalidatePath() 같은 Next.js 특화 작업도 이 계층에서 처리합니다.

core/는 업무 로직의 세계입니다. 이 계층은 Next.js를 몰라야 합니다. 백엔드 비즈니스 로직은 Hexagonal Architecture와 inversify-typesafe 기반 IoC 컨테이너로 구성했고, 영상 유통, 라이선스, 이커머스 같은 도메인별로 Domain, Application, Port, Adapter를 나누고, UseCase를 통해 기능을 노출합니다.

lib/db/는 Drizzle 스키마와 데이터베이스 연결을 담습니다. 실제 SQL에 가까운 쿼리와 마이그레이션은 이 계층을 통해 관리합니다.

app/core/ 사이의 직접적인 접점은 ApplicationContext입니다. 화면이나 Server Functions는 필요한 UseCase 이름만 알고, 실제 구현체와 Port/Adapter 연결은 BeanConfig가 담당합니다. 이 구성을 inversify-typesafe 기반으로 만들었기 때문에 문자열 Bean 키를 사용하면서도 TypeScript 타입으로 잘못된 의존성 연결을 줄일 수 있었습니다. 결과적으로 UI 코드는 비즈니스 로직의 구체 구현체를 직접 알 필요가 없었습니다.

이 구조에서 중요한 점은 배포 단위와 코드 경계를 구분했다는 것입니다. 배포는 하나의 Next.js 애플리케이션으로 단순하게 가져갔지만, 코드 내부에서는 백엔드 로직을 Core 모듈로 분리했습니다. 그래서 나중에 백엔드를 별도 서버로 분리해야 하는 상황이 오더라도 Core의 UseCase와 Port, Adapter 구조는 최대한 유지할 수 있습니다.

Server Function은 얇은 래퍼로만 둡니다. 예를 들어 영상 유통 정산 관리 화면의 Server Function은 사용자 인증을 확인하고, ApplicationContext에서 UseCase를 꺼내 호출한 뒤, 필요하면 Next.js 캐시를 갱신합니다.

"use server";

import { videoDistributionApplicationContext } from "@/core/video-distribution/config/videoDistributionApplicationContext";
import { getUser } from "@/lib/db/queries";
import { revalidatePath } from "next/cache";

export async function getSettlements(
  clientId?: string,
  startPeriod?: string,
  endPeriod?: string,
) {
  const user = await getUser();

  return videoDistributionApplicationContext()
    .get("GetSettlementsUseCase")
    .getSettlements(clientId, startPeriod, endPeriod, user?.id);
}

export async function updateSettlements(data, deletedIds = []) {
  const user = await getUser();
  if (!user) {
    return { success: false, error: "User not authenticated" };
  }

  const result = await videoDistributionApplicationContext()
    .get("UpdateSettlementsUseCase")
    .updateSettlements(data, deletedIds, user.id, user.role);

  if (result.success) {
    revalidatePath("/dashboard/video-distribution/manage-settlement");
  }

  return result;
}

여기에는 정산 업무 규칙이 들어가지 않습니다. 어떤 데이터를 어떻게 수정할지, 로그를 어떻게 남길지, 어떤 Port를 호출할지는 Core의 Application Service가 담당합니다.

Core 복잡도 제어 link
toc

Core 모듈 설계: 직접 만든 타입-안전 IoC 컨테이너의 실무 적용 link
toc

Core 모듈은 Hexagonal Architecture를 따릅니다. Domain은 순수한 도메인 모델을 담고, Application은 UseCase를 구현합니다. Port는 안쪽 계층이 바깥쪽 계층과 통신하기 위한 인터페이스이고, Adapter는 실제 데이터베이스나 스토리지 같은 외부 시스템을 다룹니다.

Hexagonal Architecture는 객체지향 원칙인 SOLID를 지키기 좋은 구조입니다. Domain과 Application이 바깥쪽 기술에 의존하지 않도록 만들 수 있고, UseCase와 Port를 기준으로 기능 경계도 명확해집니다. 디렉토리 구조만 봐도 이 프로젝트에 어떤 기능이 있고, 각 기능이 어떤 흐름으로 구현되는지 어느 정도 파악할 수 있다는 점도 장점입니다.

반대로 현실적인 번거로움도 있습니다. 기능 하나를 추가할 때 Domain 타입, UseCase, QueryService나 CommandService, In Port, Out Port, Adapter, BeanConfig를 함께 만들어야 합니다. 아키텍처 자체는 모범적이지만, 개발자가 매번 파일과 디렉토리를 직접 맞춰 만드는 일은 꽤 번거롭습니다.

그래서 프로젝트 규칙을 AGENTS.md에 명시했습니다. 사람과 AI가 같은 구조를 기준으로 코드를 읽고 만들 수 있게 하기 위해서입니다.

app/
  (dashboard)/      # 페이지 컴포넌트 및 Server Functions

core/
  common/            # 공통 도메인
    config/          # 공통 BeanConfig, Autowired, ApplicationContext
    log/             # Activity Log 도메인
  {domain}/          # 각 도메인 (예: video-distribution)
    domain/          # 도메인 모델 및 스키마
    application/     # Use Case 구현
      {Domain}QueryService.ts    # 조회 Use Case 구현
      {Domain}CommandService.ts  # 명령 Use Case 구현
      port/
        in/         # Use Case 인터페이스
        out/        # Repository 인터페이스
    adapter/
      out/          # Repository 구현
    config/         # 도메인별 BeanConfig, Autowired, ApplicationContext

lib/db/
  schema.ts         # 데이터베이스 스키마
  drizzle.ts        # 데이터베이스 연결

Next.js 풀스택 애플리케이션은 빠르게 만들기 좋지만, 업무 로직이 커질수록 객체지향적인 경계와 의존성 주입이 필요하다고 느꼈습니다. 팀이 Java와 Spring에 익숙했기 때문에 백엔드 성격의 코드는 UseCase, Service, Port 중심으로 구성하고 싶었습니다. 동시에 Next.js 서버리스 환경에서 동작하는 애플리케이션이므로, NestJS처럼 큰 웹 프레임워크를 추가하기보다는 필요한 IoC 기능만 가볍게 가져가고 싶었습니다.

그래서 직접 작성한 inversify-typesafe 기반의 inversify-typesafe-spring-like 라이브러리를 사용했습니다(Node.js 세상에서 타입-안전한 의존성 주입이 가능하다면: inversify-typesafe 라이브러리 소개). 이 라이브러리는 Inversify를 기반으로 하면서도 BeanConfig와 Autowired를 타입-안전하게 사용할 수 있게 만든 도구입니다. 문자열 기반 Bean 키를 쓰면 오타나 잘못된 타입 매칭이 런타임 오류로 이어지기 쉬운데, 이 프로젝트에서는 Bean 목록을 TypeScript 타입으로 정의하고 그 타입을 기준으로 ApplicationContext와 Autowired를 구성했습니다.

예를 들어 영상 유통 도메인의 BeanConfig는 다음과 같은 형태입니다.

import {
  commonBeanConfig,
  type CommonBeans,
} from "@/core/common/config/CommonBeanConfig";
import { BeanConfig } from "inversify-typesafe-spring-like";

export type VideoDistributionBeans = CommonBeans & {
  ClientCommandPort: ClientCommandPort;
  ClientQueryPort: ClientQueryPort;
  GetClientUseCase: GetClientsUseCase;
  SaveClientUseCase: SaveClientsUseCase;
  SettlementQueryPort: SettlementQueryPort;
  SettlementCommandPort: SettlementCommandPort;
  GetSettlementsUseCase: GetSettlementsUseCase;
  UpdateSettlementsUseCase: UpdateSettlementsUseCase;
};

export const videoDistributionBeanConfig: BeanConfig<VideoDistributionBeans> = {
  ...commonBeanConfig,

  ClientCommandPort: (bind) => bind().to(ClientCommandAdapter),
  ClientQueryPort: (bind) => bind().to(ClientQueryAdapter),
  GetClientUseCase: (bind) => bind().to(ClientQueryService),
  SaveClientUseCase: (bind) => bind().to(ClientCommandService),

  SettlementQueryPort: (bind) => bind().to(SettlementQueryAdapter),
  SettlementCommandPort: (bind) => bind().to(SettlementCommandAdapter),
  GetSettlementsUseCase: (bind) => bind().to(SettlementQueryService),
  UpdateSettlementsUseCase: (bind) => bind().to(SettlementCommandService),
};

Autowired도 도메인의 Bean 타입을 기준으로 생성합니다.

import { returnAutowired } from "inversify-typesafe-spring-like";
import { VideoDistributionBeans } from "./VideoDistributionBeanConfig";

export const { Autowired: VideoDistributionAutowired } = returnAutowired<
  VideoDistributionBeans
>();

ApplicationContext는 lazy initialization으로 필요할 때 생성합니다.

import { lazy } from "@/lib/utils";
import { ApplicationContext } from "inversify-typesafe-spring-like";
import { videoDistributionBeanConfig } from "./VideoDistributionBeanConfig";

export const videoDistributionApplicationContext = lazy(() => {
  return ApplicationContext(videoDistributionBeanConfig);
});

이 구조의 장점은 TypeScript에서도 Spring과 비슷한 개발 감각을 유지할 수 있다는 점입니다. UseCase, Service, Port, Adapter를 명확히 나누고, 의존성은 생성자에서 주입받습니다. 하지만 Spring처럼 무거운 런타임을 들고 오지는 않습니다.

Application Service는 QueryService와 CommandService로 나눴습니다. 조회와 변경의 책임을 분리하면 파일이 길어지는 것을 막을 수 있고, 어떤 UseCase가 데이터를 읽는지 변경하는지 한눈에 보입니다.

export class SettlementQueryService implements GetSettlementsUseCase {
  constructor(
    @VideoDistributionAutowired(
      "SettlementQueryPort",
    ) private readonly settlementQueryPort: SettlementQueryPort,
    @CommonAutowired(
      "SaveActivityLogUseCase",
    ) private readonly saveActivityLogUseCase: SaveActivityLogUseCase,
  ) {}

  async getSettlements(
    clientId?: string,
    startPeriod?: string,
    endPeriod?: string,
  ) {
    const settlements = await this.settlementQueryPort.getSettlements(
      clientId,
      startPeriod,
      endPeriod,
    );

    return settlements;
  }
}

export class SettlementCommandService implements UpdateSettlementsUseCase {
  constructor(
    @VideoDistributionAutowired(
      "SettlementQueryPort",
    ) private readonly settlementQueryPort: SettlementQueryPort,
    @VideoDistributionAutowired(
      "SettlementCommandPort",
    ) private readonly settlementCommandPort: SettlementCommandPort,
  ) {}

  async updateSettlements(data, deletedIds, userId, userRole) {
    return this.settlementCommandPort.updateSettlements(
      data,
      deletedIds,
      userId,
      userRole,
    );
  }
}

실제 코드는 활동 로그 기록이나 변경 이력 저장 같은 부가 로직이 더 들어가지만, 핵심은 Application Service가 Port에만 의존한다는 점입니다. Adapter의 구체 구현을 직접 알지 않기 때문에 테스트하기 쉽고, 저장소 구현을 바꾸기도 쉽습니다.

업무 도메인별 구현 link
toc

제가 작업한 도메인은 크게 영상 유통, 라이선스, 이커머스로 나눴습니다.

영상 유통 도메인은 거래처 관리, 정산 데이터 입력, WBS 표준명 매핑, ERP 결과 생성으로 구성했습니다. 유통사별 정산 파일에서 콘텐츠명과 WBS를 찾고, 내부 기준 데이터와 연결한 뒤 ERP에 입력할 수 있는 결과를 만듭니다.

라이선스 도메인은 계약, 라이선시, 증지, 판매내역, 정산을 다룹니다. 라이선시가 등록을 요청하고, 내부 담당자가 승인하거나 반려하고, 계약별로 증지와 판매내역을 관리합니다. 정산이 생성된 뒤에는 정산 상태와 다운로드 로그도 함께 관리합니다.

이커머스 도메인은 오픈마켓 정산 변환에 필요한 기준 정보를 관리합니다. 예를 들어 오픈마켓에서 들어온 상품명이나 옵션명을 ERP 코드, 프로젝트 코드와 연결하기 위한 매핑을 관리합니다. KREAM, 쿠팡, SSG, 지마켓, 옥션, 삼성카드, CJ온스타일 등 채널마다 정산서 포맷이 다르기 때문에 변환 화면은 채널별로 나누고, ERP 코드 매핑과 배부율 같은 기준 데이터는 공통 구조로 관리했습니다.

권한 관리도 별도 흐름으로 구성했습니다. 내부 사용자와 라이선시가 함께 사용하는 시스템이기 때문에 사용자, 사용자 그룹, 권한, 그룹별 권한을 관리해야 했습니다. 업무 메뉴별로 조회/쓰기/실행 권한을 분리하면 운영 중에도 담당자 역할에 맞게 접근 범위를 조정할 수 있습니다.

정산 데이터 처리 구현 link
toc

엑셀 기반 업무 UI와 매핑 자동화 link
toc

정산 업무 담당자는 여전히 엑셀을 많이 사용합니다. 그래서 이 프로젝트의 UI는 엑셀을 완전히 대체하기보다, 엑셀에서 가져온 데이터를 웹 화면에서 안전하게 검토하고 내부 기준 데이터와 연결하는 방향으로 설계했습니다.

정산 데이터 전처리의 큰 흐름은 다음과 같습니다.

영상 유통 정산 입력 화면이 대표적입니다. 사용자는 엑셀에서 여러 셀을 복사해 그리드에 붙여넣을 수 있고, 화면은 콘텐츠명을 기준으로 내부에서 사용하는 콘텐츠 표준 명칭, WBS 번호, ITEM_CODE를 자동으로 찾아 채워줍니다. 사람이 하던 반복적인 매핑 작업을 줄이는 것이 목표였습니다.

화면에서는 AG Grid를 사용했습니다. 일반 HTML 테이블로는 셀 편집, 다중 셀 붙여넣기, 키보드 이동, 컨텍스트 메뉴, 선택 행 삭제 같은 업무용 조작을 자연스럽게 만들기 어렵습니다. AG Grid를 사용하면 사용자가 엑셀에 익숙한 방식으로 데이터를 입력하면서도, 웹 애플리케이션의 검증과 자동 매핑을 함께 적용할 수 있습니다.

const handleContentNameChange = useCallback(
  (params: CellValueChangedEvent<UploadRowData>) => {
    if (params.colDef.field !== "contentName" || !params.newValue) {
      return;
    }

    const matchedWbsData = findWbsDataByContentNameCallback(params.newValue);
    if (!matchedWbsData) {
      return;
    }

    params.node.setDataValue(
      "wbsStandardContentName",
      matchedWbsData.standardContentName,
    );
    params.node.setDataValue("wbsNo", matchedWbsData.wbsNo);
    params.node.setDataValue("itemCd", matchedWbsData.itemCd);
  },
  [findWbsDataByContentNameCallback],
);

엑셀 붙여넣기도 단순 텍스트 입력으로 취급하지 않았습니다. 탭과 줄바꿈을 기준으로 셀 범위를 해석하고, 필요한 경우 행을 자동으로 추가한 뒤, 콘텐츠명 컬럼이 바뀌었다면 WBS 매핑까지 다시 수행했습니다.

const rows = pastedText.split(/\r?\n/).filter((row) => row.trim() !== "");
const newRowData = [...rowData];
const contentNameUpdates: { rowIndex: number; contentName: string }[] = [];

rows.forEach((row, rIndex) => {
  const columns = row.split("\t");
  const targetRowIndex = startRowIndex + rIndex;

  columns.forEach((value, cIndex) => {
    const column = allColumns[startColIndex + cIndex];
    const colId = column.getColId() as keyof UploadRowData;

    if (colId === "contentName" && value.trim()) {
      contentNameUpdates.push({
        rowIndex: targetRowIndex,
        contentName: value.trim(),
      });
    }
  });
});

이커머스 영역에서도 비슷한 원칙을 적용했습니다. 채널별 정산 변환 화면은 다르지만, ERP 코드 매핑은 공통 기준 데이터로 관리했습니다. 저장 시에는 같은 채널에서 같은 고유값이 중복되지 않도록 Application Service에서 먼저 검증하고, Adapter에서는 트랜잭션으로 삭제, 수정, 추가를 한 번에 처리했습니다.

const toKey = (channel: string, sourceValue: string) =>
  `${channel}::${sourceValue.trim()}`;

const seenKeys = new Set<string>();
for (const mapping of validation.data.mappings) {
  const key = toKey(mapping.channel, mapping.sourceValue);
  if (seenKeys.has(key)) {
    throw new Error("같은 채널에서 고유값은 한 번만 입력할 수 있어요.");
  }
  seenKeys.add(key);
}

이런 UI와 매핑 자동화는 성능 최적화보다 업무 시간을 직접 줄이는 데 더 큰 의미가 있었습니다. 정산 담당자가 엑셀에서 하던 검색, 복사, 붙여넣기, 코드 확인 작업을 웹 화면 안에서 일관된 규칙으로 처리하게 만드는 것이 이 프로젝트의 중요한 목표였습니다.

Drizzle과 PlanetScale Postgres로 구성한 데이터 계층 link
toc

데이터베이스는 PlanetScale에서 제공하는 Postgres를 사용했습니다. 정산 시스템은 ERP 코드, 상품 마스터, 계약, 증지, 판매내역, 정산 데이터처럼 서로 연결되는 테이블이 많고, 매핑과 대사 과정에서 조회 조건도 복잡해집니다.

그래서 ORM이 모든 것을 추상화해주기보다 SQL에 가까운 제어권을 유지하면서도, 스키마와 마이그레이션을 TypeScript 프로젝트 안에서 관리할 수 있는 도구가 필요했습니다. 이 요구사항에는 Drizzle이 잘 맞았습니다. 어떤 테이블을 조인하고, 어떤 조건으로 필터링하며, 어떤 인덱스가 필요한지 코드에서 비교적 명확하게 볼 수 있었고, 동시에 TypeScript 타입 추론과 마이그레이션 관리도 함께 가져갈 수 있었습니다.

데이터베이스 연결은 단순합니다.

import { env } from "@/core/config/env";
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import * as schema from "./schema";

export const client = postgres(env.POSTGRES_URL);
export const db = drizzle(client, { schema });

마이그레이션 설정도 TypeScript 스키마 파일과 출력 디렉토리를 지정하는 정도입니다.

import type { Config } from "drizzle-kit";

export default {
  schema: "./lib/db/schema.ts",
  out: "./lib/db/migrations",
  dialect: "postgresql",
  dbCredentials: {
    url: process.env.POSTGRES_URL!,
  },
} satisfies Config;

스키마는 TypeScript로 정의하고, drizzle-kit generate로 DDL을 생성한 뒤 drizzle-kit migrate로 데이터베이스에 반영했습니다. 테이블에는 업무 조회 패턴에 맞춰 unique 제약과 인덱스를 적극적으로 넣었습니다.

export const ecommerceErpCodeMappings = pgTable(
  "ecommerce_erp_code_mappings",
  {
    id: serial("id").primaryKey(),
    channel: varchar("channel", { length: 63 }).notNull(),
    sourceValue: varchar("source_value", { length: 255 }).notNull(),
    erpCode: varchar("erp_code", { length: 255 }).notNull(),
    itemName: varchar("item_name", { length: 255 }).notNull(),
    projectCode: varchar("project_code", { length: 255 }).notNull(),
    projectCodeName: varchar("project_code_name", { length: 255 }).notNull(),
    createdAt: timestamp("created_at").notNull().defaultNow(),
    updatedAt: timestamp("updated_at").notNull().defaultNow(),
  },
  (table) => ({
    channelSourceValueUnique: unique().on(table.channel, table.sourceValue),
    channelSourceValueIdx: index(
      "idx_ecommerce_erp_code_mappings_channel_source_value",
    ).on(table.channel, table.sourceValue),
  }),
);

정산 시스템에서는 마스터 데이터와 거래 데이터의 매칭이 많습니다. 따라서 단순히 정규화된 테이블을 만드는 것만으로는 부족하고, 실제 업무에서 자주 조회하는 조합에 인덱스를 설계해야 합니다. Drizzle은 이런 제약과 인덱스를 애플리케이션 코드와 같은 언어로 관리할 수 있어서 변경 이력을 따라가기 쉬웠습니다.

복잡한 변경 작업은 Drizzle transaction으로 묶었습니다. 계약 생성, 증지 생성, 정산 생성처럼 여러 테이블에 동시에 영향을 주는 작업은 하나의 트랜잭션에서 처리하고, 감사 로그나 활동 로그도 함께 남겼습니다. 이 정도의 요구사항은 별도 백엔드 프레임워크나 무거운 ORM 없이도 Drizzle만으로 충분히 처리할 수 있었습니다.

API Spec 없이 UI와 서버가 소통하는 개발 경험 link
toc

이 프로젝트에서 체감한 가장 큰 생산성 이점은 API Spec을 따로 만들지 않아도 된다는 점이었습니다. 화면이 서버에서 데이터를 조회해야 하면 Server Component나 SSR 흐름에서 Core UseCase를 호출하고, 사용자의 액션으로 데이터가 바뀌어야 하면 Server Functions에서 Core UseCase를 호출했습니다.

물론 내부적으로는 함수의 입력과 출력 타입이 존재합니다. 하지만 REST API 문서를 만들고, 프론트엔드 DTO와 백엔드 DTO를 맞추고, 필드 추가 시 양쪽 코드를 따로 배포하는 흐름은 없었습니다. 같은 TypeScript 프로젝트 안에서 도메인 타입과 UseCase 인터페이스를 공유할 수 있었기 때문입니다.

예를 들어 이커머스 정산 변환 화면은 서버 컴포넌트에서 사용자와 권한을 확인한 뒤, Core의 UseCase로 ERP 코드 매핑을 조회하고, 그 결과를 클라이언트 컴포넌트에 props로 전달합니다.

export default async function CoupangSettlementTransformPage() {
  const user = await getUser();
  if (!user) {
    redirect("/sign-in");
  }

  const allowed = await hasPermission(
    user.id,
    "ecommerce.coupang_settlement_transform.execute",
  );
  if (!allowed) {
    redirect("/dashboard");
  }

  const mappings = await ecommerceApplicationContext()
    .get("GetErpCodeMappingsUseCase")
    .getErpCodeMappings();

  return (
    <CoupangSettlementTransformClient
      mappings={mappings
        .filter((mapping) => mapping.channel === "COUPANG")
        .map((mapping) => ({
          sourceValue: mapping.sourceValue,
          erpCode: mapping.erpCode,
          projectCode: mapping.projectCode,
        }))}
    />
  );
}

이 코드는 HTTP API를 하나 더 만들지 않아도 됩니다. 페이지에서 필요한 데이터를 서버에서 준비하고, UI에 맞는 형태로 내려주면 됩니다. 사용자의 저장 액션은 Server Action으로 받고, Server Function은 다시 Core UseCase를 호출합니다.

이 방식은 화면 요구사항이 자주 바뀌는 업무 시스템에서 특히 편했습니다. 어떤 화면에 필드가 하나 더 필요하면 API 문서부터 수정하는 대신, UseCase가 반환하는 타입과 페이지의 props를 함께 수정하면 됩니다. 프론트엔드와 백엔드가 같은 배포 단위에 있으므로 버전 호환성을 맞추기 위한 비용도 줄어듭니다.

운영과 개발 방식 link
toc

서버리스 인프라 선택과 운영 경험 link
toc

인프라는 최대한 단순하게 가져갔습니다. Next.js 애플리케이션은 Vercel에 배포하고, 데이터베이스는 PlanetScale Postgres를 사용했습니다. 파일 저장이 필요한 부분은 객체 스토리지를 사용해 데이터베이스와 분리했습니다.

이 선택의 장점은 초기 설정 시간이 매우 짧다는 점입니다. 백엔드 서버를 띄울 인스턴스를 만들고, Nginx를 설정하고, 배포 스크립트를 짜고, 커넥션 풀을 조정하는 일부터 시작하지 않아도 됩니다. Vercel 프로젝트와 데이터베이스를 연결하고, 환경변수를 설정하면 Next.js 실행 환경과 데이터베이스 연결까지 빠르게 준비할 수 있습니다. Drizzle migration도 같은 저장소에서 관리했기 때문에 데이터베이스 변경을 배포 흐름에 포함하기 쉬웠습니다.

서버리스라고 해서 모든 문제가 자동으로 사라지는 것은 아닙니다. 긴 처리 시간, 큰 파일, 메모리 사용량, 데이터베이스 커넥션, 함수 실행 시간 제한은 계속 신경 써야 합니다. 하지만 이 프로젝트의 성격상 초당 수천 요청을 처리하는 것보다 운영 복잡도를 줄이고 업무 기능을 빠르게 만드는 것이 더 중요했습니다.

특히 내부 업무 시스템에서는 인프라 자동화에 많은 시간을 쓰기보다, 담당자가 매달 반복하는 수작업을 얼마나 빨리 줄일 수 있는지가 더 중요할 때가 많습니다. 이 프로젝트는 그런 관점에서 Vercel, PlanetScale Postgres, Drizzle 조합이 잘 맞았습니다.

이 프로젝트에서 얻은 풀스택 아키텍처의 장점 link
toc

이 프로젝트를 진행하면서 Next.js 풀스택 아키텍처의 장점을 꽤 분명하게 체감했습니다.

첫 번째 장점은 직접 만든 inversify-typesafe-spring-like 라이브러리를 실무에서 검증할 수 있었다는 점입니다. 단순한 샘플 프로젝트가 아니라 영상 유통, 라이선스, 이커머스처럼 여러 도메인이 있는 업무 시스템에서 타입-안전한 IoC 컨테이너를 사용했습니다. BeanConfig를 타입으로 관리하고, Autowired 키를 타입에 묶어두는 방식은 규모가 커질수록 효과가 있었습니다.

두 번째 장점은 API 연동 비용이 크게 줄었다는 점입니다. 화면과 서버 로직이 같은 프로젝트에 있으므로 별도의 API 계약을 유지하는 비용이 작았습니다. 업무 담당자의 피드백을 받고 화면과 서버 로직을 함께 고치는 흐름이 자연스러웠습니다.

세 번째 장점은 타입과 도메인 모델을 공유할 수 있다는 점입니다. Domain 모델, UseCase 인터페이스, Server Function 입력과 출력 타입이 모두 TypeScript 안에 있기 때문에 필드 변경의 영향 범위를 IDE와 컴파일러가 어느 정도 알려줍니다.

네 번째 장점은 개발자가 느끼는 프로젝트의 복잡도를 낮은 수준으로 유지할 수 있었다는 점입니다. 프론트엔드 작업을 할 때는 React와 Next.js의 흐름 안에서 화면과 상호작용을 고민하고, 백엔드 비즈니스 로직을 다룰 때는 Core의 UseCase와 Port를 기준으로 생각하면 됐습니다. 프론트엔드와 백엔드가 한 저장소에 있어도 둘을 뒤섞어서 고민하지 않고, 역할을 명확히 나눠 프로젝트를 진행할 수 있었습니다.

다섯 번째 장점은 배포 단위가 단순하다는 점입니다. 프론트엔드와 백엔드를 따로 배포하지 않아도 되고, 두 애플리케이션의 버전 호환성을 맞출 필요도 없습니다. 작은 팀이 중소규모 업무 시스템을 만들 때는 이 단순함이 큰 생산성으로 이어집니다.

마지막 장점은 분리 가능성을 남겨둔 상태로 빠르게 시작할 수 있었다는 점입니다. 지금은 Next.js 하나로 배포하지만, Core 모듈은 Next.js와 분리되어 있습니다. 따라서 나중에 특정 API나 배치 처리를 별도 서버로 떼어내야 하더라도 모든 업무 로직을 처음부터 다시 작성할 필요는 없습니다.

AI를 활용한 개발 방식 link
toc

이 프로젝트에서는 AI를 단순히 코드 자동완성 도구로만 쓰지 않았습니다. 더 중요했던 것은 AI가 프로젝트의 구조와 규칙을 이해한 상태에서 작업하도록 만드는 일이었습니다.

이를 위해 AGENTS.md에 프로젝트의 아키텍처와 코딩 컨벤션을 정리했습니다. Hexagonal Architecture의 계층 구조, Domain/Application/Port/Adapter의 역할, QueryService와 CommandService 분리 원칙, Server Functions와 Core 모듈의 경계, Drizzle migration 사용 방식, 문서화 규칙을 명시했습니다. AI에게 매번 같은 설명을 반복하는 대신, 프로젝트 안에 규칙을 문서로 남겨두고 그 문서를 기준으로 작업하게 한 것입니다.

AI와 협업하는 흐름은 대략 다음과 같았습니다.

이 방식은 Hexagonal Architecture와 특히 잘 맞았습니다. Hexagonal Architecture는 구조가 분명한 대신 boilerplate 성격의 파일이 많아서 개발자가 직접 맞춰 쓰기에는 번거로운 부분이 있습니다. 새로운 마스터 데이터를 추가할 때도 테이블, 도메인 타입, Port, Adapter, UseCase, BeanConfig, Server Function, 화면을 비슷한 패턴으로 만들어야 합니다. 하지만 규칙이 명확하면 AI는 기존 패턴을 찾아 초안을 만들기 쉽습니다. 사람은 모든 파일을 직접 만드는 대신, AI가 만든 구조의 의존성 방향과 도메인 규칙이 맞는지 검토하는 데 집중할 수 있습니다.

실제로는 반복적인 CRUD, 테이블 화면, Server Function, 기능 문서 초안 작성에 AI를 많이 활용했습니다. 업무 시스템은 화려한 랜딩 페이지보다 밀도 있고 반복 사용하기 좋은 화면이 중요하기 때문에, 테이블, 필터, 대량 편집, 상태 표시, 검토 플로우 같은 UI도 기존 컴포넌트와 디자인 패턴을 기준으로 초안을 만들게 했습니다. 기능 구현 후에는 docs/features/ 아래에 들어갈 기능 개요, 주요 동작 방식, 보안 고려사항, 에러 처리 문서도 코드 내용을 바탕으로 먼저 정리하게 했습니다.

다만 AI에게 모든 판단을 맡기지는 않았습니다. 정산 규칙, ERP 코드 매핑, 금액 계산, 증지와 판매내역의 정산 포함 여부처럼 도메인 책임이 큰 부분은 사람이 반드시 검증했습니다. AI는 구현 속도를 높이고 탐색 비용을 줄이는 도구로 사용했고, 업무 규칙의 최종 책임은 개발자와 현업 검증에 두었습니다.

Core 계층의 경계, 파일 구조, 네이밍 규칙, 의존성 방향이 문서화되어 있을수록 AI가 만든 코드도 의도한 아키텍처를 벗어나지 않았습니다. 이 프로젝트에서 AGENTS.md는 사람을 위한 컨벤션 문서이면서 동시에 AI와 협업하기 위한 계약서 역할을 했습니다.

회고 link
toc

한계와 주의할 점 link
toc

Next.js 풀스택 아키텍처가 모든 프로젝트에 맞는 것은 아닙니다.

외부 공개 API가 제품의 핵심이거나, 모바일 앱과 여러 클라이언트가 같은 API를 사용해야 하거나, API 서버만 독립적으로 스케일링해야 하는 서비스라면 처음부터 백엔드를 분리하는 편이 나을 수 있습니다. 그런 시스템에서는 API 계약, 인증, 버전 관리, 모니터링, 장애 대응을 독립적으로 설계하는 비용이 필요합니다.

긴 배치 작업이 많거나, 함수 실행 시간 제한을 넘는 작업이 자주 발생하는 경우도 주의해야 합니다. 서버리스 함수 안에서 모든 것을 처리하기보다 큐, 워커, 별도 배치 서버를 고려해야 할 수 있습니다.

또 하나 중요한 점은 풀스택 구조라고 해서 모든 코드를 Server Function에 넣으면 안 된다는 것입니다. Server Function은 편리하지만, 그 안에 업무 규칙이 쌓이면 금방 프레임워크에 강하게 결합된 코드가 됩니다. 이 프로젝트에서는 Server Function이 인증, 권한, 입력 검증, 캐시 갱신 같은 프레임워크 책임을 맡고, 실제 업무 로직은 Core UseCase로 넘기도록 했습니다.

Core 계층의 경계를 지키는 것도 중요합니다. Domain과 Application이 Next.js나 UI를 알기 시작하면 나중에 분리 가능성은 사라집니다. 반대로 Adapter가 Drizzle을 사용하고, UI가 Next.js를 사용하는 것은 자연스럽습니다. 바깥 계층은 구체 기술을 알아도 되지만, 안쪽 계층은 업무 규칙을 표현하는 데 집중해야 합니다.

결론: 중소규모 업무 시스템의 기본값으로서 Next.js 풀스택 link
toc

결과적으로 대규모 트래픽보다 복잡한 업무 규칙과 빠른 개발이 중요한 프로젝트에서는 Next.js 풀스택 구성이 꽤 좋은 기본값이 될 수 있다는 확신을 얻었습니다.

Vercel과 PlanetScale Postgres를 사용해 인프라 구성 시간을 줄였고, Drizzle로 SQL에 가까운 데이터 접근과 타입 안정성을 함께 얻었습니다. Next.js의 SSR과 Server Functions를 사용해 API Spec 작성과 프론트엔드/백엔드 연동 비용을 줄였습니다. 그리고 inversify-typesafe-spring-like를 사용해 TypeScript 환경에서도 Spring에 익숙한 팀이 이해하기 쉬운 객체지향 구조를 만들 수 있었습니다.

AI도 생산성에 중요한 역할을 했습니다. 다만 AI가 효과적이었던 이유는 프로젝트 구조가 명확했기 때문입니다. AGENTS.md에 아키텍처와 컨벤션을 정리하고, Core 계층의 경계를 유지하고, 반복되는 패턴을 일관되게 만든 덕분에 AI가 아키텍처를 벗어나지 않는 코드를 작성할 수 있었습니다.

처음부터 모든 것을 분리하고 크게 설계하는 대신, 분리 가능한 구조로 시작하되 실제 배포와 운영은 단순하게 가져가는 편이 더 실용적일 때가 많습니다. 이 프로젝트에서 사용한 Next.js, Drizzle, PlanetScale Postgres, Vercel, inversify-typesafe-spring-like 기술 스택과 AI 협업 프로세스의 조합은 작은 팀이 복잡한 업무 시스템을 빠르게 만드는 데 충분히 강력했다고 생각합니다.