URL Shortener - 프로젝트 시작
쇼트너 만들기
우선 일경험 인턴으로 두달간 코드잇에서 인턴으로 있게 되었다. 이렇게라도 실무와 비슷한 환경에서 업무를 해볼수 있다는 점이 참 다행이라는 생각이 든다. 우선 한달이라는 시간이 지났다. 지금까지는 기존에 사용하던 백오피스 프로그램의 버그를 픽스하고, 기능 확장을 하는 방식의 업무가 대부분이었다. 이제부터는 단순히 확장 기능이 아닌 새로운 기능을 만드는 과정이다. 아마 남은 기간동안은 url shortener를 만들게 될 것이다.
url shortener로 가장 유명한 서비스는 bitly이다. 서비스 기능은 간단하다. 긴 url을 짧은 url로 변환해주고 변환된 url로 접속해도 원래 url로 접속되는 기능이다.
변환한 url이다. 지금 이 블로그 주소는 짧아서 길이가 줄어든게 체감안되겠지만 url주소가 길어질수록 체감이 많이 된다. 특히 현재 회사에서 사용하는 url링크들은 utm을 통해 특정 페이지에 들어온 사람이 어디서 어떤 경로로 왔는지 수집한다. 그래서 각 상황에 따라 여러 utm이 추가되고 그로 인해 url이 길어지는 현상이 발생한다.
그래서 우리 인턴들이 이 백오피스 서비스를 만들고자 한다. 물론 전부 프론트엔드이지만 가능하다.
프로젝트 초기 세팅
초기 세팅이라고 할것도 없다. 원래는 다른 도메인을 세팅해서 별도의 서비스를 운영하는 것이 목표였지만 인턴 기간이 짧기 때문에 초기 세팅에 많은 시간을 할애할수 없다. 그래서 기존에 사용하던 백오피스 서비스에 추가적으로 지원하는 기능으로 구현하기로 했다.
간단하게 백오피스 서비스 스펙이나 구조를 설명하자면
- BackEnd : Amplify Gen2, DynamoDB, NextAuth.js, Google Calendar API
- FrontEnd : Next.JS 13(Page router), TypeScript, TanStack Query, Jotai, Tailwind CSS
이런 구조로 되어있다.
URL Shortener 핵심 로직 구상
가장 중요한 것은 url을 어떻게 줄일 것인가에 대한 고민이다. 짧은 문자열을 만드는 방법으로 우리는 Base62를 사용하기로 결정했다. url을 줄이는데 있어서 핵심적인 부분은 다양한 url을 함축시켰을때 중복되지 않고 유일한 값인지에 대한 것이다. 단순히 줄이는 것뿐만 아니라 중복되지 않는 문자로 표현하는 것이 핵심이다. 10진수는 0 ~ 9, 16진수는 0 ~ 9, A ~ F를 사용해서 숫자를 표현한다. 표현하는 문자가 많아질수록 한 단위에 더 많은 숫자를 표현 가능하다는 것이다.
- 1000을 10진수로 표현하면 1000
- 1000을 16진수로 표현하면 3e8
Base62라는 것은 62진수로 표현한다는 것이다. 그렇다면 앞의 10진수나 16진수보다 더 큰 숫자를 작은 단위로 표현이 가능하다는 것이다. 그래서 10진수 1000을 62진수로 변환하면 g8이 나온다. 확연하게 줄어들었다.
- 62진수인 이유 : 기존에는 문자를 변환하는 방법으로 ASCII(아스키코드)가 사용되었다. 하지만 네트워크에서 6bit를 요구하면서 7bit인 ASCII가 부적절했고, 그 대신 채택된 방법이 64진법이다. 하지만 64진법에서
=은 패딩을 표현하는데 URL에서=은 예약어이기 때문에 문제가 발생한다. 그래서 패딩 문자가 없는 62진법이 등장한 것이다.
62진법을 통해 7자리 문자를 만들었을때 ZZZZZZZ이고, 이 값을 10진법으로 변환하면 약 3조 5천억정도가 된다. 즉, 사실상 중복없이 제한 없이 생성이 가능하다는 것이다.
const BASE62_CHARS = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"; export function toBase62(num: number): string { let base62 = ""; let tempNum = num; while (tempNum > 0) { base62 = BASE62_CHARS[tempNum % 62] + base62; tempNum = Math.floor(tempNum / 62); } return base62 || "0"; }
그렇게 구현된 숫자를 Base62로 변환하는 로직이다. 숫자를 62로 나눈 나머지 값이 62진수 문자열의 인덱스가 되기 때문에 해당하는 문자를 넣고, 숫자를 62로 나눈 값을 반복해서 전체 숫자를 62진수로 변환하는 것이다.
이제 고민해야할 것은 어떤 값을 사용할 것인지다. url은 숫자로 이뤄진 것이 아니라 문자로 되어있다. 그래서 우리는 약간의 꼼수를 사용했다. url이 입력된 시점을 사용하는 것이다.
Date.now() + Math.floor(Math.random() * 1000)
이렇게하면 현재 시간에 랜덤한 숫자를 더해서 중복되지 않는 숫자를 생성해줄 수 있다. 사실상 중복되지 않는 짧은 ID를 생성하는 것과 같다고 볼수 있다.
URL 저장 및 중복 검사
이제 남은 단계는 짧은 URL로 접근시 원본 URL로 접근되어야한다. 그래서 원본 URL과 단축 URL을 저장해줘야한다.
Shortener: a .model({ transferUrl: a.string().required(), originUrl: a.string().required(), }) .authorization((allow) => [ allow.authenticated().to(["read", "create", "update", "delete"]), allow.groups(["ADMIN", "MEMBER"]), allow.guest(), ]),
그래서 기존의 db테이블에 새로운 모델을 추가해줬다. 간단하게 변경된 URL과 원본 URL만 표시되는 모델이다. 여기에서 id, createdAt, updatedAt은 자동으로 삽입되어서 명시해줄 필요가 없다. 밑부분은 정책관련한 옵션으로 CRUD, 접근 가능한 그룹을 정의해놨다.
이제 db에 없는 url은 db에 새롭게 저장하고, 이미 생성된 데이터가 있다면 다시 생성하지 않도록 만들어줘야한다.
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 base62Url = toBase62(Date.now() + Math.floor(Math.random() * 1000)); const createData = await client.models.Shortener.create({ originUrl: url, transferUrl: base62Url, }); return createData.data!.transferUrl; };
해당 로직은 우선 간단하게 만들어봤다. url이 들어오면 db에서 originUrl과 완전히 일치하는지(filter, eq) 확인한다. 일치하는 데이터가 있다면 그대로 변환된 데이터를 리턴하고, 없다면 위에서 만든 Base62인코딩 함수를 통해 짧은 id를 생성해서 새로운 데이터를 생성한다.
id가 자동으로 생성되는데 왜 짧은 id을 만들었나? 자동으로 생성되는 id는 uuid방식이다. uuid로 생성된 id는 상당히 길다. 짧은 문자열과는 거리가 있기 때문에 base62를 통해서 짧은 문자열을 생성해줬다.
URL 입력 및 구분 로직
우리가 받은 요청사항중 구글 스프레드 시트를 복사해서 붙여넣는 경우가 많을 것이라고 들었다. 그래서 url을 한개만 추가하는 인풋이 아니라 textarea를 사용해야 했다. 구글 스프레드 시트를 복사해서 넣게되면 각 셀은 줄바꿈으로 복사가 된다. 즉, 각 URL을 구분하는 방법으로 줄바꿈을 인식해야한다.
const urls = textareaValue.trim().split(/\r?\n/);
그래서 textarea의 value를 가져와서 정규식으로 구분했다. /\r?\n/은 모든 종류의 줄바꿈을 매칭하는 정규식이다. 그래서 요청 사항의 구글 스프레드 시트의 복사 붙여넣기를 통해 각 url을 구분할 수 있게 되었다. 추가적으로 해당 url의 형식이 올바른지 확인해봐야한다.
const isValidUrl = (url: string): boolean => { try { // eslint-disable-next-line no-new new URL(url); return true; } catch { return false; } };
new URL에 문자열을 넣었을때, 해당 문자열이 url형식이 아니면 타입에러가 발생한다. 간편하게 url 형식검사까지 마친 상태이다. 이제 지금까지 만든 로직을 하나로 합쳐보겠다.
const handleTransform = async () => { if (textareaValue.trim()) { const urls = textareaValue.trim().split(/\r?\n/); const invalidUrls = urls.filter((url) => !isValidUrl(url)); if (invalidUrls.length > 0) { error("유효하지 않은 URL이 있습니다"); // toast 에러 return; } const transformedUrls = await Promise.all( urls.map(async (url) => { const result = await checkUrl(url); return { id: result, original: url, shortened: `/code.it/${result}`, createdAt: new Date().toLocaleDateString(), }; }), ); setUrlList((prev) => [...prev, ...transformedUrls]); setTextareaValue(""); success("URL이 성공적으로 변환되었습니다!"); // toast 성공 } };
우선 textarea로 받은 url을 분리해서 형식 검사를 한다. 한개라도 형식이 올바르지 않으면 에러를 발생시킨다. 만약 전부 형식이 올바르면 Promise.all을 통해서 모든 url에 중복 및 단축 url 생성 작업을 한다. checkUrl로직의 결과는 단축 url만 리턴되기 때문에 기존 url과 단축 url을 가지고 배열을 만들어서 state값으로 변경해준다.
아래 test로 변경된 것은 데이터 생성이 정상적으로 이뤄지는지 전에 확인해본 것이다. 즉, 기존에 생성된 url이 있으면 기존 단축 url을 리턴하고, 없다면 base62로 생성된 id로 데이터를 생성한다는 것이다.
URL 리다이렉트
짧게 변환만 해서 성공한 것이 아니다. 이제 짧은 URL로 진입하면 원본 URL로 리다이렉트 시켜줘야한다. 처음에는 백엔드를 건드려서 해야하나 근심이 많았다. 하지만 프로젝트가 Next.JS로 만들어졌기 때문에 이를 활용하면 좋겠다는 생각이 들었다. Next.JS는 next서버를 통해서 페이지 소스를 받기 때문에 미들웨어 사용이 가능하다.
- 미들웨어 : 요청와 응답 사이에 실행되는 함수로, 서버 측에서 요청을 가로채고 처리할 수 있는 기능을 제공한다. 이를 통해 리다이렉트, 리라이트, 응답 헤더 추가, 인증 처리 등 다양한 작업을 수행할 수 있다. 즉, 필터와 같은 역할이다.
그래서 특정 route로 진입하게 되면 예외 처리를 할 수 있다는 것이다.
// middleware if (pathname.startsWith("/code.it")) { const shortUrl = pathname.split("/code.it/")[1]; // /short 뒤의 ID 추출 if (!shortUrl) { return NextResponse.redirect( new URL("https://www.codeit.kr/404", request.url), ); } const matchedPath = await client.models.Shortener.list({ filter: { transferUrl: { eq: shortUrl } }, }); if (matchedPath.data.length > 0) { // 해당 ID에 매칭되는 origin으로 리다이렉트 return NextResponse.redirect(matchedPath.data[0].originUrl); } // 매칭되는 ID가 없으면 404 페이지로 return NextResponse.redirect( new URL("https://www.codeit.kr/404", request.url), ); }
우선 우리는 /code.it으로 시작하는 경로를 예외처리 해줄 것이다. pathname에서 앞의 공통 경로를 제외한 단축 url을 분리한다. 그리고 이 분리된 단축 url을 활용해서 db에서 해당 단축 url에 해당하는 원본 url이 있는지 확인하고, 있다면 원본 url로 리다이렉트, 없다면 404페이지로 리다이렉트 시켜준다.
현재 사용하는 백오피스 도메인을 그대로 사용하지만 일반 사용자는 접근할수가 없다. 미들웨어를 통해 서버에 요청을 보내기전에 처리를 하기 때문에 동일한 도메인이지만 구분되어서 백오피스 서비스가 노출될 우려가 없다고 보면 된다.
마무리
우선 가장 핵심적인 부분들은 구현되었다. 하지만 아직 대략적인 기능만 구현해둔 상태라서 예외 상황이라던지 사용자의 동작을 예상해서 구현하지는 못했다. 계속 테스트하고 디벨롭시켜야겠다.
지금 아쉬운것은 만약 500개의 url을 변환할때는 500개 전부 중복 및 생성 절차를 거쳐야하고, Promise.all로 처리하면 모든 요청이 네트워크탭에 확인될 것이다. 그래서 이 로직을 next API로 변경해줘서 한번의 요청으로 처리하고 싶다. 아무래도 이 부분은 속도가 관건이 될것이다.
개의 댓글
1
쇼트너 만들기
프로젝트 초기 세팅
URL Shortener 핵심 로직 구상
URL 저장 및 중복 검사
URL 입력 및 구분 로직
URL 리다이렉트
마무리