[NextJS] 페이지 링크 공유하기 기능

우지끈·2025년 1월 14일
0
post-thumbnail

오늘의 메뉴 추천 기능

프로젝트에서 오늘의 메뉴 추천 기능을 맡게 되었다.

아래 사진과 같이 퍼널 패턴을 통해 유저에게 총 네 개의 응답을 수집한 후, 이를 기반으로 OpneAI API에 프롬프트를 작성하여 추천 메뉴 결과를 반환하는 방식이다. 반환된 결과는 다음과 같이 결과 페이지에 표시된다.

위와 같은 결과 페이지에서 공유하기 버튼을 눌렀을 때 현재 내가 보고 있는 페이지(답변 1, 2, 3, 4 제외 테스트용임)를 다른 사람들도 링크를 통해 확인할 수 있는 기능을 구현하고 싶었다.

첫 번째 시도

그래서 처음 생각했던 방법은 단순하게 페이지별로 랜덤한 id 값을 url에 부여하여 결과 페이지로 들어올 수 있게 하면 되지 않을까... 였는데 이 방법을 사용하면 공유받은 사용자가 링크에 접속할 대마다 새로운 api 요청이 발생하여, 의도했던 결과와 다른 아예 새로운 결과가 생성되는 문제가 있었다.

두 번째 시도

따라서 두 번째로 생각했던 방법은 추천 메뉴 결과를 Query Parameters에 인코딩하여 전달하는 방법이다. 따라서 해당 url로 접속했을 때 새로운 페이지(공유 링크 전용)에서 추천 내용을 디코딩하여 화면에 뿌려주는 것이었다.

 const shareResult = () => {
    const queryParams = new URLSearchParams({
      recommendation: encodeURIComponent(menuRecommendation)
    }).toString();

    const shareUrl = `${window.location.origin}/result/shared?${queryParams}`

    if (navigator.share) {
      navigator.share({
        title: "추천 메뉴",
        text: "저의 추천 메뉴를 확인해보세요!",
        url: shareUrl
      }).then(() =>  console.log("공유 성공!"))
      .catch((error) => console.error("공유 실패:", error))
    } else {
      alert("이 브라우저는 공유 기능을 지원하지 않습니다.")
    }
  }

이런 식으로...

근데 이렇게 했을 때의 문제는 url이 아주아주아주 많이 길어진다는 것이다.

추천 메뉴
http://localhost:3000/result/shared?recommendation=%25EC%2598%25A4%25EB%258A%2598%2520%25EA%25B8%25B0%25EB%25B6%2584%25EC%259D%25B4%2520%25EC%25A2%258B%25EA%25B3%25A0%252C%2520%25EC%25A2%2585%25EB%25A5%2598%25EC%259D%2598%2520%25EC%259D%258C%25EC%258B%259D%25EC%259D%25B4%2520%25EB%2595%25A1%25EA%25B8%25B0%25EC%258B%25A0%25EB%258B%25A4%25EB%25A9%25B4%252C%2520%25EB%25A7%25A4%25EC%259A%25B4%2520%25EC%259D%258C%25EC%258B%259D%25EC%259D%2584%2520%25EC%25A0%259C%25EC%2599%25B8%25ED%2595%2598%25EA%25B3%25A0%2520%27%25EB%258B%25AD%25EA%25B0%2580%25EC%258A%25B4%25EC%2582%25B4%2520%25EC%2583%2590%25EB%259F%25AC%25EB%2593%259C%27%25EB%25A5%25BC%2520%25EC%25B6%2594%25EC%25B2%259C%25EB%2593%259C%25EB%25A6%25BD%25EB%258B%2588%25EB%258B%25A4.%2520%25EB%258B%25AD%25EA%25B0%2580%25EC%258A%25B4%25EC%2582%25B4%25EC%259D%2580%2520%25EB%258B%25A8%25EB%25B0%25B1%25EC%25A7%2588%25EC%259D%25B4%2520%25ED%2592%258D%25EB%25B6%2580%25ED%2595%2598%25EA%25B3%25A0%2520%25EB%258B%25B4%25EB%25B0%25B1%25ED%2595%259C%2520%25EB%25A7%259B%25EC%259D%25B4%25EC%2596%25B4%25EC%2584%259C%2520%25EA%25B1%25B4%25EA%25B0%2595%25EC%2597%2590%25EB%258F%2584%2520%25EC%25A2%258B%25EA%25B3%25A0%252C%2520%25EC%2583%2590%25EB%259F%25AC%25EB%2593%259C%25EC%2599%2580%2520%25ED%2595%25A8%25EA%25BB%2598%2520%25EB%2593%259C%25EC%258B%259C%25EB%25A9%25B4%2520%25EC%2583%2581%25ED%2581%25BC%25ED%2595%25A8%25EA%25B3%25BC%2520%25EC%2595%2584%25EC%2582%25AD%25ED%2595%259C%2520%25EC%258B%259D%25EA%25B0%2590%25EC%259D%25B4%2520%25EB%258D%2594%25ED%2595%25B4%25EC%25A0%25B8%2520%25EC%25A6%2590%25EA%25B2%2581%25EA%25B2%258C%2520%25EB%2593%259C%25EC%258B%25A4%2520%25EC%2588%2598%2520%25EC%259E%2588%25EC%259D%2584%2520%25EA%25B1%25B0%25EC%2598%2588%25EC%259A%2594.%2520%25EB%258B%25A4%25EC%259D%25B4%25EC%2596%25B4%25ED%258A%25B8%25EB%25A5%25BC%2520%25EA%25B3%25A0%25EB%25A0%25A4%25ED%2595%2598%25EC%258B%25A0%25EB%258B%25A4%25EB%25A9%25B4%2520%25EC%2595%2584%25EC%25A3%25BC%2520%25EC%25A2%258B%25EC%259D%2580%2520%25EC%2584%25A0%25ED%2583%259D%25EC%259D%25B4%2520%25EC%2595%2584%25EB%258B%2590%25EA%25B9%258C%2520%25EC%258B%25B6%25EC%258A%25B5%25EB%258B%2588%25EB%258B%25A4.%2520%25EB%25A7%259B%25EC%259E%2588%25EA%25B2%258C%2520%25EB%2593%259C%25EC%2584%25B8%25EC%259A%2594%21
저의 추천 메뉴를 확인해보세요!

