
우테코 합격 후 판교로 가기전 의미있는 활동을 하나라도 더 하고 싶었다.
그래서 CMC 부산 1기에 참여해 팀원과 함께 사용자를 받을 수 있는 간단한 프로젝트를 진행하는 것을 목표로 진행했다.
프로젝트를 간단하게 진행하려면 어떻게 해야 될까?
정답은 MVP를 정하면 된다!
하지만 대부분의 프로젝트를 경험하며 느낀 것은 이 MVP의 사이즈가 생각보다 크다는 것이다... 이건 설계 방법론에 따라 갈리는 것 같은데 대표적인 Waterfall 방식은 MVP의 사이즈가 생각보다 클 수 있다.
하지만 우리는 사용자 유입이 목표인 만큼 프로젝트를 Agile하게 가져가야 했고, 작은 사이즈의 MVP에서 사용자 피드백에 따른 점진적인 확장을 가져가는 방향으로 진행하게 됐다.
기획 팀원이 원하는 방향의 핵심 가치는 요식업 소개이다.
이 중 세부적인 기술은 자체 큐레이션 기반 맛집 소개, 혼잡도 예측 등이 있었지만, 진짜 사용자가 원하는 것이 맞나?라는 의문이 들었다.
그래서 부가적인 내용 없이 우선은 핵심 가치에 대한 사용자들의 흥미를 먼저 테스트하기 위해, 랜딩 페이지를 우선적으로 구현하는 것에 초점을 뒀다.
여기서 중요한 것은 "세부적인 기술은 사용자의 반응에 따라 추가한다"는 것이다.
프로젝트의 구현 방향은 설계가 잡혔고 3월까지는 랜딩 페이지를 마무리하는 것을 목표로, 부산 지역 내 요식업 소개를 코어로 잡았다.
그리고 6월 BTS 공연으로 인해 부산이 혼잡해 질 것으로 예상했고, 이 때 BTS 팬들을 유입하는 목적으로 서비스를 구현한다.

CloudFlare Trouble Shooting 포스트에서 소개했듯, CloudFlare Workers를 활용해서 api 처리를 했다. 그리고 Storage와 Database는 Supabase에서 PostGre SQL을 제공하기 때문에 Supabase Sass를 선택했다.
PostGre SQL은 지리 정보를 제공하기 때문에 (지도 기반)요식업 소개 플랫폼에서 필수불가결이었다. 그리고 Supabase 자체에서 Storage도 지원해주기 때문에 Supabase 생태계를 적극 활용한다.
Cloudflare workers를 활용하면서 아키텍처의 interface도 적용할 수 있게 되었다. front는 cdn과 api라는 TLD(Third-Level-Domain)만 바라보고 있기 때문에 그 요청이 CF인지 AWS인지 전혀모르게 된다.

현재는 MVP이기 때문에 사용자가 적어 CF에서 처리하게되지만 일일 요청 수가 10만건이 넘어간다면, 자연스레 서버 확장이 되어야한다.
6월 BTS 공연 좌석은 스탠딩석을 포함해 약 8만석이다.
단순 정보 조회 서비스에서 사용자 1인 당 약 10회 내외의 요청을 가정했을 때, 10만 건의 요청은 훌쩍 뛰어넘을 것이기 때문에 필수적으로 확장이 필요함을 예측 할 수 있다.
이 때, TLD 요청을 처리를 Functional 기반이 아닌 확장 가능한 서버로 대체해도 Front는 전혀 수정할 필요가 없어진다. 즉 사용자는 기존 서비스의 변경 사항을 전혀 못느끼게 된다.
현재 Supabase Api를 활용하면서 한가지 문제점이 생긴다.
front에서 api 요청을 할 때, https://api.ondoguide.com/rest/v1/store?select=* 와 같이 Supabase에 강하게 결합된 형식으로 요청해야한다.
그럼 6월을 대비해 서버 아키텍처가 변하게 된다면, Front 코드도 모두 변하게 될 것이다. 그래서 api 또한 아키텍처의 interface 처럼 형식화를 해두는 것이 좋다.
기존 GET https://api.ondoguide.com/rest/v1/store?select=*를 GET https://api.ondoguide.com/v1/store와 같은 형식으로 처리한다면 Server를 교체하더라도 front 코드는 수정이 없게 된다.

