백엔드 API 서버를 운영하다 보면 자동화된 취약점 스캐너의 요청이 끊임없이 들어옵니다. .env, docker-compose.yml, aws.env.json, .git/config 같은 설정 파일을 탐색하는 요청들인데, 서비스와 무관한 경로임에도 서버가 매번 처리하고 404를 응답해야 합니다.
이 글에서는 Cloudflare의 무료 WAF Custom Rules를 활용하여 이런 악성 요청을 서버에 도달하기 전에 차단하는 방법을 다룹니다.
서버 로그를 확인하면 아래와 같은 요청이 반복적으로 들어오는 것을 볼 수 있습니다.
GET /app/docker-compose.yml
GET /aws-codecommit/
GET /docker/overlay/config.json
GET /opt/mailcow-dockerized/mailcow.conf
GET /aws.env.json
GET /serverless.yml
GET /sam-template.yaml
GET /attacker/docker-compose.yml
GET /amplify/team-provider-info.json
GET /.git/config
GET /.env
이들은 대부분 VPS나 호스팅 서버에서 실행되는 자동화된 스캐너입니다. 설정 파일이 실수로 노출된 서버를 찾아 API 키, 데이터베이스 자격 증명 등을 탈취하려는 목적입니다.
서버에서 이 요청들을 하나하나 처리하면 불필요한 리소스가 소모됩니다. 더 좋은 방법은 서버 앞단에서 차단하는 것입니다.
Cloudflare 대시보드에서 DNS 페이지를 열고 API 서버 레코드를 확인합니다.
| Type | Name | Proxy status |
|---|---|---|
| A | api | Proxied (주황색 구름) |
회색 구름(DNS only)이면 클릭하여 주황색으로 변경합니다. Proxied 상태에서만 Cloudflare의 보안 기능이 적용됩니다.
Cloudflare 설정 전에, 서버 자체에서도 방어 계층을 추가하는 것이 좋습니다. API 서버에는 정해진 경로만 존재하므로, 허용된 경로 외에는 즉시 차단합니다.
Node.js(Fastify) 예시:
const ALLOWED_PREFIXES = ['/keys', '/auth', '/billing', '/admin', '/users', '/health']
fastify.addHook('onRequest', async (request, reply) => {
const url = request.url.split('?')[0]
if (!ALLOWED_PREFIXES.some(p => url === p || url.startsWith(p + '/'))) {
reply.code(404).header('connection', 'close').send()
return
}
})
이 훅은 라우팅 전에 실행되므로, Fastify가 라우트 매칭을 시도하지 않아 처리 비용이 최소화됩니다. connection: close 헤더로 연결도 즉시 종료합니다.
Express라면 미들웨어로 동일하게 구현할 수 있습니다.
app.use((req, res, next) => {
const url = req.path
if (!ALLOWED_PREFIXES.some(p => url === p || url.startsWith(p + '/'))) {
return res.status(404).set('connection', 'close').end()
}
next()
})
이것만으로도 서버 측 방어는 되지만, 요청이 서버까지 도달한다는 점은 변하지 않습니다.
Cloudflare의 Custom Rules를 사용하면 요청이 서버에 도달하기 전에 차단할 수 있습니다. Free 플랜에서 5개까지 무료로 사용 가능합니다.
가장 효과적인 방식은 허용할 경로만 명시하고 나머지를 모두 차단하는 것입니다.
Rule name:
Allow only API paths
Edit expression 클릭 후 아래 내용을 붙여넣습니다:
(http.host eq "api.example.com" and not starts_with(http.request.uri.path, "/keys") and not starts_with(http.request.uri.path, "/auth") and not starts_with(http.request.uri.path, "/billing") and not starts_with(http.request.uri.path, "/admin") and not starts_with(http.request.uri.path, "/users") and not starts_with(http.request.uri.path, "/health"))
api.example.com은 실제 API 도메인으로 변경하고, 경로 목록은 서비스의 실제 API 경로에 맞게 수정합니다.
Choose action: Block
Deploy 클릭으로 즉시 적용됩니다.
(
http.host eq "api.example.com" // API 도메인에만 적용
and not starts_with(uri.path, "/keys") // /keys/* 허용
and not starts_with(uri.path, "/auth") // /auth/* 허용
... // 나머지 허용 경로
)
조건을 모두 만족하는 요청, 즉 API 도메인이면서 허용된 경로가 아닌 요청이 Block 대상이 됩니다.
스캐너의 요청은 Cloudflare 엣지에서 즉시 차단됩니다.
GET /docker-compose.yml → Cloudflare에서 Block (서버 미도달)
GET /.env → Cloudflare에서 Block (서버 미도달)
GET /aws.env.json → Cloudflare에서 Block (서버 미도달)
GET /keys/abc123 → 서버로 정상 전달
GET /auth/me → 서버로 정상 전달
서버 로그에 스캐너 요청이 더 이상 나타나지 않고, 서버 리소스도 절약됩니다.
Custom Rules는 5개까지 사용할 수 있으므로, 필요에 따라 추가 규칙을 만들 수 있습니다.
서비스 대상이 아닌 국가에서 오는 악성 요청을 차단합니다.
(http.host eq "api.example.com" and ip.geoip.country in {"RU" "CN"})
알려진 스캐너 도구의 User-Agent를 차단합니다.
(http.host eq "api.example.com" and (
http.user_agent contains "sqlmap" or
http.user_agent contains "nikto" or
http.user_agent contains "dirbuster"
))
서버 측 화이트리스트와 Cloudflare WAF를 함께 사용하면 이중 방어가 됩니다.
[스캐너 요청]
↓
[Cloudflare WAF] → Block (대부분 여기서 차단)
↓ (허용된 경로)
[서버 화이트리스트] → 2차 검증
↓ (통과)
[API 라우터] → 정상 처리
Cloudflare를 우회하여 서버 IP로 직접 접근하는 경우를 대비해 서버 측 방어도 유지하는 것이 좋습니다. 서버 방화벽(iptables, ufw 등)에서 Cloudflare IP 대역만 허용하면 직접 접근 자체를 차단할 수도 있습니다.
| 기능 | Free | Pro ($20/월) |
|---|---|---|
| Custom Rules | 5개 | 20개 |
| Rate Limiting Rules | 1개 | 2개 |
| Managed Rules (자동 WAF) | X | O |
| Bot Management | X | O |
Free 플랜의 Custom Rules 5개만으로도 기본적인 API 보안은 충분히 구성할 수 있습니다.
백엔드 API 서버를 운영한다면 자동화된 스캐너 요청은 피할 수 없습니다. 서버에서 일일이 처리하기보다, Cloudflare 같은 CDN/WAF를 앞단에 두고 엣지에서 차단하는 것이 훨씬 효율적입니다.
Cloudflare Free 플랜만으로도 Custom Rules를 통해 API 경로 화이트리스트를 구성할 수 있고, 서버 측 화이트리스트와 함께 사용하면 이중 방어가 완성됩니다. 설정에 드는 시간은 몇 분이지만, 서버 리소스 절약과 보안 강화 효과는 상당합니다.
]]>
모노레포에서 프론트엔드와 백엔드를 각각 다른 인프라에 배포해야 하는 경우가 많습니다. 프론트엔드는 Cloudflare Pages 같은 정적 호스팅에, 백엔드는 직접 관리하는 VM에 배포하는 구성이 대표적입니다.
이 글에서는 하나의 GitHub 저장소에서 main 브랜치에 push할 때 프론트엔드는 Cloudflare Pages로, 백엔드는 Self-hosted Runner를 통해 VM으로 자동 배포하는 파이프라인을 구축하는 방법을 다룹니다.
repo/
├── frontend/ # Cloudflare Pages로 배포
├── backend/ # VM에 배포 (Self-hosted Runner)
└── .github/workflows/
├── deploy-fe.yml # 프론트엔드 배포
└── deploy-be.yml # 백엔드 배포
두 워크플로우 모두 main push에 반응하되, paths 필터로 각자 담당하는 디렉토리가 변경된 경우에만 실행됩니다.
Cloudflare Pages는 GitHub Actions에서 wrangler를 통해 배포할 수 있습니다. GitHub의 ubuntu 러너에서 빌드 후 결과물을 Cloudflare에 업로드하는 방식입니다.
name: Deploy Frontend
on:
push:
branches: [main]
paths:
- 'frontend/**'
- '.github/workflows/deploy-fe.yml'
jobs:
deploy:
runs-on: ubuntu-latest
defaults:
run:
working-directory: frontend
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
cache-dependency-path: frontend/package-lock.json
- name: Install dependencies
run: npm ci
- name: Build
run: npm run build
- name: Deploy to Cloudflare Pages
uses: cloudflare/wrangler-action@v3
with:
apiToken: $
accountId: $
command: pages deploy dist/ --project-name=my-project --commit-dirty=true
workingDirectory: frontend
| Secret | 설명 |
|---|---|
CLOUDFLARE_API_TOKEN |
Cloudflare API 토큰 (Edit Cloudflare Pages 권한) |
CLOUDFLARE_ACCOUNT_ID |
Cloudflare 계정 ID |
Cloudflare 대시보드에서 API 토큰을 생성할 때 Cloudflare Pages > Edit 권한을 부여해야 합니다.
백엔드 서버가 외부에서 SSH 접근이 어려운 환경(사설 네트워크, VPN 등)에 있다면 Self-hosted Runner가 좋은 선택입니다. 서버 VM에 runner를 설치하면 GitHub Actions가 해당 서버에서 직접 명령을 실행할 수 있습니다.
서버 VM에 SSH 접속 후 실행합니다.
# runner 디렉토리 생성 및 다운로드
mkdir -p ~/actions-runner && cd ~/actions-runner
curl -o actions-runner-linux-x64-2.322.0.tar.gz -L \
https://github.com/actions/runner/releases/download/v2.322.0/actions-runner-linux-x64-2.322.0.tar.gz
tar xzf ./actions-runner-linux-x64-2.322.0.tar.gz
GitHub에서 등록 토큰을 발급받습니다:
Repository > Settings > Actions > Runners > New self-hosted runner
# runner 등록
./config.sh --url https://github.com/<owner>/<repo> --token <YOUR_TOKEN>
등록 시 물어보는 항목:
my-server)backend)# 시스템 서비스로 등록 (재부팅 시 자동 시작)
sudo ./svc.sh install
sudo ./svc.sh start
name: Deploy Backend
on:
push:
branches: [main]
paths:
- 'backend/**'
- '.github/workflows/deploy-be.yml'
env:
DEPLOY_DIR: /home/ubuntu/my-project
jobs:
deploy:
runs-on: [self-hosted, backend]
steps:
- name: Pull latest code
run: >
cd $DEPLOY_DIR &&
git pull https://x-access-token:$@github.com/<owner>/<repo>.git main
- name: Restart backend
shell: bash -l {0}
run: |
cd $DEPLOY_DIR/backend
STATUS=$(npx pm2 jlist 2>/dev/null | node -e "
const d = JSON.parse(require('fs').readFileSync('/dev/stdin','utf8') || '[]');
const p = d.find(x => x.name === 'server');
console.log(p ? p.pm2_env.status : 'not_found');
" 2>/dev/null || echo "no_daemon")
echo "PM2 server status: $STATUS"
case "$STATUS" in
online)
npx pm2 reload server --update-env
;;
stopped|errored)
npx pm2 delete server
NODE_ENV=production npx pm2 start server.js --name server
;;
*)
npx pm2 delete server 2>/dev/null
NODE_ENV=production npx pm2 start server.js --name server
;;
esac
npx pm2 save
- name: Health check
run: |
for i in 1 2 3 4 5; do
if curl -sf http://localhost:4000/ > /dev/null 2>&1; then
echo "Health check passed"
exit 0
fi
sleep 2
done
echo "Health check failed after 10s"
cd $DEPLOY_DIR/backend && npx pm2 logs server --lines 20 --nostream
exit 1
shell: bash -l {0} — 로그인 셸로 실행합니다. Self-hosted runner는 서비스로 동작하기 때문에 사용자의 .bashrc, .profile이 자동으로 로드되지 않습니다. nvm이나 특정 PATH가 필요한 경우 이 설정이 필수입니다.
GITHUB_TOKEN으로 HTTPS pull — 서버의 git remote가 SSH로 설정되어 있어도, runner 환경에서는 GitHub의 host key 문제가 발생할 수 있습니다. GITHUB_TOKEN을 활용한 HTTPS pull이 가장 안정적입니다.
절대 경로 사용 — ~(틸드)는 YAML의 working-directory에서 shell 확장이 되지 않습니다. 반드시 /home/ubuntu/... 같은 절대 경로를 사용해야 합니다.
PM2 상태별 분기 — pm2 kill(데몬 전체 종료) 대신 프로세스 상태를 확인하고 분기 처리합니다:
| 상태 | 동작 |
|---|---|
online |
pm2 reload (graceful restart, 무중단) |
stopped / errored |
pm2 delete 후 새로 pm2 start |
| 프로세스 없음 / 데몬 없음 | 새로 pm2 start |
서버 재부팅이나 비정상 종료 등 어떤 상황에서도 배포가 정상 동작합니다.
npx 사용 — PM2를 글로벌 설치하지 않고 프로젝트의 node_modules에 있는 PM2를 npx로 실행합니다. 글로벌 패키지 의존성을 줄일 수 있습니다.
헬스체크 — 배포 후 서버가 실제로 응답하는지 확인합니다. 2초 간격으로 5회 재시도하고, 실패 시 PM2 로그를 출력하여 원인 파악이 가능합니다.
Self-hosted runner를 처음 도입하면 예상치 못한 문제를 만나게 됩니다. 실제로 겪었던 문제들을 정리합니다.
서버의 git remote가 SSH(git@github.com:...)로 설정되어 있으면, runner에서 git pull origin main 실행 시 Host key verification failed 에러가 발생합니다. runner 서비스 환경에는 known_hosts가 설정되어 있지 않기 때문입니다.
해결: GITHUB_TOKEN을 활용한 HTTPS URL로 pull합니다.
run: git pull https://x-access-token:$@github.com/owner/repo.git main
Runner가 시스템 서비스로 동작하면 사용자의 shell profile이 로드되지 않습니다. node, npm, pm2 등의 명령어를 찾지 못하는 command not found 에러가 발생합니다.
해결: shell: bash -l {0}으로 로그인 셸을 명시합니다.
~) 경로 미확장GitHub Actions의 working-directory 설정에서 ~/my-project처럼 틸드를 사용하면 문자 그대로 ~/my-project라는 디렉토리를 찾으려 합니다.
해결: env로 절대 경로를 정의하고 run 블록 내에서 cd로 이동합니다.
pm2 kill은 PM2 데몬 자체를 종료합니다. Runner 환경에서 데몬을 죽인 뒤 다시 시작하면 환경 차이로 프로세스가 제대로 뜨지 않을 수 있습니다.
해결: pm2 reload나 pm2 restart로 프로세스만 재시작하고, 프로세스가 없는 경우에만 pm2 start로 새로 등록합니다. pm2 save로 상태를 저장해두면 서버 재부팅 후에도 PM2가 자동 복구합니다.
설치 이후 운영에 필요한 명령어를 정리합니다.
# 서비스 상태 확인
sudo ./svc.sh status
# 서비스 중지 / 시작
sudo ./svc.sh stop
sudo ./svc.sh start
# 서비스 제거 (runner 해제 시)
sudo ./svc.sh uninstall
# runner 등록 해제
./config.sh remove --token <TOKEN>
# 로그 확인
journalctl -u actions.runner.<서비스명> -f
이 구성의 장점은 네트워크 제약 없이 배포를 자동화할 수 있다는 것입니다. 프론트엔드는 GitHub의 클라우드 러너에서 빌드하여 Cloudflare에 배포하고, 백엔드는 서버에 설치된 Self-hosted runner가 직접 pull하고 재시작합니다.
SSH 포트를 외부에 열거나, 별도의 CI/CD 서버를 구축하지 않아도 됩니다. main 브랜치에 push하면 변경된 디렉토리에 따라 각각의 워크플로우가 독립적으로 실행됩니다.
제가 운영 중인 서비스의 경우, 처음에는 프론트엔드를 Vercel에서 서비스하다가 Cloudflare Pages로 이전했고, 백엔드는 집에서만 접속 가능한 NAS를 경유해 VM에 수동으로 접근한 뒤 git pull과 PM2 재시작을 직접 수행하는 방식이었습니다. 이번에 위 구조로 정리하고 나니, main 브랜치에 push만 하면 프론트와 백엔드가 각각 자동으로 배포되어 훨씬 간편해졌습니다.
다만 백엔드 배포 시 PM2가 프로세스를 재시작하는 동안 짧은 다운타임이 발생한다는 점은 아직 과제로 남아 있습니다. 다음 글에서는 이 부분을 어떻게 개선할 수 있을지 고민한 결과를 공유해 보겠습니다.
]]>
Stripe를 사용하여 SaaS 프로젝트에 구독 결제를 연동하는 방법을 단계별로 정리했습니다. Stripe Checkout, Customer Portal, Webhook까지 실제 운영에 필요한 전체 플로우를 다룹니다.
# Stripe CLI 설치 (macOS)
brew install stripe/stripe-cli/stripe
# 로그인
stripe login
Stripe Dashboard에서 Sandbox를 생성합니다. Sandbox는 실제 결제가 발생하지 않는 격리된 테스트 환경입니다.
생성 후 Developers → API keys에서 키를 확인합니다:
| 키 | 용도 |
|---|---|
Publishable key (pk_test_...) |
프론트엔드용 (Checkout Session 방식이면 미사용) |
Secret key (sk_test_...) |
백엔드 서버에서 사용 |
Secret key는 절대 프론트엔드에 노출하면 안 됩니다.
Dashboard에서 Product catalog → Add product로 상품을 생성합니다.
| 항목 | 값 |
|---|---|
| Name | Pro |
| Description | 상품 설명 |
| Pricing model | Standard pricing |
| Price | $10.00 USD |
| Billing period | Monthly |
상품 생성 후 해당 상품 페이지의 Pricing 섹션에서 Price를 클릭하면 price_로 시작하는 ID를 확인할 수 있습니다.
Product ID (
prod_xxx)와 Price ID (price_xxx)는 다릅니다. Checkout Session을 생성할 때 사용하는 것은 Price ID입니다.
npm install stripe dotenv
STRIPE_SECRET_KEY=sk_test_xxx
STRIPE_WEBHOOK_SECRET=whsec_xxx
STRIPE_PRO_PRICE_ID=price_xxx
require('dotenv').config()
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY)
사용자가 구독을 시작할 때 Stripe Checkout 페이지로 리다이렉트합니다. Stripe이 결제 UI를 제공하므로 직접 카드 입력 폼을 만들 필요가 없습니다.
app.post('/billing/checkout', async (req, res) => {
const { priceId } = req.body
// Stripe Customer 생성 (또는 기존 Customer 재사용)
const customer = await stripe.customers.create({
email: req.user.email,
metadata: { userId: req.user.id },
})
const session = await stripe.checkout.sessions.create({
mode: 'subscription',
customer: customer.id,
line_items: [{ price: priceId, quantity: 1 }],
success_url: 'https://yourapp.com?billing=success',
cancel_url: 'https://yourapp.com?billing=cancel',
subscription_data: {
metadata: { userId: req.user.id },
},
})
res.json({ url: session.url })
})
프론트엔드에서는 응답받은 url로 window.location.href를 설정하면 됩니다.
기존 구독자가 플랜을 변경하거나 취소할 때 Stripe Customer Portal로 보냅니다.
app.post('/billing/portal', async (req, res) => {
const session = await stripe.billingPortal.sessions.create({
customer: req.user.stripeCustomerId,
return_url: 'https://yourapp.com',
})
res.json({ url: session.url })
})
Stripe에서 발생하는 이벤트(결제 완료, 구독 변경, 취소 등)를 서버에서 수신하려면 Webhook을 설정해야 합니다.
Checkout 완료 후 ?billing=success로 리다이렉트되지만, 이 시점에 실제 결제 처리가 완료되었다는 보장이 없습니다. Webhook은 Stripe 서버에서 직접 보내는 이벤트이므로, 이를 통해 DB를 업데이트하는 것이 안전합니다.
stripe listen --forward-to localhost:4000/billing/webhook
실행 시 출력되는 whsec_... 값을 .env의 STRIPE_WEBHOOK_SECRET에 설정합니다.
Stripe Dashboard → Developers → Webhooks → Add endpoint:
| 항목 | 값 |
|---|---|
| Endpoint URL | https://api.yourapp.com/billing/webhook |
| Events | checkout.session.completed, customer.subscription.updated, customer.subscription.deleted |
생성 후 Signing secret (whsec_...)을 프로덕션 환경변수에 설정합니다.
로컬 CLI의
whsec_와 Dashboard Webhook의whsec_는 별개의 값입니다.
Webhook 요청의 body를 raw Buffer 상태로 받아야 서명 검증이 가능합니다. JSON으로 파싱된 body를 사용하면 서명 검증에 실패합니다.
// express.json()보다 먼저 등록해야 합니다
app.post('/billing/webhook',
express.raw({ type: 'application/json' }),
async (req, res) => {
const sig = req.headers['stripe-signature']
let event
try {
event = stripe.webhooks.constructEvent(
req.body, // raw Buffer
sig,
process.env.STRIPE_WEBHOOK_SECRET,
)
} catch (err) {
console.error(`Webhook signature verification failed: ${err.message}`)
return res.status(400).send('Invalid signature')
}
switch (event.type) {
case 'checkout.session.completed': {
const session = event.data.object
// session.customer, session.subscription 등으로 DB 업데이트
break
}
case 'customer.subscription.updated': {
const subscription = event.data.object
// subscription.items.data[0].price.id로 플랜 변경 반영
break
}
case 'customer.subscription.deleted': {
const subscription = event.data.object
// 플랜을 free로 변경
break
}
}
res.json({ received: true })
}
)
Fastify는 기본적으로 application/json을 자동 파싱하므로, Webhook 엔드포인트에서 raw Buffer를 받으려면 별도의 플러그인 스코프에서 content type parser를 오버라이드해야 합니다.
// 별도 플러그인 스코프로 등록해야 다른 라우터에 영향을 주지 않습니다
fastify.register(async function webhookPlugin(app) {
app.addContentTypeParser(
'application/json',
{ parseAs: 'buffer' },
(_req, body, done) => done(null, body),
)
app.post('/billing/webhook', async (request, reply) => {
const sig = request.headers['stripe-signature']
let event
try {
event = stripe.webhooks.constructEvent(
request.body, // Buffer 그대로 전달
sig,
process.env.STRIPE_WEBHOOK_SECRET,
)
} catch (err) {
fastify.log.error(`Webhook signature verification failed: ${err.message}`)
return reply.code(400).send({ error: 'Invalid signature' })
}
// 이벤트 처리 (위 Express 예시와 동일)
reply.send({ received: true })
})
})
주의:
req.rawBody에 저장하고 나중에request.raw.rawBody로 접근하는 방식은 Fastify의 플러그인 스코프에서 동작하지 않을 수 있습니다.request.body에 Buffer를 직접 전달하는 것이 가장 확실한 방법입니다.
사용자가 플랜 변경, 결제 수단 변경, 구독 취소 등을 직접 관리할 수 있는 페이지를 Stripe이 제공합니다.
Stripe Dashboard → Settings → Billing → Customer portal
| 설정 | 권장값 |
|---|---|
| Cancel subscriptions | 활성화 |
| Update subscriptions | 활성화 |
| When customers change plans | Prorate charges and credits |
| Charge timing | Invoice prorations immediately at the time of the update |
| Products | 전환 가능한 모든 상품 추가 |
| 설정 | 설명 |
|---|---|
| Return URL | Portal에서 돌아올 URL (예: https://yourapp.com) |
| Payment methods | 결제 수단 변경 허용 |
| Invoices | 청구서 내역 조회 허용 |
“Update subscriptions” 또는 “Switch plans” 옵션이 보이지 않을 때:
- “Subscriptions” 섹션이 비활성화되어 있으면 먼저 토글을 켜주세요.
- Products가 등록되어 있지 않으면 플랜 전환 옵션이 나타나지 않습니다.
- Sandbox에서는 메뉴명이 다를 수 있습니다 (“Update subscriptions”, “Switching plans”, “Plan changes” 등).
| 변경 방향 | 처리 방식 |
|---|---|
| 무료 → 유료 | Stripe Checkout으로 새 구독 생성 |
| 유료 → 다른 유료 (업그레이드/다운그레이드) | Customer Portal에서 플랜 변경 |
| 유료 → 무료 (취소) | Customer Portal에서 구독 취소 |
업그레이드/다운그레이드 시:
플랜 변경 시 Stripe가 customer.subscription.updated 웹훅을 발생시키므로, 이 이벤트에서 DB의 플랜 정보를 업데이트하면 됩니다.
| 시나리오 | 카드 번호 |
|---|---|
| 결제 성공 | 4242 4242 4242 4242 |
| 결제 실패 (거부) | 4000 0000 0000 0002 |
| 3D Secure 인증 필요 | 4000 0025 0000 3155 |
| 잔액 부족 | 4000 0000 0000 9995 |
12/34)123)stripe listen 터미널에서 Webhook 이벤트 수신 확인# checkout.session.completed 이벤트
stripe trigger checkout.session.completed
# 구독 변경 이벤트
stripe trigger customer.subscription.updated
# 구독 취소 이벤트
stripe trigger customer.subscription.deleted
라이브 전환 전에 확인해야 할 사항입니다:
sk_live_...)success_url, cancel_url이 프로덕션 도메인인지 확인return_url이 프로덕션 도메인인지 확인Webhook 서명 검증에 실패하는 가장 흔한 원인입니다. stripe.webhooks.constructEvent()에 전달하는 body가 raw Buffer가 아닌 JSON 파싱된 객체이면 발생합니다.
해결: Webhook 엔드포인트에서 body를 JSON으로 파싱하지 않고 raw Buffer 상태로 받아야 합니다. 위 4번의 코드 예시를 참고해주세요.
checkout.session.completed 이벤트에서 session.subscription으로 구독 정보를 조회하고 있는지 확인해주세요.metadata에 userId를 포함시켰는지 확인해주세요. Checkout Session의 subscription_data.metadata에 설정해야 Subscription 객체에서도 접근 가능합니다.Stripe는 테스트 모드와 라이브 모드의 데이터를 완전히 분리합니다. 상품, 가격, 고객, 구독 등 모든 데이터를 라이브 모드에서 새로 생성해야 합니다.
국제화 처리를 위한 파일을 직접 작성 없이 구글 시트에 입력된 내용을 기반으로 파일을 생성하고, 나아가 파일이 없어도 국제화 처리를 할 수 있도록 예시를 들어 설명합니다.
국제화를 고려한 웹 애플리케이션을 작성해야 하는 경우, 화면 내에 등장하는 모든 텍스트가 별도의 국제화 처리기를 통해 표현될 수 있도록 준비해야 합니다.
저는 현재 nuxt.js로 웹 앱을 주로 만들고 있고, 국제화 처리를 위해 nuxt-i18n 모듈을 함께 사용하고 있습니다.
이런 환경에서, 국제화 처리를 위해 별도로 key:value 형태로 텍스트를 모아 둔 파일(이하 사전으로 표현)을 다음과 같이 만들어 왔습니다.
// locales/ko/index.js 파일
export default {
common: {
title: '제목',
// ...
},
// 다른 속성들
}
당연한 얘기이지만 위와 같은 형태로 작업하면, 번역된 텍스트가 변경 될 때마다 해당 텍스트 파일을 수정해야 하고, 그 이후에 별도로 배포를 해야 해당 환경에 적용 됩니다.
이렇게 수동으로 사전 파일을 작성해야 한다는 것은 매우 불편하고, 또 실수하기 쉬운 일이라고 생각했습니다.
그래서 이 파일을 손으로 직접 작성하지 않고, 구글 시트에 저장된 내용을 기반으로 파일이 만들어지도록 하는 컨셉에서, 더 나아가 아예 파일을 만들지 않고 구글 시트의 내용을 바로 끌어다 쓰면 어떨까 하는 생각에서 이 작업을 시작하게 되었습니다.
이를 구현하기 위해, 이전에 만들었던 public-google-sheets-parser 라이브러리를 기반으로 한 nuxt-google-sheets-parser 모듈을 통해 예시용 웹앱을 한번 작성 해 보려고 합니다.
실제로 얼마나 유용하게 쓰일 수 있을지는 잘 모르겠지만, 최소한 개발 단계에서는 사전용 파일을 만들지 않아도, 원하는 키에 원하는 값들을 편리하게 정의하고 사용할 수 있다는 점이 장점이 될 것이라 생각하며 글 작성을 시작합니다.
먼저, 구글 시트 문서를 다음과 같은 형태로 준비합니다.
| ko | en | ja | key | key1 | key2 | … | key10 |
|---|---|---|---|---|---|---|---|
| 제목 | title | タイトル | 수식 | common | title | ||
| 내용 | Contents | 内容 | 수식 | common | description | ||
| 버튼 | button | ボタン | 수식 | common | button | ||
| 문서 경로 | Documents path | ドキュメントパス | 수식 | common | sheetsPath |
위 문서는 ko 필드에만 한글 텍스트를 넣었고, en, ja 필드 값은 전부 GOOGLETRANSLATE 함수를 통해 구글이 번역해준 결과를 사용하도록 해 두었습니다. 실제 번역된 텍스트가 나오기 전 까지 임시로 사용할 수 있을 것이며, 이 부분은 최종적으로 검수를 마친 텍스트로 대체되어야 할 것입니다.
입력이 불편해서 시트의 key 열이 key10의 오른쪽에 있었으면 하는 생각이 드신다면, 그렇게 이동하시고 위 함수의 참조 셀들의 값을 변경하시면 됩니다. 또한 key10이 너무 많다면, 불필요한 만큼 지우고 사용하셔도 됩니다.
각각의 key 셀에는 다음과 같은 함수를 넣어, key1 ~ key10까지의 값을 dot(.)을 기준으로 병합되여 표현되도록 했습니다. depth가 더 필요한 경우엔 그만큼 추가하거나, 감소할 수 있습니다.
// D2 셀 기준, 그 아래 셀은 이 수식을 복사하여 사용
=CONCATENATE(
E2,
IF(F2 <> "", "."&F2, ""),
IF(G2 <> "", "."&G2, ""),
IF(H2 <> "", "."&H2, ""),
IF(I2 <> "", "."&I2, ""),
IF(J2 <> "", "."&J2, ""),
IF(K2 <> "", "."&K2, ""),
IF(L2 <> "", "."&L2, ""),
IF(M2 <> "", "."&M2, ""),
IF(N2 <> "", "."&N2, ""),
IF(O2 <> "", "."&O2, "")
)
이렇게 만들어진 key와 locale(ko, en, ja, …) 셀에 담긴 값들을 기준삼아 Javascript의 Object로는 다음과 같이 표현되기를 기대했습니다.
// key필드에 담긴 값이 'common.title'이고, ko 필드에 담긴 값이 '제목'인 경우
{
common: {
title: '제목'
}
}
위 형태로 얻은 결과를 사전으로 사용할 수 있도록, locale/${locale}/index.js 파일을 다음과 같은 형태로 작성했습니다.
// locale/base.js
import set from 'lodash/set'
export default async (context, locale) => {
// https://docs.google.com/spreadsheets/d/1TZu5G5VxPRoXeCjY7-OQbHFp09y73wGTiDsyzk6UwPQ/edit
const sheetId = '1TZu5G5VxPRoXeCjY7-OQbHFp09y73wGTiDsyzk6UwPQ'
const sheetName = 'dictionary'
const dictionary = {}
const response = await context.$gsparser.parse(sheetId, sheetName)
response.forEach((item) => set(dictionary, item.key, item[locale]))
return Promise.resolve(dictionary)
}
// locale/ko/index.js
import base from '../base'
export default (context) => {
return base(context, 'ko')
}
// locale/en/index.js
import base from '../base'
export default (context) => {
return base(context, 'ko')
}
// 그 외 필요한 만큼 생성..
이렇게 준비한 뒤, nuxt.config.js 파일의 nuxt-i18n 설정을 다음과 같이 했습니다.
// nuxt.config.js
{
// ...다른 설정 생략...
modules: [
[
'nuxt-i18n',
{
locales: [
{ code: 'ko', iso: 'ko-KR', file: 'ko/index.js' },
{ code: 'en', iso: 'en-US', file: 'en/index.js' },
{ code: 'ja', iso: 'ja-JP', file: 'ja/index.js' },
],
langDir: 'locales/',
lazy: true,
defaultLocale: 'ko',
strategy: 'prefix_and_default',
vuex: {
moduleName: 'i18n',
syncLocale: true,
syncMessages: true,
syncRouteParams: true,
},
},
],
// 반드시 nuxt-i18n 모듈 등록 이후에 등록되어야 합니다.
'nuxt-google-sheets-parser',
// 다른 모듈 생략
],
// ...다른 설정 생략...
}
그리고 이렇게 연결된 상태라면, 다음과 같이 template 내에서 사용할 수 있습니다.
<template>
<div>
<!-- key가 common.title인 국제화 텍스트가 알맞게 표현됩니다. -->
<h1 :text="$t('common.title')" />
</div>
</template>
이처럼 연결시켜 두면, 별도의 파일을 만들지 않고도 국제화 텍스트를 쉽게 표현할 수 있습니다.
하지만 이렇게 두면 구글 시트 API에 장애가 발생했을 때, 사이트의 모든 텍스트가 깨질 수 있다는 치명적인 단점이 있습니다.
이 부분은 구글 시트 파일을 기반으로 사전용 파일을 자동 생성하여 소스코드에 포함되도록 해 주는 방식으로 커버할 수 있을 것이라고 생각했습니다.
그래서 아래와 같은 스크립트를 통해 locale별 fallback.json 파일이 자동으로 생성될 수 있도록 했습니다.
// makeDictionary.js
const fs = require('fs')
const _ = require('lodash')
const PublicGoogleSheetsParser = require('public-google-sheets-parser')
const parser = new PublicGoogleSheetsParser()
const sheetId = '1TZu5G5VxPRoXeCjY7-OQbHFp09y73wGTiDsyzk6UwPQ'
const sheetName = 'dictionary'
const targetLanguages = ['ko', 'en', 'ja']
parser.parse(sheetId, sheetName).then((rows) => {
const dictionary = {}
// 언어별 사전을 생성합니다. { [locale]: { key: value, key: value, ... } } 형태가 되도록 합니다.
rows.forEach((row) => targetLanguages.forEach((lang) => _.set(dictionary, `${lang}.${row.key}`, row[lang])))
// 필요한 언어들에 대한 fallback 파일을 생성합니다.
targetLanguages.forEach((lang) => fs.writeFileSync(`./locales/${lang}/fallback.json`, JSON.stringify(dictionary[lang])))
console.log(`${targetLanguages.length} files created.`)
})
이제 필요할 때 마다 위 스크립트를 실행하면, 언어별 fallback 파일이 작성되도록 준비가 되었습니다. 터미널에서 아래 명령을 실행하면, locales 디렉토리에 각각의 fallback 파일이 생성됩니다.
$ node makeDictionary
# 3 files created.
위 부분을 package.json에 넣어서 사용해도 되고, 필요할 때 마다 그냥 호출해서 사용할 수 있을 것입니다.
추가된 fallback 파일의 지원을 위해, 위에 작성했던 base.js파일에 response가 falsy한 경우 fallback 파일을 읽어와서 사전으로 사용하도록 처리를 추가합니다.
import set from 'lodash/set'
export default async (context, locale) => {
// https://docs.google.com/spreadsheets/d/1TZu5G5VxPRoXeCjY7-OQbHFp09y73wGTiDsyzk6UwPQ/edit
const sheetId = '1TZu5G5VxPRoXeCjY7-OQbHFp09y73wGTiDsyzk6UwPQ'
const sheetName = 'dictionary'
const dictionary = {}
const response = await context.$gsparser.parse(sheetId, sheetName)
if (response.length) {
response.forEach((item) => set(dictionary, item.key, item[locale]))
} else {
const { default: fallbackDictionary } = require(`./${locale}/fallback.json`)
Object.assign(dictionary, fallbackDictionary)
}
return Promise.resolve(dictionary)
}
이렇게 하면, API로부터 제대로된 응답을 돌려받지 못하게 되는 경우, fallback 파일을 사용할 수 있게 됩니다.
이런 방식으로 국제화를 구현하게 되면 개발 중에는 최대한 편하게 진행할 수 있는 것 같습니다.
만약 사전에 더 이상 추가될 것도, 수정될 것도 없다면, 파일만 가져와서 동작하도록 base.js 파일의 내용을 변경해서 사용할 수 있을 것 같습니다.
json파일을 신경쓰지 않고, 공개 권한을 가진 스프레드시트만으로 최대한 간단하게 국제화 처리를 구현할 수 있는 방법을 소개 해 보았습니다.
개발 단계이거나, 아직 안정화가 덜 되어 문서를 수시로 수정해야 하는 경우에 특히 요긴하게 사용할 수 있을 거라고 생각했습니다.
운영 환경에서는 동적으로 가져오지 않고, 등록된 fallback.json 파일만 사용하겠다면 그런 방식으로 설정해서 사용할 수도 있습니다.
이 내용을 간단히 정리하여 공개 저장소에 등록 해 두었습니다.
읽어주신 분들에게 조금이라도 도움이 되는 내용이었으면 합니다.
읽어주셔서 감사합니다!
]]>

