한 반년전쯤에 나는 크롬에 종속적인 북마크가 불편해저셔 나만의 북마크 관리 서비스를 만들자! 하고 만들게 된 서비스가 있다.
내가 만든 북마켓은 NestJS 기반의 백엔드와 PostgreSQL 데이터베이스, NextJS 기반의 프론트로 이루어진 서비스이다.
주 목적은 내가 평소에 카카오톡 개인톡에 보내는 여러 링크들이나 크롬 북마크에 저장된 링크들을 한 곳에서 모아보고 싶어서 만들었는데, 만들다보니 내 자신이 너무 잘쓰게 돼서 그냥 나랑 내 지인들 몇명만 사용하는 북마크 관리 서비스가 되었다.
사실 이 북마켓도 처음에 만들었을 때에 비해 엄청난 발전과 변경들을 거치게 되었는데,
처음에는 NextJS 풀스택으로 만들었었고, 인증은 Clerk, DB는 NeonDB, ORM은 Drizzle로 유행하는 NextJS 풀스택 기술들을 사용해서 만들었었다.
근데 NestJS 공부를 어느정도 했었고, 그냥 실전에 부딪히면서 배워보자는 생각에 무작정 NextJS / NestJS 프론트/백엔드 분리 된 서비스로 리팩토링을 했었다.
리팩토링하는 과정에서
Client Component위주였던 웹을 거의 전부 Server Component와 Server Action을 활용하는 구조로 리팩토링을 했고,
Clerk를 걷어내서 자체 인증/인가를 구현하고
NestJS와 Drizzle이 아직 호환이 잘 안되는건지, 찾을 수 있는 자료가 많지 않아서 TypeORM으로 ORM을 변경했었다.
이렇게 보니까 되게 그냥 수월한 리팩토링같지만, 하나하나가 정말 머리가 아픈… 리팩토링이었는데, 아무튼 리팩토링을 다 했었다.
근데 NestJS로 백엔드를 구현을 했다보니 배포를 해야했는데, 사이드 프로젝트에 돈이 나가는게 싫어서 AWS 배포는 안하고 Railway라는 서비스를 사용해서 배포를 했었다.
근데 NextJS/NestJS로 리팩토링하면서 Pnpm Workspace를 사용해서 모노레포를 구축했었는데, Railway가 npm이 아닌 다른 패키지 매니저를 잘 지원하지 않았어서, pnpm을 지원하는 다른 호스팅 서비스를 찾아서 Render라는 서비스를 사용하게 됐었다.
하지만 Render의 가장 큰 단점은 Cold Start로, 한시간정도동안 쓰지 않으면 다음 요청이 들어오면 약 2분 가까이 걸려서 인스턴스를 다시 생성하는 과정을 거쳤기에... 만들었던 나조차도 쓰기 싫은 서비스가 되고 있었다.
돈을 내고 AWS EC2에 배포를 하면 해결이 될 문제였지만, 나는 오히려 돈을 쓸거면 제대로 쓰자 생각해서 나만의 홈서버 구축을 위한 라즈베리파이를 그냥 사버렸다.
그래서 나는 홈서버 오너가 되었으니, 불편했던 Render 서버는 바로 닫아버리고, 내 라즈베리파이에 북마켓 백엔드를 배포했다!!
하지만 DB는 느렸다
홈서버 덕분에 백엔드 서버는 내 라즈베리파이에서 돌아가게 되어서 되게 빨랐지만, 데이터베이스는 아직 NeonDB를 사용하고 있었는데, NeonDB는 아시아 리전이 없어서.. 미국 리전을 선택할 수밖에 없었다.
그러다보니 백엔드 서버 자체는 응답 속도가 정말 빨랐지만, 데이터베이스가 물리적으로 멀어서 레이턴시가 엄청 길어서 백엔드 서버가 빨라진건지 잘 체감이 안될 정도였다.
그래서 DB도 온프레미스로 운영하자는 생각을 했었는데,, 아무리 내가 직접 관리하는 라즈베리파이라지만, 만에 하나 뭔가 잘못되어 데이터가 날라가면 어쩌지라는 생각에 조심스러웠었다.
그치만 생각을 더 해봤더니, 실사용 DB는 라즈베리파이에서 도커로 같이 띄우고, 매일 크론잡으로 로컬의 데이터를 기존 DB인 NeonDB에 백업을 해준다면 내가 걱정하던 데이터 손상 문제가 해결이 될 것 같았다.
사실 supabase나 prisma postgres같은 아시아에 리전을 둔 다른 DB 호스팅 서비스를 사용할까 생각도 했었는데, 그래도 결국 최소한의 레이턴시를 위해선 데이터베이스를 서버와 함께 온프레미스로 운영하는 것이 좋겠다라고 생각했다.
그래서 퇴근하자마자 바로 컴퓨터 키고 작업을 시작했다!
작업 플랜은 이런 식으로 잡았다.
- 기존에 있던 docker-compose.yml을 수정해서, postgres를 같이 띄우자
- NeonDB의 데이터를 몽땅 가져오자
- 매일 0시에 로컬 DB의 데이터를 NeonDB에 백업시키자
되게 두루뭉실하게 계획을 잡았는데, 아니나다를까 중간중간 어려움들이 꽤나 많았다.
컨테이너로 띄운 DB에 마이그레이션이 안된다…
백엔드 개발 숙련자라면 겪지 않았을 문제이긴 하다..
내가 NestJS로 prod 환경을 신경쓰는 개발을 제대로 해보지 못한 탓에, 초반에 북마켓을 개발하면서 마이그레이션 없이 synchronize: true 상태로 개발환경과 배포환경이 운영되고 있었다.
그러다가 어느 순간 이러면 습관 잘못 들겠다 싶어서 synchronize를 끄고 마이그레이션을 생성하면서 테이블 수정을 하기 시작했다.
근데 이렇게 하니까, 이전에 synchronize가 켜져있던 상태에서의 변경들을 마이그레이션이 없으니까, 새로운 DB에 북마켓 서버를 연결하려고 하니까 마이그레이션이 제대로 안됐었다. 가장 첫 마이그레이션을 하려고 해도 이미 존재하는 칼럼들 갖고 뭔가를 해달라고 요청을 하는데, 그 칼럼들이 존재하지 않으니 오류가 계속 났었다.
그래서 어떻게 해야하지 고민을 좀 했는데, 지피티는 계속 synchronize를 잠시 키고, 디비 초기 세팅 끝나면 다시 꺼라 이러는데, 그러고 싶지가 않았다. 그냥 고집이었다.
뭐 그래서 사실 거창한 방법으로 이 문제를 해결한건 아니고, 그냥 따로 postgres 컨테이너를 띄운 다음에, 볼륨으로 컨테이너와 호스트를 연결해주고, NeonDB ⇒ 로컬DB 마이그레이션 스크립트를 짜서, 마이그레이션을 돌렸다.
#!/usr/bin/env bash
set -euo pipefail
source "$(dirname "$0")/.env"
echo "→ Ensuring local DB exists…"
PGPASSWORD=$LOCAL_PASSWORD psql \
-h $LOCAL_HOST -p $LOCAL_PORT -U $LOCAL_USER \
-tc "SELECT 1 FROM pg_database WHERE datname = '$LOCAL_DB';" \
| grep -q 1 || \
PGPASSWORD=$LOCAL_PASSWORD psql \
-h $LOCAL_HOST -p $LOCAL_PORT -U $LOCAL_USER \
-c "CREATE DATABASE $LOCAL_DB;"
echo "→ Dumping Neon WITHOUT owner/ACL info…"
PGPASSWORD=$NEON_PASSWORD pg_dump \
-h $NEON_HOST -p $NEON_PORT -U $NEON_USER \
--format=custom \
--no-owner \
--no-acl \
$NEON_DB > /tmp/neon.dump
echo "→ Restoring into local Postgres as $LOCAL_USER…"
PGPASSWORD=$LOCAL_PASSWORD pg_restore \
-h $LOCAL_HOST -p $LOCAL_PORT -U $LOCAL_USER \
--clean \
--if-exists \
--no-owner \
--dbname=$LOCAL_DB \
/tmp/neon.dump
echo "✅ Migration complete."
.env 파일에 NeonDB 접속을 위한 환경 변수들, 로컬DB 접속을 위한 환경 변수들을 넣어주고, 스크립트에서 불러온다.
그리고 pg_dump를 통해 NeonDB에 있는 데이터를 dump로 가져오고, pg_restore를 통해 로컬DB로 데이터를 불러왔다.
이렇게 하면 자연스레 필요한 테이블과 칼럼들이 생기니까, 기존의 마이그레이션 문제는 해결이 됐다.
물론 더 정석적인 방법으로 마이그레이션 문제를 해결하면 좋았겠다 싶었다.
아무튼, 이렇게 하니까 라즈베리파이 내부에는 그동안 북마켓에 쌓인 약 300개의 데이터가 불러와졌고, 도커 볼륨 덕에 이 임시 컨테이너를 죽이더라도 데이터가 날라가지 않게 됐다.
그러니 이제 북마켓의 docker-compose.yml을 수정해서, 서비스에 사용할 디비를 띄우고 연결해주자.
services:
server:
build:
context: .
dockerfile: apps/server/Dockerfile
ports:
- 8000:8000
depends_on:
db:
condition: service_healthy
db:
image: postgres
ports:
- 5432:5432
volumes:
- ../bookmarket-db-data:/var/lib/postgresql/data
env_file:
- ./apps/server/.env
healthcheck:
test: ['CMD', 'pg_isready', '-U', 'postgres', '-d', 'postgres']
interval: 10s
timeout: 5s
retries: 5
데이터베이스가 떠야지 서버가 뜰 수 있으니,
db의 healthcheck가 확인이 되면 서버가 뜨게 해줬고,
db에서는 기존에 연결해줬던 볼륨을 그대로 사용하게 경로를 이어줬다.
그리고 필요한 환경 변수들은 서버 디렉토리 안의 .env 파일을 참조하게 해줬다.
이렇게 하고 docker compose up를 하니..
db-1 |
db-1 | PostgreSQL Database directory appears to contain a database; Skipping initialization
db-1 |
db-1 | 2025-05-20 14:20:17.703 UTC [1] LOG: starting PostgreSQL 17.5 (Debian 17.5-1.pgdg120+1) on aarch64-unknown-linux-gnu, compiled by gcc (Debian 12.2.0-14) 12.2.0, 64-bit
db-1 | 2025-05-20 14:20:17.703 UTC [1] LOG: listening on IPv4 address "0.0.0.0", port 5432
db-1 | 2025-05-20 14:20:17.703 UTC [1] LOG: listening on IPv6 address "::", port 5432
db-1 | 2025-05-20 14:20:17.711 UTC [1] LOG: listening on Unix socket "/var/run/postgresql/.s.PGSQL.5432"
db-1 | 2025-05-20 14:20:17.725 UTC [28] LOG: database system was shut down at 2025-05-20 14:18:48 UTC
db-1 | 2025-05-20 14:20:17.748 UTC [1] LOG: database system is ready to accept connections
db-1 | 2025-05-20 14:25:17.760 UTC [26] LOG: checkpoint starting: time
db-1 | 2025-05-20 14:25:18.413 UTC [26] LOG: checkpoint complete: wrote 9 buffers (0.1%); 0 WAL file(s) added, 0 removed, 0 recycled; write=0.628 s, sync=0.008 s, total=0.654 s; sync files=7, longest=0.004 s, average=0.002 s; distance=19 kB, estimate=19 kB; lsn=0/1A75168, redo lsn=0/1A750D8
db-1 | 2025-05-20 14:30:17.513 UTC [26] LOG: checkpoint starting: time
db-1 | 2025-05-20 14:30:17.650 UTC [26] LOG: checkpoint complete: wrote 2 buffers (0.0%); 0 WAL file(s) added, 0 removed, 0 recycled; write=0.112 s, sync=0.007 s, total=0.137 s; sync files=2, longest=0.004 s, average=0.004 s; distance=8 kB, estimate=18 kB; lsn=0/1A772B0, redo lsn=0/1A77258
server-1 |
server-1 | > bookmarket-server@0.0.1 start /app/apps/server
server-1 | > nest start
server-1 |
server-1 | (node:18) [DEP0190] DeprecationWarning: Passing args to a child process with shell option true can lead to security vulnerabilities, as the arguments are not escaped, only concatenated.
server-1 | (Use `node --trace-deprecation ...` to show where the warning was created)
server-1 | [Sentry Profiling] You are using a Node.js version that does not have prebuilt binaries ([object Object]). The @sentry/profiling-node package only has prebuilt support for the following LTS versions of Node.js: 16, 18, 20, 22. To use the @sentry/profiling-node package with this version of Node.js, you will need to compile the native addon from source. See: https://github.com/getsentry/sentry-javascript/tree/develop/packages/profiling-node#building-the-package-from-source
server-1 | [Nest] 35 - 05/20/2025, 2:20:36 PM LOG [NestFactory] Starting Nest application...
server-1 | [Nest] 35 - 05/20/2025, 2:20:36 PM LOG [InstanceLoader] SentryModule dependencies initialized +64ms
server-1 | [Nest] 35 - 05/20/2025, 2:20:36 PM LOG [InstanceLoader] TypeOrmModule dependencies initialized +0ms
server-1 | [Nest] 35 - 05/20/2025, 2:20:36 PM LOG [InstanceLoader] ConfigHostModule dependencies initialized +2ms
server-1 | [Nest] 35 - 05/20/2025, 2:20:36 PM LOG [InstanceLoader] AppModule dependencies initialized +0ms
server-1 | [Nest] 35 - 05/20/2025, 2:20:36 PM LOG [InstanceLoader] JwtModule dependencies initialized +0ms
server-1 | [Nest] 35 - 05/20/2025, 2:20:36 PM LOG [InstanceLoader] ConfigModule dependencies initialized +0ms
server-1 | [Nest] 35 - 05/20/2025, 2:20:36 PM LOG [InstanceLoader] ConfigModule dependencies initialized +0ms
server-1 | [Nest] 35 - 05/20/2025, 2:20:36 PM LOG [InstanceLoader] TypeOrmCoreModule dependencies initialized +225ms
server-1 | [Nest] 35 - 05/20/2025, 2:20:36 PM LOG [InstanceLoader] TypeOrmModule dependencies initialized +1ms
server-1 | [Nest] 35 - 05/20/2025, 2:20:36 PM LOG [InstanceLoader] TypeOrmModule dependencies initialized +0ms
server-1 | [Nest] 35 - 05/20/2025, 2:20:36 PM LOG [InstanceLoader] TypeOrmModule dependencies initialized +0ms
server-1 | [Nest] 35 - 05/20/2025, 2:20:36 PM LOG [InstanceLoader] UsersModule dependencies initialized +1ms
server-1 | [Nest] 35 - 05/20/2025, 2:20:36 PM LOG [InstanceLoader] CategoriesModule dependencies initialized +0ms
server-1 | [Nest] 35 - 05/20/2025, 2:20:36 PM LOG [InstanceLoader] BookmarksModule dependencies initialized +0ms
server-1 | [Nest] 35 - 05/20/2025, 2:20:36 PM LOG [InstanceLoader] IamModule dependencies initialized +1ms
server-1 | [Nest] 35 - 05/20/2025, 2:20:36 PM LOG [RoutesResolver] BookmarksController {/bookmarks}: +10ms
server-1 | [Nest] 35 - 05/20/2025, 2:20:36 PM LOG [RouterExplorer] Mapped {/bookmarks, POST} route +4ms
server-1 | [Nest] 35 - 05/20/2025, 2:20:36 PM LOG [RouterExplorer] Mapped {/bookmarks, GET} route +1ms
server-1 | [Nest] 35 - 05/20/2025, 2:20:36 PM LOG [RouterExplorer] Mapped {/bookmarks/s/:username, GET} route +1ms
server-1 | [Nest] 35 - 05/20/2025, 2:20:36 PM LOG [RouterExplorer] Mapped {/bookmarks/:id, GET} route +0ms
server-1 | [Nest] 35 - 05/20/2025, 2:20:36 PM LOG [RouterExplorer] Mapped {/bookmarks/:id, PATCH} route +1ms
server-1 | [Nest] 35 - 05/20/2025, 2:20:36 PM LOG [RouterExplorer] Mapped {/bookmarks/:id/category, PATCH} route +0ms
server-1 | [Nest] 35 - 05/20/2025, 2:20:36 PM LOG [RouterExplorer] Mapped {/bookmarks/:id, DELETE} route +1ms
server-1 | [Nest] 35 - 05/20/2025, 2:20:36 PM LOG [RoutesResolver] CategoriesController {/categories}: +0ms
server-1 | [Nest] 35 - 05/20/2025, 2:20:36 PM LOG [RouterExplorer] Mapped {/categories, POST} route +1ms
server-1 | [Nest] 35 - 05/20/2025, 2:20:36 PM LOG [RouterExplorer] Mapped {/categories, GET} route +1ms
server-1 | [Nest] 35 - 05/20/2025, 2:20:36 PM LOG [RouterExplorer] Mapped {/categories/:id, PATCH} route +1ms
server-1 | [Nest] 35 - 05/20/2025, 2:20:36 PM LOG [RouterExplorer] Mapped {/categories/:id, DELETE} route +0ms
server-1 | [Nest] 35 - 05/20/2025, 2:20:36 PM LOG [RouterExplorer] Mapped {/categories/s/:username, GET} route +0ms
server-1 | [Nest] 35 - 05/20/2025, 2:20:36 PM LOG [RoutesResolver] UsersController {/users}: +0ms
server-1 | [Nest] 35 - 05/20/2025, 2:20:36 PM LOG [RouterExplorer] Mapped {/users, POST} route +1ms
server-1 | [Nest] 35 - 05/20/2025, 2:20:36 PM LOG [RouterExplorer] Mapped {/users/me, GET} route +0ms
server-1 | [Nest] 35 - 05/20/2025, 2:20:36 PM LOG [RouterExplorer] Mapped {/users/check-username, GET} route +0ms
server-1 | [Nest] 35 - 05/20/2025, 2:20:36 PM LOG [RouterExplorer] Mapped {/users/:id, GET} route +0ms
server-1 | [Nest] 35 - 05/20/2025, 2:20:36 PM LOG [RouterExplorer] Mapped {/users, PATCH} route +1ms
server-1 | [Nest] 35 - 05/20/2025, 2:20:36 PM LOG [RouterExplorer] Mapped {/users/:id, DELETE} route +0ms
server-1 | [Nest] 35 - 05/20/2025, 2:20:36 PM LOG [RoutesResolver] AuthenticationController {/authentication}: +0ms
server-1 | [Nest] 35 - 05/20/2025, 2:20:36 PM LOG [RouterExplorer] Mapped {/authentication/signup, POST} route +1ms
server-1 | [Nest] 35 - 05/20/2025, 2:20:36 PM LOG [RouterExplorer] Mapped {/authentication/signin, POST} route +0ms
server-1 | [Nest] 35 - 05/20/2025, 2:20:36 PM LOG [RouterExplorer] Mapped {/authentication/refresh-token, POST} route +0ms
server-1 | [Nest] 35 - 05/20/2025, 2:20:36 PM LOG [RoutesResolver] GoogleAuthenticationController {/authentication/google}: +0ms
server-1 | [Nest] 35 - 05/20/2025, 2:20:36 PM LOG [RouterExplorer] Mapped {/authentication/google, POST} route +1ms
server-1 | [Nest] 35 - 05/20/2025, 2:20:36 PM LOG [RoutesResolver] GithubAuthenticationController {/authentication/github}: +0ms
server-1 | [Nest] 35 - 05/20/2025, 2:20:36 PM LOG [RouterExplorer] Mapped {/authentication/github, POST} route +0ms
server-1 | [Nest] 35 - 05/20/2025, 2:20:36 PM LOG [NestApplication] Nest application successfully started +4ms
짜라란~ 서버와 데이터베이스가 모두 내 라즈베리파이에서 실행이 됐다!
이렇게 하고 서비스에 접속을 하니 속도가 정말 정말 정말로 빨라졌다!!
기존에는 약 4~5초가량 걸리던 초기 로딩이 1초 내외로 줄었고, Render의 고질적인 문제던 Cold Start도 해결되어서, 이제는 실제로 사용할 수 있는 속도의 서비스가 되었다!
이제 서비스는 빨라졌으니, 서비스의 안정성을 위해 데이터베이스 백업 스크립트 및 크론을 작업했다!
#!/usr/bin/env bash
set -euo pipefail
source "$(dirname "$0")/.env"
echo "→ Streaming plain-SQL dump from local → Neon…"
docker exec -i bookmarket-db-1 \
pg_dump \
-U $LOCAL_USER \
--clean \
--no-owner \
--no-acl \
$LOCAL_DB \
| PGPASSWORD=$NEON_PASSWORD psql \
-h $NEON_HOST \
-p $NEON_PORT \
-U $NEON_USER \
-d $NEON_DB
echo "✅ Backup to Neon complete."
현재 띄워져있는 postgres 컨테이너에 접속해서 데이터를 전부 NeonDB에 백업을 시키는 스크립트이다.
그리고 크론은 매일 0시에 돌아가게 해서,
0 0 * * * /usr/local/bin/backup_local_to_neon.sh >> /var/log/backup_neon.log 2>&1
로 작업을 해두었다.
이제 내 북마켓은
서버도 빠르고
데이터베이스도 빠르고
백업도 되는
완전체가 되었다! (물론 아님)