데이터 유출은 인터넷에서 가장 심각한 문제 중 하나입니다. Statista에 따르면 2022년 한 해에만 4억 명 이상이 데이터 유출 피해를 입었습니다. 누구도 자신의 개인 정보가 인터넷에 무분별하게 노출되기를 원하지 않기 때문에, 사용자 프라이버시를 존중하는 보안성 높은 애플리케이션을 구축하는 것은 수많은 산업 분야에서 필수적인 과제가 되었습니다.
데이터 유출에 대응하는 효과적인 방법 중 하나는 문제가 될 수 있는 데이터를 다른 서비스나 사용자에게 전달하기 전에 필터링하는 것입니다. 이 방식이 제대로 작동하려면 두 가지 핵심 요건이 필요합니다. 첫째, 데이터가 필터를 빠져나가지 않도록 항상 최신 상태를 유지해야 하고, 둘째, 필터링 과정이 성능에 미치는 영향을 최소화할 수 있도록 저지연(low latency) 인프라를 갖춰야 합니다.
Upstash for Redis®와 Vercel Edge Functions는 바로 이 두 가지 요건을 동시에 충족하며 문제를 해결할 수 있는 강력한 조합입니다. 두 서비스 모두 저지연 서버리스 솔루션으로, 사용자와 지리적으로 가까운 위치에 배포할 수 있습니다. 여기에 Vercel의 새로운 cron 기능을 더하면 필터 데이터를 주기적으로 자동 갱신할 수도 있습니다.
이 글에서는 이러한 필터가 실제로 어떻게 작동하는지 보여주기 위해, 서버리스 엣지 기술을 활용해 비속어를 필터링하는 프론트엔드와 백엔드를 직접 만들어 보겠습니다.
주요 기능
이 애플리케이션은 Vercel의 cron 기능을 사용하여 원격 API에서 가져온 최신 단어 목록으로 Upstash for Redis 데이터베이스를 업데이트합니다.
데이터를 조회하는 방법은 세 가지입니다:
- 필터링된 텍스트가 포함된 웹페이지 반환
- 데이터 저장소에서 필터링된 텍스트를 JSON으로 반환
- 사용자가 보낸 텍스트를 받아 필터링 후 JSON으로 반환
사용 기술
애플리케이션은 Next.js로 개발하고 Vercel에 배포합니다. 이렇게 하면 엣지 함수(Edge Functions)를 사용할 때 매끄러운 서버리스 개발 경험을 얻을 수 있습니다.
데이터 저장소는 낮은 지연 시간과 뛰어난 사용 편의성 덕분에 Upstash for Redis®를 선택했습니다.
두 서비스 모두 무료 티어를 제공하며, 종량제 요금제도 지원합니다.
사전 준비 사항
다음 서비스들의 계정이 필요합니다:
- GitHub: 코드를 업로드하여 Vercel이 다운로드하고 배포할 수 있도록 함
- Vercel: 홈페이지와 엣지 함수를 호스팅
- Upstash: 필터링할 단어 목록을 저장
구현 시작하기
먼저 새로운 GitHub 리포지토리를 생성하고 "Add a README file" 옵션을 체크하여 빈 리포지토리가 되지 않도록 합니다. 리포지토리가 비어 있지 않으면 GitHub에서 Codespace를 바로 시작할 수 있으며, Codespace에는 Node.js와 Git-GitHub 연결이 미리 구성되어 있습니다.
그림 1: Codespace 시작
다음 명령어로 새로운 Next.js 프로젝트를 생성하고 Upstash Redis 클라이언트를 설치합니다:
$ npx create-next-app@latest --typescript
$ npm i @upstash/redis
목록 갱신 함수 구현하기
가장 먼저 구현할 기능은 금지어 목록을 갱신하는 함수입니다. pages/api/refresh-list.ts 파일을 새로 만들고 아래 내용을 작성합니다.
파일 pages/api/refresh-list.ts:
import { Redis } from "@upstash/redis";
export const config = { runtime: "edge" };
const redisClient = new Redis({
url: process.env.UPSTASH_REDIS_URL,
token: process.env.UPSTASH_REDIS_TOKEN,
});
export default async function handler() {
const wordResponse = await fetch(
"https://raw.githubusercontent.com/kay-is/List-of-Dirty-Naughty-Obscene-and-Otherwise-Bad-Words/master/en",
);
const words = await wordResponse.text();
const redisCommands = redisClient.pipeline();
words
.trim()
.split("\n")
.forEach((word) => redisCommands.sadd("words", word));
await redisCommands.exec();
}
먼저 함수가 엣지에서 실행되도록 설정합니다. 이 함수는 백그라운드에서 실행되기 때문에 반드시 필요한 것은 아니지만, Vercel Edge Functions는 Cloudflare Workers 기반으로 동작하기 때문에 Node.js가 기본적으로 지원하지 않는 fetch 메서드를 사용할 수 있다는 장점이 있습니다.
핸들러(handler)는 금지어가 담긴 텍스트 파일을 불러와 Upstash Redis에 저장합니다. 파이프라인(pipeline) 기능 덕분에 모든 Redis 명령어를 하나의 요청으로만 전송할 수 있습니다.
단어는 Set(집합) 자료형으로 저장합니다. 그러면 중복이 발생하지 않고, 나중에 하나의 명령어로 모든 단어를 문자열 배열 형태로 불러올 수 있습니다.
만약 개인정보를 필터링하고 싶다면, 계정 데이터베이스에서 사용자의 이메일, 전화번호, 이름, 주소 등을 가져와 필터의 기반 데이터로 활용할 수도 있습니다.
이 함수를 cron 작업으로 실행하도록 Vercel에 알려주려면 프로젝트 루트에 vercel.json 파일을 만들고 아래 내용을 추가합니다.
파일 vercel.json:
{
"crons": [
{
"path": "/api/refresh-list",
"schedule": "0 10 * * *"
}
]
}
이 설정 덕분에 Vercel은 매일 UTC 기준 오전 10시에 refresh-list 함수를 실행합니다.
Vercel 무료 계정은 하루에 한 번만 자동 실행을 지원합니다. 이 예제에서는 충분하지만, 실제 운영에서 데이터가 더 자주 변경된다면 갱신 주기를 늘려야 합니다.
필터 유틸리티 함수 구현하기
다음은 텍스트 속 단어가 데이터베이스의 금지어와 일치할 때 해당 단어를 마스킹 처리하는 유틸리티 함수입니다. utils/word-filter.ts 파일을 생성하고 아래 코드를 작성합니다.
파일 utils/word-filter.ts:
import { Redis } from "@upstash/redis";
const redisClient = new Redis({
url: process.env.UPSTASH_REDIS_URL,
token: process.env.UPSTASH_REDIS_TOKEN,
});
export async function filter(text: string) {
const filteredWords = await redisClient.smembers("words");
let maskedText = text;
for (let word of filteredWords)
maskedText = maskedText.replaceAll(new RegExp(word, "gi"), "[REDACTED]");
return maskedText;
}
역시 Upstash Redis 클라이언트를 사용하지만, 이번에는 앞서 저장한 데이터를 불러옵니다.
문자열 배열을 받기 때문에 단순히 반복문을 돌며 치환 함수를 호출하면, 텍스트 속 모든 금지어가 "[REDACTED]"로 대체됩니다.
이 함수는 필터링 대상 단어의 종류를 구분하지 않습니다. 여기서는 "직장에서 보기 부적절한(not safe for work)" 단어들이지만, 필터링 로직 자체는 앞서 저장한 데이터에만 의존합니다.
홈 페이지 구현하기
필터가 실제로 작동하는 모습을 확인하기 위해 pages/index.tsx의 내용을 아래 코드로 교체합니다.
파일 pages/index.tsx:
import Head from "next/head";
import { filter } from "@/utils/word-filter";
export const config = { runtime: "experimental-edge" };
interface HomeProps {
maskedText: string;
}
export default function Home(props: HomeProps) {
return (
<>
<Head>
<title>Text with Filtered Words</title>
</Head>
<div>
<h1>Text with Filtered Words</h1>
<p>{props.maskedText}</p>
</div>
</>
);
}
export async function getServerSideProps(): Promise<{ props: HomeProps }> {
const maskedText = await filter(
"He slipped and fell on his butt. Well, that wasn't very sexy."
);
return { props: { maskedText } };
}
config 설정 덕분에 서버 사이드 렌더링(SSR)까지 포함한 모든 처리가 엣지에서 실행됩니다. 이 Vercel 기능은 아직 실험적(experimental) 단계입니다.
여기서 핵심은 getServerSideProps 함수입니다. 이 함수는 정적 텍스트에 앞서 만든 filter 함수를 적용하며, 서버에서만 호출되기 때문에 필터링되지 않은 원본 데이터는 절대 클라이언트로 전송되지 않습니다.
실제 애플리케이션에서는 이 텍스트가 개인정보를 포함한 데이터베이스에서 넘어오고, 화면에 표시되기 전에 정제 과정을 거치게 될 것입니다.
첫 번째 API 라우트 구현하기
첫 번째 API 라우트는 홈 페이지와 동일하게 동작하지만, HTML 대신 JSON을 반환합니다. pages/api/filtered-data.ts 파일을 생성하고 아래 코드를 작성합니다.
파일 pages/api/filtered-data.ts:
import { filter } from "@/utils/word-filter";
export const config = { runtime: "edge" };
export default async function handler() {
const maskedText = await filter(
"He slipped and fell on his butt. Well, that wasn't very sexy.",
);
return new Response(JSON.stringify({ text: maskedText }), {
status: 200,
headers: { "content-type": "application/json" },
});
}
런타임은 역시 엣지이며, getServerSideProps 함수와 마찬가지로 정적 텍스트를 사용합니다.
두 번째 API 라우트 구현하기
이 라우트는 요청으로 텍스트를 받아 필터링된 결과를 반환합니다. pages/api/filter.ts 파일을 새로 만들고 아래 코드를 작성합니다.
파일 pages/api/filter.ts:
import type { NextApiRequest } from "next";
import { filter } from "@/utils/word-filter";
export const config = { runtime: "edge" };
export default async function handler(request: NextApiRequest) {
const { text } = await new Response(request.body).json();
const maskedText = await filter(text);
return new Response(JSON.stringify({ text: maskedText }), {
status: 200,
headers: { "content-type": "application/json" },
});
}
이번에는 필터링할 텍스트를 꺼내기 위해 요청 본문(body)을 파싱해야 합니다. Vercel의 Edge Functions에서 body는 ReadableStream 타입인데, 이를 Response 객체로 변환하면 네이티브 JSON 파서로 데이터를 손쉽게 추출할 수 있습니다.
요청에서 데이터를 가져온 후의 처리 흐름은 이전과 동일합니다.
코드 변경 사항 푸시하기
모든 구현이 끝났으니, 다음 명령어로 코드를 GitHub 리포지토리에 푸시합니다:
$ git add -A
$ git commit -m "Init"
$ git push
이후 코드는 온라인상에서 접근 가능해지며, Vercel이 다운로드하여 배포할 수 있습니다.
배포하기
환경 변수에 사용할 자격 증명을 얻으려면 Upstash Redis 데이터베이스를 생성하고, Vercel 프로젝트도 만들어야 합니다.
Redis 데이터베이스 생성하기
Upstash 콘솔에서 "Create database" 버튼을 클릭해 새로운 Redis 데이터베이스를 만들 수 있습니다. 설정 화면은 그림 2와 같습니다. 이 예제에서는 리전(regional) 데이터베이스로 충분하지만, 전 세계에 분산된 사용자를 대상으로 하고 낮은 지연 시간을 유지하고 싶다면 글로벌(global) 타입을 선택할 수도 있습니다.