개인적으로 몇 년 동안 머릿속에 담아두고 제대로 꺼내놓지 못한 상태의 프로젝트가 있습니다. 이 것을 구현하려면 빠른 검색 서버가 반드시 필요합니다. 이 상태에서 2백만개 음식 레시피 검색 데모 사이트를 처음 보자마자 Typesense라는 검색엔진에 특별한 관심을 가지게 되었던 것 같습니다. 데모 사이트에서 키워드로 검색해 본 결과가 엄청나게 빨랐거든요.
원래 우분투 20.04에서 typesense 서버 설치부터 node.js 클라이언트 예제 실행까지 게시글을 통해 샘플 웹 페이지를 작성해서 한국어로 검색하는 부분까지 작성 해 보고 싶었는데, 그냥 검색엔진이 설치된 서버만 만들고 같은 서버 내에서 클라이언트로 요청 해 보는 아주 간소화된 공식 가이드 버전 답습하기 수준으로 마무리가 되어, 나머지 부분에 대한 내용을 별도로 정리하자 마음을 먹고 이 글을 작성하게 되었습니다.
이 글은 제가 깊이가 부족하여 그런 것도 있지만, 각각의 기술에 대한 깊이 있는 활용 방법보다는 Typesense라는 오픈소스 검색 엔진을 이용하여, 검색을 위한 웹 API 서버를 구축하기위해 여러 프로그램 및 서비스들을 어떻게 활용했는지에 대한 작업 과정을 개인적으로 정리해 봄과 동시에 이쪽에 관심을 가지고 계시는 다른 분들에게 공유하기 위한 목적으로 작성합니다.
아직 Typesense에는 한국어 형태소 분석기가 없는 것으로 알고 있는데요. 잠깐 본 것이 전부였지만, 정확하고 빠른 한국어 검색 결과를 얻지 못하는 한계점이 가장 아쉬웠습니다. 그래서 실제 묵혀두었던 아이디어를 구현하는 시점에는 Elasticsearch를 통해 검색 서버를 구현하게 될 것 같습니다. 하지만 정확하게 일치하는 키워드에 대한 검색 결과를 얻어야 하는 상황에서는 충분히 사용할 만한 가치가 있다고 생각합니다.
이 게시글을 작성하기 전에 데모용 웹페이지를 하나 만들어 두었습니다. 해당 페이지는 전국 행정동 정보를 담은 구글 스프레드시트 문서에 담긴 정보를 Typesense 검색엔진을 이용하여 빠른 검색 결과를 확인할 수 있도록 했습니다. Oracle의 무료 계층 인스턴스로도 충분히 빠른 검색 결과를 얻는 모습을 확인하실 수 있습니다. 다만 아웃바운드 트래픽을 마냥 낭비할 수는 없어, 최대 응답 갯수는 10개로 두었으나 무자비하도록 빠른 검색 요청-응답을 개발자도구 네트워크탭에서 확인하실 수 있도록 debounce를 적용하지 않았고, nginx의 burst 제한도 풀어 두었습니다. 그럴리는 없겠지만, 이후 과도한 트래픽이 유입된다면 클라이언트에는 debounce, 웹서버에는 burst 제한을 걸고 내려주는 응답의 갯수를 10개에서 n배 이상으로 늘리는 형태로 변경 될 수도 있습니다.
위에서 언급한 데모용 웹페이지에서 사용된 API 서버와 검색 서버를 구축하기 위해 사용된 기술들을 정리 해 보고, 직접 구축하는 방법을 알아보겠습니다.
| 기술 | 설명 |
|---|---|
| Oracle Cloud VM 인스턴스 (VM.Standard.E2.1.Micro 2대, 항상 무료스펙) |
웹 API용, Typesense용 서버 각 1대 |
| Ubuntu 20.04 LTS | 운영체제 |
| nginx 1.18.0 | 웹 서버 |
| Let’s Encrypt using python3-certbot-nginx | TLS 인증서 발급용 (이번 게시글에서는 따로 설명하지 않습니다.) |
| Typesense 0.17.0 | 검색엔진 |
| node.js v14.15.1 | 자바스크립트 런타임 |
| fastify 3.9.1 | node.js용 web framework |
| pm2 4.5.0 | node.js용 프로세스 관리자 |
| typesense-js 0.9.1 | node.js용 Typesense 클라이언트 |
| public-google-sheets-parser 1.0.24 | 구글 스프레드시트 공개 문서를 JSON Array로 파싱하기 위한 라이브러리 |
| Vue.js / bootstrap v4.5 / axios | 샘플 웹 페이지 제작을 위한 js/css 웹 프레임워크 및 http 클라이언트 |
| 순서 | 처리주체 | 내용 |
|---|---|---|
| 1 | 웹페이지 |
웹 페이지의 input창에 focus된 상태에서 키보드가 keyup 될 때 마다 axios를 이용하여 검색 API 호출 (debounce 없이, trim된 키워드가 truthy 하다면 API 서버에 검색 요청) |
| 2 | API서버 |
전달받은 키워드가 truthy한 경우, typesense 클라이언트를 이용해 검색 서버에 질의 |
| 3 | 검색서버 |
질의 결과를 API 서버로 응답 |
| 4 | API서버 |
검색서버로부터 전달받은 응답을 highlight된 snippet만 내려주도록 가공하여 응답 처리 |
| 5 | 웹페이지 |
웹 페이지에서 발생시킨 http 요청에 대한 응답을 vue.js를 이용하여 화면에 표현 |
이 전 게시글에서 설명한 대로 Typesense를 설치한 서버가 준비되어있어야 합니다. GPLv3 라이센스가 걸려있지만 검색 엔진은 별도의 서버 또는 Docker를 통해 운영되기때문에 라이센스로 인한 문제가 발생할 일은 없을 것으로 보입니다.
이 전 게시글을 통해 검색 서버 설치가 완료 되었다면
1) ~/typesense-client 디렉토리로 이동한 뒤,
2) vi make-data.js 명령을 실행하여 아래의 코드를 복사 & 붙여넣기 해 줍니다.
3) 이 후 node make-data.js 명령을 실행하면, address collection과 documents가 생성되고, 테스트 쿼리 결과가 콘솔에 출력됩니다.
// make-data.js
const Typesense = require('typesense')
const PublicGoogleSheetsParser = require('public-google-sheets-parser')
const parser = new PublicGoogleSheetsParser()
// collection 및 document 생성을 위한 client 준비
const client = new Typesense.Client({
nodes: [{
host: 'localhost',
port: '8108',
protocol: 'http'
}],
apiKey: '/etc/typesense/typesense-server.ini에 기록된 키', // 반드시 수정 해 주세요
connectionTimeoutSeconds: 2
})
// collection 생성
const collectionName = 'address'
const addressSchema = {
'name': collectionName,
'fields': [
{'name': 'id', 'type': 'string' },
{'name': 'index', 'type': 'int32' },
],
'default_sorting_field': 'index'
}
await client.collections().create(addressSchema)
// documents import 처리
const rawAddress = await parser.parse('1aXq4ISWG-ionVc8b0SPgASxJi7WdbQ8oz2pJ56zooCY')
const address = rawAddress.map(({ id }, index) => ({ id, index }))
await client.collections(collectionName).documents().import(address, { action: 'create' })
// documents가 잘 저장되었는지 테스트 쿼리 전송 및 결과 확인
const searchParameters = {
q: '수지구 죽전동',
query_by: 'id',
per_page: 5,
}
const searchResult = await client.collections(collectionName).documents().search(searchParameters)
console.log(searchResult)
검색 서버 <-> API 서버는 HTTP 프로토콜만으로 통신하도록 해 두면 검색 서버에는 별도의 TLS 인증서를 설치하지 않아도 됩니다.
저는 검색서버의 80번 포트를 열었고, 80번 포트로 요청이 들어오면 nginx의 proxy_pass를 통해 typesense의 기본 포트인 8108쪽으로 요청과 응답이 처리되도록 설정 해 두었습니다.
# 다음의 명령으로 검색서버용 nginx 설정파일을 하나 만들어 줍니다.
sudo vi /etc/nginx/conf.d/search.conf
# /etc/nginx/conf.d/search.conf 파일 내용:
server {
listen 80;
location / {
proxy_pass http://127.0.0.1:8108;
proxy_http_version 1.1;
}
}
# 다음의 명령으로 nginx를 reload합니다.
sudo systemctl reload nginx
# 다음의 명령을 통해 검색서버의 public IP를 확인 해 두세요.
curl https://api.fureweb.com/ip
# 위에서 확인한 ip의 80번 포트로 HTTP GET 요청이 정상적으로 되는지 확인 해 봅니다.
curl 위에서-얻은-public-ip
# { "message": "Not Found"} 이런 응답이 와야 정상입니다.
만약 응답이 오지 않는다면, 80번 포트가 정상적으로 열려있는지 확인 후 조치가 필요합니다. 이 부분은 클라우드 업체마다 다를 수 있어서 별도 확인 후 처리를 진행 해 주세요.
이제 검색서버에서 할 일은 모두 마무리 되었습니다. 클라우드 서비스의 방화벽 설정에서 API 서버가 아닌 다른 IP에서 요청이 들어오는 경우 Drop 처리되도록 하는 설정 등은 선택적으로 진행하셔도 됩니다.
저는 fastify를 이용해서 간단하게 API 서버를 만들었습니다. 아래 경로는 원하는대로 사용하셔도 됩니다.
# api 디렉토리를 사용자 홈 하위에 생성한 뒤 이동합니다.
mkdir -p ~/api && cd ~/api
# 초기화 후 fastify API 서버 코드를 작성하기 위한 패키지들을 설치합니다.
npm init -y && npm install fastify fastify-cors pm2 http-errors typesense @babel/runtime
# pm2를 통해 실행 시킬 파일을 하나 생성합니다.
vi app.js
아래의 코드를 app.js 내용으로 만들어 주세요. 단, 내용 중 검색 서버 IP와 apiKey는 상황에 맞게 수정 해 주셔야합니다. 실제로는 이런 형태로 코드를 작성하지 않지만, 최대한 간단하게 설명하기 위해 하나의 파일로 묶어 두었습니다.
// app.js (아래 검색서버 IP와 apiKey는 반드시 변경 해 주셔야 합니다.)
const httpErrors = require('http-errors')
const Typesense = require('typesense')
const typesenseClient = new Typesense.Client({
nodes: [{
host: 'typesense 서버 IP', // 반드시 변경 해 주세요
port: '80',
protocol: 'http'
}],
apiKey: 'master-or-search-only-api-key', // 반드시 변경 해 주세요
connectionTimeoutSeconds: 2
})
const fastify = require('fastify')({ trustProxy: true })
// cors 허용을 위한 플러그인 등록
fastify.register(require('fastify-cors'))
// routes에 추가
fastify.get('/search/address', async function (request, reply) {
const { keyword } = request.query
if (!keyword) return reply.send(httpErrors.BadRequest('keyword is required'))
// 검색 서버로 질의 및 응답용 결과 가공
const searchParameters = { q: keyword, query_by: 'id', per_page: 10 }
const searchResults = await typesenseClient.collections('address').documents().search(searchParameters)
const result = searchResults.hits.map((res) => (res.highlights[0] || {}).snippet || res.document.id)
return reply.send(result)
})
// API 서버 실행
fastify.listen(3000, (err, address) => {
if (err) throw err
fastify.log.info(`server listening on ${address}`)
})
app.js 파일을 정상적으로 생성했다면, 다음 명령을 통해 pm2로 서버를 실행시킵니다.
# pm2를 이용하여 app.js 파일 실행
npx pm2 start app.js
# API 서버가 실행됐다면, 아래의 명령을 통해 응답이 정상적으로 내려오는지 확인합니다.
curl localhost:3000/search/address?keyword=%EC%A3%BD%EC%A0%84%EB%8F%99
이제 API 서버도 준비가 완료 되었습니다. 검색 서버에서 nginx config파일을 추가한 뒤 reload 한 것 처럼, API 서버도 같은 작업을 진행 해 줍니다.
# 서버 IP 80번 포트에 대한 기본 설정 파일에 대한 링크를 삭제합니다.
# sites-enabled 디렉토리 내 파일은 /etc/nginx/sites-available 파일에 대한 심볼릭 링크입니다.
sudo rm /etc/nginx/sites-enabled/default
# 다음의 명령으로 API 서버용 nginx 설정파일을 하나 만들어 줍니다.
sudo vi /etc/nginx/conf.d/api.conf
# /etc/nginx/conf.d/api.conf 파일 내용:
server {
listen 80;
location / {
proxy_pass http://127.0.0.1:3000$uri$is_args$args; # URI와 파라미터를 모두 fastfy쪽으로 전달합니다.
proxy_http_version 1.1;
}
}
# 다음의 명령으로 nginx를 reload합니다.
sudo systemctl reload nginx
# 다음의 명령을 통해 API 서버의 public IP를 확인 해 보세요.
curl https://api.fureweb.com/ip
# API 서버의 public IP로 검색 요청을 해 보고 정상 요청이 일어나는지 확인 해 봅니다.
curl 위에서-얻은-public-ip/search/address?keyword=%EC%A3%BD%EC%A0%84%EB%8F%99
# ["경상북도 상주시 <mark>죽전동</mark>",... 같은 형태의 응답이 내려와야 정상입니다.
만약 응답이 오지 않는다면, 80번 포트가 정상적으로 열려있는지 확인 후 조치가 필요합니다. 이 부분은 클라우드 업체마다 다를 수 있어서 별도 확인 후 처리를 진행 해 주세요.
서버의 public IP로 요청한 응답이 기대한 대로 내려왔다면, API 서버에 대한 설정도 완료 되었습니다.
현재 사용중인 PC의 적절한 위치에 다음과 같은 내용을 담은 HTML 파일을 하나 만들어 봅니다.
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Typesense 검색 엔진을 이용한 행정동 한글 검색 성능 테스트</title>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@100;300;400;500;700;900&display=swap">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@4.5.3/dist/css/bootstrap.min.css" integrity="sha384-TX8t27EcRE3e/ihU7zmQxVncDAy5uIKz4rEkgIXeMed4M0jlfIDPvg6uqKI2xXr2" crossorigin="anonymous">
<style>
body {
font-size: 16px;
font-family: 'Noto Sans KR', sans-serif;
max-width: 1000px;
margin: 0 auto;
}
.search-wrap {
padding: 1.5rem;
}
.search-result {
margin-top: 2rem;
}
.card {
margin-bottom: 2rem;
}
.card-body {
background-color: #efffff;
}
.search-result {
min-height: 300px;
}
</style>
</head>
<body>
<div class="search-wrap">
<h3>Typesense 검색 엔진을 이용한 행정동 한글 검색 성능 테스트</h3>
<label for="keyword">검색 키워드</label>
<input id="keyword" type="text" autocomplete="off" class="form-control" placeholder="검색할 행정동 키워드를 입력하세요. 예) 수지구 죽전동" @input="keyword = $event.target.value" @keyup="search">
<section class="search-result">
<h4>검색 결과</h4>
<ul v-if="searchResult.length > 0">
<li v-for="result of searchResult" v-html="result"></li>
</ul>
<div v-else>검색 결과가 없습니다.</div>
</section>
</div>
<script src="https://cdn.jsdelivr.net/npm/vue@2.6.0"></script>
<script src="https://unpkg.com/axios/dist/axios.min.js"></script>
<script>
(() => {
const APIServer = 'API 서버의 ip 주소를 입력해 주세요'
new Vue({
el: '.search-wrap',
data () {
return {
keyword: null,
searchResult: [],
}
},
methods: {
async search () {
const keyword = String(this.keyword).trim()
if (!keyword) {
return this.searchResult = []
}
this.searchResult = (await axios.get(`http://${APIServer}/search/address?keyword=${keyword}`).then((r) => r.data)) || []
},
},
})
})()
</script>
</body>
</html>
위 내용 중 APIServer 변수의 값은 API 서버의 public 아이피로 업데이트 해 주세요.
위 html 페이지를 원하는 이름으로 저장한 뒤, 해당 파일을 브라우저에서 열어 API 서버와 검색 서버의 응답이 얼마나 빨리 내려오는지 한번 확인 해 보세요!
웹페이지에서 각 요청에 대한 네트워크 응답속도를 확인 해 보고 너무 놀랐었는데, 다른분들은 어떠셨을지 잘 모르겠네요. 요청자의 ip만 내려주는 간단한 API도 응답에 6~15ms가 소요되었는데, API 서버에서 검색 서버까지 들렀다 오는데도 많이 차이가 나지 않는 수준의 응답속도를 보여준다는게 정말 믿겨지지 않았습니다.
얼마나 검색 속도가 빠를까, 내가 직접 만들어볼 수 있을까? 라는 질문에서 시작하긴 했던 컨텐츠였는데 생각보다는 큰 어려움이 없었습니다. 런타임에 컬렉션에 대한 수정을 한다거나, 기록 시 발생할 수 있는 동시성 문제 등에 대해서는 경험 해 보지 못했기때문에 이 부분도 조금 궁금함으로 남기고 마무리 하게 되는 것 같습니다. 무엇보다도, 아직 한국어 형태소 분석기의 부재로 정확한 결과를 얻을 수 없다는 점이 가장 아쉬운 점인 것 같네요.
뭔가 정리하다보니 원래 이렇게 쓰려고 했던게 맞는지 잊어버리고 급 마무리가 되는 느낌이긴 합니다 ^^;
위에 작성해 둔 코드들의 실행 여부를 확인하며 게시글을 작성했지만, 정리하는 과정에서 누락되거나 충분하지 않은 설명이 있을 수 있으니 만약 내용과 관련하여 궁금하신 점이 있다면 언제든 문의를 남겨주세요.
긴 글 읽어주셔서 감사합니다!
]]>

