← Writing

Plan Do! 인프라 — Lightsail 한 인스턴스 위의 모든 것

Plan Do!는 기획·개발·디자인·운영을 한 사람이 책임집니다. 인프라는 그 한 사람이 새벽에 일어나지 않을 정도로 단순해야 했어요. AWS Lightsail 한 인스턴스 위에 모든 걸 묶은 그 구성과, 그 안에서 내린 작은 결정들을 기록해둡니다.

1인 운영의 세 가지 제약

인프라 결정에 들어가기 전, 제약 조건부터 정리했어요.

  • 새벽 알림이 와도 30분 안에 복구할 수 있어야 — 단순한 구조가 필수
  • 월 비용이 예측 가능해야 — 사용량 비례가 아니라 정액으로 시작
  • 배포가 두렵지 않아야 — 자주 출시할 수 있는 흐름

이 셋이 모든 결정의 1순위였습니다.

구성 한눈에

Plan Do! 인프라 구성

저한텐 한 화면에 다 들어오는 것이 중요했어요. 그래야 혼자 굴리기가 편하더라고요.

Lightsail에 다 올렸다

웹 서버도, 데이터베이스도, 업로드 파일도 모두 하나의 AWS Lightsail 인스턴스에 묶여있습니다.

  • 인스턴스 — 4GB 메모리 / 월 $24 정액 / 서울 리전 / Ubuntu
  • PostgreSQL — 같은 인스턴스에 직접 설치 (Lightsail Managed Database 아님)
  • 파일 업로드 — 인스턴스 로컬 디스크 /opt/todo/uploads (S3 아님)
  • 리버스 프록시 — 인스턴스 안의 Nginx

후보를 다 보고 나니, 결국 지루한 구성이 제일 낫겠다 싶었어요.

후보안 고른 이유
ECS/EKS한 사람이 굴리기에 운영 표면이 너무 넓음
EC2 raw보안그룹·VPC·과금 항목까지 직접 관리. 예상치 못한 청구서가 무서움
Heroku/Render사용량 비례 비용, 한국 리전이 약함
LambdaWebSocket 상시 연결과 안 맞음
Lightsail정액 요금 + EC2 수준의 자유도

결정적이었던 건 두 가지 — WebSocket 상시 연결월 비용이 정확히 얼마인지 안다는 점. Lightsail은 인스턴스, 데이터 전송, 스냅샷이 한 정액 안에 묶여, 평소에 청구서를 들여다보지 않아도 안심할 수 있어요.

DB도, 파일도 같은 인스턴스 — 왜 분리하지 않았나

상식적으로는 DB를 별도(Managed) 인스턴스로 두고, 파일은 S3에 올리는 게 표준이에요. 분리하지 않은 이유는 단순합니다 — 단순함의 가치 > 분리의 이점이라고 판단했습니다.

Managed Database를 안 쓴 이유

  • 정액 $24 안에 4GB 메모리 인스턴스가 들어옴 → PostgreSQL을 같이 돌리기에 충분
  • Managed DB를 따로 두면 고정 비용이 1.5~2배에 가까워짐
  • 현재 트래픽 규모에선 분리의 이점이 비용 증가를 정당화하지 않음

S3를 안 쓴 이유

