Hono로 firebase 정리하기

jingjinge·2025년 3월 24일
0

OpenSource

목록 보기
6/9
post-thumbnail

spec이 바뀌어버린 바람에 예상보다 길게 가져가게된 firebase plugin 개발기 거의 마지막인듯 하다.

stars가 500을 넘었다. 많관부..!

기존 스펙은 아래와 같았다.

  1. fileDownloadURL을 DB에 저장
  2. firebase functions(serverless)에서 그대로 꺼내서 씀

위와 같은 방식은 무슨 문제가 있을까

  • URL 탈취시 무제한 다운로드 가능(시간, 공간 제약 X)
  • 인가되지 않은 사람들 또한 다운로드 가능

위 이유로 plugin의 spec이 변경되었다. (0.13.0에 나갈줄 알았는데..)

  1. DB에 fileDownloadURL 저장 X
  2. signedURL 혹은 JWT를 이용한 URL을 serverless에서 발급
  3. 해당 URL은 60초 후 자동 폐쇄 (토큰 만료)

스펙을 맞추는 동안 문제가 생겼고 그에 대한 트러블 슈팅 포스팅이다.

배경은 아래와 같다.


배경

serverless에 존재하는 함수는 하나의 엔드 포인트를 제공한다.
그 엔드포인트는 정해진 헤더와 요청을 받는다.
이 요청은 정해진 response를 뱉는데 이 response에 생성된 URL이 같이 들어가야한다.(spec상 그렇다.)
생성된 URL에 접속하면 파일이 받아져야한다.


가정

난 처음 아래와 같이 생각했다.

  1. 유저가 request를 보낸다
  2. updateInfo 함수는 fileDownloadURL 함수를 실행해 response를 받는다.
  3. updateInfo 함수는 합쳐진 response를 유저에게 보내준다.

그렇다면, 유저에게 내려주는 serverless 함수 하나, serverless 함수에 본인의 URL을 내려주는 함수 하나가 생기게 된다.

실제로 이런식으로 만들었었고, 모든 요구에 부합했다.

하지만 이 방식엔 치명적인 문제점이 존재하는데 그것은 cold start이다.


Cold Start

출처: https://payproglobal.com/ko/%EB%8B%B5%EB%B3%80/%EC%BD%9C%EB%93%9C-%EC%8A%A4%ED%83%80%ED%8A%B8%EB%9E%80/

내가 첫 번째 함수를 실행하고, 두 번째 함수를 연달아서 실행한다면 각 함수는 각각 리소스를 할당받아 새로운 시작을 한다.

이 과정에서 리소스를 할당 받고, 서버를 키면서 딜레이가 생기는 Cold Start가 두번 생길 수도 있다는 얘기이다.

한번의 실행은 감수해야하지만, 두 번의 실행의 경우 운이 안좋다면 정말 오랜 시간이 걸릴 수도 있다.

코딩을 운에 기대면 안되지 않겠는가.


고난

하나의 함수에서 fileURL을 생성해서 내려주는 방식을 생각해 보았지만, firebase에서 파일 데이터를 전체를 긁어서 정보를 내려주는 일은 하지 않는 것으로 보였다.

한 엔드포인트에서 다른 헤더를 넣고, 그 헤더에 따라 다른 함수나 다른 결과값을 얻어야한다면 우리는 어떤 방법을 택할 수 있을까?

if문을 통해 request에서 parameter에 따라 다른 함수를 실행하면 되지 않을까?


if문 선택