얼마 전 geeknews에 등록된 2백만개 음식 레시피 검색 엔진 게시글을 읽어본 뒤, 이 엔진의 검색 속도가 정말 빠르기에 한번 사용해보고싶다는 생각이 들어서 간단하게 서버에 설치 후 클라이언트를 사용 해 보았습니다. 현재의 게시글에서는 웹애플리케이션을 통해 검색결과를 표현하는 형태가 아닌, 공식 가이드 문서에서 설명하고 있는 방법을 한글 샘플 데이터로 간단히 사용하는 방법을 알아보려고 합니다. 실제로 한번 설치 후 사용 해 보니 그리 어렵지 않게 사용할 수 있을 것 같아서, 정리할 겸 이렇게 게시글을 작성하게 되었습니다.
# 현재 기준 최신버전 0.17.0
wget --trust-server-names https://dl.typesense.org/releases/0.17.0/typesense-server-0.17.0-amd64.deb
# 설치 성공 후 자동으로 typesense-server가 실행됨
sudo apt install ./typesense-server-0.17.0-amd64.deb
; /etc/typesense/typesense-server.ini
; Typesense Configuration
[server]
api-address = 0.0.0.0
api-port = 8108
data-dir = /var/lib/typesense
api-key = 자동으로생성된마스터API키
log-dir = /var/log/typesense
curl http://localhost:8108/health
# 기대 응답> {"ok":true}
curl -sL https://deb.nodesource.com/setup_14.x | sudo -E bash -
sudo apt-get install -y nodejs
mkdir ~/typesense-client
cd ~/typesense-client
npm install typesense @babel/runtime public-google-sheets-parser
--experimental-repl-await 옵션을 준 뒤 REPL을 실행합니다.node --experimental-repl-await
# 아래의 순서대로 쭉 작성 해 봅니다.(공식 문서에 나와있는 내용 거의 그대로 진행)
# apiKey는 /etc/typesense/typesense-server.ini 파일에 입력된 내용을 미리 확인하여 준비 해 주세요.
const Typesense = require('typesense')
const PublicGoogleSheetsParser = require('public-google-sheets-parser')
const parser = new PublicGoogleSheetsParser()
const client = new Typesense.Client({
nodes: [{
host: 'localhost',
port: '8108',
protocol: 'http'
}],
apiKey: '/etc/typesense/typesense-server.ini에 기록된 키',
connectionTimeoutSeconds: 2
})
const booksSchema = {
name: 'books',
fields: [
{ name: 'title', type: 'string' },
{ name: 'authors', type: 'string[]' },
{ name: 'publisher', type: 'string' },
{ name: 'publication_year', type: 'int32' },
{ name: 'ratings_count', type: 'int32' },
{ name: 'average_rating', type: 'float' },
{ name: 'authors_facet', 'type': 'string[]', 'facet': true },
{ name: 'publication_year_facet', 'type': 'string', 'facet': true },
],
default_sorting_field: 'ratings_count',
}
const afterCreateBooksSchema = await client.collections().create(booksSchema)
// 궁금하시다면 afterCreateBooksSchema를 확인 해 보세요. 여기서는 부연설명을 하지 않겠습니다.
const booksSpreadsheetId = '16i6SZEmwZ_F1MO2pGNJeq7WLJ6bP0QJkXKA_vA0GTG8'
const rawBooks = await parser.parse(booksSpreadsheetId)
const books = rawBooks.map((book) => {
// 각각 올바른 타입으로 형변환 합니다.
book.authors = JSON.parse(book.authors)
book.authors_facet = JSON.parse(book.authors_facet)
book.publication_year_facet = `${book.publication_year_facet}`
return book
})
// 얻어온 book 정보를 books 컬렉션에 document로 각각 추가합니다.
books.forEach((book) => client.collections('books').documents().create(book))
// 아래 실행할 코드에서 재활용하기 위해 let으로 선언합니다. (이하 마찬가지)
let searchParameters = {
q: '파이썬',
query_by: 'title',
sort_by: 'ratings_count:desc'
}
let searchResults = await client.collections('books').documents().search(searchParameters)
/** 결과는 다음과 같습니다.
{
facet_counts: [],
found: 7, // 파이썬이라는 키워드가 들어간 책이 15권 중에 7권 이라니.. 엄청난 인기군요 ^^;
hits: [
{
document: {
authors: [ '박응용' ],
authors_facet: [ '박응용' ],
average_rating: 9.5,
id: '0',
publication_year: 2019,
publication_year_facet: '2019',
publisher: '이지스퍼블리싱',
ratings_count: 49,
title: 'Do it! 점프 투 파이썬'
},
highlights: [
{
field: 'title',
matched_tokens: [ '파이썬' ],
snippet: 'Do it! 점프 투 파이썬'
}
],
text_match: 33488996
},
// ... 생략
],
page: 1,
request_params: { per_page: 10, q: '파이썬' },
search_time_ms: 0
}
*/
mark태그가 붙은 결과를 snippet 속성에 담아 내려줍니다.&& 연산자를 사용하여 추가 조건을 계속 나열할 수 있습니다.searchParameters = {
q: '파이썬',
query_by: 'title',
filter_by: 'average_rating:>9.6',
sort_by: 'average_rating:desc'
}
searchResults = await client.collections('books').documents().search(searchParameters)
/** 결과는 다음과 같습니다.
{
facet_counts: [],
found: 1,
hits: [
{
document: {
authors: [ '권철민' ],
authors_facet: [ '권철민' ],
average_rating: 10,
id: '7',
publication_year: 2020,
publication_year_facet: '2020',
publisher: '위키북스',
ratings_count: 2,
title: '파이썬 머신러닝 완벽 가이드'
},
highlights: [
{
field: 'title',
matched_tokens: [Array],
snippet: '파이썬 머신러닝 완벽 가이드'
}
],
text_match: 33488996
}
],
page: 1,
request_params: { per_page: 10, q: '파이썬' },
search_time_ms: 0
}
*/
공식 가이드 문서에는 패싯 검색 등 다른 예제가 더 있지만, 제가 이 부분을 위한 데이터를 만들기가 귀찮(-_-;)아서 일단은 여기까지만 간단하게 사용 방법을 확인 해 보았습니다. 관심이 있으시다면 해당 문서를 확인 해 보세요!
고가용성(High Availability)을 위한 설정도 매우 간단하고, 검색 속도도 굉장히 빠르기 때문에 검색서버를 운영할 계획이 있으시다면 관심가지고 한번 확인 해 보시는것이 좋을 것 같습니다. 단, GPLv3 라이센스이기때문에 이 부분은 확실히 확인 후 결정하시면 될 것 같습니다. (검색용 API 서버로 분리해 두면 별 이슈는 없는 것으로 알고 있습니다.)
충분히 많은 데이터셋을 통해 웹 브라우저에서 검색 속도 테스트를 해 보고 싶은데, 이 부분은 다음에 데모용 데이터셋을 많이 만들어낸 뒤, 데모 웹사이트를 만들어 다른 게시글을 통해 소개할 수 있도록 해 볼 계획입니다.
읽어주셔서 감사합니다~
]]>

