URL Shortener - id 중복 현상 방지, next API 활용기
문제 상황
물론 원할하게 프로젝트가 진행되면 참 다행이지만 한편으론 너무 무난하면 불안한 순간들이 있다. 이번에도 너무 무난하게 흘러가서 걱정이 약간 있었는데 문제가 발생했다.
단축 url 중복 현상
가장 심각한 문제다. 기존의 로직으로 대용량 데이터를 생성한다고 약 50개의 링크를 변환해봤다. 겨우 50개인데 같은 Base62 값이 3개나 발생한 것이다.
const base62Url = toBase62(Date.now() + Math.floor(Math.random() * 1000));
기존의 로직을 확인해보면 현재 시간에 랜덤 숫자를 넣는 로직이다. 그런데 동시에 여러개를 생성할 경우 현재 시간에 대한 값이 모두 동일하게 입력되고, 랜덤 숫자에만 의지하는 것이다. 그래서 위의 변환된 이미지를 확인해보면 앞의 다섯 자리는 uzmjb로 고정된 것을 볼수가 있다. 사실상 Base62의 이점을 살리지 못하고 뒤 두자리로만 중복을 피하고 있는 상황인 것이다.
과도한 요청
이 부분은 어쩔수가 없는 부분이긴 하다. 왜냐하면 입력된 모든 url을 검사해서 db에 존재하는지 확인하는 절차를 거쳐야 하기 때문이다. 하지만 이렇게 브라우저상에서 모든 요청을 처리하는 것은 브라우저 입장에서 무리가 갈수 밖에 없다.
해결 방법 고안
그래서 현 상황을 개선하고자 두가지 방법을 고안했다.
- 중복 방지 : uuid 활용, 더 복잡한 숫자 생성 및 Base62 생성
- 다중 요청 : next API 활용, 요청 병렬처리
1. uuid 활용
프로젝트를 어떻게 구현해야 할지 구상하는 단계에서 한번 생각했던 기술이긴 하다.
uuid란? UUID는 일반적으로 32개의 16진수로 구성되며, 4개의 하이픈(-)으로 구분된다.
말그대로 유일한 값을 만드는 방법이다. uuid도 한가지만 있는 것이 아니다. v1부터 v5까지 각 상황에 맞게 사용을 하는데 우리는 유일한 값이 중요함으로 랜덤한 값을 생성하는 uuid v4를 사용하도록 하겠다.
그런데 바로 사용해서는 안된다. 위에서 설명했듯이 uuid는 하이픈 포함 36자이다. 36자는 암래도 짧은 주소는 전혀 아니다. 그래서 이 값을 변환해야하는데 uuid는 16진수이기 때문에 정수로 변환이 가능하다. 그렇다는 것은 우리가 전에 만들어준 Base 62변환 로직에도 사용이 가능하다는 것이다.
uuid 랜덤 값 생성 -> uuid값 정수로 변환 -> 변환한 정수를 Base 62로 변환
Base 62는 62진수로 표현하기 때문에 16진수로 표현한 uuid보다 짧은 값을 가진다. 하지만 그조차 길기 때문에 변환한 값의 앞 7자만 사용하기로 했다.
import { client } from "@/lib/api/amplify/helper"; import { v4 as uuidv4 } from "uuid"; export const checkUrl = async (url: string) => { const { data } = await client.models.Shortener.list({ filter: { originUrl: { eq: url } }, }); if (data.length > 0) { return data[0].transferUrl; } const uuid = parseInt(uuidv4().replace(/-/g, ""), 16); const uuidToBase62 = toBase62(uuid); const shortUrl = uuidToBase62.toString().slice(0, 7); const createData = await client.models.Shortener.create({ originUrl: url, transferUrl: shortUrl, }); return createData.data!.transferUrl; };
uuid를 생성하는것은 uuid 라이브러리를 설치했다. 그래서 위에서 말한 순서 그대로 parseInt를 통해 정수로 변환하고 Base 62로 변환해 앞의 7자만 사용하는 방식을 적용했다.
다시 모든 데이터를 삭제하고 50개를 생성해본 결과, 동일한 값이 생성되는 현상은 발견되지 않았고, 앞자리가 똑같았던 전과 달리, 모든 값들이 랜덤하게 생성되어 있는 것을 확인했다.
url shortener를 만들때 참고할 포스트를 추천받았다. 해당 글에서는 백엔드와 관련된 로직이라 프론트인 우리가 참고하기엔 조금 와닿지 않지만 그래도 짧게 줄인다는 목표는 같기 때문에 추가해놓겠다! 참고 포스트
2. 다중 요청 방지
Next API는 간단하게 백엔드 로직을 next 서버에서 처리해서 end point로 사용이 가능하다는 것이다. 그래서 Next.js가 풀스택 프레임워크로 불리는 것이다. 현재 우리가 계속해서 작업하고 있는 백오피스 서비스도 백엔드 로직 없이 서버리스로 구현된 풀스택 서비스이다. 그래서 일정 부분은 Next API를 통해 데이터 패칭을 시도하고 있다.
fetch를 사용해서 데이터 패칭을 시도할때 /api/(api name)으로 요청을 보내면 우리가 해당 파일에 작성한 패칭 로직을 지정한 end point로 요청이 가능하다. 그렇다면 여러개의 요청을 하나의 end point로 묶어서 결과를 받을수 있다는 것이다.
// @/src/pages/shortener/index.ts import type { NextApiRequest, NextApiResponse } from "next"; import { createShortUrl } from "./helper"; export default async function handler( req: NextApiRequest, res: NextApiResponse, ) { try { switch (req.method) { case "POST": return await createShortUrl(req, res); default: res.setHeader("Allow", ["POST", "GET"]); return res .status(405) .send({ message: `Method ${req.method} Not Allowed` }); } // eslint-disable-next-line } catch (error) { return res.status(500).json({ error: "Internal Server Error" }); } }
Next API 로직은 이렇게 작성하면 된다. 각 메서드에 따른 로직과 response를 지정해주는 것이다. 일단 POST 메서드만 작성해준다.
import { checkUrl } from "@/components/pages/shortener/util"; import { client } from "@/lib/api/amplify/helper"; import { NextApiRequest, NextApiResponse } from "next"; export const createShortUrl = async ( req: NextApiRequest, res: NextApiResponse, ) => { try { const { urls } = req.body; const transformedUrls = await Promise.all( urls.map(async (url: string) => { const result = await checkUrl(url); return { id: result, original: url, shortened: result, createdAt: new Date().toLocaleDateString(), }; }), ); if (!transformedUrls) { return res.status(404).json({ error: "No data found" }); } return res.status(201).json(transformedUrls); } catch (error) { return res.status(500).json({ error: "Internal Server Error" }); } };
실행할 내부로직은 전과 동일하다. 다른점은 각 상황마다 응답 코드를 지정하고 리턴할 결과를 직접 지정해줘야 한다.
const response = await fetch("/api/shortener", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ urls, }), }); const result = await response.json(); setUrlList((prev) => [...prev, ...result]);
이제 실제 요청을 보내는 로직이다. 전에는 직접 db에 접근해서 Promise.all로 처리했기 때문에 모든 url에 대해서 요청이 클라이언트에서 가는 상황이였다. 하지만 이렇게 하면 요청을 Next 서버에서 처리하기 때문에 브라우저 부하가 발생하지 않는다. 전과 다르게 유의할 점은 실제 fetch를 사용해서 데이터를 가져올 때처럼 json 형태로 오기 때문에 파싱을 해줘야 사용이 가능하다.
※ 추가 문제 ※
위의 방식으로 하게 되면 request body에 모든 url이 전달된다. 즉, url이 많아질수록 크기가 너무 커진다는 문제가 있다. 그래서 요청을 하나로 줄인다고 해도 결국 부하가 발생한다.
이럴때 사용하는 방법이 요청을 쪼개서 보내는 것이다. 이런 방법은 batch라고 한다.
batch : 일괄 처리라고도 하는 과정으로서 실시간으로 요청에 의해서 처리되는 방식이 아닌 일괄적으로 한꺼번에 대량의 프로세스를 처리하는 방식
물론 지금 우리가 하는 방식이 엄청나게 많고 무거운 데이터는 아니지만 그래도 조금이라도 부하를 줄이도록 노력하는게 개발자가 아닐까 싶다... 그래서 url 중복 검사 및 생성도 batch처리 해주겠다.
const BATCH_SIZE = 50; // 한 번에 처리할 URL 수 const batches = []; // 배치를 담을 배열 for (let i = 0; i < urls.length; i += BATCH_SIZE) { batches.push(urls.slice(i, i + BATCH_SIZE)); } const results: Url[] = []; await Promise.all( batches.map(async (item) => { try { const response = await fetch("/api/shortener", { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ urls: item, }), }); if (!response.ok) { throw new Error("URL 변환 중 오류가 발생했습니다."); } const res = await response.json(); results.push(...res); } catch (err) { error("URL 변환 중 오류가 발생했습니다."); } }), ); setUrlList((prev) => [...prev, ...results]);
간단하게 현재 입력된 url을 쪼개서 이중 배열을 만들어준다. 그리고 만들어진 배열에 Promise.all을 통해 나눠준만큼 요청을 보내주면 되는 것이다. 나는 너무 잘게 쪼개는 것은 batch처리한 의미가 없는 것 같아서 우선 50개를 설정했다. 만약 150개의 요청을 보내면 50개씩 3개의 요청이 갈 것이다.
지금까지의 과정을 예시를 들어서 설명하면 150개의 url을 변환하는 상황에서 기존 방식은 150개의 요청을 브라우저 측에서 처리한다. 이후에 수정한 코드를 통해서 진행하면 Next API와 Batch처리를 통해 브라우저에서 3개의 요청만 처리한다.
마무리
뭔가 술술 풀리면 불안하다는 말이 맞다는 생각이 든다. 지금 이렇게 해결한 방법도 완전한 해법은 아니다. 왜냐하면 중복된 값이 발생할 확률은 0이 아니기 때문에 중복된 값이 나왔을때에 처리가 지금 미흡하다. 이 부분은 어떻게 해야할지 조금더 고민해봐야겠다.
얼추 핵심 로직은 끝났으니 세세한 부분들을 만지면서 쓰기 편하고 효율적이게 만들어야겠다. 시간이 얼마 남지 않은 만큼 열심히 해보자!
개의 댓글
1
문제 상황
단축 url 중복 현상
과도한 요청
해결 방법 고안
1. uuid 활용
2. 다중 요청 방지
※ 추가 문제 ※
마무리