프로젝트에서 오늘의 메뉴 추천 기능을 맡게 되었다.
아래 사진과 같이 퍼널 패턴을 통해 유저에게 총 네 개의 응답을 수집한 후, 이를 기반으로 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이 아주아주아주 많이 길어진다는 것이다.
...😅
따라서 결과를 매번 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에 포함시키는 방법을 시도해봤다.
구현 방식은 다음과 같다.
zlib
라이브러리를 사용하여 압축encodeURIComponent
사용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를 사용해 직접 데이터(메뉴 추천 결과)를 처리하는 방법인 거 같았다.
그래서
하는 방식으로 구현해봤다.
먼저 데이터를 저장하기 위한 임시 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도 만들 수 있을 것이다.
오늘 아주 다사다난... 정신 없었지만 덕분에 많은 방법들을 시도해보며, 각 방법의 장단점과 구현 방법에 대해 공부해볼 수 있어서 좋았다.
앞으로도 단순히 기능 구현에만 집중하지 않고, 상황에 맞는 더 나은 방법을 고민하는 개발자가 되어야겠다고 다짐했다!