First of all, I ask for your understanding that it may not be smooth because it is written with the help of Google Translator because I’m not good at English.
I created a free API using a library called Public Google Sheets Parser and wrote a post to share how to use it.
I used AWS Lightsail to create an inexpensive server in the ap-northeast-2 region, where I deployed the https://api.fureweb.com service with a simple document. If your country is far from South Korea, the API response may be slow.
To use this API, you need to create a Google Spreadsheet document, fill in the header for the first row, data from the second row, and get the Spreadsheet ID. Also, the document’s view permission must be set to public.
Spreadsheet ID refers to the value between https://docs.google.com/spreadsheets/d/ and /edit#gid=0.
Click the sample Google Spreadsheet document link below to see the structure and content of the document.
https://docs.google.com/spreadsheets/d/1oCgY0UHHRQ95snw7URFpOOL_DQcVG_wydlOoGiTof5E/edit
If you request through curl like the following,
curl -X GET "https://api.fureweb.com/spreadsheets/1oCgY0UHHRQ95snw7URFpOOL_DQcVG_wydlOoGiTof5E" -H "accept: */*"
Then, you can get a response like below.
{
"data": [
{
"id": 1,
"title": "This is a title of 1",
"description": "This is a description of 1",
"createdAt": "2020-11-12",
"modifiedAt": "2020-11-18"
},
{
"id": 2,
"title": "This is a title of 2",
"description": "This is a description of 2",
"createdAt": "2020-11-12",
"modifiedAt": "2020-11-18"
},
{
"id": 3,
"title": "This is a title of 3",
"description": "This is a description of 3",
"createdAt": "2020-11-12",
"modifiedAt": "2020-11-18"
},
{
"id": 4,
"title": "This is a title of 4",
"description": "This is a description of 4",
"createdAt": "2020-11-12",
"modifiedAt": "2020-11-18"
},
{
"id": 5,
"title": "This is a title of 5",
"description": "This is a description of 5",
"createdAt": "2020-11-12",
"modifiedAt": "2020-11-18"
},
{
"id": 6,
"title": "This is a title of 6",
"description": "This is a description of 6",
"createdAt": "2020-11-12",
"modifiedAt": "2020-11-18"
},
{
"id": 7,
"title": "This is a title of 7",
"description": "This is a description of 7",
"createdAt": "2020-11-12",
"modifiedAt": "2020-11-18"
},
{
"id": 8,
"title": "This is a title of 8",
"description": "This is a description of 8",
"createdAt": "2020-11-12",
"modifiedAt": "2020-11-18"
},
{
"id": 9,
"title": "This is a title of 9",
"description": "This is a description of 9",
"createdAt": "2020-11-12",
"modifiedAt": "2020-11-18"
},
{
"id": 10,
"title": "This is a title of 10",
"description": "This is a description of 10",
"createdAt": "2020-11-12",
"modifiedAt": "2020-11-18"
}
]
}
If the spreadsheet ID is invalid or you enter a document ID for which do not have access permission, you will receive an empty array of responses as follows.
{
"data": []
}
Please refer to the simple form written in API document use before.
By sending an HTTP request, you can get JSON response such as user list, product list, etc., created based on the contents of the spreadsheet you created, with the desired key and value.
If the ID does not exist, or an unauthorized document, the response is always ‘200 OK’.
From the client developer’s point of view, it can be cumbersome to develop the screen first if the server’s API response has not yet been confirmed.
At this time, you can register the actual API response that you expect to receive from the server in Google Spreadsheet and receive the desired JSON response.
There is a big limitation that only public documents can be used, but I think it has its own advantages because API Key is not required. I think it’s a good way to use it when it fits in such a case, so I share the content.
I hope it is used well.
Thanks for reading.
]]>

