Sign In
블로그 ✍️

월간 ProductHunt - 2023년 9월

Kidow
벌써 월간 ProductHunt를 작성할 때가 왔군요. 이번 달은 시간이 정말 빨리 지나간 것 같아요.
이 블로그를 봐주시는 분들이 별로 많지는 않지만, 직장에서가 아닌 스스로 만들어낸 루틴을 꾸준히 지키는 것은 시간이 지나고 되돌아 보면 뿌듯하고 보람찬 것 같습니다.
(매달 블로그를 쓸 때마다 다른 주제를 쓰다 보니 점점 아이디어를 떠올리는 게 골이 아파 오네요 ㅎㅎ)
이번 월간 ProductHunt에서는 간단하게 프로그래밍적인 이야기를 다루어 볼까 합니다. 운영하고 있는 https://daily-producthunt.kidow.me를 어떻게 만들었는 지에 대한 이야기입니다. 뭐랄까, 어떻게 사람들에게 프로그래밍적으로 콘텐츠를 전달하는 지 궁금해 하는 분들이 계실 것 같아서요.

1. 웹 개발자

저는 웹 개발자입니다. 주로 프론트엔드를 다루는데요, Next.js를 주로 사용해서 개발을 하고 있습니다. 경력을 떠나서 개발자의 길을 걷게 된 건 23년 기준으로 5년 반이 지났네요.
처음 개발을 시작할 때는 풀스택을 지향했는데, 계속 공부를 하다 보니 한 쪽만 잘하기에도 엄청난 공부가 필요하더라구요. 저는 디자인에도 관심이 많은 편이라, 보이지 않는 뒷단을 정교하게 짜는 백엔드 대신 이쁜 웹사이트를 만들 수 있는 프론트엔드에 마음이 끌렸습니다.

2. 사용한 솔루션들

이 프로젝트는 저 혼자 만들고 운영 중인데요, 풀스택을 사용한 것은 아닙니다. 백엔드는 Supabase라는 솔루션을 사용해서 구축했는데요, Firebase처럼 DB와 인증, 스토리지 등을 직접 구축할 필요없이 제공해 줍니다. 일간 ProductHunt도 이 솔루션을 사용했습니다.
Supabase 메인 홈페이지
세상이 참 좋아져서인지 백엔드는 이렇게 솔루션으로 대체하고 프론트엔드만 빠르게 개발해도 프로젝트를 혼자 운영할 수가 있는 시대가 됐습니다. 프론트엔드를 선택한 게 참 다행이라는 생각이 든답니다.
호스팅은 그 유명한 Vercel을 사용했습니다. Vercel은 정말... 웹 개발계에서는 저에게는 애플과 같은 혁신 기업이 아닐까 생각이 듭니다. 처음 등장할 때부터 사용해왔는데 이전에 쓰던 Heroku같은 느린 서비스와 비교해보면 Vercel로 인해 절감한 시간이 얼마나 많을지 상상이 안됩니다. UI/UX도 너무 좋고 무료로도 웬만한 좋은 기능들은 다 제공해 줍니다.
일간 ProductHunt의 Vercel 대시보드

3. 대시보드는 어떻게 생겼을까?

모르는 분들이 더 많으실텐데 사실 대시보드 페이지가 이 프로젝트 안에 숨어있습니다. /login 페이지로 들어가면 로그인할 수 있는 화면이 나오는데요,
들어가보면 이렇게 아주 간단한 로그인 폼이 있습니다. Supabase가 제공하는 매직 링크 기능을 통해서 이메일 로그인을 할 수 있는데,
이렇게 이메일로 전달받은 인증 코드를 입력해서 대시보드에 들어갈 수 있습니다. 당연히 저만의 이메일로만 로그인할 수 있습니다 ㅎㅎㅎ (여러분들은 안되구요.)
그렇게 대시보드에 들어가면
보낼 메시지를 미리 예약할 수 있는 페이지를 볼 수 있습니다. 평일에 보낼 콘텐츠들을 이 곳에서 미리 작성해 둡니다.
💡
사실 서비스명이 일간 ProductHunt이지만, 매일매일 그날 소개된 제품을 큐레이션하지는 않습니다. 어떤 날은 흥미로운 제품들이 많은가 하면, 어떤 날은 재미없는 제품만 있어서 들쭉날쭉하거든요. 그냥 그날그날 추천을 많이 받으면서 제 기준에서 이해가능한 제품들을 위주로 소개하고 있습니다.
슬랙과 디스코드로 보낼 시 어떤 ui로 보이게 될 지 미리보기도 만들어 둔 모습입니다.
ProductHunt의 타임 트레블 페이지를 들어가보면 월/일자별로 그날 올라온 제품들을 한 눈에 볼 수 있는데요,
이 페이지에서 하나하나 들어가보면서 소개할 만한 제품들을 직접 찾아봅니다.
마지막으로 예약 폼의 화면입니다. 흥미로운 제품을 찾으면 이 화면에서 관련된 정보들을 적습니다. 예약을 누르면 전송할 데이터를 하나 생성합니다.
저는 보통 모든 제품들을 다 가입해보고 테스트하지는 않습니다. 대부분 가입이 필수인데다가, 너무 많은 가입을 하면 gmail이 마케팅 메일로 지저분해지기도 하고, 무료 버전이 없는 경우도 많거든요. 홈페이지에서 어떤 서비스인지 빠르게 파악하고 직접 정리하는 식으로 내용을 작성하고 있습니다.
저는 크롤러를 사용해서 내용을 긁어온다던지, AI를 활용해서 내용을 요약하거나 하는 일도 하지 않습니다. 제가 이 프로젝트를 시작한 건 세상의 다양한 IT 프로덕트들을 들여다 보면서 사업 영감을 받고 싶었기 때문이거든요. 저 스스로를 위해 시작한 프로젝트이니까요.