export const hotUpdaterService = functions
  .region(HotUpdater.REGION)
  .https.onRequest(async (req: functions.Request, res: functions.Response) => {
    try {
      const path = req.path || "/";
      
      // 루트 경로 처리
      if (path === "/" || path === "") {
        return res.status(200).json({ message: "Hot Updater Service is running" });
      }
      
      // 업데이트 확인 경로 처리
      if (path === "/check-update") {
        const platformHeader = req.headers["x-app-platform"] as string;
        const appVersion = req.headers["x-app-version"] as string;
        const bundleId = req.headers["x-bundle-id"] as string;
        const minBundleId = req.headers["x-min-bundle-id"] as string;
        const channel = req.headers["x-channel"] as string;

        if (!platformHeader || !appVersion || !bundleId) {
          return res.status(400).json({
            error: "Missing required headers (x-app-platform, x-app-version, x-bundle-id)"
          });
        }

        const platform = validatePlatform(platformHeader);
        if (!platform) {
          return res.status(400).json({
            error: "Invalid platform. Must be 'ios' or 'android'"
          });
        }

        // 업데이트 정보 가져오기 로직
        const db = admin.firestore();
        const updateInfo = await getUpdateInfo(db, {
          platform,
          appVersion,
          bundleId,
          minBundleId: minBundleId || NIL_UUID,
          channel: channel || "production",
        });

        if (!updateInfo) {
          return res.status(200).json(null);
        }

        // 나머지 업데이트 확인 로직 실행
        // ...

        return res.status(200).json(updateInfo);
      }
      
      // 번들 다운로드 경로 처리
      if (path.startsWith("/bundle-download/")) {
        const filePath = path.replace("/bundle-download/", "");
        const token = req.query.token as string;
        
        // 토큰 검증 및 파일 다운로드 로직
        // ...
        
        return res.status(200).send("Bundle file content would be here");
      }
      
      // 일치하는 경로가 없는 경우
      return res.status(404).json({ error: "Not Found" });
      
    } catch (error: unknown) {
      console.error("Error handling request:", error);
      const errorMessage = error instanceof Error ? error.message : "Unknown error";
      return res.status(500).json({ error: "Internal Server Error", details: errorMessage });
    }
  });

이렇게 if문을 쓰는 것 보다 더 좋은 방식이 뭐가 있을까?

express.js, find-my-way, Hono 등 문자열 파싱을 통한 api를 호출하는 라이브러리를 사용하는 것이다.


Hono 선택

왜 express.js나 find-my-way 등 다양한 것 많은데 Hono를 선택했을까

사실 이런 문자열을 파싱해 API 핸들러를 실행하는 라이브러리들은 전부 '문자열'을 얼마나 빨리 파싱하느냐에 달려있다.

확실히 빠르다고 한다. 선택하지 않을 이유가 없었다.

// 순서상 /check-update를 먼저 체크한다.
app.get("/check-update", async (c) => {...}
app.get("/bundle-download/*", async (c) => {...}

export const hotUpdaterService = functions
  .region(HotUpdater.REGION)
  .https.onRequest(async (req: functions.Request, res: functions.Response) => {
    //Hono에서 요구하는 request 타입을 맞춰주기 위해 바꾸는 과정
    const host = req.hostname || "localhost";
    const protocol = req.protocol || "https";
    //여기를 통해 파라미터가 정해지고, 그에 맞는 app.get으로 분기를 타게됨
    const fullUrl = `${protocol}://${host}${req.originalUrl || req.url}`;

    const request = new Request(fullUrl, {
      method: req.method,
      headers: req.headers as HeadersInit,
      body:
        req.method !== "GET" && req.method !== "HEAD" ? req.body : undefined,
    });

    try {
      //hono에 미리 만들어둔 app에 request를 보낸다
      const honoResponse = await app.fetch(request);

      //hono에서 받아온 status 넣기
      res.status(honoResponse.status);

      //hono에서 넘어온 헤더값 넣기
      honoResponse.headers.forEach((value: string, key: string) => {
        res.set(key, value);
      });

      //hono에서 온 body값 넣기
      const body = await honoResponse.text();
      res.send(body);
    } catch (error: unknown) {
      console.error("Hono app error:", error);
      const errorMessage =
        error instanceof Error ? error.message : "Unknown error";
      res
        .status(500)
        .json({ error: "Internal Server Error", details: errorMessage });
    }
  });

그렇다, Hono와 express.js는 고급 if문과 비슷하다.


결론

결론적으로 serverless 함수를 두개 쓰는 환경에서 1개를 쓰지만 if문으로 복잡한 코드를 거쳐, Hono를 사용한 깔끔한 코드까지 도달하게 되었다.

어쩌다보니 saas를 이용한 Backend 코드들도 다루고 있는데, 재미있다.

saas를 이용하면 단순한 CRUD만 활용할 줄 알았는데, 하다보니 점점 복잡해지고 그를 해결하는 것이 재미있는 것 같다.

0개의 댓글