안녕하세요. 이 전의 브라우저에서 Google Sheets를 서버 없이 데이터베이스처럼 사용하기 게시글을 작성하기 위해 만들었던 Public Google Sheets Parser라는 라이브러리를 이용한 무료 API를 만들어, 사용 방법을 공유하기위해 게시글을 작성하게 되었습니다.
AWS Lightsail을 이용해 가장 저렴한 서버를 생성했고, 그곳에 https://api.fureweb.com 서비스를 간단한 문서와 함께 배포 했습니다.
구글 스프레드시트 문서를 하나 만든 뒤, 첫번째 행은 머리글, 두번째 행 부터는 데이터를 입력한 뒤 반드시 공개 보기 설정을 해 둔 상태에서 스프레드시트 ID만 가져와 사용하시면 편하게 사용하실 수 있을 것 같습니다.
스프레드시트 ID는 https://docs.google.com/spreadsheets/d/ 와 /edit 사이에 있는 값을 의미합니다.
아래의 샘플 구글 스프레드시트 문서 링크를 클릭해서 내용을 확인 해 보세요. https://docs.google.com/spreadsheets/d/1oCgY0UHHRQ95snw7URFpOOL_DQcVG_wydlOoGiTof5E/edit
만약 다음과 같이 curl을 통해 요청한다면,
curl -X GET "https://api.fureweb.com/spreadsheets/1oCgY0UHHRQ95snw7URFpOOL_DQcVG_wydlOoGiTof5E" -H "accept: */*"
아래와 같은 응답을 받을 수 있습니다.
{
"data": [
{
"id": 1,
"title": "This is a title of 1",
"description": "This is a description of 1",
"createdAt": "2020-11-12",
"modifiedAt": "2020-11-18"
},
{
"id": 2,
"title": "This is a title of 2",
"description": "This is a description of 2",
"createdAt": "2020-11-12",
"modifiedAt": "2020-11-18"
},
{
"id": 3,
"title": "This is a title of 3",
"description": "This is a description of 3",
"createdAt": "2020-11-12",
"modifiedAt": "2020-11-18"
},
{
"id": 4,
"title": "This is a title of 4",
"description": "This is a description of 4",
"createdAt": "2020-11-12",
"modifiedAt": "2020-11-18"
},
{
"id": 5,
"title": "This is a title of 5",
"description": "This is a description of 5",
"createdAt": "2020-11-12",
"modifiedAt": "2020-11-18"
},
{
"id": 6,
"title": "This is a title of 6",
"description": "This is a description of 6",
"createdAt": "2020-11-12",
"modifiedAt": "2020-11-18"
},
{
"id": 7,
"title": "This is a title of 7",
"description": "This is a description of 7",
"createdAt": "2020-11-12",
"modifiedAt": "2020-11-18"
},
{
"id": 8,
"title": "This is a title of 8",
"description": "This is a description of 8",
"createdAt": "2020-11-12",
"modifiedAt": "2020-11-18"
},
{
"id": 9,
"title": "This is a title of 9",
"description": "This is a description of 9",
"createdAt": "2020-11-12",
"modifiedAt": "2020-11-18"
},
{
"id": 10,
"title": "This is a title of 10",
"description": "This is a description of 10",
"createdAt": "2020-11-12",
"modifiedAt": "2020-11-18"
}
]
}
id가 유효하지 않거나, 접근 권한이 없는 문서 ID를 입력한 경우 다음과 같이 빈 배열의 응답을 받게 됩니다.
{
"data": []
}
API 문서에 적어 둔 형태로 HTTP 요청을 보내주시면, 본인이 작성해 둔 스프레드시트의 원하는 사용자 리스트, 상품 리스트 등의 데이터를 원하는 key와 value를 가진 JSON Array로 돌려받을 수 있으며, ID가 존재하지 않거나, 권한이 없는 문서여서 실패하는 경우라도 응답은 모두 200 OK로 내려가게 해 두었습니다.
개인적으로는 웹이든 앱이든 클라이언트 입장에서 화면을 개발해야 할 때, 아직 실제 API 응답을 내려받을 수 없을 때 요긴하게 사용할 수 있을 것 같다는 생각을 해서 이렇게 만들어 보게 되었는데요.
기획 롤을 맡은 분이 스프레드시트로 필드명과 값들을 입력 해 둔 상태에서 클라이언트 개발을 맡은 분에게, 특정 스프레드시트 문서에 입력해 둔 내용을 JSON 응답을 받을 수 있으니, 이걸 기반으로 개발 해 주세요~ 하는 식의 요청도 할 수 있지 않을까 생각 해 보았습니다 ㅎㅎ;
얼마나 쓸모가 있을지는 잘 모르겠지만, 클라이언트 개발 시 요긴하게 사용될 수 있었으면 합니다.
읽어주셔서 감사합니다!
]]>

