
Fluo를 만들면서 중요하게 생각한 것 중 하나는, 이미 만든 기능을 다른 실행 환경에서도 다시 쓸 수 있어야 한다는 것이었다. 인증이나 검증, 데이터베이스 연동을 만들어 놓고 새로운 서버를 지원할 때마다 그 기능들을 다시 연결해야 한다면, 패키지를 나눠 둔 보람이 별로 없을 것 같았다. 그래서 애플리케이션이 사용하는 계약과 실제 플랫폼에 붙는 부분을 분리했다.
내 블로그는 그 설계를 직접 써 볼 만한 곳이었다. 화면도 Next.js로 만들었고 백엔드도 Next.js의 라우트 핸들러를 사용하고 있었다. 여기에 Fluo를 연결하는 어댑터를 만들면, 기존 Fluo 패키지들을 블로그에서도 사용할 수 있지 않을까. 예제를 하나 더 만드는 것보다는 내가 계속 사용하는 서비스에 넣어 보는 편이 궁금한 것을 더 많이 확인할 수 있을 것 같았다.
그래서 @fluojs/platform-nextjs를 만들고 블로그의 백엔드를 옮겨야겠다고 생각했다. 실제로 기존 패키지들을 가져다 쓸 수 있었다. 다만 작업은 거기서 끝나지 않았다. 블로그 쪽에서 직접 작성하다 보니 Fluo가 제공하는 편이 낫겠다는 기능들이 보였고, 그것들을 패키지에 반영해 버전을 올린 뒤 다시 블로그에 적용했다. 블로그의 백엔드를 바꾸려고 시작했는데, 그 과정에서 Fluo도 꽤 달라졌다.
플랫폼이 바뀐다고 다시 만들고 싶지는 않았다
서버에서 요청을 읽고 응답을 보내는 방식은 실행 환경마다 다르다. Fastify에서는 Fastify가 제공하는 요청과 응답을 다루고, Bun에서는 Bun.serve()에 연결할 핸들러가 필요하다. Next.js에서는 파일 라우팅으로 발견된 Route Handler가 요청을 받는다. 이 차이를 컨트롤러나 서비스가 직접 알기 시작하면, 플랫폼을 바꿀 때 수정해야 하는 코드도 그만큼 안쪽으로 들어온다.
Fluo에서는 이 사이에 공통 계약을 두었다. 요청의 헤더와 본문을 읽고, 응답의 상태와 헤더를 설정하는 코드는 플랫폼의 원시 객체 대신 Fluo의 요청·응답 파사드를 사용한다. 파사드는 여기서 서로 다른 구현을 같은 방식으로 사용할 수 있도록 드러낸 표면이다. 플랫폼 어댑터는 실제 요청과 응답을 그 계약에 연결한다.
이 구분은 HTTP 객체에만 해당하지 않는다. 의존성 주입(DI)으로 컨트롤러에 서비스를 연결하고, 모듈에 제공자를 등록하며, 요청에 가드와 검증을 적용하는 일은 Fluo의 공통 실행 경로가 맡는다. 애플리케이션의 가드를 플랫폼의 훅이나 라우트 함수에 맞춰 다시 작성하지 않아도 된다. 인증과 캐시, 데이터베이스 통합도 각 패키지가 맡은 계약을 기준으로 애플리케이션에 연결된다.
그래서 패키지를 나눌 때는 파일이 어디에 놓이는지보다, 새 플랫폼을 연결할 때 어느 부분을 다시 작성하게 되는지를 보려고 했다. 플랫폼을 추가해도 기존 애플리케이션 기능을 가져다 쓸 수 있다면, 나눠 둔 경계가 실제로 일을 하고 있는 셈이다.
물론 플랫폼의 모든 기능을 같은 것으로 만들 수는 없다. 특정 서버의 원시 객체를 직접 사용하거나 그 환경에만 있는 기능을 요구하면, 그 부분에는 플랫폼 의존성이 남는다. 공통 계약으로 처리할 수 있는 코드와 그렇지 않은 코드를 구분하는 것이지, 차이가 없다고 가정하는 것은 아니다.
Next.js에 연결하는 어댑터를 만들었다
Next.js에는 이미 HTTP 서버와 요청을 받아 줄 경로가 있다. Fluo를 쓰겠다고 별도 서버를 하나 더 띄울 필요는 없었다. Next.js가 받은 요청을 Fluo의 처리 경로에 넘기고, Fluo가 만든 응답을 다시 Next.js로 돌려주면 된다. 이 연결을 맡긴 패키지가 @fluojs/platform-nextjs다.
구조를 요청이 지나가는 순서로 보면 다음과 같다.
Next.js Route Handler → platform-nextjs의 요청·응답 연결 → Fluo의 공통 요청 처리(미들웨어·가드·입력 바인딩 등) → 컨트롤러 → 주입받은 서비스와 저장소응답은 다시 어댑터를 거쳐 Next.js로 돌아간다. 어댑터가 인증이나 의존성 주입을 별도로 구현하는 구조는 아니다. Fluo의 런타임이 애플리케이션과 요청 처리기를 구성하고, Next 어댑터는 그 처리기를 연결받아 Web 표준 Request와 Response 사이의 실행을 이어 준다. HTTP 서버의 소유권은 계속 Next.js에 있다.
이 덕분에 Next.js를 지원하려고 passport-nextjs나 prisma-nextjs를 따로 만들지 않았다. 이미 만들어 둔 @fluojs/passport, @fluojs/jwt, @fluojs/validation, @fluojs/prisma, @fluojs/cache-manager 같은 패키지를 같은 모듈과 DI 구조 안에서 사용할 수 있었다. 애플리케이션이 사용하는 패키지들은 의존성으로 추가하고 설정해야 하지만, 그 패키지들의 Next 전용 구현을 다시 만드는 일은 필요하지 않았다.
빌드 도구와 연결되는 부분은 별개의 작업이었다. Fluo의 데코레이터 변환에 사용하던 Vite 플러그인을 Next.js에 그대로 꽂을 수는 없다. 공유할 수 있는 변환 방식은 유지하되, Next.js의 Turbopack에서 실행할 로더와 설정 도우미를 어댑터 패키지에 넣었다. 플랫폼 경계가 분리돼 있다는 것은 이런 차이를 없애 준다는 뜻보다는, 이런 차이를 처리할 자리가 정해져 있다는 뜻에 가깝다.
내 블로그의 백엔드를 옮겼다
연결 방법이 생겼으니 실제 블로그에 적용했다. 기존 블로그는 Next.js의 Route Handler와 그 주변 코드에서 글과 댓글, 분류와 시리즈, 인증과 관리 기능을 처리하고 있었다. 이 로직을 Fluo의 컨트롤러와 서비스, 모듈로 옮겼다. Next 라우트 함수를 Fluo 컨트롤러에서 다시 호출하는 형태로 감싸 놓고 끝내지는 않았다.
옮기고 나서의 차이는 Next.js와 연결하는 코드, 그리고 그 뒤에서 실행되는 코드를 나란히 보면 잘 드러난다. 아래에서는 연결부와 댓글 조회 경로를 중심으로 본다. import와 블로그의 타입·헬퍼 선언은 생략했고, 조회 코드는 실제 메서드에서 발췌했다.
Next.js에는 연결부를 남겼다
부트스트랩에서 선택하는 플랫폼은 createNextAdapter()다. 이 어댑터를 Fluo의 애플리케이션 생성 함수에 전달한다. AppModule은 블로그의 기능 모듈들을 모으는 루트 모듈이며, 아래는 블로그 전용 본문 정책과 미들웨어, 예외 필터를 덜어 낸 연결 예시다.
const adapter = createNextAdapter();const app = await FluoFactory.create(AppModule, { adapter });await app.listen();이때 app.listen()이 별도 HTTP 서버를 하나 더 띄우지는 않는다. Fluo 런타임이 구성한 요청 처리기를 Next 어댑터에 연결한다. 애플리케이션은 Fluo가 구성하고, 실제 요청을 받는 서버는 계속 Next.js가 소유한다.
Next.js 쪽에는 그 어댑터를 사용하는 핸들러를 연결한다. 다음은 블로그의 공통 라우트 구성에서 발췌한 부분이다. getBlogApplication()은 위 생성 경로를 거쳐 애플리케이션을 얻고 공유하는 접근 함수다.
const handlers = createNextAppRouterHandler(async () => { return (await getBlogApplication()).adapter;});블로그는 여기에 기존 쓰기 요청의 호환 처리를 덧붙여 blogRouteHandlers를 만들고, 각 Next.js 라우트 파일에서 GET이나 POST 같은 메서드를 내보낸다. 데코레이터 변환은 앞서 설명한 Next용 로더로 연결한다. 이런 플랫폼 연결을 한 번 마련한 뒤에는, 기능마다 Next.js용 인증과 DI를 다시 작성할 필요가 없었다.
댓글 조회는 컨트롤러에서 레포지토리로 이어진다
댓글 조회 요청은 다음 경로를 지난다.
GET /api/posts/:slug/comments → CommentController.list() → CommentService.list() → CommentQueryPort.listThread() → PrismaCommentRepository.listThread()CommentController는 @Controller("/api/posts/:slug/comments")로 경로를 선언한다. 아래 조회 메서드의 PostPathInput은 slug를 받는 DTO다. this.access()는 요청에서 사용자와 slug, 잠금 해제 쿠키를 모으고, this.comments는 생성자로 주입받은 CommentService다.
@Get()@UseOptionalAuth("blog")@RequestDto(PostPathInput)async list(input: PostPathInput, context: RequestContext) { return unwrapResult( await this.comments.list(await this.access(input, context)) );}@UseOptionalAuth("blog")로 익명 요청도 받되 사용자가 있으면 등록된 인증 전략으로 식별한다. unwrapResult는 서비스의 성공·실패 결과를 HTTP 처리 경로로 연결하는 블로그의 함수다. 컨트롤러는 Fluo의 요청 문맥을 사용하므로, 이 메서드가 Next.js의 Request나 NextResponse를 직접 다룰 필요는 없다.
서비스에서는 요청 객체 대신 사용자와 slug, 잠금 해제 정보를 받는다. CommentService.list()의 this.post()는 글을 읽을 수 있는지 확인하고, 발행 상태와 잠금 상태도 검사한다. 검사를 통과하면 CommentQueryPort로 주입받은 this.queries를 통해 댓글을 조회한다. CommandResult<T>는 애플리케이션의 성공·실패 결과를 비동기로 반환하는 타입이다.
async list( input: CommentAccess): CommandResult<PostCommentsAPI["res"]["get"]> { const post = await this.post(input); if (!post.ok) return post; const comments = await this.queries.listThread({ postId: post.value.id }); const replies = new Map<CommentId, PostCommentDTO[]>(); for (const comment of comments) { if (comment.parentId === null) continue; const group = replies.get(comment.parentId) ?? []; group.push(serializeComment(comment, input.actor)); replies.set(comment.parentId, group); } const roots = comments .filter((comment) => comment.parentId === null) .map((comment) => ({ ...serializeComment(comment, input.actor), replies: replies.get(comment.id) ?? [], })); return ok({ comments: roots, totalCount: roots.reduce( (count, comment) => count + 1 + comment.replies.length, 0 ) });}댓글을 부모 댓글과 답글로 묶는 일은 서비스가 한다. serializeComment는 내부 댓글 레코드를 화면에 전달할 DTO로 바꾸면서 날짜와 삭제 상태, 수정·삭제 가능 여부 등을 표현한다. 어떤 글의 댓글을 제공할 수 있는지, 그 결과를 어떻게 구성할지가 이곳의 책임이다.
반면 Prisma의 조회 조건과 정렬은 PrismaCommentRepository에 있다. 이 레포지토리는 CommentQueryPort를 구현하며, @Inject(PrismaService)로 받은 Prisma 파사드를 this.client로 사용한다. 아래는 댓글 목록 조회 메서드다. commentSelect는 필요한 DB 필드를 선택하고, mapComment는 조회 결과를 내부 CommentRecord로 바꾼다.
async listThread( { postId }: { readonly postId: PostId }): Promise<readonly CommentRecord[]> { const rows = await this.client.postComment.findMany({ where: { postId }, orderBy: [{ createdAt: "asc" }, { id: "asc" }], select: commentSelect, }); return rows.map(mapComment);}컨트롤러는 HTTP 입력을 해석하고, 서비스는 조회 가능 여부와 결과 구성을 결정하며, 레포지토리는 DB 쿼리를 실행한다. Next.js만 사용해도 이렇게 코드를 나눌 수 있다. 내가 Fluo에 맡긴 것은 이 클래스와 계약들을 연결하고, 등록된 컨트롤러까지 요청을 보내며, 필요한 의존성을 제공하는 일이었다. 어댑터 덕분에 그 실행 경로를 Next.js 안에서도 사용할 수 있었다.
필요한 의존성은 모듈에서 공개한다
이 연결에는 모듈 등록도 필요하다. 블로그의 CommentModule은 댓글 컨트롤러와 서비스를 등록하고, PersistenceModule은 Prisma와 레포지토리를 등록한다. 후자의 imports에 들어가는 Prisma 설정은 다음과 같다.
PrismaModule.forRoot({ client: prisma, rollbackObserver: prismaRollbackObserver, strictTransactions: true, global: true,})Prisma를 전역 모듈로 등록했으므로 다른 기능 모듈에서도 공개된 PrismaService를 주입받을 수 있다. 이를 감싸는 PersistenceModule 자체도 @Module({ global: true, ... })로 선언돼 있다. 다만 전역으로 쓸 수 있는 것은 그 모듈이 export한 의존성이다. 댓글 저장소는 아래와 같이 토큰에 연결하며, 여러 등록 항목 중 댓글 항목만 발췌했다.
const portProviders = [ { provide: COMMENT_QUERY_PORT, useExisting: PrismaCommentRepository },];PrismaCommentRepository는 별도로 등록된 제공자이고, useExisting은 그 인스턴스를 COMMENT_QUERY_PORT로 사용할 수 있게 한다. PersistenceModule은 이 토큰을 export한다. 루트 AppModule에서 전역 모듈을 가져왔기 때문에 CommentModule이 이를 다시 import하지 않아도 서비스에 주입할 수 있다. 여기서 전역은 Fluo 애플리케이션 안의 모듈 가시성을 말하는 것이지, 서버리스 인스턴스 사이에서 DB 연결 하나를 공유한다는 뜻은 아니다.
이 구조가 마음에 들었던 것은 HTTP 요청 밖에서도 같은 애플리케이션을 사용할 수 있다는 점이었다. 서버 컴포넌트와 사이트맵은 조회 서비스를 사용하도록 했고, Model Context Protocol(MCP) 경로도 운영 서비스를 직접 호출하도록 바꿨다. 화면을 그리기 위해 내 서버의 HTTP API를 다시 호출하거나, MCP에서 기존 HTTP 핸들러를 흉내 내지 않아도 됐다.
그렇다고 기존 도구를 전부 걷어낸 것은 아니다. GitHub OAuth와 CSRF 같은 프로토콜 처리는 NextAuth에, S3 서명과 MCP 전송은 해당 SDK에 남겼다. 화면의 공개 데이터 캐시도 Next.js 쪽에서 관리한다. 화면과 배포 구성을 모두 바꾸지 않고, 그 안에서 백엔드의 책임을 Fluo로 옮길 수 있었다.
실제로 쓰니 더 필요한 기능이 보였다
기존 패키지를 가져다 쓸 수 있다는 것은 확인했다. 그런데 기능이 있다는 것과, 소비하는 애플리케이션에서 필요한 형태로 제공된다는 것은 조금 달랐다. 패키지 안의 기능들을 조합하는 마지막 코드는 여전히 블로그에서 작성해야 했고, 그중에는 블로그만의 사정이라고 보기 어려운 것도 있었다.
처음부터 이 코드를 모두 프레임워크에 넣으려고 한 것은 아니다. 우선 블로그가 동작하도록 연결하고 나니, 어떤 부분이 도메인 정책이고 어떤 부분이 다른 소비자도 반복해서 작성할 기반 코드인지 구분할 수 있었다.
여러 번들에서 같은 애플리케이션을 사용하려면
Next.js에서는 라우트, 서버 컴포넌트, 인증 관련 코드가 서로 다른 번들로 평가될 수 있다. 소스에 같은 클래스가 있다고 해서 런타임에서도 같은 생성자 객체인 것은 아니다. 한 번들에서 등록한 클래스를 다른 번들에서 가져온 생성자로 찾으면, 공유하고 있는 DI 컨테이너에서도 해당 서비스를 찾지 못할 수 있다. 블로그에서는 실제로 이 차이가 RSS 프리렌더링에서 드러났다.
처음에는 블로그가 직접 애플리케이션 Promise를 globalThis에 보관하고, 번들 경계를 넘는 서비스에는 Symbol.for로 만든 별칭을 사용했다. 이 방식으로 연결은 할 수 있었다. 다만 같은 패턴이 다른 Next.js 애플리케이션에서도 필요할 텐데, 소비자가 매번 전역 변수와 초기화 규칙을 작성해야 하는지는 생각해 볼 문제였다.
그래서 명시적인 키와 로더를 받는 defineNextApplication을 추가했다. 블로그에서 수동으로 관리하던 애플리케이션 접근 함수는 다음 코드로 바뀌었다. 여기서 createBlogApplication은 블로그 모듈을 구성하고 Fluo 애플리케이션을 시작하는 함수다.
import { defineNextApplication } from "@fluojs/platform-nextjs";export const getBlogApplication = defineNextApplication({ key: "ayden.backend.application", load: () => import("@/backend/kernel").then(({ createBlogApplication }) => createBlogApplication(), ),});같은 JS 전역 환경에서 같은 키로 접근하는 코드들은 하나의 초기화 Promise를 공유한다. 라우트와 서버 컴포넌트, 인증 경로가 이 함수를 사용하도록 연결하면 각자 애플리케이션을 초기화하는 코드를 둘 필요가 없다. 별도 프로세스나 서버리스 인스턴스까지 하나로 묶어 주는 기능은 아니다. 초기화 실패도 보존하며, 개발 중 DI 구성을 바꿨다면 서버를 다시 시작해야 한다. HMR이 사용 중인 서비스들을 조용히 교체하는 것으로 만들지는 않았다.
공개 서비스 토큰에는 publicToken<T>(namespace)도 추가했다. 기존 Symbol.for 별칭에 서비스 타입의 추론을 연결하는 API다. 같은 이름의 클래스를 강제로 같은 객체로 취급하지 않고, 외부에 공개할 서비스에만 명시적인 토큰을 둔다. 해당 토큰의 제공자 등록과 모듈의 export는 여전히 필요하다. 수동으로 할 수 있던 일을 없앤 것이 아니라, 권장할 만한 조합을 API로 드러낸 것이다.
데이터가 저장된 다음에 캐시를 지우려면
Prisma 쪽에서는 트랜잭션 뒤에 실행할 작업이 눈에 들어왔다. 블로그의 글이나 분류를 수정하면 관련 화면의 캐시를 무효화해야 한다. 독립적인 트랜잭션 호출을 기다린 뒤 캐시를 지우는 코드는 자연스럽다. 문제는 그 작업이 더 큰 트랜잭션 안에 조합될 때다.
Fluo의 Prisma 통합은 이미 활성화된 트랜잭션이 있으면 그 클라이언트를 재사용한다. 따라서 안쪽의 transaction() 호출이 반환됐다고 해서 실제 DB 커밋이 끝난 것은 아니다. 바깥 작업은 아직 진행 중일 수 있고, 나중에 롤백될 수도 있다. 그 사이에 캐시를 먼저 지우면 다른 요청이 커밋 전 데이터를 읽어 캐시를 다시 채우는 상황도 생각할 수 있다.
마이그레이션한 코드의 트랜잭션과 후처리를 검토하면서, 함수의 완료와 실제 커밋을 구분할 공개 계약이 필요하다고 판단했다. 소비하는 서비스마다 바깥 트랜잭션의 존재를 추적하게 두기보다는, 트랜잭션을 소유하는 쪽에서 그 시점을 알려주는 편이 맞았다.
그래서 @fluojs/prisma@2.1.0에 PrismaService.afterCommit()을 추가했다. 활성 트랜잭션 안에서 후속 작업을 등록하면, 같은 Fluo 래퍼가 소유한 최외곽 트랜잭션의 실제 커밋이 성공하고 트랜잭션 문맥이 종료된 뒤 실행한다. 중첩된 작업에서 등록한 훅도 같은 커밋 경계를 따른다. 롤백되거나 커밋에 실패하면 등록한 훅은 실행하지 않는다.
블로그에서는 이 기능에 기존 Next 캐시 무효화 처리를 연결했다. 다음은 PrismaCacheInvalidator의 메서드만 발췌한 것이다. database는 주입받은 Fluo Prisma 파사드이고, rootClient는 트랜잭션 밖에서 사용하는 원본 클라이언트다. effects는 기존 Next 캐시 무효화 구현이며, CacheInvalidationPlan은 어떤 변경에 어떤 캐시를 지울지 나타내는 블로그의 타입이다.
async invalidate(plan: CacheInvalidationPlan): Promise<void> { if (this.database.current() !== this.rootClient) { this.database.afterCommit(() => this.effects.invalidate(plan)); return; } await this.effects.invalidate(plan);}트랜잭션이 활성화돼 있으면 무효화를 등록하고, 트랜잭션 밖이라면 바로 실행해 완료를 기다린다. 따라서 트랜잭션 안에서 이 메서드를 기다렸다는 것은 등록이 끝났다는 뜻이며, 실제 무효화는 바깥 커밋 뒤에 실행된다. 어떤 태그와 경로를 무효화할지는 계속 블로그가 결정한다. Prisma 패키지가 next/cache를 알아야 하는 구조는 아니다.
어떤 캐시를 지울지는 블로그가 알고, 언제 커밋이 끝났는지는 트랜잭션을 소유하는 쪽이 안다. 두 가지를 연결하려고 필요했던 것이 이 훅이었다. 이 요구를 정리하면서 Prisma뿐 아니라 Drizzle과 Mongoose 통합에도 같은 의미의 커밋 후 작업 계약을 제공했다.
후속 작업의 실패는 따로 구분해야 한다. DB 커밋이 끝난 다음 캐시 무효화가 실패했다고 해서 저장한 데이터가 다시 롤백되지는 않는다. Fluo는 이 경우 이미 커밋됐다는 정보를 가진 AfterCommitError로 보고한다. DB 저장과 캐시 갱신을 하나의 원자적 작업으로 만들어 주거나, 프로세스가 종료돼도 후속 작업을 반드시 재실행해 주는 기능은 아니다. 블로그 통합 테스트에는 바깥 커밋 전에는 무효화하지 않는 경우, 롤백하면 무효화하지 않는 경우, 무효화가 실패해도 이미 저장된 데이터는 남는 경우를 넣었다.
캐시 값을 읽고 쓰는 것만으로는 부족할 때
비밀번호로 잠근 글에는 실패 횟수와 차단 상태가 있다. 이것을 Fluo의 CacheService에 저장했지만, get으로 읽은 뒤 계산해서 set하는 것만으로는 충분하지 않았다. 같은 키의 요청이 겹치면 둘 다 같은 이전 값을 읽고 갱신해서, 한쪽의 실패 기록을 잃을 수 있기 때문이다.
처음에는 블로그에서 키별 대기 작업을 관리하며 갱신을 직렬화했다. 동작은 했지만 실패 횟수라는 정책과 안전하게 값을 갱신하기 위한 기반 코드가 함께 있었다. 이 중 후자는 블로그에만 필요한 것이 아니다. 그래서 캐시 패키지에 CacheService.update를 추가하고, 읽기·변경·저장을 스토어가 제공하는 원자성 계약 아래에서 수행하도록 했다.
블로그는 현재 값으로 다음 실패 기록과 만료 시간을 계산하고, 저장할지 삭제할지를 반환한다. 같은 키의 갱신 순서를 직접 관리하던 코드는 캐시 쪽으로 넘어갔다. 몇 번 실패하면 얼마나 차단할지는 여전히 블로그 정책이다. 종료할 때 진행 중인 작업을 기다리는 처리도 남아 있다. API 하나를 추가했다고 소비자의 책임이 모두 사라진 것은 아니다.
이 블로그의 저장소는 프로세스 로컬이므로, 여기서 얻은 보장을 여러 서버에 걸친 분산 차단으로 설명할 수는 없다. 다만 어떤 범위에서 갱신을 보장하는지 캐시 계약으로 드러내고, 애플리케이션에서 직접 작성하던 키별 조정 코드를 줄일 수 있었다.
플랫폼 정책과 실행 결과도 표현할 자리가 필요했다
작은 호환 처리들도 같은 방식으로 다시 보게 됐다. Next.js가 GET 경로에 자동으로 전달한 HEAD 요청을 처리하려고, 처음에는 블로그가 HEAD를 GET으로 바꿔 실행한 뒤 응답 본문을 제거했다. 이후 어댑터에 headRouting: "explicit-or-get" 옵션을 추가했다. 이제 요청 메서드를 HEAD로 유지하면서 명시적인 HEAD 경로를 우선하고, 없으면 공통 라우트 선택 규칙에 따라 GET 경로까지 사용할 수 있다. 블로그의 HEAD 변환 래퍼는 삭제했다.
요청 본문도 마찬가지였다. 블로그는 Content-Type과 상관없이 JSON을 읽던 경로가 있었고, 잘못된 JSON보다 인증 오류가 먼저 보여야 하는 기존 동작도 있었다. 이를 맞추기 위해 보통 요청의 헤더를 바꿔 새 Request를 구성하던 처리는 bodyParser: "text"로 대체했다. 어댑터는 크기 제한을 유지하며 텍스트를 읽고, 그 텍스트를 어떻게 해석할지는 블로그가 결정한다. 이 옵션이 인증 순서를 자동으로 바꿔 주는 것은 아니다. 입력 필드 처리에는 InputPolicy를 사용했고, 엄격해야 하는 관리자 검증과 JSON 전용 경로의 multipart 호환 처리는 남겼다.
이벤트에는 처리 결과를 관찰하는 publishWithResult를 추가했다. API 토큰의 최근 사용 기록은 실패해도 인증 자체를 막지 않아야 하지만, 그렇다고 기록 작업이 성공했는지까지 모를 필요는 없다. 블로그에서는 이벤트 핸들러의 완료와 실패를 관찰하되, 인증 쪽의 비차단 정책은 유지했다. 실패가 중요한지와 실패를 알 수 있는지는 서로 다른 선택이었다.
버전을 올리고 다시 블로그에 적용했다
이 기능들은 블로그 안의 도우미로만 남겨 두지 않았다. 패키지의 공개 API로 정리하고 배포한 뒤, 블로그의 의존성을 올려 다시 적용했다. 처음 마이그레이션할 때 사용한 platform-nextjs는 1.0.0이었고, 후속 적용에서는 1.1.0을 사용했다. @fluojs/prisma도 2.0.0에서 2.1.0으로 올렸다. 이것은 Prisma ORM 자체가 아니라 Fluo의 통합 패키지 버전이다. cache-manager와 core, validation은 함께 2.1.0으로, http와 runtime, event-bus는 3.1.0으로 올렸다.
Next.js 설정에서도 블로그가 직접 적어 두었던 데코레이터 변환 규칙을 패키지의 설정 도우미로 교체했다. 아래는 설정 파일의 가져오기와 최종 내보내기 부분이며, nextConfig에는 기존 이미지 설정 등 블로그의 Next.js 설정이 들어 있다.
import { withFluoNextBackend } from "@fluojs/platform-nextjs/next-config";export default withFluoNextBackend(nextConfig, { include: /(^|\/)backend\//, preserveModulePaths: true,});변환할 백엔드 경로를 지정하고 원래 모듈 경로를 보존한다는 요구는 같지만, 그 요구를 위해 Turbopack 규칙과 로더 경로를 블로그에서 직접 조립하지 않아도 됐다. 애플리케이션 접근 함수와 HEAD 처리, 캐시 갱신에서도 비슷한 변화가 있었다. 처음에 블로그에서 작성했던 코드가 사라지거나, 패키지에 전달하는 옵션으로 바뀌었다.
Prisma의 반환값 기반 롤백도 이때 적용했다. 블로그의 작업 결과는 성공과 실패를 Result 값으로 표현하는데, 이전에는 실패값을 받으면 내부 예외를 던져 롤백한 뒤 바깥에서 잡아 원래 결과로 돌려주는 코드가 있었다. shouldRollback: (result) => !result.ok 정책을 사용하면서 그 예외 변환 코드를 제거했다. 실제 롤백을 확인할 수 있도록 Prisma 클라이언트 생성 시점부터 관찰자를 연결하는 설정도 함께 적용했다. 이것은 커밋 후 작업을 등록하는 afterCommit과는 별개의 기능이다.
후속 적용은 의존성 숫자만 바꾸는 작업으로 끝내지 않았다. 실제 Next 서버에서 요청과 응답을 확인하고, PostgreSQL 통합 테스트와 브라우저의 주요 사용 흐름도 다시 확인했다. 특히 새 API를 사용하면서 기존 로그인과 권한, 오류 응답, 캐시 무효화의 의미가 바뀌지 않는지를 봤다. 검증은 격리된 DB와 테스트 자격증명으로 수행했으며, 실제 외부 OAuth 로그인이나 S3 업로드까지 운영에서 모두 다시 실행했다는 뜻은 아니다. 성능 향상도 별도로 측정하지 않았다.
코드가 줄었다는 것만으로 이 변경이 좋다고 말하고 싶지는 않다. 줄어든 코드가 어디로 갔는지가 더 중요했다. 블로그의 실패 횟수 정책이나 캐시 태그는 블로그에 남고, 같은 애플리케이션에 접근하는 규칙이나 커밋 시점, 원자적 갱신은 해당 패키지가 맡았다. 블로그에서 필요했던 조합을 다른 소비자도 사용할 수 있는 계약으로 옮긴 셈이다.
블로그를 옮기면서 Fluo도 바뀌었다
내가 만든 도구를 직접 사용해 보는 일을 도그푸딩(dogfooding)이라고 부른다. 이번 작업에서 그 말이 실감 난 지점은, 패키지가 설치되고 첫 요청이 성공했을 때보다 블로그의 직접 구현을 다시 지울 수 있었을 때였다. 처음에는 블로그를 동작시키기 위해 필요했던 코드였는데, 다시 보니 다른 Fluo 사용자도 비슷하게 작성할 코드가 있었다.
블로그를 옮기지 않았다면 Next 번들 사이에서 애플리케이션을 얻는 방법이나, 트랜잭션에 후처리를 연결하는 방법을 이 정도로 구체적으로 요구하지는 않았을 것 같다. 반대로 모든 불편을 프레임워크 기능으로 만들 수도 없었다. 어떤 데이터를 공개할지, 어떤 캐시를 지울지, 기존 쿠키를 어떻게 유지할지는 이 블로그의 판단이다. 직접 써 보니 패키지에 넣을 기능뿐 아니라 남겨 둘 코드도 더 잘 보였다.
내가 처음 기대했던 것은 기존 Fluo 패키지들을 Next.js 안에서도 사용할 수 있다는 것이었다. 그 기대는 확인했고, 사용하면서 발견한 요구를 다시 Fluo에 반영했다. 소비하는 애플리케이션과 만드는 프레임워크를 번갈아 고치다 보니, 어느 쪽에서 책임져야 하는지 애매했던 부분들이 조금씩 정리됐다.
나중에 Next.js 밖으로 나가더라도
이 구조에는 아직 사용하지 않은 장점도 있다. 지금 내 블로그는 Next.js 안에서 화면과 백엔드를 함께 운영한다. 당장은 이 구성이 맞다. 하지만 언젠가 블로그가 커져 백엔드를 별도로 배포하거나 확장할 필요가 생긴다면, Fluo 애플리케이션 로직을 독립 서버로 옮기는 선택을 할 수 있다.
Node.js에서 Fastify 서버로 운영하려면 @fluojs/platform-fastify를, Bun을 실행 환경으로 선택한다면 @fluojs/platform-bun을 연결하면 된다. 플랫폼에 독립적으로 작성한 컨트롤러와 서비스, 도메인 로직은 유지하고, 애플리케이션을 시작하는 부분과 플랫폼 의존적인 제공자를 새 환경에 맞추는 것이다. Next.js 안으로 가져올 때 기존 패키지를 다시 만들지 않았던 이유가, Next.js 밖으로 가져갈 때도 같은 방식으로 작용한다.
물론 지금 블로그의 파일을 통째로 복사하고 어댑터의 import 하나만 바꾸면 분리가 끝난다는 뜻은 아니다. next/cache를 호출하는 구현은 프론트엔드의 캐시를 갱신할 수 있는 통신 경로로 바꿔야 하고, 서버 컴포넌트에서 서비스를 직접 부르던 부분도 API 호출 같은 프로세스 간 연결로 바뀌어야 한다. 세션과 쿠키의 전달 경계도 정해야 한다. Bun으로 옮긴다면 사용하는 DB 드라이버와 다른 의존성의 런타임 호환성도 확인해야 한다. 실제 분리 배포를 이번에 수행한 것은 아니다.
그렇더라도 백엔드의 핵심 로직을 새로운 프레임워크의 컨트롤러와 서비스로 다시 작성하는 일과는 차이가 크다. 예를 들어 afterCommit에 등록한 캐시 무효화의 구체적인 구현은 바뀔 수 있지만, 데이터를 저장한 뒤 어떤 후속 작업이 필요한지 결정하는 애플리케이션의 판단까지 바꿔야 하는 것은 아니다. 바꾸어야 할 것이 연결부인지, 글과 권한을 다루는 로직 전체인지가 다르다.
미래에 커질지도 모른다는 이유로 지금부터 서버를 나누고 싶지는 않다. 그렇다고 지금 Next.js 안에 둔 백엔드가 앞으로도 반드시 그 안에 있어야 하는 것은 아니었으면 한다. 처음에 파사드와 어댑터의 경계를 나눠 둔 이유도 여기에 닿아 있다. 지금은 함께 운영하고, 필요해지면 나누되, 그 사이에 만든 애플리케이션은 계속 가져가고 싶다.
댓글
댓글을 불러오는 중...