4. 콘텐츠를 전달하는 방법

보낼 내용을 저장하고 목록으로 나열하는 과정을 위에서 설명했는데요, 이제 콘텐츠를 전달하는 방법에 대해 소개할 때가 되었습니다. 위 사진들에서 목록 사진을 보시면 종이비행기 아이콘이 보이실텐데요,
저 아이콘을 누르면 여러분들에게 콘텐츠를 보내는 로직이 실행됩니다.
Next.js에서는 /api라는 경로를 통해 자체 api를 구축할 수 있습니다. 콘텐츠를 보내는 경로는 /api/send-message인데요, 코드를 하나하나 뜯어서 최대한 간단하게 소개해보겠습니다.
import { NextResponse } from 'next/server' import { createRouteHandlerClient } from '@supabase/auth-helpers-nextjs' import { cookies } from 'next/headers' import { Client } from '@notionhq/client' import TelegramBot from 'node-telegram-bot-api' export function POST(req: Request) { const { id } = await req.json() const supabase = createRouteHandlerClient<Database>({ cookies }) const [{ data }, { data: users }] = await Promise.all([ supabase.from('reserves').select('*').eq('id', id).single(), supabase.from('connections').select('*') ]) }
미리 예약해둔 콘텐츠의 id를 통해 콘텐츠 정보들을 reserves라는 테이블을 쿼리하여 가져옵니다. Promise.all을 사용하여 동시에 구독자 목록을 connections 테이블을 쿼리하여 가져옵니다.
const { data: page } = await supabase .from('histories') .insert({ url: data.url, icon_url: data.icon_url, cover_url: data.cover_url, name: data.name, title: data.title, intro: data.intro, core: data.core, platform: data.platform, pricing: data.pricing, tags: data.tags }) .select('id') .single()
먼저 histories 테이블에 새 데이터를 생성합니다. histories 테이블은 전송 목록으로, 홈 화면과 상세 화면에서 사용되는 데이터를 저장합니다.
생성된 데이터의 id를 단일 데이터로 쿼리합니다. 이제 유저들에게 콘텐츠를 전달할 때입니다.
const result = await Promise.allSettled([ ...users.map((item) => { // 슬랙 if (item.slack_webhook_url) { return fetch(item.slack_webhook_url, { method: 'POST', headers: new Headers({ 'Content-Type': 'application/json' }), body: JSON.stringify({ text: `${data.name} - ${data.title}`, blocks: [ { type: 'section', text: { type: 'mrkdwn', text: `*<https://daily-producthunt.kidow.me/l?id=${page?.id}|${data.name} - ${data.title}>*` } }, { type: 'section', text: { type: 'mrkdwn', text: `• ${data.intro}\n• ${data.core}\n• ${data.platform}\n• ${data.pricing}` }, accessory: { type: 'image', image_url: data.icon_url, alt_text: data.name } }, { type: 'section', text: { type: 'mrkdwn', text: data.tags.map((text) => '`' + text + '`').join(' ') } }, { type: 'image', image_url: data.cover_url, alt_text: 'Cover image' } ] }) }) } // 노션 if (!!item.notion_database_id && !!item.notion_token) { const notion = new Client({ auth: item.notion_token }) return notion.pages.create({ parent: { type: 'database_id', database_id: item.notion_database_id! }, icon: { type: 'external', external: { url: data.icon_url } }, cover: { type: 'external', external: { url: data.cover_url } }, properties: { 이름: { title: [ { text: { content: data.name }, annotations: { bold: true } } ] }, 타이틀: { rich_text: [ { text: { content: data.title }, annotations: { bold: true } } ] }, 태그: { multi_select: data.tags.map((text) => ({ name: text })) }, URL: { url: data.url }, '한 줄 소개': { rich_text: [{ text: { content: data.intro } }] }, '핵심 기능': { rich_text: [{ text: { content: data.core } }] }, '지원 플랫폼': { rich_text: [{ text: { content: data.platform } }] }, '가격 정책': { rich_text: [{ text: { content: data.pricing } }] } }, children: [ { object: 'block', type: 'image', image: { type: 'external', external: { url: data.cover_url } } } ] }) } // 디스코드 if (!!item.discord_webhook_url) { return fetch(item.discord_webhook_url!, { method: 'POST', headers: new Headers({ 'Content-Type': 'application/json' }), body: JSON.stringify({ username: '일간 ProductHunt', avatar_url: `${process.env.NEXT_PUBLIC_SUPABASE_URL}/storage/v1/object/public/publics/daily-producthunt.png`, embeds: [ { description: `- ${data.intro}\n- ${data.core}\n- ${data.platform}\n- ${data.pricing}`, image: { url: data.cover_url }, thumbnail: { url: data.icon_url }, title: `${data.name} - ${data.title}`, url: `https://daily-produchunt.kidow.me/l?id=${page?.id}` } ] }) }) } // 텔레그램 if (!!item.telegram_chatting_id) { const bot = new TelegramBot( process.env.NEXT_PUBLIC_TELEGRAM_BOT_TOKEN ) return bot.sendMessage( item.telegram_chatting_id!, `**[${data.name} - ${data.title}](https://daily-producthunt.kidow.me/l?id=${page?.id})**\n- ${data.intro}\n- ${data.core}\n- ${data.platform}\n- ${data.pricing}`, { parse_mode: 'Markdown' } ) } if (!!item.jandi_webhook_url) { return fetch(item.jandi_webhook_url, { method: 'POST', headers: new Headers({ Accept: 'application/vnd.tosslab.jandi-v2+json', 'Content-Type': 'application/json' }), body: JSON.stringify({ body: `**[일간 ProductHunt]** ${data.name} - ${data.title}`, connectColor: '#da552f', connectInfo: [ { title: `[${data.name} - ${data.title}](https://daily-producthunt.kidow.me/l?id=${page?.id})`, description: `- ${data.intro}\n- ${data.core}\n- ${data.platform}\n- ${data.pricing}` } ] }) }) } return undefined }), // 티스토리 fetch('https://www.tistory.com/apis/post/write', { method: 'POST', headers: new Headers({ 'Content-Type': 'application/json' }), body: JSON.stringify({ access_token: process.env.NEXT_PUBLIC_TISTORY_ACCESS_TOKEN, output: 'json', blogName: 'wcgo2ling', title: `${data.name} - ${data.title}`, category: 1113722, visibility: 3, tag: data.tags.join(','), content: `<p><figure class='imageblock alignCenter' data-origin-width='1304' data-origin-height='750' data-ke-mobilestyle='alignCenter'><span data-url='${ data.cover_url }' data-lightbox='lightbox' data-alt=''><img src='${ data.cover_url }' srcset='${data.cover_url}' data-filename='${ data.name }.png' data-ke-mobilestyle='alignCenter'/></span></figure></p>\n<ul style=\"list-style-type: disc;\" data-ke-list-type=\"disc\">\n<li>${ data.intro }</li>\n<li>${data.core}</li>\n<li>${data.platform}</li>\n<li>${ data.pricing }</li>\n</ul>\n<figure id=\"og_1687252633313\" contenteditable=\"false\" data-ke-type=\"opengraph\" data-ke-align=\"alignCenter\" data-og-type=\"website\" data-og-title=\"${ data.name } - ${data.title}\" data-og-description=\"${ data.intro }\" data-og-host=\"${ new URL(data.url).origin }\" data-og-source-url=\"${data.url}\" data-og-url=\"${ data.url }\" data-og-image=\"${data.icon_url}\"><a href=\"${ data.url }\" target=\"_blank\" rel=\"noopener\" data-source-url=\"${ data.url }\">\n<div class=\"og-image\" style=\"background-image: url('${ data.icon_url }');\">&nbsp;</div>\n<div class=\"og-text\">\n<p class=\"og-title\" data-ke-size=\"size16\">${ data.name } - ${ data.title }</p>\n<p class=\"og-desc\" data-ke-size=\"size16\">${ data.intro }</p>\n<p class=\"og-host\" data-ke-size=\"size16\">${ new URL(data.url).host }</p>\n</div>\n</a></figure>\n<figure id=\"og_1687252745800\" contenteditable=\"false\" data-ke-type=\"opengraph\" data-ke-align=\"alignCenter\" data-og-type=\"website\" data-og-title=\"일간 ProductHunt\" data-og-description=\"일간 ProductHunt는 ProductHunt에 올라오는 상위 5개 제품을 요약해서 슬랙, 디스코드를 통해 메시지를 전달하고 노션을 통해 전송 내역을 저장해줍니다. ProductHunt는 전 세계 450만 명 이상의 IT 메이커\" data-og-host=\"slashpage.com\" data-og-source-url=\"https://slashpage.com/daily-producthunt\" data-og-url=\"https://slashpage.com/daily-producthunt\" data-og-image=\"https://scrap.kakaocdn.net/dn/6g056/hyS2xRSxbO/gxT6rt6y5pWTeT3V7bFkj0/img.jpg?width=512&amp;height=512&amp;face=0_0_512_512,https://scrap.kakaocdn.net/dn/W8hKM/hyS4pLqVGi/YQVketpQssqDqN4beZrmYk/img.jpg?width=512&amp;height=512&amp;face=0_0_512_512\"><a href=\"https://slashpage.com/daily-producthunt\" target=\"_blank\" rel=\"noopener\" data-source-url=\"https://slashpage.com/daily-producthunt\">\n<div class=\"og-image\" style=\"background-image: url('https://scrap.kakaocdn.net/dn/6g056/hyS2xRSxbO/gxT6rt6y5pWTeT3V7bFkj0/img.jpg?width=512&amp;height=512&amp;face=0_0_512_512,https://scrap.kakaocdn.net/dn/W8hKM/hyS4pLqVGi/YQVketpQssqDqN4beZrmYk/img.jpg?width=512&amp;height=512&amp;face=0_0_512_512');\">&nbsp;</div>\n<div class=\"og-text\">\n<p class=\"og-title\" data-ke-size=\"size16\">일간 ProductHunt</p>\n<p class=\"og-desc\" data-ke-size=\"size16\">일간 ProductHunt는 ProductHunt에 올라오는 상위 5개 제품을 요약해서 슬랙, 디스코드를 통해 메시지를 전달하고 노션을 통해 전송 내역을 저장해줍니다. ProductHunt는 전 세계 450만 명 이상의 IT 메이커</p>\n<p class=\"og-host\" data-ke-size=\"size16\">slashpage.com</p>\n</div>\n</a></figure>` }) }) ])
connections라는 테이블은 다음과 같은 칼럼들을 가지고 있습니다. 각각의 외부 서비스를 통해 보내기 위한 키들을 가리키죠.
칼럼명
용도
slack_webhook_url
Slack 웹훅 url
notion_token
개인화된 Notion 토큰
notion_database_id
복제한 데이터베이스의 id
discord_webhook_url
Discord 웹훅 url
telegram_chatting_id
Telegram 봇 id
jandi_webhook_url
잔디 웹훅 url
한 열의 하나의 외부 서비스 키가 할당되는 구조입니다. 예를 들어 slack에 연결했다면 slack_webhook_url만을 저장한 데이터가 한 줄 생성되는 것이죠. 이렇게 하면 Promise.allSettled와 map 메소드로 한 번에 모든 유저에게 동시에 메시지를 전송할 수 있게 됩니다.
티스토리의 경우는 궁금해서 한 번 만들어봤습니다. 콘텐츠를 전달할 때마다 제 티스토리에도 글이 같이 올라옵니다.

마무리

개발자지만 프로그래밍을 일반인들도 알 수 있을 정도로 설명해본 적이 없어서 여러분들에게 내용 전달이 잘 됐을지 모르겠네요. 어떻게 보면 개발이란게 아직도 배워야할 게 산더미인, 한숨이 나오는 여정을 걷는 과정의 연속이지만 사람들에게 가치를 제공하는 서비스를 만드는 것은 생각보다 복잡한 로직이나 설계가 필요하지는 않은 것 같습니다. 지금까지의 개발자 여정을 돌아보면 이 서비스를 만든 건 제 입장에서 별로 어렵지 않았거든요. 그냥 지금까지 배워왔던 것들을 이용해 만들었으니까요.
은근히 많은 개발자들이 가치를 창출하는 프로덕을 만들기 위해선 개발의 초고수가 되어야 한다고 생각하는 경우가 있는데, 중요한 것은 간단한 투두리스트라 할지라도 작게나마 시작해보는 것이 아닐까요? 🤗
Subscribe to 'daily-producthunt'
Subscribe to my site to be the first to receive notifications and emails about the latest updates, including new posts.
Join Slashpage and subscribe to 'daily-producthunt'!
Subscribe
👍