서버 없이 브라우저를 통해 구글 시트의 데이터를 데이터베이스에 저장되어있는 데이터 처럼 활용할 수 있는 방법을 혹시 알고 계셨나요? 매우 간단한 방법이지만 많이 알지 못하고 계시는 것 같아서 공유 해 보려고 합니다.
아주 간단히 동적인 컨텐츠를 제공해야 하는 웹페이지 입장에서 데이터베이스 서버와 웹서버를 구축하여 API로 뽑아내 내려주거나, AWS Lambda같은 서버리스 기능을 사용하는것이 무겁고 어쩌면 사치라고 느껴지는 경우에 요긴할 것 같다고 생각합니다. 또는 프론트엔드 화면을 빨리 만들어야 할 때, 가짜 데이터를 만들기 위한 좋은 대안이 될 수 있지 않을까 생각합니다.
fetch API를 지원하지 않는 브라우저에서는 사용할 수 없다는 점, 문서가 누구나 읽을 수 있는 공개 상태여야한다는 점, 시트를 지정할 수 없이 맨 처음에 위치한 시트의 데이터만 가져올 수 있다는 점이 가장 큰 제약사항이기는 합니다. 하지만 그만큼 API 키가 필요 없고, 간단히 쓰기 편한 것 같다고 느껴 공유할 생각을 하게 되었던 것 같아요.
원래는 그냥 직접 호출해서 가공하는 방법을 풀어 써 보려다가 이 글을 쓰기 위한 Public Google Sheets Parser라는 이름의 라이브러리를 간단히 만들어 보았기에, 이것을 어떻게 사용할 수 있는지 설명 해 보겠습니다.
http://fureweb.com/public-google-sheets-parser.html 경로에 데모 페이지를 업데이트 해 두었습니다.
사용 해 보는 방법은 매우 간단합니다.
https://docs.google.com/spreadsheets/d/스프레드시트ID/edit#gid=0 형태로 되어있음을 알 수 있습니다. 여기서 스프레드시트ID로 표시해 둔 영역의 범위를 미리 잘 확인 해 주세요. 그리고 우측 상단의 공유 버튼을 눌러 호출되는 팝업 페이지에서 변경 버튼을 눌러 링크가 있는 모든 사용자에게 공개를 선택한 뒤 완료 버튼을 눌러 공개 상태로 문서를 만들어 준 뒤, 아래와 같이 데이터를 입력 해 보겠습니다.