app.upload.dir=/opt/todo/uploads
  • S3 SDK, IAM 권한, presigned URL — 그 모든 코드가 사라짐
  • Nginx가 /uploads/**를 정적으로 서빙해서 응답 속도도 좋음
  • 첨부가 인스턴스에 묶이는 트레이드오프는 인정. Lightsail 스냅샷에 의존

트레이드오프는 명확해요 — 인스턴스가 죽으면 서비스도 첨부도 같이 죽음. 멀티 인스턴스로 확장하는 순간 이 구조는 깨집니다. 다만 그 시점이 오면 그때 옮기겠다는 결정.

복잡도는 필요해지면 그때 붙이자고 생각했어요. 미리 사둘 이유는 없었습니다.

도메인·TLS·프록시 — 작지만 매번 신경 쓰는 것들

  • 도메인 — 가비아에서 등록 (imjaewoo.dev, 연 ₩29,000). Route 53 안 쓴 이유는 가비아 콘솔이 익숙하고 비용 차이가 무의미해서
  • TLSLet’s Encrypt + certbot. 자동 갱신 cron에 묶어두면 손이 안 가는 영역
  • 리버스 프록시 — 인스턴스 안 Nginx. WebSocket upgrade, SPA fallback, /uploads 정적 서빙을 한 곳에서
# 개념적으로
location /ws/      { proxy_pass http://localhost:8080; proxy_http_version 1.1; ... }
location /api/     { proxy_pass http://localhost:8080; }
location /uploads/ { alias /opt/todo/uploads/; }
location /         { try_files $uri /index.html; }

추가로 한 줄 — server.forward-headers-strategy=framework. Spring이 X-Forwarded-* 헤더를 신뢰하게 해두지 않으면 OAuth2 redirect URI가 http로 깨져요. Nginx 뒤에 두는 모든 Spring 앱이 챙겨야 하는 디테일.

시크릿은 default 없이

# application-prod.properties
spring.datasource.password=${DB_PASSWORD}
jwt.secret=${JWT_SECRET}
spring.ai.openai.api-key=${OPENAI_API_KEY}
# ...

prod 프로필에 default 값을 두지 않습니다. 누락되면 부팅 실패로 즉시 드러남. 환경 변수가 빠진 상태로 부팅돼서 운영 중에 발견되는 사고를 구조적으로 막는 패턴.

운영 중인 환경 변수 전체 — JWT_SECRET, DB_URL, DB_USERNAME, DB_PASSWORD, OPENAI_API_KEY, KAKAO_CLIENT_SECRET, GMAIL_APP_PASSWORD, APNS_KEY_ID, APNS_TEAM_ID, APNS_KEY_CONTENT.

두 종류의 푸시 — APNs와 Web Push

iOS와 웹의 푸시 메커니즘이 완전히 다르기 때문에 각자 클라이언트를 두기로 했어요.

  • APNs (iOS)com.eatthepath:pushy 라이브러리로 직접 HTTP/2 연결. ApnsToken 엔티티로 디바이스 토큰 관리
  • Web Push (브라우저) — VAPID 키 기반. nl.martijndwars:web-push + BouncyCastle. PushSubscription으로 구독 관리

하나로 추상화하려다 결국 분기 로직만 늘었던 시도가 있어서 두 채널을 그냥 따로 두는 게 깔끔했습니다. 라이브러리도, 토큰 형식도, 만료 처리도 너무 달라요.

CORS에 ionic://localhost가 있는 이유

application-prod.properties의 CORS 화이트리스트를 보면 의외의 줄이 있어요.

app.cors.allowed-origins=
  http://localhost:5173,
  https://plando.imjaewoo.dev,
  capacitor://localhost,
  ionic://localhost

iOS Capacitor는 capacitor://localhost로 동작하는데, Android Capacitor가 ionic://localhost 스킴도 함께 쓰기 때문에 같이 등록해뒀습니다. 처음엔 왜 안드로이드에서 CORS가 막히지로 한참 헤맨 부분.

배포 흐름 — 수동의 단순함

지금 굴리는 순서:

  1. 프론트 빌드npm run build (Vite → dist/)
  2. 백엔드로 복사dist/를 백엔드 src/main/resources/static/으로 교체
  3. bootJar./gradlew bootJarbuild/libs/에 jar 생성
  4. scp로 서버에 업로드 — Lightsail 인스턴스로 전송
  5. 환경 변수 확인 후 재기동systemctl restart

자동화도 없고, GitHub Actions도 안 씁니다. 무중단이 아닌 짧은 다운타임 배포.

자동화가 좋은 건 분명한데, 지금 단계에선 수동의 단순함이 자동화의 운영 비용보다 가치 있다고 판단했어요. 자동화 파이프라인이 무너지는 날 디버깅에 들어가는 시간 vs 배포 빈도가 그리 높지 않은 현재 — 저울이 수동 쪽으로 기울었습니다.

모니터링은 없다

정직하게 — 외부 모니터링이나 로그 수집 시스템은 깔려있지 않습니다.

문제가 생기면 사용자가 먼저 알려줘서 인지하는 상태. 이게 좋은 신호가 아니라는 건 알고 있고, 다음에 챙길 일 1순위로 올려놨어요.

지금까지 운영하면서 큰 트러블이나 다운 사건은 없었습니다. 다행이기도 하고, 그래서 모니터링을 자꾸 미루게 되는 이유이기도 한 것 같아요.

비용 — 정액이 주는 안심

연 단위로 정리하면:

  • Lightsail 4GB / 서울 — $24/월 × 12 = $288/년
  • 도메인 (가비아)₩29,000/년
  • Apple Developer Program$129/년
  • OpenAI API — 사용량 기반, 유일하게 가변적

가장 큰 강점은 Lightsail 정액 요금 자체에요. AWS 표준 EC2처럼 시간·EBS·데이터 전송이 각각 따로 계산되는 불안이 없습니다. 매달 얼마 나올지 정확히 알고 시작한다는 게, 1인 운영의 정신 건강에 큰 차이를 만들어요.

트레이드오프 — 지금 단계의 의도된 한계

지금 구조는 명백한 한계가 있어요.

  • Lightsail 인스턴스 한 대 = 단일 장애점 — 인스턴스가 죽으면 서비스 정지
  • 파일이 인스턴스 디스크에 묶임 — 멀티 인스턴스로 확장하려면 S3로 옮겨야 함
  • DB도 같은 인스턴스에서 self-hosted — read replica 없음
  • 모니터링 부재 — 일이 터질 때까지 모름

각각이 진짜 문제가 되는 건 트래픽이 어느 임계점을 넘을 때일 텐데, 그 전까지는 단순한 쪽이 낫다고 봤어요. 그때가 오면 그때 옮기면 되니까요.

돌아보면

  • 인프라는 가능한 한 지루한 게 좋았어요. 화려한 도구일수록 새벽에 멈추면 손도 못 대는데, 익숙한 기본기는 그냥 고쳐지더라고요.
  • 단순하게 가는 것도 결국 하나의 선택이었던 것 같아요. S3가 표준이라 쓰는 게 아니라, 왜 안 쓰는지를 스스로 설명할 수 있을 때 단순함이 유지됐어요.
  • 시크릿에 default를 안 둔 건 잘한 선택이었어요. 빠지면 부팅이 바로 실패해서, 운영 중에 뒤늦게 찾는 일이 없었거든요.
  • 정액 요금이 주는 마음의 여유는 직접 굴려보기 전엔 잘 몰랐어요. 매달 청구서를 안 무서워해도 된다는 게 생각보다 컸습니다.

다음에 해볼 것

  • 모니터링 도입 — Uptime 체크부터. 사용자보다 내가 먼저 알자가 1순위
  • CI/CD 자동화 — GitHub Actions로 빌드·배포. 배포 빈도가 늘면 그때
  • 백업 정책 정리 + 복구 리허설복구해본 백업만 백업
  • S3 + CloudFront로 첨부 이전 — 멀티 인스턴스가 필요해지는 시점에

인프라는 서비스가 자라는 만큼만 같이 키우면 된다고 생각해요. 미리 깔지도, 너무 늦지도 않게.