목차
작은 서비스를 하나둘 만들다 보면, 서버는 한 대인데 올릴 앱은 계속 늘어나요. 이 글은 그 서버 한 대를 여러 서비스가 함께 쓰도록 배포 방식을 잡는 순서를 정리해요.
혹시 이런 고민 있으신가요?
- 서비스가 늘 때마다 서버나 데이터베이스를 하나씩 더 만들어야 하나 싶어요.
- 쿠버네티스를 배워야 하나 고민되는데, 서버는 한 대뿐이에요.
- 배포는 됐다고 나오는데 실제로 사이트가 열리는지, 문제가 생기면 어떻게 되돌릴지 막막해요.
이 글을 읽으면
- 자체 호스팅 PaaS 로 시작할지, 오케스트레이터로 갈지 판단할 기준과 넘어갈 시점을 정할 수 있어요.
- 데이터베이스 하나를 스키마로 나눠 여러 서비스가 안전하게 같이 쓰게 할 수 있어요.
- CI 에서 이미지를 만들고, 배포가 성공했을 때만 릴리스를 남기고, 이전 릴리스로 되돌리는 흐름을 만들 수 있어요.
1단계. 오케스트레이터 대신 자체 호스팅 PaaS 로 시작하기
먼저 말을 정리할게요. PaaS(Platform as a Service)는 코드나 이미지를 주면 실행, 주소 연결, 인증서까지 알아서 해 주는 플랫폼이에요. 자체 호스팅 PaaS 는 그걸 여러분 서버에 직접 설치해 쓰는 거예요. Coolify 가 대표적인 예예요.
오케스트레이터는 여러 서버에 컨테이너를 나눠 띄우고 관리하는 도구예요. 쿠버네티스(Kubernetes)나 Docker Swarm 이 여기에 속해요. 식당으로 치면, PaaS 는 주방 하나를 잘 굴리는 매니저이고, 오케스트레이터는 지점 여러 곳을 한꺼번에 관리하는 본사예요.
서버가 한 대라면 본사가 필요 없어요. 저희는 원래 가상화 도구 위에 Docker Swarm 과 관리 화면을 올리는 안을 세웠다가, Coolify 하나로 바꿨어요. 컨테이너 관리, 배포 파이프라인, 리버스 프록시를 한 도구가 다 해 주기 때문이었어요. 쿠버네티스는 서버 한 대에 과하다고 판단했어요.
중요한 건 "언제 바꿀지"를 미리 적어 두는 거예요. 저희는 이렇게 정해 뒀어요.
| 지금 쓰지 않는 것 | 꺼내는 시점 |
|---|---|
| 가상화(여러 VM 으로 나누기) | 물리 자원을 VM 끼리 격리해야 할 때. 그때 PaaS 를 VM 안으로 옮겨요 |
| 멀티 호스트 오케스트레이터 | 서비스나 서버 수가 한 호스트를 넘어설 때 |
그리고 그 시점이 오기 전에는 코드나 설정을 미리 준비하지 않는다는 규칙도 같이 뒀어요. 언젠가를 위한 설정은 지금 아무 일도 하지 않으면서 관리할 거리만 늘리거든요.
TIP
새 도구를 들이기 전에 "지금 있는 데이터베이스, 캐시, 배포 도구로 안 되는가"를 먼저 물어보세요. 안 된다는 측정이 나오기 전에는 들이지 않는 게 서버 한 대 운영의 가장 좋은 습관이에요.
2단계. 데이터베이스와 캐시는 하나씩 두고 안에서 나누기
서비스마다 데이터베이스를 따로 띄우면 메모리도, 백업 대상도, 비밀번호도 서비스 수만큼 늘어요. 저희는 PostgreSQL 인스턴스 하나와 Redis 하나를 모든 서비스가 같이 써요.
대신 안에서 나눠요. PostgreSQL 의 스키마는 데이터베이스 안의 폴더 같은 거예요. 같은 건물 안에서 층을 나눠 쓰는 셈이에요.
- 모든 서비스가 같이 쓰는 데이터는 공용 스키마에 둬요.
- 서비스마다 자기 표는 서비스 이름과 같은 스키마에 둬요.
- 분석 이벤트는 따로 된 스키마에 두고, 전용 계정만 써요. 이 계정은 다른 스키마를 읽지 못해요.
- 여러 스키마를 묶어 보는 대시보드는 읽기 전용 계정 하나로만 붙어요.
인스턴스는 하나씩, 서비스와 권한은 스키마와 계정으로 나눠요
-- 예시: 서비스 하나당 스키마 하나, 분석은 전용 계정만CREATE SCHEMA shop;CREATE SCHEMA analytics;CREATE ROLE analytics_writer LOGIN;GRANT USAGE, CREATE ON SCHEMA analytics TO analytics_writer;REVOKE ALL ON SCHEMA public FROM analytics_writer;스키마 이름을 서비스 이름과 똑같이 맞추면 좋아요. 저희는 앱 폴더, 컨테이너 이미지, 배포 도구의 리소스, 로그인 서버의 클라이언트, 분석 이벤트의 서비스 값까지 한 이름을 써요. 어느 도구에서든 같은 이름으로 한 서비스를 따라갈 수 있어요.
Redis 에는 캐시, 세션, 요청 횟수 제한 카운터, 이벤트 버퍼만 둬요. 원칙은 "Redis 에만 있는 원본은 없다"예요. 누가 실수로 비워도 시간을 잃을 뿐 데이터는 잃지 않아야 해요.
참고
데이터베이스가 하나면 백업도 한 곳이 무너질 때 전부 잃는 구조예요. 저희는 매일 밤 PostgreSQL 덤프를 서버 밖 저장소에 올려요. 백업은 호스트 밖에 있어야 백업이에요. 다만 파일 저장소 같은 다른 볼륨은 아직 백업 대상이 아니에요.
3단계. 이미지는 CI 에서 만들고 배포는 커밋 태그로 요청하기
서버에서 직접 코드를 받아 빌드하면, 빌드하는 동안 서비스가 쓸 자원을 빌드가 먹어요. 그래서 컨테이너 이미지는 CI 에서 만들어 레지스트리에 올리고, 배포 도구는 완성된 이미지를 받기만 하게 해요. 레지스트리는 이미지를 보관하는 저장소예요. 저희는 GitHub 의 컨테이너 레지스트리를 써요.
이때 이미지 태그를 무엇으로 붙이느냐가 롤백을 좌우해요. 저희는 배포할 때 늘 sha-<커밋 해시> 태그를 불러요. latest 나 main 처럼 계속 움직이는 이름은 배포에 쓰지 않아요. 커밋 태그로 배포하면, 되돌리기는 "이전 커밋 태그를 다시 고르기"가 돼요.
흐름을 워크플로 하나로 묶으면 이런 모양이에요. 아래는 뼈대만 남긴 일반 예시예요.
# 예시: 사람이 버튼을 눌러야만 도는 릴리스 워크플로on: workflow_dispatch: inputs: rollback_to: description: 되돌릴 버전. 비우면 새 버전으로 배포 default: ""concurrency: group: release cancel-in-progress: false # 배포 도중에 끊기면 반쯤 바뀐 상태가 남아요jobs: plan: # 다음 버전 이름을 정해요 build: # 이미지를 만들어 sha-<커밋> 태그로 올려요 smoke: # 이미지를 한 번씩 실행해 응답하는지 봐요 deploy: # 배포 도구 API 로 태그를 바꾸고 배포를 요청한 뒤 끝날 때까지 기다려요 release: # 여기까지 성공했을 때만 git 태그와 릴리스를 만들어요배포는 Coolify 의 API 로 요청해요. 워크플로가 각 서비스의 이미지 태그를 새 커밋 태그로 바꾸고, 배포를 요청하고, 끝날 때까지 기다려요. 요청만 보내고 끝나면 "배포 완료" 알림을 믿을 수 없어요.
순서도 정해 둬요. 저희는 데이터베이스 마이그레이션을 먼저 끝내고, 그다음 API, 마지막으로 서비스 웹을 배포해요. 새 컨테이너가 준비된 뒤 옛 컨테이너를 내리는 롤링 업데이트를 쓰기 때문에, 잠깐은 옛 버전과 새 버전이 함께 돌아요. 그래서 마이그레이션은 옛 버전이 읽고 쓰는 칸을 같은 배포에서 지우거나 바꾸지 않아요.
롤링 업데이트를 받으려면 서비스를 Compose 묶음이 아니라 앱 하나짜리 리소스로 올려야 했어요. Coolify 문서가 Compose 리소스에는 롤링 업데이트가 없다고 적고 있어서예요(Coolify 롤링 업데이트 문서). 호스트 포트를 열거나 컨테이너 이름을 고정해도 롤링 업데이트가 막혀요.
TIP
이미지를 빌드할 때마다 새 빌더를 만들고 버리면 캐시가 남지 않아 느려요. 러너들이 같은 호스트의 Docker 를 쓴다면 그 호스트의 빌더에 캐시를 남기고, 오래된 빌드 캐시는 일주일 단위로 지워 디스크 상한을 두세요.
4단계. 배포 뒤 공개 주소로 확인하고 성공한 것만 릴리스로 남기기
컨테이너가 떴다고 서비스가 된 건 아니에요. 확인은 두 번 해요.
첫째, 배포 전에 이미지를 한 번씩 실행해 봐요. 이걸 스모크 테스트라고 불러요. 전원을 켜서 연기가 나는지만 보는 검사라는 뜻이에요. 저희는 앱마다 헬스 체크 경로, 첫 페이지, 매니페스트, 아이콘이 응답하는지 봐요.
둘째, 배포가 끝난 뒤 공개 주소가 실제로 응답하는지 봐요. 방문자가 여는 바로 그 주소예요.
이 두 확인을 통과했을 때만 git 태그와 릴리스를 만들어요. 태그가 배포를 시작하는 게 아니라, 배포가 성공한 결과로 태그가 생기는 거예요. 반대로 하면 배포가 실패해도 태그가 남아서, 버전 번호가 운영에 나간 적 없는 것을 가리키게 돼요.
버전 이름은 날짜와 순번으로 붙여요. v2026.10.01.1, 같은 날 두 번째면 .2 예요. 외부에 버전을 약속하는 라이브러리가 아니라 계속 배포되는 서비스라서, "언제 나간 것인가"가 가장 쓸모 있는 정보이기 때문이에요. 릴리스 본문에는 직전 릴리스 이후 합쳐진 변경을 종류별로 묶어 둬요.
되돌리기는 같은 워크플로에 이전 버전 이름만 넣어 실행해요. 빌드 없이 그 버전의 이미지로 다시 배포해요.
릴리스는 배포와 확인이 성공한 뒤에 생기고, 되돌리기는 빌드 없이 배포부터 다시 해요
# 예시: 새 릴리스gh workflow run release.yml --ref main# 예시: 이전 릴리스로 되돌리기gh workflow run release.yml --ref main -f rollback_to=v2026.10.01.1단, 되돌릴 이미지가 레지스트리에 남아 있어야 해요. 저희는 저장 한도 때문에 패키지마다 커밋 태그가 붙은 최신 두 버전만 남겨요. 지금 도는 것과 그 직전이에요. 그래서 빌드 없이 되돌릴 수 있는 건 바로 앞 릴리스까지이고, 더 오래된 버전은 그 커밋에서 새로 릴리스해야 해요. 릴리스 이름을 이미지 태그로도 붙이지 않는 것도 같은 이유예요. 붙이면 정리 대상에서 빠져 모든 릴리스의 이미지가 영원히 남아요.
TIP
한 번도 실제로 돌려 본 적 없는 헬스 체크를 다른 서비스의 기동 조건으로 걸지 마세요. 저희는 그런 조건 하나 때문에 스택 전체가 뜨지 못한 적이 있어요. 확인 전에는 "시작만 되면 된다"로 낮추고, 헬스 체크는 신호로만 남겨 두세요.
참고
배포 도구의 헬스 체크는 컨테이너 안에서 명령을 실행하는 방식이 많아요. 이미지에 curl 도 wget 도 없으면 멀쩡한 컨테이너가 비정상으로 표시돼요. 켜기 전에 이미지 안에 확인할 도구나 방법이 있는지 먼저 보세요.
5단계. 서비스 하나 더 올리는 순서를 체크리스트로 만들기
서비스를 세 번쯤 올리면, 매번 빠뜨리는 항목이 보이기 시작해요. 저희는 실제로 올리며 한 일을 뽑아 체크리스트 하나로 뒀어요. 각 줄에 코드로 끝나는 일인지, 사람이 도구 화면에서 눌러야 하는 일인지도 적어요.
| 순서 | 할 일 | 누가 |
|---|---|---|
| 이름 | 서비스 이름을 정하고, 모든 도구에 같은 이름을 써요 | 사람 |
| 이미지 | Dockerfile 을 쓰고, 빌드 목록과 스모크 테스트에 서비스를 더해요 | 코드 |
| 정리 | 오래된 이미지를 지우는 목록에도 더해요. 빠지면 그 서비스 이미지만 끝없이 쌓여요 | 코드 |
| 등록 | 배포 도구에 앱을 만들고 이미지, 네트워크, 도메인, 포트, 환경변수를 넣어요 | 사람, 처음 한 번 |
| 연결 | 배포 워크플로에 그 앱의 배포 단계와 확인할 주소를 더해요 | 코드 |
| 확인 | 첫 화면, 핵심 기능 한 바퀴, 이벤트 기록, 에러 추적 수집을 봐요 | 사람 |
등록할 때 저희가 실제로 걸렸던 것도 적어 둘게요.
- 도메인 칸에 호스트 뒤로 포트를 붙이지 마세요. 붙이면 리버스 프록시가 그 포트로 보내요.
- 앱을 API 와 같은 내부 네트워크에 두세요. 다른 곳에 두면 앱이 API 이름을 찾지 못해 모든 호출이 실패해요.
- 브라우저에 노출되는 공개 설정값은 빌드할 때 이미지에 구워지니, 배포 도구의 환경변수에 또 넣지 않아도 돼요.
- 설정을 바꾼 뒤에도 다시 배포해야 반영돼요.
DNS 는 와일드카드 레코드 하나로 모든 서브도메인이 이 서버를 가리키게 해 뒀어요. 그래서 새 서비스마다 DNS 작업은 없고, 도메인 칸에 주소를 적으면 인증서도 알아서 받아요.
참고
배포 도구 화면에서 이미지 태그를 sha- 로 시작하는 값으로 넣으면, 그 값을 이미지 다이제스트로 읽고 형식이 틀렸다며 거절할 수 있어요. 저희는 처음 등록할 때는 main 태그로 만들고, 이후 배포는 워크플로가 API 로 커밋 태그를 바꾸게 했어요.
자주 묻는 질문
Q1. 나중에 쿠버네티스로 옮기려면 처음부터 거기에 맞춰 두는 게 낫지 않나요?
저희는 반대로 판단했어요. 옮길 시점이 오기 전에 준비한 설정은 지금 아무 일도 하지 않으면서 관리 비용만 들어요. 대신 커밋 태그 이미지, 스키마로 나눈 데이터베이스처럼 어디로 가도 그대로 쓸 수 있는 습관을 먼저 들여 두면 옮길 때도 덜 아파요.
Q2. 데이터베이스를 하나로 쓰면 한 서비스가 다른 서비스 데이터를 건드리지 않을까요?
스키마와 계정 권한으로 막아요. 분석 계정처럼 목적이 분명한 계정은 자기 스키마 밖을 읽지 못하게 하고, 여러 스키마를 묶어 보는 도구는 읽기 전용 계정으로만 붙여요. 서비스 코드끼리 서로의 모듈을 가져다 쓰지 않는 규칙도 같이 두면 경계가 오래 가요.
Q3. 롤백을 더 빠르게, 예를 들어 즉시 전환하고 싶다면요?
늘 두 벌을 띄워 두고 주소만 바꾸는 블루-그린 배포가 있어요. 저희도 검토했지만, 전환 장치를 직접 만들어야 하고 앱마다 메모리가 두 배라서 지금은 쓰지 않아요. 방문자가 느끼는 무중단은 롤링 업데이트로도 얻을 수 있어서, 즉시 되돌리기가 꼭 필요해질 때 붙이기로 했어요.
정리
- 서버가 한 대라면 자체 호스팅 PaaS 로 시작하고, 오케스트레이터로 넘어갈 시점을 미리 적어 둬요.
- 데이터베이스와 캐시는 하나씩 두고, 스키마와 계정 권한으로 서비스를 나눠요.
- 이미지는 CI 에서 만들고, 배포는 움직이지 않는 커밋 태그로 요청해요.
- 배포 뒤 공개 주소까지 확인한 것만 릴리스로 남기고, 롤백은 이전 릴리스를 다시 배포하는 것으로 둬요.
- 새 서비스를 올릴 때마다 걸렸던 것을 체크리스트에 더해요.
서버 한 대로 여러 서비스를 굴리는 힘은 도구의 크기보다, 매번 같은 순서로 올리고 같은 방법으로 되돌릴 수 있다는 데서 나와요.