스프레드시트ID를 정확하게 복사한 뒤, 데모 페이지로 이동하여 input창에 붙여넣기 후 GET ITEMS 버튼을 눌러 응답받은 결과를 확인 해 보세요. 아니면 창에 떠 있는 SAMPLE ID로 주어진 값을 복사해서 input창에 붙여넣어 버튼을 눌러보세요. 방금 직접 입력하셨던 머리글과 그 값이 가공되어 배열로 내려오는 것을 확인하실 수 있습니다. 응답이 정상적으로 안내려온다면, 문서 권한에 모두 읽기 권한이 없어서 그럴 가능성이 가장 높습니다.

코드가 많이 부끄럽긴 하지만, 관심이 있으시다면 GitHub에 등록한 소스코드에서 확인하실 수 있습니다.
Google Visualization API에서 내려주는 문자열을 직접 가공해서 필요한 형태의 배열로 만들어 주는게 전부인데, 너무 간단한 내용이라 자세히 설명하기 뭐해서 궁금하시면 소스코드를 확인 해 주시면 될 것 같습니다.
스프레드시트에 form으로 입력받은 내용을 리스트로 뿌려서 보여준다거나, 제목과 상세내용 그리고 이미지 경로 등을 머리글로 만들어 두고 데이터를 넣으면, 간단한 mock용 API로도 활용할 수 있지 않을까 생각합니다.
Node.js에서도 다음과 같이 사용할 수 있습니다.
// public-google-sheets-parser 모듈 설치
$ npm i public-google-sheets-parser
// node.js를 통해 다음의 코드를 실행
const PublicGoogleSheetsParser = require('public-google-sheets-parser')
const spreadsheetId = '10WDbAPAY7Xl5DT36VuMheTPTTpqx9x0C5sDCnh4BGps'
const parser = new PublicGoogleSheetsParser(spreadsheetId)
parser.parse().then((items) => {
// items는 다음과 같습니다: [{ a: 1, b: 2, c: 3}, { a: 4, b: 5, c: 6 }, { a: 7, b: 8, c: 9 }]
console.log(items)
})
보안에 크게 문제될 부분이 없는 데이터를 서비스하는 정적인 웹 사이트에서 사용하면 특히 괜찮은 방법이지 않을까 생각하고 있습니다.
아직 여러 플랫폼에서 지원하는 형태로 npm 모듈로 만들어 배포하는 방법을 잘 몰라서, CDN을 통한 브라우저에서의 사용과 node.js에서 require로 사용하는 부분밖에 지원하지 못하고 있는 상태입니다. 이 부분과 태스크 러너를 통해 배포용 파일을 만드는 부분을 공부해서 적용할 계획입니다.
읽어주셔서 감사합니다!
]]>
아래 얘기들은 어쩌면 너무 당연한 얘기일 수도 있는데, 처리해 본 적이 없으면 삽질을 많이 하게 되는 경우가 있습니다.
저 같은 경우는 특정 HTML 요소의 높이가 변경될 가능성이 없는 경우에는 요구사항에 적혀있지 않더라도 기본적으로 말줄임표 처리가 될 수 있도록 작업을 하고 있습니다.
부모 요소가 자식들 보다 더 좁은 너비를 가져 모든 자식들이 표현되기 어려운 상황이라면, 스크롤 바를 두어 모든 자식들이 스크롤을 통해 보일 수 있도록 기회를 주거나 아니면 말줄임표 처리가 되도록 많이 하고 있을텐데요.
스크롤 바를 제공한다면 모든 자식들이 사용자에게 노출 될 기회가 있을것이고, 말줄임표 처리를 하게 된다면 앞선 자식들만 노출되고 나머지 자식들은 부모가 충분한 너비를 가지지 못한다면 영원히 사람들에게 잊혀진 존재가 될 것입니다.
이처럼 말줄임표 처리도 포기할 수 없고, 숨겨진 자식들도 빛을 봐 주게 해야한다면 어떻게 해야할까요?
제목처럼, 자식의 너비를 확인 해 봤더니 부모의 너비보다 더 넓었다는게 확인되었다면, 이 때 마우스를 부모 위에 올렸을 때 자식들이 모두 표현될 수 있도록 툴팁을 바로 아래 표현을 하거나 하는 방법으로 목표를 달성할 수 있을 것 같습니다.
저는 이런 상황에서 아래와 같은 방법으로 문제를 해결했습니다.
우선, 말줄임표 처리를 위해서는 부모에 다음과 같은 css 속성을 가지게 해 주면 됩니다.
<style>
.parent {
width: 130px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
</style>
<div class="parent">
<!-- 아래 label은 16px 기준으로 200px 정도의 너비를 가집니다. -->
<label>이건 뭐 하는 새끼손가락일까요?</label>
</div>
위와 같은 상황이라면, 부모는 130px이고 자식은 200px이 되기때문에 자식이 가진 모든 텍스트를 보여주지 못하고

라는 욕설로 변하게 될 수가 있습니다. -_-;
아무튼 다음의 css 속성들을 이용하여 말줄임표 처리를 할 수 있습니다.
| 속성 | 값 | 설명 |
|---|---|---|
| white-space | nowrap | 부모가 가진 width를 초과하는 자식의 텍스트가 있더라도, 부모가 주어진 높이 내에서 계속 옆으로 이어 작성될 수 있도록 해 주는 설정 |
| overflow | hidden | 부모가 가진 영역 바깥에 자식이 표현되지 않도록 해 주는 설정 |
| text-overflow | ellipsis | 위 설정을 통해 자식의 너비가 부모 너비를 초과한 경우에 적절하게 말줄임표 처리를 하도록 하는 설정 |
이제 다음과 같은 HTML 코드가 있다고 해 보겠습니다.
<style>
.text-container {
font-size: 40px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
</style>
<div class="text-container">
<label class="text">난 text-container의 너비에 따라 말줄임표 처리가 될 수 있어!</label>
</div>
위와 같이 코드를 작성한 상황에서, text class에 담긴 문자의 길이가 길어져 브라우저에서 말줄임표 처리가 되었음을 알기 위해선 어떤 방법이 있을까요?
처음엔 HTMLElement에서 overflowed된 상태를 혹시 제공해주는건 아닐까 찾아보았으나.. 존재하는데 제가 잘못알고 있는 것일 수도 있지만, 그런건 존재하지 않았습니다.
그래서 결국 text를 감싸고 있는 text-container의 너비와, 그 안의 text의 너비를 비교해 본 뒤, text가 text-container보다 더 너비가 넓은지를 비교해 보는 방법으로 말줄임표 처리가 되었는지 확인 해 보았습니다.
const textContainerElement = document.querySelector('.text-container')
const textElement = document.querySelector('.text')
if (textElement.offsetWidth > textContainerElement.offsetWidth) {
// 말줄임표 처리 된 상태
}
하지만 실제 환경에서는 이처럼 고정된 너비들을 가진 요소들만 제어할 리가 없겠죠? 저는 다음과 같은 시나리오를 어떻게 처리해야하는지 고민 해 본 적이 있었습니다.
.flexible-options {
height: 40px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
.option {
display: inline-block;
border: 1px solid black;
}
}
<dd class="flexible-options">
<dl class="option">동적으로 변하는 옵션 1</dl>
<dl class="option">동적으로 변하는 옵션 2</dl>
<dl class="option">동적으로 변하는 옵션 3</dl>
<dl class="option">...</dl>
</dd>
위와 같은 상황에서는, 각 option이 차지하는 너비에 따라 flexible-options가 말줄임표 처리가 될 수도, 아닐 수도 있게 됩니다.
결국 flexible-options 내에 children들이 존재하는 상태에서 children의 offsetWidth 값을 모두 더해 봐야만 말줄임표 처리가 되었는지 확인할 수 있습니다.
const flexibleOptionsElement = document.querySelector('.flexible-options')
const parentWidth = flexibleOptionsElement.offsetWidth
const childrenWidth = [...flexibleOptionsElement.children]
.map(({ offsetWidth }) => offsetWidth)
.reduce((p, c) => p + c, 0)
if (childrenWidth > parentWidth) {
// 말줄임표 처리 된 상태
}
윈도우 resize 또는 option의 텍스트가 변경 될 때 마다 위 요소들의 offsetWidth를 확인해서 말줄임표 처리가 되었는지 확인할 수 있게 됩니다.
만약 최신 상태의 자식의 너비가 부모 너비보다 더 넓은 경우, 해당 parent에 마우스를 올렸을 때 툴팁을 표현하여 모든 요소가 보일 수 있도록 작업을 해 주는 형태 또는 부모의 높이/너비 등을 충분히 넓혀주는 식으로 처리하게 된다면 자식이 더 넓어서 화면에 보여지지 않을 수 있는 상황에서 모두 노출시킬 수 있게 됩니다.
]]>