전체적으로 불필요한 분리는 피했다.
그리고 HandlerMapping에서 TS의 Decorator를 활용해 Spring IOC와 같이, Endpoint 별로 Controller Method들을 매핑했다.
npm install -g wrangler
npx wrangler init
npm install @supabase/supabase-js
다음 명령어 들로 CF에서 제공하는 wrangler를 통해 Worker 프로젝트를 생성할 수 있다. 그리고 supabase sdk를 활용하기 위해 supabase 전용 sdk도 설치한다.
코드는 가장 핵심적인 Index.ts와 IOC를 활용한 Handler & Controller를 소개한다.
// index.ts (dispatcher Servlet)
import { HandlerMapping } from './handlers/handlerMapping.js';
import { AppError } from './exception/appError.js';
import { storageClient } from './infrastructure/storageClient.js';
import { dbConnector } from './infrastructure/databaseConnector';
interface Env {
SUPABASE_URL: string;
SUPABASE_ANON_KEY: string;
CORS_URIS: string;
CDN_PREFIX: string;
}
export default {
async fetch(request: Request, env: Env) {
this.initialize(env);
const origin = request.headers.get("Origin");
try {
this.checkCors(origin, env);
if (request.method === "OPTIONS") {
return this.handleSuccess(null, request);
}
const handler = this.resolveHandler(request);
const result = await this.dispatch(request, handler);
return this.handleSuccess(result, request);
} catch (e) {
const error = e instanceof Error ? e : new Error(String(e));
return this.handleError(error, request);
}
},
async dispatch(request: Request, handler: Function | null) {
if (!handler) {
throw new AppError("요청하신 페이지를 찾을 수 없습니다.", 404);
}
return await handler(request);
},
initialize(env: Env) {
storageClient.init(env.CDN_PREFIX);
dbConnector.init(env.SUPABASE_URL, env.SUPABASE_ANON_KEY);
},
// TODO: 필터 체인으로 분리
checkCors(origin: string | null, env: Env) {
if (!origin) {
return;
}
const allowedOrigins = env.CORS_URIS?.split(',') || [];
const isAllowed = allowedOrigins.includes(origin) || allowedOrigins.includes("*");
if (!isAllowed) {
throw new AppError("CORS Policy: This origin is not allowed.", 403);
}
},
resolveHandler(request: Request) {
const url = new URL(request.url);
return new HandlerMapping().getHandler(request.method, url.pathname);
},
// TODO: 아래로 View Resolver로 분리
handleSuccess(data: any, request: Request) {
const status = data === null ? 204 : 200;
return this.buildResponse(data, status, request);
},
handleError(e: Error, request: Request) {
const status = e instanceof AppError ? e.status : 500;
const message = e.message || "Internal Server Error";
return this.buildResponse({ error: message }, status, request);
},
buildResponse(body: any, status: number, request: Request) {
const origin: string | null = request.headers.get("Origin");
const headers: Record<string, string> = {
"Content-Type": "application/json;charset=UTF-8",
};
if (origin) {
headers["Access-Control-Allow-Origin"] = origin;
headers["Access-Control-Allow-Methods"] = "GET, POST, PUT, DELETE, OPTIONS";
headers["Access-Control-Allow-Headers"] = "Content-Type, Authorization";
}
return new Response(body ? JSON.stringify(body) : null, { status, headers });
}
};
cloudflare workers env를 적극 활용해, CORS Origin 정보를 은닉하고 Dispatcher Servlet을 모방한 코드다.
// HandlerMapping.js
import { ROUTES_KEY } from '../decorator/apiDecorator';
import { CategoryController } from '../controller/CategoryController';
import { StoreController } from '../controller/StoreController';
export class HandlerMapping {
#routeMap = new Map();
constructor() {
const controllers = [
new StoreController(),
new CategoryController()
];
controllers.forEach(controller => {
const routes = controller.constructor[ROUTES_KEY] || [];
routes.forEach(route => {
const key = `${route.method}:${route.path}`;
this.#routeMap.set(key, controller[route.handlerName].bind(controller));
});
});
}
getHandler(method, pathname) {
const key = `${method.toUpperCase()}:${pathname}`;
return this.#routeMap.get(key) || null;
}
}
// apiDecorator.ts
export const ROUTES_KEY = Symbol('routes');
export function Get(path: string) {
return function (target: any, propertyKey: string) {
if (!target.constructor[ROUTES_KEY]) {
target.constructor[ROUTES_KEY] = [];
}
target.constructor[ROUTES_KEY].push({
method: 'GET',
path,
handlerName: propertyKey
});
};
}
// ... POST, DELETE, PUT도 추가
TS의 Decorator를 활용해 선언된 target의 메서드 정보를 가져와 index.ts의 dispatch()에서 실행한다.
// StoreController.ts
import { storageClient } from '../infrastructure/storageClient.js';
import { StoreRepository } from '../repository/StoreRepository';
import { Get } from '../decorator/apiDecorator';
export class StoreController {
#storeRepository = new StoreRepository();
@Get("/v1/stores")
async getStores(request: Request) {
const data: any = await this.#storeRepository.findAll();
return data.map((item: any) => ({
sid: item.sid,
name: item.name,
address: item.address,
description: item.description,
latitude: item.latitude,
longitude: item.longitude,
thumbnailUri: item.thumbnail_key ? `${storageClient.getFullUri(item.thumbnail_key)}` : null,
categories: item.store_categories?.map((sc: any) => sc.categories?.name) || []
}));
}
}
Controller는 decorator를 활용해 비즈니스 로직에만 집중한다.
Workers 관련 레파지토리 주소
이번 프로젝트를 통해 "확장 가능한 MVP"를 설계하는 경험을 했다.
CloudFlare Workers와 Supabase라는 제한된 환경에서도, Spring Web MVC의 설계 원칙을 적용해 계층을 명확히 분리했다. 특히 TypeScript Decorator를 활용한 선언적 라우팅은 코드의 가독성과 유지보수성을 크게 향상시켰다.
핵심은 "지금 당장 필요한 것만 구현하되, 미래의 확장을 막지 않는 구조"였다.
api.ondoguide.com만 바라보기 때문에 서버 기술 스택 변경에 영향받지 않음6월 BTS 공연에서 실제 사용자 피드백을 받고, 데이터를 기반으로 다음 기능을 결정할 예정이다.
작게 시작해서 올바른 방향으로 크게 확장하는 것, 그것이 이번 프로젝트의 목표다.