SA
메인 내용으로 이동
잠깐!
  • 이 글이 작성된지 3년 이상 지났습니다.
  • 새로운 일들이 일어나기 충분한 시간입니다.
  • 저는 이 글에 더 이상 동의하지 않을지도 모릅니다.
Google에서 새로운 자료 찾아보기

Meme 추천 시스템 개발기
🧑🏻‍🍳

집계 중...

최근 경쟁률이 매우 높은 (합격률 5% 내외) 교내 기술창업 동아리를 지원하는데 다음과 같은 질문이 있었습니다.

여기서 meme이란 '웃음을 유발하는 짧은 동영상'과 같은 뜻입니다.

여기서 meme이란 '웃음을 유발하는 짧은 동영상'과 같은 뜻입니다.

반드시 들어가고 싶었던 동아리였기에 많은 고민이 들었습니다. 사람마다 관심사와 웃음 코드가 다르며 특정 그룹에게는 웃긴 내용이 다른 그룹에게는 불쾌한 내용으로 다가갈 수 있기 때문입니다.

그러다 "차라리 선택에 기반해 meme을 추천해주는 서비스를 만들면 어떨까?"하는 생각이 들었습니다. 반응형 추천 시스템(새로 누적되는 데이터에 기반해 추천 내용이 동적으로 변하는 시스템)이 아니기 때문에 기술적인 복잡도도 그렇게 높지 않을 것 같았습니다. 마침 주말이 있었기 때문에 빠르게 만들어보기로 결정했습니다.

🎬 시스템 기획하기

우선 제가 재밌게 본 동영상들을 리스트업했습니다. (틱톡, 트위터, 인스타그램 대신 유튜브를 사용해도 상관 없을 것이라 판단했습니다.)

단톡방에서 youtu 키워드로 검색해서 제가 웃기다고 생각한 동영상들을 쉽게 찾을 수 있었습니다.

단톡방에서 youtu 키워드로 검색해서 제가 웃기다고 생각한 동영상들을 쉽게 찾을 수 있었습니다.

이후 Notion 페이지에서 카테고리별로 분류했습니다. 음악, 영화, 게임, 코딩, 일반적 Meme으로 나눌 수 있었습니다.

선택 기반 추천 테스트를 구상하다 보니 2가지 접근 방법이 있는 것 같았습니다. 하나는 질문의 답변마다 가중치를 주고, 최종 점수를 계산하여 결과를 추천해주는 시스템입니다. 나머지 하나는 전체적인 시나리오 트리를 설정해두고 선택지의 조합에 따라 결과를 추천해주는 것입니다. 요즘 유행하는 MBTI 결과물들을 분석해본 결과 대개 첫 번째 점수 기반 추천 시스템을 사용했습니다. 저는 두 번째 시나리오 트리 기반 추천 시스템을 사용했습니다. 이유는 다음과 같습니다.

  • 점수 플래그 시스템을 구성하기 너무 복잡했습니다.
    • MBTI는 점수 플래그가 단순합니다. E/I, N/S, T/F, J/P 이렇게 4개의 플래그만 존재하기 때문에 점수 상태관리를 하기 비교적 편리합니다.
    • 저는 당장 음악, 영화, 게임, 코딩, 일반 이렇게 5개의 카테고리가 존재했고 각 카테고리마다 다양한 하위 카테고리가 존재했기 때문에 플래그를 잡기가 애매했습니다.
    • 예를 들어 단순히 게임 플래그 점수가 높다고 해서 무작정 롤 Meme 영상을 추천해줄 수는 없습니다. 롤의 규칙을 모르거나 그 웃음대에 공감하지 못할 수 있기 때문입니다. 즉 이 문제를 해결하려면 롤 점수 플래그를 추가하거나 "제일 좋아하는 게임" 플래그를 따로 지정해두어야 합니다.
    • 이 경우 상태 관리가 무척 복잡해지며 그렇게까지 기술적 난이도를 높이고 싶지 않았습니다.
    • 하지만 높아지는 기술적 난이도보다 더 올라가는 것은 기획적 난이도입니다. 무엇보다도 영상이 플래그마다 어느 점수 영역대에 추천되어야 하는지 정교하게 기획하기 매우 어렵다고 느꼈습니다. 즉, 점수 기반으로는 '취향저격'하기가 어렵습니다.
  • 모든 엔딩 보기가 가능하게 만들고 싶었습니다.
    • 선택 기반 게임에서는 마지막 결정 하나만 변경해서 다른 엔딩을 보고 싶을 수 있습니다 (특히 단순한 MBTI가 아닌 이런 Meme 추천은 더욱이 그럴 수 있습니다).
    • 하지만 점수 기반 시스템은 대개 테스트를 처음부터 다시 시작해야하며 선택 undo 액션을 추가하려면 더 많은 엔지니어링이 투입되어야 합니다.
    • 시나리오 트리 기반을 활용한다면 이 부분이 더 편리해집니다. Parent Node로 이동하면 되기 때문입니다.
    • 후술되겠지만, 저의 경우 Next Link를 활용했기 때문에 브라우저에서 뒤로 가는 것만으로 액션 undo가 됩니다.
  • 일반적인 선택지 어휘가 아닌 큐레이팅된 선택지 어휘를 넣고 싶었습니다.
    • 점수 기반 시스템에서는 굉장히 일반적인 형태의 질문과 답변만 하게 됩니다. 즉 follow-up 질문을 하지 못합니다.
    • 저는 시나리오 트리를 활용해 질문과 답변이 서로 정확하게 들어맞도록 만들어 저와 대화하는 듯한 경험을 주기 위해 노력했습니다.
    • 또한 결과적으로 이 시스템은 "동아리 지원서에 첨부될" 목적입니다.
    • 아무리 재미있는 영상을 추천해준다고 하더라도 제 이름을 기억하지 못하고 영상만 기억하게 된다면 본말이 전도된 것입니다.