...😅

세 번째 시도

따라서 결과를 매번 DB에 저장하고 불러오는 방식도 고민해봤지만, 이 테스트는 비로그인 유저도 가볍게 즐길 수 있도록 설계되었고, 모든 결과를 저장하게 되면 리소스 소모가 커질 가능성이 높아 적합하지 않다고 판단했다.
따라서...

네 번째 시도

Bitly나 Tinyurl 같은 API를 사용하면 url 길이를 단축시킬 수 있다는 것을 알게되었다. 사이트를 살펴보니 free플랜을 사용하는 경우 Tinyurl이 더 많은 url을 변환시킬 수 있기에 Tinyurl을 사용해보기로 했다.

url 단축을 위한 util 함수를 만들어준 뒤,

export const shortenUrl = async (longUrl: string): Promise<string | null> => {
  const API_TOKEN = process.env.NEXT_PUBLIC_TINY_URL_API_TOKEN || "";
  const API_ENDPOINT = "https://api.tinyurl.com/create";

  try {
    const response = await fetch(API_ENDPOINT, {
      method: "POST",
      headers: {
        Authorization: `Bearer ${API_TOKEN}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        url: longUrl,
        domain: "tinyurl.com",
      }),
    });

    if (!response.ok) {
      throw new Error(`Error: ${response.statusText}`);
    }

    const shortenUrlData = await response.json();
    return shortenUrlData.data.tiny_url;
  } catch (error) {
    console.error("URL 단축 실패:", error);
    return null;
  }
};

공유 버튼을 눌렀을 때, 단축된 url을 제공해주고

  const shareResult = async () => {
    const queryParams = new URLSearchParams({
      recommendation: encodeURIComponent(menuRecommendation),
    }).toString();

    const longUrl = `${window.location.origin}/recommend/result/shared?${queryParams}`;

    try {
      const shortUrl = await shortenUrl(longUrl);

      if (!shortUrl) {
        alert("URL 단축에 실패했습니다. 다시 시도해주세요.");
        return;
      }

      if (navigator.share) {
        navigator
          .share({
            text: "저의 추천 메뉴를 확인해보세요!",
            url: shortUrl,
          })
          .then(() => console.log("공유 성공!"))
          .catch((error) => console.error("공유 실패:", error));
      } else {
        alert(
          `이 브라우저는 공유 기능을 지원하지 않습니다. URL을 직접 복사하세요:\n${shortUrl}`,
        );
      }
    } catch (error) {
      console.error("공유 실패:", error);
      alert("공유 과정에서 문제가 발생했습니다.");
    }
  };

실제로 사용자가 단축된 url로 접속했을 때, longUrl로 리다이렉트 될 것이기 때문에 longUrl 경로에 page를 하나 만들어준 뒤,

"use client"

import Link from "next/link";
import { useSearchParams } from "next/navigation";

const SharedResultPage = () => {
  const searchParams = useSearchParams();
  const encodedRecommendation = searchParams.get("recommendation");

  if (!encodedRecommendation) {
    return <p>유효하지 않은 URL입니다.</p>;
  }

  const recommendation = decodeURIComponent(encodedRecommendation);
  return (
    <div id="result-container" className="bg-white text-black">
      <h1>결과 페이지</h1>
      <p>추천 메뉴: {recommendation}</p>
      <Link href="/recommend">
        <button className="mt-4 border p-1">나도 하러 가기</button>
      </Link>
    </div>
  );
};
export default SharedResultPage;

해당 페이지에서 searchParams를 통해 추천 메뉴 값을 받아와 디코딩 해준 뒤 화면에 표시하도록 구현했다.

목표했던 대로 기능 구현은 성공했으나 한 가지 아쉬운 점이 남는다.

외부 API를 사용하게 되면 해당 사이트에 의존적이게 되며, 무료로 사용할 수 있는 한도가 정해져있다는 것이다.

이 문제를 해결하기 위해, 데이터를 효율적으로 전송하고 외부 서비스에 의존하지 않으면서도 url 길이를 줄일 수 있는 방법을 찾고자 했다.

따라서 다른 시도..를 또 하게 되는데...

다섯 번째 시도

자체적으로 url을 압축하는 것이다. 긴 데이터를 압축 알고리즘(ex. zlib)으로 처리한 후, Base64로 인코딩하여 url에 포함시키는 방법을 시도해봤다.

구현 방식은 다음과 같다.

  1. 데이터 압축: 추천 메뉴 데이터를 zlib 라이브러리를 사용하여 압축
  2. Base64 인코딩: 압축된 데이터는 이진 데이터이기에 url에 사용하기 위해 Base64 형식으로 변환
  3. URL 인코딩: Base64 데이터에는 +, /, = 같은 특수 문자가 포함되기에, 안전하게 url을 사용하기 위해 encodeURIComponent 사용
  4. 데이터 복원: URL 디코딩 -> Base64 디코딩 -> 압축 해제 -> 화면에 표시
import zlib from "browserify-zlib"
import { Buffer } from "buffer";

export const compressData = (data: string): string => {
    const compressed = zlib.deflateSync(data); // 압축
    return compressed.toString("base64") // 이진 데이터 -> Base64 텍스트로 변환
}

export const decompressData = (compressData: string): string => {
    const buffer = Buffer.from(compressData, "base64"); // Base64 텍스트 -> 이진 데이터
    const decompressed = zlib.inflateSync(buffer); // 압축 해제
    return decompressed.toString();
}

위와 같이 데이터 압축 및 변환을 위한 util 함수를 작성해주고,

const shareResult = async () => {
    const compressedData = compressData(menuRecommendation);
    const shareUrl = `${window.location.origin}/recommend/result?compressedData=${encodeURIComponent(compressedData)}`

    try {
      if (navigator.share) {
        navigator
          .share({
            text: "저의 추천 메뉴를 확인해보세요!",
            url: shareUrl,
          })
          .then(() => console.log("공유 성공!"))
          .catch((error) => console.error("공유 실패:", error));
      } else {
        alert(
          `이 브라우저는 공유 기능을 지원하지 않습니다. URL을 직접 복사하세요:\n${shareUrl}`,
        );
      }
    } catch (error) {
      console.error("공유 실패:", error);
      alert("공유 과정에서 문제가 발생했습니다.");
    }
  };
"use client"

import { decompressData } from "@/app/recommend/_utils/compression";
import Link from "next/link";
import { useSearchParams } from "next/navigation";

const SharedResultPage = () => {
  const searchParams = useSearchParams();
  const compressedData = searchParams.get("compressedData");

  if (!compressedData) {
    return <p>유효하지 않은 URL입니다.</p>;
  }

  const recommendation = decompressData(decodeURIComponent(compressedData));
  return (
    <div id="result-container" className="bg-white text-black">
      <h1>결과 페이지</h1>
      <p>추천 메뉴: {recommendation}</p>
      <Link href="/recommend">
        <button className="mt-4 border p-1">나도 하러 가기</button>
      </Link>
    </div>
  );
};
export default SharedResultPage;

기존의 페이지들도 조금씩 수정해주었다.


근데 여기서 또 다른 문제... 튜터님께 둘 중 어떤 방법을 선택하면 좋을지를 여쭤봤는데 아예 새로운 방법을 추천해주셨다!^^

여섯 번째 시도

route에서 직접 POST로 구현한다... 라는 힌트만 주고 사라지셔서...

좀 찾아보니 API Routes를 사용해 직접 데이터(메뉴 추천 결과)를 처리하는 방법인 거 같았다.

그래서

  1. 클라이언트에서 POST 요청으로 서버에 데이터를 전달하고,
  2. 서버에서 데이터를 저장한 뒤, 고유 ID(UUID)를 생성하여 반환해주고,
  3. 클라이언트에서 반환된 ID를 포함한 URL을 생성하여 공유

하는 방식으로 구현해봤다.


먼저 데이터를 저장하기 위한 임시 database.ts 파일을 생성해주었다.

export const database = new Map<string, string>();

그 다음에 route.ts 파일을 생성해주고

import { database } from "@/app/recommend/_utils/database";
import { NextResponse, type NextRequest } from "next/server";
import {v4 as uuidv4} from "uuid"

export const POST = async (req:NextRequest) => {
    try {
        const body = await req.json();
        const {data} = body;
        
        if (!data || typeof data !== "string") {
            return NextResponse.json({error: "유효하지 않은 데이터입니다."}, {status: 400})
        }

        const id = uuidv4()
        database.set(id, data)
        return NextResponse.json({id});
    } catch (error) {
        console.error("POST 요청에 실패했습니다.", error);
        return NextResponse.json({error: "서버 에러가 발생했습니다."}, {status: 500});
    }
}

export const GET = async (req:NextRequest)=> {
    const {searchParams} = new URL(req.url);
    const id = searchParams.get("id");

    if (!id || !database.has(id)) {
        return NextResponse.json({error: "데이터를 찾을 수 없습니다."}, {status: 404});
    }

    const data = database.get(id);
    return NextResponse.json({data});
}

POST 요청을 받으면, 이를 db에 저장한 뒤 id를 반환해주고,
GET 요청을 받으면 id를 추출해내 db에서 해당 id를 key값으로 갖는 데이터를 꺼내와 반환하게 하였다.

그 다음에 결과 페이지에서는

const shareResult = async () => {
    try {
      const response = await fetch("/api/recommend", {
        method: "POST",
        headers: { "Content-type": "application/json" },
        body: JSON.stringify({ data: menuRecommendation }),
      });

      if (!response.ok) {
        alert("결과 공유에 실패했습니다. 다시 시도해주세요.");
        throw new Error("데이터 저장 실패");
      }

      const { id } = await response.json();
      const url = `${window.location.origin}/recommend/result?id=${id}`;

      if (navigator.share) {
        await navigator.share({
          text: "저의 추천 메뉴를 확인해보세요!",
          url,
        });
      } else {
        alert(`URL을 복사하여 공유해보세요: ${url}`);
      }
    } catch (error) {
      console.error("결과 공유에 실패했습니다.", error);
      alert("결과 공유에 실패했습니다. 다시 시도해주세요.");
    }
  };

POST 요청을 보내고, 이를 기반으로 url을 생성하게 하였으며

공유된 링크를 통해 들어오게 되는 페이지에서는

"use client";

import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { useEffect, useState } from "react";

const SharedResultPage = () => {
  const searchParams = useSearchParams();
  const id = searchParams.get("id");
  const [recommendation, setRecommendation] = useState<string>(""); 

  const fetchData = async () => {
    try {
      const response = await fetch(`/api/recommend?id=${id}`); // GET요청
      if (!response.ok) {
        throw new Error("데이터를 받아오는 데에 실패했습니다.");
      }

      const { data } = await response.json();
      setRecommendation(data);
    } catch (error) {
      console.error("데이터를 받아오는 데에 실패했습니다.", error);
      setRecommendation("데이터를 불러올 수 없습니다.");
    }
  };

  useEffect(() => {
    if (id) fetchData();
  }, [id]);

  if (!id) {
    return <p>유효하지 않은 url입니다.</p>;
  }

  return (
    <div id="result-container" className="bg-white text-black">
      <h1>결과 페이지</h1>
      <p>추천 메뉴: {recommendation}</p>
      <Link href="/recommend" className="mt-4 border p-1">
        나도 하러 가기
      </Link>
    </div>
  );
};
export default SharedResultPage;

GET 요청을 보내 해당 데이터를 받아와 표시하게 했다!

그 결과로
http://localhost:3000/recommend/result?id=13d56fa5-5e7b-4626-b28c-c7328acdeaa3

이렇게 아주 적당한 길이의 url을 통해 공유할 수 있게 되었다 하하하하
uuid를 사용하지 않고 임의의 난수를 사용한다면 더 짧은 길이의 url도 만들 수 있을 것이다.


느낀 점

오늘 아주 다사다난... 정신 없었지만 덕분에 많은 방법들을 시도해보며, 각 방법의 장단점과 구현 방법에 대해 공부해볼 수 있어서 좋았다.
앞으로도 단순히 기능 구현에만 집중하지 않고, 상황에 맞는 더 나은 방법을 고민하는 개발자가 되어야겠다고 다짐했다!

0개의 댓글

관련 채용 정보