
프로그래밍을 처음 배울 때 배포는 보통 개발한 코드를 서버에 올려 사용자가 접속할 수 있게 만드는 일이라고 배운다.
await deploy({
application: "learning-platform",
environment: "production",
});
개발 환경에서 완성한 애플리케이션을 운영 환경으로 옮긴다는 뜻이다.
기본 개념을 이해하기에는 충분한 설명이다. 하지만 실제 서비스를 배포할 때는 파일이 서버에 도착했는지보다 변경사항이 사용자에게 어떤 과정으로 전달되는지를 판단해야 한다.
배포는 완성된 코드를 서버에 올리는 일이 아니다.
배포는 변경사항을 검증 가능한 단위로 만들고, 기존 서비스와의 호환성을 유지하며, 관찰과 복구가 가능한 방식으로 사용자에게 전달하는 과정이다.
크리스가 온라인 강의 서비스를 개발한다고 생각해 보자.
강의 영상 아래에 새로운 퀴즈 기능을 추가한 뒤 코드를 기본 브랜치에 합쳤다고 해서 모든 사용자가 즉시 기능을 사용하는 것은 아니다.
변경사항은 여러 단계를 통과한다.
flowchart TD
A["코드 변경"] --> B["테스트·정적 검사"]
B --> C["배포 아티팩트 생성"]
C --> D["스테이징 검증"]
D --> E["일부 운영 트래픽"]
E --> F{"지표가 정상인가?"}
F -->|정상| G["전체 사용자 공개"]
F -->|이상| H["중단·롤백"]
이 흐름은 배포가 한 번의 업로드가 아니라 여러 판단 지점을 가진 과정이라는 것을 보여준다.
각 단계는 서로 다른 질문에 답한다.
| 단계 | 확인할 질문 |
|---|---|
| 코드 변경 | 의도한 기능과 수정 범위가 명확한가? |
| 자동 검증 | 기존 약속을 깨뜨리지 않는가? |
| 아티팩트 생성 | 동일한 결과물을 다시 식별할 수 있는가? |
| 스테이징 | 운영과 비슷한 환경에서 실행되는가? |
| 일부 공개 | 실제 트래픽에서도 오류와 지연이 정상인가? |
| 전체 공개 | 모든 사용자에게 전달해도 되는가? |
| 복구 | 문제가 생겼을 때 안전하게 중단할 수 있는가? |
코드가 저장소에 존재하는 것, 서버에서 실행되는 것, 사용자가 기능을 사용할 수 있는 것은 서로 다른 상태다.
개발 환경과 운영 환경에서 각각 애플리케이션을 다시 빌드한다고 생각해 보자.
type ReleaseArtifact = {
commitSha: string;
imageDigest: string;
builtAt: string;
};
이 타입은 배포되는 결과물이 어떤 코드에서 언제 만들어졌으며, 어떤 고유한 내용 식별자를 가졌는지 표현한다.
운영 환경에서 다시 빌드하면 다음과 같은 차이가 생길 수 있다.
스테이징에서 검증한 결과물과 운영에 올린 결과물이 서로 다르다면 스테이징 검증의 의미가 약해진다.
따라서 한 번 생성한 아티팩트를 여러 환경으로 승격하는 방식을 사용할 수 있다.
async function promoteRelease(
artifact: ReleaseArtifact
) {
await deployToStaging(artifact);
await verifyStaging(artifact);
await deployToProduction(artifact);
}
이 코드는 스테이징에서 검증한 것과 같은 아티팩트를 운영 환경으로 전달한다.
배포의 Source of Truth는 개발자의 노트북에 있는 폴더나 브랜치 이름이 아니다. 실제 실행 중인 아티팩트의 버전, 이미지 다이제스트, 설정과 배포 기록이 운영 상태를 설명해야 한다.
배포 스크립트가 서버에 접속해 코드를 복사하는 명령만 포함한다면 검증 단계가 사람의 기억에 의존하게 된다.
배포 파이프라인에는 변경사항이 통과해야 할 조건을 표현할 수 있다.
jobs:
verify:
steps:
- run: npm ci
- run: npm run lint
- run: npm run typecheck
- run: npm test
build:
needs: verify
steps:
- run: npm run build
deploy-production:
needs: build
environment: production
steps:
- run: npm run deploy
이 파이프라인은 정적 검사와 테스트가 성공한 뒤에만 빌드하고, 빌드가 성공한 결과만 운영 환경에 배포하도록 순서를 제한한다.
자동화가 있다고 안전한 배포가 자동으로 완성되는 것은 아니다. 자동화는 정의된 절차를 일관되게 실행할 뿐이다.
따라서 파이프라인에는 실제 위험에 맞는 검증이 포함되어야 한다.
중요한 점은 도구의 개수가 아니라 실패한 변경이 다음 단계로 넘어가지 못하도록 경계를 만드는 것이다.
온라인 강의 서비스에 여러 서버 인스턴스가 실행되고 있다고 생각해 보자.
새 버전을 한 번에 모두 교체하지 않는다면 일정 시간 동안 이전 버전과 새 버전이 같은 트래픽을 처리한다.
사용자 요청
↓
로드 밸런서
├─ 기존 서버 v1
├─ 기존 서버 v1
├─ 신규 서버 v2
└─ 신규 서버 v2
이 과정에서 v2 서버가 만든 데이터를 v1 서버도 읽을 수 있어야 한다. 프론트엔드와 API가 서로 다른 시점에 교체된다면 이전 프론트엔드가 새로운 API와 통신하는 상황도 발생할 수 있다.
크리스가 강의 진행률 응답의 필드 이름을 바꾼다고 생각해 보자.
기존 응답은 다음과 같다.
type CourseProgressV1 = {
courseId: string;
progressPercent: number;
isCompleted: boolean;
};
새 버전에서 isCompleted를 제거하고 곧바로 completionStatus만 반환하면 아직 이전 프론트엔드를 사용하는 사용자 화면이 깨질 수 있다.
전환 기간에는 두 필드를 함께 제공할 수 있다.
type CourseProgressTransition = {
courseId: string;
progressPercent: number;
isCompleted: boolean;
completionStatus:
| "not_started"
| "in_progress"
| "completed";
};
이 타입은 기존 클라이언트가 isCompleted를 읽는 동안 새 클라이언트가 completionStatus를 사용하도록 허용한다.
모든 클라이언트와 서버가 새 계약으로 이동한 뒤에 기존 필드를 제거할 수 있다.
배포 호환성은 현재 코드끼리만 맞는지를 확인하는 일이 아니다. 전환 과정에서 만날 수 있는 이전 버전과 새 버전의 조합까지 고려하는 일이다.
크리스가 강의 진행률을 백분율 대신 시청한 초 단위로 관리하도록 변경한다고 생각해 보자.
기존 컬럼을 바로 삭제하고 새 컬럼을 필수로 추가할 수 있다.
ALTER TABLE course_progress
DROP COLUMN progress_percent;
ALTER TABLE course_progress
ADD COLUMN watched_seconds INTEGER NOT NULL;
이 변경은 기존 서버가 여전히 progress_percent를 읽고 쓰는 동안 실행되면 오류를 만든다. 기존 데이터에는 watched_seconds 값도 없으므로 NOT NULL 추가가 실패할 수 있다.
배포 중 여러 버전이 공존할 수 있다면 데이터베이스 변경도 단계적으로 진행해야 한다.
첫 번째 단계에서는 기존 구조를 유지한 채 새 컬럼을 추가한다.
ALTER TABLE course_progress
ADD COLUMN watched_seconds INTEGER;
이 변경은 기존 코드가 계속 progress_percent를 사용하는 동안 새 코드가 watched_seconds를 인식할 수 있게 한다.
그다음 새 버전은 전환 기간 동안 두 값을 함께 기록할 수 있다.
await progressRepository.update({
enrollmentId,
progressPercent,
watchedSeconds,
});
이 코드는 이전 버전과 새 버전이 모두 필요한 값을 읽을 수 있도록 두 표현을 함께 유지한다.
기존 데이터는 별도의 작업으로 채운다.
UPDATE course_progress
SET watched_seconds =
ROUND(
total_duration_seconds
* progress_percent
/ 100.0
)
WHERE watched_seconds IS NULL;
이 쿼리는 기존 진행률을 사용해 새로운 컬럼의 초기값을 계산한다. 실제 서비스에서는 데이터 양을 고려해 작은 배치로 나누고 실행 시간과 잠금 영향을 관찰해야 한다.
모든 코드가 새 컬럼을 사용하고 데이터 전환이 검증된 뒤에 제약을 강화하고 기존 컬럼을 제거할 수 있다.
ALTER TABLE course_progress
ALTER COLUMN watched_seconds SET NOT NULL;
ALTER TABLE course_progress
DROP COLUMN progress_percent;
이 과정을 확장, 전환, 축소의 흐름으로 볼 수 있다.
새 구조 추가
↓
이전·새 구조를 함께 지원
↓
기존 데이터 변환
↓
모든 사용처 전환 확인
↓
이전 구조 제거
스키마 변경은 코드 배포와 생명주기가 다르다. 코드 롤백은 이전 이미지를 실행하면 될 수 있지만, 삭제하거나 변환한 데이터는 같은 방법으로 되돌아오지 않는다.
새 인스턴스의 프로세스가 시작되었더라도 아직 요청을 처리할 준비가 되지 않았을 수 있다.
준비 상태를 확인하는 엔드포인트를 제공할 수 있다.
@Controller("health")
export class HealthController {
constructor(
private readonly database: DatabaseService
) {}
@Get("live")
checkLiveness() {
return { status: "alive" };
}
@Get("ready")
async checkReadiness() {
await this.database.ping();
return {
status: "ready",
release: process.env.RELEASE_SHA,
};
}
}
live 응답은 프로세스 자체가 동작하는지 확인한다. ready 응답은 데이터베이스 연결처럼 요청 처리에 필요한 조건까지 확인한다.
두 검사를 구분하지 않으면 일시적인 외부 의존성 문제로 프로세스를 계속 재시작하거나, 준비되지 않은 서버에 사용자 요청을 전달할 수 있다.
다만 준비 상태 검사에 모든 외부 서비스를 무조건 포함해서도 안 된다. 선택 기능 하나의 장애가 전체 서버를 트래픽에서 제외해야 하는지는 서비스의 의존 관계에 따라 판단해야 한다.
배포 방식은 변경 위험과 서비스 규모에 따라 달라진다.
| 방식 | 동작 | 적합한 상황 | 주의할 점 |
|---|---|---|---|
| 재생성 | 기존 버전을 종료하고 새 버전을 시작 | 중단을 허용할 수 있는 작은 서비스 | 배포 중 서비스 중단 |
| 롤링 배포 | 기존 인스턴스를 새 인스턴스로 점진적 교체 | 일반적인 무중단 교체 | 이전·새 버전 호환성 |
| 블루·그린 | 기존 환경과 새 환경을 함께 준비한 뒤 트래픽 전환 | 빠른 전환과 복구가 중요 | 두 환경의 비용과 데이터 변경 |
| 카나리 | 일부 사용자나 트래픽에 먼저 공개 | 실제 환경에서 위험을 작게 검증 | 사용자 그룹 비교와 지표 필요 |
예를 들어 새 퀴즈 채점 기능을 전체 트래픽의 5%에 먼저 배포할 수 있다.
function selectQuizVersion(userId: string) {
const bucket =
stableHash(userId) % 100;
return bucket < 5 ? "v2" : "v1";
}
이 함수는 같은 사용자가 요청할 때마다 같은 그룹에 배정되도록 안정적인 해시를 사용한다.
카나리 배포 후에는 단순히 서버가 살아 있는지만 확인해서는 안 된다.
트래픽을 조금씩 늘리는 것은 위험을 작게 나누는 방법이다. 문제를 판단할 지표와 중단 기준이 없다면 느리게 전체 장애로 이동하는 과정이 될 수도 있다.
코드는 운영 환경에 배포했지만 기능은 아직 사용자에게 보이지 않게 할 수 있다.
const canUseQuizV2 =
await featureFlags.isEnabled({
key: "quiz-v2",
userId: currentUser.id,
});
if (canUseQuizV2) {
return quizV2Service.getQuiz(lectureId);
}
return quizV1Service.getQuiz(lectureId);
이 코드는 같은 배포 안에 이전 기능과 새 기능을 두고 사용자별로 공개 여부를 결정한다.
이렇게 하면 다음 작업을 분리할 수 있다.
그러나 기능 플래그도 영구적인 안전장치가 아니다.
오래된 플래그가 쌓이면 테스트해야 할 코드 조합이 증가한다. 플래그의 담당자, 제거 조건과 만료 시점을 함께 관리해야 한다.
배포는 코드를 환경에 전달하는 일이고, 릴리스는 사용자가 기능을 사용할 수 있게 하는 일이다. 두 과정은 같을 수도 있지만 반드시 같아야 하는 것은 아니다.
스테이징과 운영 환경은 데이터베이스 주소, 외부 서비스 키와 기능 정책이 다를 수 있다.
환경별 값을 코드에 직접 넣으면 같은 아티팩트를 승격하기 어려워진다.
const RuntimeConfigSchema = z.object({
databaseUrl: z.string().url(),
videoStorageRegion: z.string().min(1),
releaseSha: z.string().min(7),
quizV2Enabled: z.coerce.boolean(),
});
외부에서 주입되는 설정은 검증 전까지 신뢰할 수 없다. 이 스키마는 애플리케이션 시작 시 필수 설정과 형식을 확인한다.
const runtimeConfig =
RuntimeConfigSchema.parse({
databaseUrl: process.env.DATABASE_URL,
videoStorageRegion:
process.env.VIDEO_STORAGE_REGION,
releaseSha: process.env.RELEASE_SHA,
quizV2Enabled:
process.env.QUIZ_V2_ENABLED,
});
잘못된 설정으로 일부 요청만 실패하게 두기보다 시작 단계에서 문제를 명확히 발견할 수 있다.
설정에는 서로 다른 책임이 존재한다.
| 설정 성격 | 관리 위치 |
|---|---|
| 데이터베이스 주소 | 환경 설정 또는 비밀 관리 시스템 |
| 외부 서비스 인증정보 | 비밀 관리 시스템 |
| 배포된 코드 버전 | 배포 메타데이터 |
| 운영자가 바꾸는 기능 정책 | 설정 저장소 또는 기능 플래그 시스템 |
| 사용자별 강의 진행률 | 데이터베이스 |
| 코드에 반복되는 규칙 | 애플리케이션 또는 도메인 로직 |
환경변수를 사용한다는 사실보다 누가 변경할 수 있고, 언제 적용되며, 변경 이력이 남는지가 중요하다.
배포 명령이 성공했다는 것은 인프라가 요청을 받아들였다는 의미일 수 있다. 사용자가 정상적으로 강의를 듣고 있다는 뜻은 아니다.
배포된 버전을 로그와 지표에 포함할 수 있다.
logger.info("lecture_progress_saved", {
enrollmentId,
releaseSha: runtimeConfig.releaseSha,
durationMs,
});
이 로그는 진행률 저장 이벤트가 어느 버전에서 발생했는지 연결한다. 실제 운영에서는 개인정보와 민감한 식별자를 로그 정책에 맞게 마스킹하거나 대체해야 한다.
배포 전후에는 기술 지표와 사용자 흐름을 함께 확인해야 한다.
| 종류 | 확인할 예 |
|---|---|
| 인프라 | 준비된 인스턴스 수, 재시작 횟수 |
| 애플리케이션 | 오류율, 응답 시간, 예외 유형 |
| 데이터 | 마이그레이션 실패, 저장 누락, 중복 |
| 사용자 흐름 | 영상 재생 성공률, 퀴즈 제출 성공률 |
| 비즈니스 | 강의 완료율, 결제 또는 등록 실패 |
예를 들어 전체 API 오류율은 정상이어도 퀴즈 제출 API만 실패할 수 있다. 서버 CPU가 안정적이어도 강의 진행률이 저장되지 않을 수 있다.
따라서 배포 판단에는 다음 세 가지가 필요하다.
관찰 기준이 배포 전에 정해져 있어야 문제가 생긴 뒤에 성공 기준을 바꾸지 않게 된다.
새 퀴즈 기능에서 오류가 발생하면 이전 애플리케이션 이미지를 다시 실행할 수 있다.
await rollbackDeployment({
application: "learning-platform",
releaseSha: previousReleaseSha,
});
이 코드는 실행 중인 애플리케이션 버전을 이전 버전으로 전환한다.
하지만 배포 과정에서 다음 변경이 이미 일어났다면 코드 롤백만으로는 충분하지 않을 수 있다.
복구 방법은 변경의 성격에 따라 달라진다.
| 변경 | 가능한 복구 |
|---|---|
| 애플리케이션 코드 | 이전 아티팩트 재배포 |
| 기능 플래그 | 새 기능 비활성화 |
| 호환 가능한 컬럼 추가 | 이전 코드 유지 가능 |
| 데이터 변환 | 역변환 또는 백업 복원 검토 |
| 외부 메시지 발송 | 취소보다 후속 정정 필요 |
| 잘못 생성된 데이터 | 보정 작업과 감사 기록 |
모든 배포가 즉시 롤백 가능하다고 가정해서는 안 된다. 되돌릴 수 없는 변경에는 전진 수정, 데이터 보정과 사용자 안내가 필요할 수 있다.
자동 롤백은 유용하지만 모든 지표 변화에 즉시 반응하면 정상적인 일시 변동에도 배포가 반복해서 되돌아갈 수 있다.
중단 기준은 구체적으로 정의해야 한다.
type ReleaseGuard = {
maximumErrorRate: number;
maximumP95LatencyMs: number;
minimumQuizSubmissionSuccessRate: number;
observationMinutes: number;
};
이 타입은 배포를 계속할지 판단하기 위한 지표와 관찰 시간을 표현한다.
예를 들어 다음 상황에서는 배포 확장을 중단할 수 있다.
중단은 반드시 실패를 의미하지 않는다. 더 많은 사용자에게 영향을 주기 전에 판단을 보류하는 안전장치다.
여러 기능, 의존성 업데이트와 데이터베이스 변경을 하나의 큰 배포에 포함하면 문제가 발생했을 때 원인을 찾기 어렵다.
퀴즈 기능 변경
+ 동영상 플레이어 교체
+ 인증 라이브러리 업데이트
+ 데이터베이스 구조 변경
+ 런타임 버전 변경
이 배포에서 오류가 발생하면 어느 변경이 원인인지 분리하기 어렵다. 이전 버전으로 돌아가면 서로 관련 없는 개선까지 모두 되돌아간다.
변경을 작게 나누면 다음 장점이 있다.
배포 횟수를 줄이는 것이 항상 안전한 것은 아니다. 큰 변경을 드물게 배포하는 것보다 검증 가능한 작은 변경을 자주 전달하는 편이 위험을 작게 나눌 수 있다.
모든 서비스가 복잡한 롤링, 블루·그린이나 카나리 인프라를 필요로 하지는 않는다.
사용자가 적고 짧은 중단을 허용할 수 있는 내부 교육 서비스라면 공지된 유지보수 시간에 배포하는 편이 더 단순하고 안전할 수 있다.
중요한 것은 “무중단”이라는 기술적 목표가 아니라 다음 조건에 맞는 방식을 선택하는 일이다.
간단한 배포 방식도 백업, 검증, 공지와 복구 절차가 명확하면 적절한 설계가 될 수 있다.
스테이징에서 검증한 결과물과 운영 결과물이 달라질 수 있다.
한 번 만든 식별 가능한 아티팩트를 환경 사이에서 승격해야 한다.
테스트는 알려진 조건을 검증하지만 실제 트래픽, 데이터 규모와 환경 차이를 모두 재현하지 못한다.
일부 트래픽 배포와 운영 지표 관찰이 필요하다.
이전 버전이 배포 중 함께 실행되거나 오래된 클라이언트가 남아 있을 수 있다.
추가, 전환, 제거를 서로 다른 단계로 나눠야 한다.
프로세스가 실행 중이어도 데이터베이스 연결이나 초기화가 끝나지 않았을 수 있다.
준비 상태를 확인한 뒤 요청 대상으로 포함해야 한다.
서버는 정상이어도 영상 재생, 퀴즈 제출이나 진행률 저장이 실패할 수 있다.
사용자의 핵심 흐름을 함께 관찰해야 한다.
삭제된 데이터와 외부로 전달된 작업은 이전 이미지를 실행한다고 복구되지 않는다.
변경별 복구 가능성을 배포 전에 확인해야 한다.
오래된 플래그는 실행 경로와 테스트 조합을 계속 늘린다.
공개가 안정된 뒤 플래그와 이전 코드를 정리해야 한다.
여러 변경을 한 번에 묶으면 배포 횟수는 줄지만 실패 원인과 복구 범위가 커진다.
독립적으로 검증하고 되돌릴 수 있는 크기로 변경을 나누는 편이 낫다.
프로그래밍을 처음 배울 때는 배포를 개발한 코드를 서버에 올리는 일이라고 이해해도 충분하다.
await deploy({
application: "learning-platform",
environment: "production",
});
하지만 실제 서비스에서 배포는 업로드 명령이 성공한 뒤에도 계속된다.
배포는 완성된 코드를 서버에 올리는 일이 아니다.
배포는 변경사항을 검증 가능한 단위로 만들고, 기존 서비스와의 호환성을 유지하며, 관찰과 복구가 가능한 방식으로 사용자에게 전달하는 과정이다.