그 과정에서 나는 이 동아리에 꼭 들어가고 싶다!!!라는 느낌을 뿜뿜하게 주고 싶었습니다.

그 과정에서 나는 이 동아리에 꼭 들어가고 싶다!!!라는 느낌을 뿜뿜하게 주고 싶었습니다.

🥞 기술 스택 결정하기

프론트엔드에 있어서는 별다른 고민을 하지 않았습니다. 최근 TypeScript Next와 사랑에 빠졌기 때문에 자연스럽게 선택하게 되었고 Vercel과 Next의 호환성을 알기에 Vercel에 호스팅하기로 결정했습니다. 스타일은 styled-component를 사용했습니다.

데이터를 어디에 저장할지에 대한 부분이 문제였습니다. Meme에 대한 데이터는 동적인 데이터가 아니고, 유저 정보는 저장할 일이 없다고 판단했기에 DB 또는 DBaaS를 따로 사용하는 대신 모든 데이터를 모듈화하여 하드코딩하기로 결정했습니다. 여기에서 하드코딩된 정보들을 보실 수 있습니다.

백엔드 또한 마찬가지로 구성할 필요가 없었습니다. 서버리스한 형태로 만들기로 했습니다.

💻 개발하기

다음과 같이 요약할 수 있습니다.

  1. 질문은 질문마다, 동영상은 동영상마다 고유 링크를 가지며, 선택지를 고르면 그 링크로 접속하는 것입니다.
  2. 각 선택지는 '다음 질문' 또는 '결과 동영상' 필드를 가진 Object 형태이며 이를 기반으로 인터페이스를 구성합니다.
  3. getStaticPropsgetStaticPaths를 사용해 반응 속도를 초고속으로 만듭니다.

🔗 1. 고유 링크 구조

각 질문과 동영상은 다음과 같은 URI 구조를 가집니다.

https://smile.cho.sh/question/[id]
https://smile.cho.sh/video/[id]

예시:

💬 2. 타입 구조

TypeScript의 장점을 활용하여 type 구조를 미리 정의했습니다.

export type Question = {
id: number
contents: string[]
answers: Answer[]
}

export type Answer = {
id: number
content: string
result: number | null
nextQuestion: number | null
}

export type Video = {
id: number
title: string
uploader: string
desc: string
youtubeId: string
}

type Answer에서 resultnextQuestion은 둘 중 한 쪽만 값을 가질 수 있습니다. 이를 바탕으로 링크를 생성합니다. 이렇게 2가지로 별도의 필드를 통해 questionvideo를 혼동하는 실수를 방지할 수 있었습니다. 또한 데이터를 작성할 때 기본값을 0으로 두어 의도치 않은 null 오류를 방지하고자 했습니다. 그 흔적은 /question/0에서 확인하실 수 있습니다.

🚀 3. 초고속화

예를 들어 /question/[id]에 해당하는 페이지들은 다음 코드를 통해 빌드 타임에 정적으로 생성됩니다.

export const getStaticPaths: GetStaticPaths = async () => {
const paths = questionData.map((question) => ({
params: { id: question.id.toString() },
}))
return { paths, fallback: false }
}

export const getStaticProps: GetStaticProps = async ({ params }) => {
try {
const id = params?.id
const item = questionData.find((data) => data.id === Number(id))
return { props: { item } }
} catch (err) {
return { props: { errors: err.message } }
}
}

여기서 getStaticPaths는 정적으로 생성되어야 할 페이지들의 path들의 리스트를 정해주며, getStaticPropspath와 일치하는 질문 데이터를 검색해서 React App에 props 형태로 전달합니다. 이를 통해 모든 질문과 동영상 페이지를 정적으로 미리 생성해둘 수 있습니다. 더 나아가 next/link<Link>까지 조합하여 활용한다면 페이지들을 prefetch해올 수 있기 때문에 인터랙션을 초고속으로 만들 수 있습니다. (말 그대로, 브라우저 패비콘에서 로딩 도는 것도 보이지 않습니다!)

💅 4. 스타일링 및 디테일 잡기

다시 말해서 인트로 페이지와 엔딩 페이지를 만들고, 빼먹은 디테일을 추가하는 일입니다. 예를 들어 특수한 경우를 위해 다른 형태의 View를 처리하는 작업을 했습니다. 사용자가 모든 질문에 대해서 잘 모른다고 답할 경우 다음과 같은 결과를 보여줍니다. 다른 뷰들은 동영상을 바로 embed하는데 반해 이 경우에만 버튼의 형태로 보여주었습니다.

Fallback Video

무슨 영상인지는 직접 확인해보세요!

✨ 결과

  • smile.cho.sh
  • 직접 사용해보시고 의견을 알려주세요!
  • 동아리에 최종 합격했습니다!

🔥 회고

  • 기획적 난이도와 기술적 난이도 사이에서 적당히 밸런스를 잘 맞춘 것 같습니다.
  • ES6+map 함수를 정말 자주, 잘 사용한 것 같습니다!
  • Static TypeScript Next를 활용하는 방법에 대해서 잘 알게 된 것 같습니다.
  • Favicon, Metadata, SEO 등에 신경을 쓰지 못한 것은 조금 아쉽지만 검색이나 SNS 유입이 필요하지 않아 따로 추가하지는 않을 것 같습니다.
  • 주말 동안 갈아넣으니까 완성되긴 하는구나...라는 생각을 했습니다 😉