그림 2: 새 데이터베이스 생성
생성이 완료되면 REST API 카테고리에서 환경 변수에 필요한 URL과 토큰을 확인할 수 있습니다. 그림 3처럼 표시됩니다.

그림 3: 데이터베이스 자격 증명
Vercel 프로젝트 생성하기
새 Vercel 프로젝트를 만들려면 브라우저에서 Vercel 대시보드를 열고 화면 중앙의 "Create a New Project"를 클릭합니다. Vercel을 GitHub 계정과 연동한 뒤, 임포트할 리포지토리를 선택하면 됩니다.
기본 설정을 유지한 채, 앞서 확인한 Upstash Redis 자격 증명으로 환경 변수를 추가합니다. 참고용으로 Vercel의 프로젝트 생성 UI는 그림 4와 같습니다.

그림 4: Vercel 프로젝트 생성
환경 변수 이름은 UPSTASH_REDIS_URL과 UPSTASH_REDIS_TOKEN입니다. 이전 단계에서 확인한 값들을 입력해 생성합니다.
"Deploy" 버튼을 클릭하면 Vercel이 GitHub 리포지토리에서 코드를 다운로드하여 배포를 진행합니다.
애플리케이션 테스트하기
배포 직후에는 cron 작업이 아직 실행되지 않았기 때문에 앱이 필터링되지 않은 단어를 그대로 보여줍니다. 하지만 첫 실행을 수동으로 진행할 수 있습니다. "Continue to Dashboard" 버튼을 클릭하고 "Cron Jobs" 탭을 선택하세요.
여기서 /api/refresh-list 함수를 확인할 수 있으며, "Run" 버튼을 클릭하면 됩니다.
함수 실행이 완료되면 "Project" 탭으로 이동해 "Domain" 아래의 URL 중 하나를 클릭합니다. 브라우저에서 필터링된 텍스트가 담긴 웹사이트가 열리고, 그림 5와 같은 화면이 나타날 것입니다.
그림 5: 필터링된 웹사이트
URL 뒤에 /api/filtered-data를 붙이면 API 응답에도 필터가 적용되는 것을 확인할 수 있습니다. 응답은 아래 예시와 같습니다:
{
"text": "He slipped and fell on his [REDACTED]. Well, that wasn't very [REDACTED]y."
}
마지막으로 cURL로 /api/filter 엔드포인트에 요청을 보내면, 직접 입력한 커스텀 텍스트도 필터링됩니다. <PROJECT> 부분은 본인의 Vercel 프로젝트 이름으로 교체하세요.
$ curl -X POST https://<PROJECT>.vercel.app/api/filter \
-H "Content-Type: application/json" \
-d '{"text":"He fell on his butt."}'
응답 결과:
{
"text": "He fell on his [REDACTED]."
}
다음 단계
이 튜토리얼을 따라 하다 보면 이런 의문이 들 수 있습니다. "원본 데이터가 업데이트되었는데 cron 작업이 아직 데이터베이스를 갱신하지 않았다면 어떻게 되지?" 좋은 관찰입니다!
cron 작업이 갱신 함수를 트리거하지만, 이 함수는 어디까지나 일반적인 API 함수이기 때문에 원하는 방식으로 언제든 호출할 수 있습니다.
실제 데이터 필터를 구축한다면 데이터 변경이 감지될 때 함수를 트리거하는 방식을 고려할 수 있습니다. 다만 구현 세부 사항은 필터의 기반이 되는 데이터 저장소에 크게 좌우되므로, 설계 시 이 점을 반드시 염두에 두시기 바랍니다.
추가 자료
완성된 프로젝트 전체 코드는 GitHub에서 확인할 수 있습니다.