# 5. 배포 가이드

[목차로 돌아가기](./README.md)

---

## 파이프라인 개요

### 전체 흐름

```
개발자 push -> 개발서버 Gitea -> post-receive hook -> CI/CD Gitea push
-> Webhook -> Jenkins -> 빌드/배포
```

### 파이프라인 구성

| 저장소 | 파이프라인 | 트리거 브랜치 | 배포 대상 |
|--------|-----------|-------------|----------|
| sam-react-prod | React 빌드+배포 | develop, main | 개발 / Stage→승인→운영 |
| sam-api | Laravel API 배포 | main | Stage→승인→운영 |
| sam-manage | Laravel Admin 배포 | main | 운영 (직접) |
| sam-sales | 레거시 PHP 배포 | main | 운영 (직접) |

### Slack 알림 채널

| 채널 | 용도 | 알림 내용 |
|------|------|----------|
| `#product_infra` | 빌드/배포 상태 | 빌드 시작, 배포 성공/실패 |
| `#product_deploy` | 운영 배포 승인 | Stage 배포 완료 후 승인 대기 알림 (Jenkins 승인 링크 포함) |

### 2-Branch 전략 (develop + main)

> **stage 브랜치 없음.** main 브랜치 push 시 Stage 자동 배포 → Jenkins 승인 → Production 배포.

| 브랜치 | react | api | mng | sales |
|--------|-------|-----|-----|-------|
| develop | Jenkins 빌드 → 개발서버 | 기존 post-update hook | 기존 post-update hook | 기존 post-update hook |
| main | Stage 배포 → **승인** → Production 배포 | Stage 배포 → **승인** → Production 배포 | Production 직접 배포 | Production 직접 배포 |

**main 브랜치 배포 흐름 (react/api):**
1. 개발자가 develop → main 머지 후 push
2. post-receive hook → CI/CD Gitea 자동 push
3. Jenkins 빌드 → Stage 자동 배포
4. `#product_deploy` Slack 채널에 승인 대기 알림 전송
5. Jenkins UI에서 **승인 클릭** → Production 배포 (24시간 타임아웃)

> **동시 빌드 방지:** 모든 파이프라인에 `disableConcurrentBuilds()` 적용.
> 같은 프로젝트에서 빌드가 동시에 2개 이상 돌지 않음.
> 승인 대기 중 새 push 시 → 기존 빌드 Abort 후 새 빌드 자동 시작.

**main 브랜치 배포 흐름 (mng/sales):**
1. 개발자가 main push → hook → CI/CD Gitea → Jenkins → Production 직접 배포

---

## Git 동기화 전략

**방침**: 개발서버 Gitea(origin) 유지 + CI/CD Gitea에 **선택적 브랜치 push** (post-receive hook)

> Gitea Push Mirror는 전체 브랜치를 미러링하므로 사용하지 않음.
> 대신 개발서버 Gitea의 **post-receive hook**으로 필요한 브랜치만 CI/CD Gitea에 push.

```
개발자 로컬
    │ git push origin (develop / main)
    ▼
개발서버 Gitea (114.203.209.83:3000) ← 모든 개발자의 origin
    │
    ├─ develop push 시
    │   ├─ api/mng/sales: 기존 post-update hook (개발서버 pull) ← 현행 유지
    │   └─ react: hook → CI/CD Gitea push → Jenkins 빌드 → 개발서버 배포
    │
    └─ main push 시
        ├─ react: hook → CI/CD Gitea → Jenkins 빌드 → Stage 배포 → 승인 → Production 배포
        ├─ api:    hook → CI/CD Gitea → Jenkins → Stage 배포 → 승인 → Production 배포
        ├─ mng:    hook → CI/CD Gitea → Jenkins → Production 직접 배포
        └─ sales:  hook → CI/CD Gitea → Jenkins → Production 직접 배포
```

### 브랜치별 배포 정책 상세

| 브랜치 | 저장소 | CI/CD Gitea 동기화 | Jenkins 배포 | 배포 대상 |
|--------|--------|-------------------|-------------|----------|
| **main** | react | 자동 (hook) | 빌드 → Stage → **승인** → 재빌드 → Production | Stage + Production |
| **main** | api | 자동 (hook) | rsync → Stage → **승인** → rsync → Production | Stage + Production |
| **main** | mng | 자동 (hook) | rsync + npm build → Production | Production |
| **main** | sales | 자동 (hook) | rsync → Production | Production |
| **develop** | react | 자동 (hook) | 빌드 → 개발서버 배포 | 개발서버 |
| **develop** | api/mng/sales | ❌ (현행 유지) | ❌ | 개발서버 (post-update hook) |

### post-receive hook 동기화 요약

| 저장소 | hook 대상 브랜치 | 동작 |
|--------|-----------------|------|
| sam-react-prod | main, develop | CI/CD Gitea에 push |
| sam-api | main | CI/CD Gitea에 push |
| sam-manage | main | CI/CD Gitea에 push |
| sam-sales | main | CI/CD Gitea에 push |
| sam-landing | main | CI/CD Gitea에 push |

hook 스크립트 경로: `/data/GIT/samproject/<repo>.git/hooks/post-receive.d/push-to-cicd`
토큰 환경변수: `/data/GIT/.cicd-env` (chmod 600, owner: git)

### Webhook 설정 (CI/CD Gitea → Jenkins)

각 저장소에 Webhook 추가 (CI/CD Gitea 웹 UI):

```
Repository Settings → Webhooks → Add Webhook (Gitea)
- URL: https://ci.sam.it.kr/gitea-webhook/post
- Content Type: application/json
- Secret: <webhook_secret>
- Events: Push events
```

---

## 배포 흐름도

```
개발자 로컬
    │ git push origin (develop / main)
    ▼
┌──────────────────────────────────────────────────────────────┐
│  개발서버 Gitea  (114.203.209.83:3000)  ← 모든 개발자 origin │
│                                                               │
│  post-receive hooks:                                          │
│                                                               │
│  ┌─ develop push ────────────────────────────────────────┐   │
│  │  react  → hook: CI/CD Gitea push ──→ Jenkins 빌드     │   │
│  │           → 빌드 결과 rsync → 개발서버 배포             │   │
│  │  api    → 기존 post-update hook (pull + migrate)       │   │
│  │  mng    → 기존 post-update hook (pull + build)         │   │
│  │  sales  → 기존 post-update hook (pull)                 │   │
│  └───────────────────────────────────────────────────────┘   │
│                                                               │
│  ┌─ main push (모든 저장소 자동) ────────────────────────┐   │
│  │  react  → hook: CI/CD Gitea push ──→ Jenkins          │   │
│  │           → Stage 빌드+배포 → 승인 → Production 재빌드  │   │
│  │  api    → hook: CI/CD Gitea push ──→ Jenkins          │   │
│  │           → Stage rsync+배포 → 승인 → Production 배포   │   │
│  │  mng    → hook: CI/CD Gitea push ──→ Jenkins          │   │
│  │           → Production rsync + build                    │   │
│  │  sales  → hook: CI/CD Gitea push ──→ Jenkins          │   │
│  │           → Production rsync                            │   │
│  └───────────────────────────────────────────────────────┘   │
└───────────────────────────────────────────────────────────────┘

┌─ Jenkins 승인 흐름 (react/api main) ─────────────────────────┐
│                                                               │
│  Jenkins 빌드 시작                                            │
│     │                                                         │
│     ├─ Stage 자동 배포 (react: .env.stage 빌드)               │
│     │                                                         │
│     ├─ 📢 #product_deploy Slack 알림 (승인 링크 포함)         │
│     │                                                         │
│     ├─ ⏸️  승인 대기 (24시간 타임아웃)                         │
│     │   https://ci.sam.it.kr 에서 "운영 배포 진행" 클릭       │
│     │                                                         │
│     ├─ Production 배포 (react: .env.main 재빌드)              │
│     │                                                         │
│     └─ 완료                                                   │
│                                                               │
└───────────────────────────────────────────────────────────────┘
```

### 환경별 배포 비교

| 항목 | Production (main→승인) | Stage (main→자동) | 개발 (develop) |
|------|----------------------|------------------|----------------|
| **트리거** | main push → Jenkins 승인 | main push → 자동 | react만 자동 (hook), 나머지 기존 hook |
| **react 전략** | CI/CD 빌드(.env.main) → rsync | CI/CD 빌드(.env.stage) → rsync | CI/CD 빌드(.env.develop) → rsync |
| **api 전략** | rsync + Release 심링크 | rsync + Release 심링크 | 기존 post-update (pull) |
| **mng 전략** | rsync + npm build + Release 심링크 | - | 기존 post-update (pull + build) |
| **롤백** | 이전 릴리즈 심링크 | 이전 릴리즈 심링크 | git revert |
| **릴리즈 보관** | 최근 5개 | 최근 3개 | - |

---

## React (Next.js) 배포

### 자동 배포 흐름

```
CI/CD Gitea push -> Webhook -> Jenkins
-> npm install -> npm run build -> rsync -> PM2 reload
```

**브랜치별 배포 대상:**

| 브랜치 | 배포 단계 | 대상 서버 | 대상 경로 | PM2 이름 |
|--------|----------|----------|----------|----------|
| develop | 개발서버 | 114.203.209.83 | /home/webservice/react/ | sam-react |
| main | Stage (자동) | 211.117.60.189 | /home/webservice/react-stage/releases/ | sam-front-stage |
| main | Production (승인 후) | 211.117.60.189 | /home/webservice/react/releases/ | sam-front |

**환경변수 파일 (CI/CD 서버):** /var/lib/jenkins/env-files/react/

| 파일 | API URL | Frontend URL | APP_ENV | DEV_TOOLBAR |
|------|---------|-------------|---------|-------------|
| .env.develop | https://api.codebridge-x.com | https://dev.codebridge-x.com | development | - |
| .env.stage | https://stage-api.sam.it.kr | https://stage.sam.it.kr | staging | - |
| .env.main | https://api.sam.it.kr | https://sam.it.kr | production | false |

> `NEXT_PUBLIC_APP_ENV` 값으로 타이틀 접두사 결정: `development` → `[D]`, `local` → `[L]`, 그 외 → 없음

**rsync 주의:** trailing slash 사용 금지: `.next` (O), `.next/` (X)

**릴리즈 보관:** 운영 5개, Stage 3개

### Jenkinsfile (react/Jenkinsfile)

```groovy
pipeline {
    agent any

    options {
        disableConcurrentBuilds()
    }

    environment {
        DEPLOY_USER = 'hskwon'
        RELEASE_ID  = new Date().format('yyyyMMdd_HHmmss')
    }

    stages {
        stage('Checkout') {
            steps {
                checkout scm
                script {
                    env.GIT_COMMIT_MSG = sh(script: "git log -1 --pretty=format:'%s'", returnStdout: true).trim()
                }
                slackSend channel: '#product_infra', color: '#439FE0', tokenCredentialId: 'slack-token',
                    message: "🚀 *react* 빌드 시작 (`${env.BRANCH_NAME}`)\n${env.GIT_COMMIT_MSG}\n<${env.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>"
            }
        }

        stage('Prepare Env') {
            steps {
                script {
                    if (env.BRANCH_NAME == 'main') {
                        // main: Stage 빌드 먼저 (승인 후 Production 재빌드)
                        sh "cp /var/lib/jenkins/env-files/react/.env.stage .env.production"
                    } else {
                        def envFile = "/var/lib/jenkins/env-files/react/.env.${env.BRANCH_NAME}"
                        sh "cp ${envFile} .env.production"
                    }
                }
            }
        }

        stage('Install') {
            steps { sh 'npm install --prefer-offline' }
        }

        stage('Build') {
            steps { sh 'npm run build' }
        }

        // ── develop → 개발서버 배포 ──
        stage('Deploy Development') {
            when { branch 'develop' }
            steps {
                sshagent(credentials: ['deploy-ssh-key']) {
                    sh """
                        rsync -az --delete \
                            --exclude='.git' --exclude='.env*' --exclude='ecosystem.config.*' \
                            .next package.json next.config.ts public node_modules \
                            ${DEPLOY_USER}@114.203.209.83:/home/webservice/react/
                        scp .env.production ${DEPLOY_USER}@114.203.209.83:/home/webservice/react/.env.production
                        ssh ${DEPLOY_USER}@114.203.209.83 'cd /home/webservice/react && pm2 restart sam-react'
                    """
                }
            }
        }

        // ── main → 운영서버 Stage 배포 ──
        stage('Deploy Stage') {
            when { branch 'main' }
            steps {
                sshagent(credentials: ['deploy-ssh-key']) {
                    sh """
                        ssh ${DEPLOY_USER}@211.117.60.189 'mkdir -p /home/webservice/react-stage/releases/${RELEASE_ID}'
                        rsync -az --delete \
                            .next package.json next.config.ts public node_modules \
                            ${DEPLOY_USER}@211.117.60.189:/home/webservice/react-stage/releases/${RELEASE_ID}/
                        scp .env.production ${DEPLOY_USER}@211.117.60.189:/home/webservice/react-stage/releases/${RELEASE_ID}/.env.production
                        ssh ${DEPLOY_USER}@211.117.60.189 '
                            ln -sfn /home/webservice/react-stage/releases/${RELEASE_ID} /home/webservice/react-stage/current &&
                            cd /home/webservice && pm2 reload sam-front-stage 2>/dev/null || pm2 start react-stage/current/node_modules/.bin/next --name sam-front-stage -- start -p 3100 &&
                            cd /home/webservice/react-stage/releases && ls -1dt */ | tail -n +4 | xargs rm -rf 2>/dev/null || true
                        '
                    """
                }
            }
        }

        // ── 운영 배포 승인 ──
        stage('Production Approval') {
            when { branch 'main' }
            steps {
                slackSend channel: '#product_deploy', color: '#FF9800', tokenCredentialId: 'slack-token',
                    message: "🔔 *react* 운영 배포 승인 대기 중\n${env.GIT_COMMIT_MSG}\nStage: https://stage.sam.it.kr\n<${env.BUILD_URL}input|승인하러 가기>"
                timeout(time: 24, unit: 'HOURS') {
                    input message: 'Stage 확인 후 운영 배포를 진행하시겠습니까?\nStage: https://stage.sam.it.kr',
                          ok: '운영 배포 진행'
                }
            }
        }

        // ── main → Production 재빌드 (운영 환경변수) ──
        stage('Rebuild for Production') {
            when { branch 'main' }
            steps {
                sh "cp /var/lib/jenkins/env-files/react/.env.main .env.production"
                sh 'npm run build'
            }
        }

        // ── main → 운영서버 Production 배포 ──
        stage('Deploy Production') {
            when { branch 'main' }
            steps {
                sshagent(credentials: ['deploy-ssh-key']) {
                    sh """
                        ssh ${DEPLOY_USER}@211.117.60.189 'mkdir -p /home/webservice/react/releases/${RELEASE_ID}'
                        rsync -az --delete \
                            .next package.json next.config.ts public node_modules \
                            ${DEPLOY_USER}@211.117.60.189:/home/webservice/react/releases/${RELEASE_ID}/
                        scp .env.production ${DEPLOY_USER}@211.117.60.189:/home/webservice/react/releases/${RELEASE_ID}/.env.production
                        ssh ${DEPLOY_USER}@211.117.60.189 '
                            ln -sfn /home/webservice/react/releases/${RELEASE_ID} /home/webservice/react/current &&
                            cd /home/webservice && pm2 reload sam-front &&
                            cd /home/webservice/react/releases && ls -1dt */ | tail -n +6 | xargs rm -rf 2>/dev/null || true
                        '
                    """
                }
            }
        }
    }

    post {
        success {
            slackSend channel: '#product_infra', color: 'good', tokenCredentialId: 'slack-token',
                message: "✅ *react* 배포 성공 (`${env.BRANCH_NAME}`)\n${env.GIT_COMMIT_MSG}\n<${env.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>"
        }
        failure {
            slackSend channel: '#product_infra', color: 'danger', tokenCredentialId: 'slack-token',
                message: "❌ *react* 배포 실패 (`${env.BRANCH_NAME}`)\n${env.GIT_COMMIT_MSG}\n<${env.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>"
        }
    }
}
```

> **참고:** Next.js는 `NEXT_PUBLIC_*` 환경변수가 빌드 시 바인딩되므로,
> Stage(.env.stage)와 Production(.env.main)에서 별도 빌드가 필요하다.
> main 빌드 시 Stage용으로 먼저 빌드 → 승인 후 Production용으로 재빌드.

> **환경파일:** Jenkins는 CI/CD 서버의 env-files를 `.env.production`으로 복사하여 빌드한다.
> Next.js 우선순위: `.env.local` > `.env.production` > `.env`
> 따라서 서버에 `.env.local`이 있으면 `.env.production`을 덮어쓰므로 `.env.local`은 사용하지 않는다.

### PM2 수동 재시작

```bash
ssh sam-prod

# 무중단 재시작 (cluster 모드)
pm2 reload sam-front
pm2 status

# 전체 재기동 필요한 경우
pm2 stop sam-front
cd /home/webservice && pm2 start ecosystem.config.js --only sam-front
pm2 save
```

---

## API (Laravel) 배포

### 자동 배포 흐름

```
CI/CD Gitea push -> Webhook -> Jenkins
-> checkout -> rsync → Stage 배포 → 승인 → rsync → Production 배포
```

**브랜치별 배포 대상:**

| 브랜치 | 배포 단계 | 대상 서버 | 대상 경로 |
|--------|----------|----------|----------|
| main | Stage (자동) | 운영서버 | /home/webservice/api-stage/releases/ |
| main | Production (승인 후) | 운영서버 | /home/webservice/api/releases/ |
| develop | 개발서버 | 개발서버 | 기존 post-update hook |

### Jenkinsfile (api/Jenkinsfile)

```groovy
pipeline {
    agent any

    options {
        disableConcurrentBuilds()
    }

    environment {
        DEPLOY_USER = 'hskwon'
        RELEASE_ID  = new Date().format('yyyyMMdd_HHmmss')
    }

    stages {
        stage('Checkout') {
            steps {
                checkout scm
                script {
                    env.GIT_COMMIT_MSG = sh(script: "git log -1 --pretty=format:'%s'", returnStdout: true).trim()
                }
                slackSend channel: '#product_infra', color: '#439FE0', tokenCredentialId: 'slack-token',
                    message: "🚀 *api* 빌드 시작 (`${env.BRANCH_NAME}`)\n${env.GIT_COMMIT_MSG}\n<${env.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>"
            }
        }

        // ── main → 운영서버 Stage 배포 ──
        stage('Deploy Stage') {
            when { branch 'main' }
            steps {
                sshagent(credentials: ['deploy-ssh-key']) {
                    sh """
                        ssh ${DEPLOY_USER}@211.117.60.189 'mkdir -p /home/webservice/api-stage/releases/${RELEASE_ID}'
                        rsync -az --delete \
                            --exclude='.git' --exclude='.env' \
                            --exclude='storage/app' --exclude='storage/logs' \
                            --exclude='storage/framework/sessions' --exclude='storage/framework/cache' \
                            . ${DEPLOY_USER}@211.117.60.189:/home/webservice/api-stage/releases/${RELEASE_ID}/
                        ssh ${DEPLOY_USER}@211.117.60.189 '
                            cd /home/webservice/api-stage/releases/${RELEASE_ID} &&
                            mkdir -p bootstrap/cache storage/framework/{views,cache/data,sessions} storage/logs &&
                            ln -sfn /home/webservice/api-stage/shared/.env .env &&
                            ln -sfn /home/webservice/api-stage/shared/storage/app storage/app &&
                            composer install --no-dev --optimize-autoloader --no-interaction &&
                            php artisan config:cache &&
                            php artisan route:cache &&
                            php artisan view:cache &&
                            php artisan migrate --force &&
                            ln -sfn /home/webservice/api-stage/releases/${RELEASE_ID} /home/webservice/api-stage/current &&
                            sudo systemctl reload php8.4-fpm &&
                            cd /home/webservice/api-stage/releases && ls -1dt */ | tail -n +4 | xargs rm -rf 2>/dev/null || true
                        '
                    """
                }
            }
        }

        // ── 운영 배포 승인 ──
        stage('Production Approval') {
            when { branch 'main' }
            steps {
                slackSend channel: '#product_deploy', color: '#FF9800', tokenCredentialId: 'slack-token',
                    message: "🔔 *api* 운영 배포 승인 대기 중\n${env.GIT_COMMIT_MSG}\nStage API: https://stage-api.sam.it.kr\n<${env.BUILD_URL}input|승인하러 가기>"
                timeout(time: 24, unit: 'HOURS') {
                    input message: 'Stage 확인 후 운영 배포를 진행하시겠습니까?\nStage API: https://stage-api.sam.it.kr',
                          ok: '운영 배포 진행'
                }
            }
        }

        // ── main → 운영서버 Production 배포 ──
        stage('Deploy Production') {
            when { branch 'main' }
            steps {
                sshagent(credentials: ['deploy-ssh-key']) {
                    sh """
                        ssh ${DEPLOY_USER}@211.117.60.189 'mkdir -p /home/webservice/api/releases/${RELEASE_ID}'
                        rsync -az --delete \
                            --exclude='.git' --exclude='.env' \
                            --exclude='storage/app' --exclude='storage/logs' \
                            --exclude='storage/framework/sessions' --exclude='storage/framework/cache' \
                            . ${DEPLOY_USER}@211.117.60.189:/home/webservice/api/releases/${RELEASE_ID}/
                        ssh ${DEPLOY_USER}@211.117.60.189 '
                            cd /home/webservice/api/releases/${RELEASE_ID} &&
                            mkdir -p bootstrap/cache storage/framework/{views,cache/data,sessions} storage/logs &&
                            ln -sfn /home/webservice/api/shared/.env .env &&
                            ln -sfn /home/webservice/api/shared/storage/app storage/app &&
                            composer install --no-dev --optimize-autoloader --no-interaction &&
                            php artisan config:cache &&
                            php artisan route:cache &&
                            php artisan view:cache &&
                            php artisan migrate --force &&
                            ln -sfn /home/webservice/api/releases/${RELEASE_ID} /home/webservice/api/current &&
                            sudo systemctl reload php8.4-fpm &&
                            sudo supervisorctl restart sam-queue-worker:* &&
                            cd /home/webservice/api/releases && ls -1dt */ | tail -n +6 | xargs rm -rf 2>/dev/null || true
                        '
                    """
                }
            }
        }

        // develop → Jenkins 관여 안함 (기존 post-update hook 유지)
    }

    post {
        success {
            slackSend channel: '#product_infra', color: 'good', tokenCredentialId: 'slack-token',
                message: "✅ *api* 배포 성공 (`${env.BRANCH_NAME}`)\n${env.GIT_COMMIT_MSG}\n<${env.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>"
        }
        failure {
            slackSend channel: '#product_infra', color: 'danger', tokenCredentialId: 'slack-token',
                message: "❌ *api* 배포 실패 (`${env.BRANCH_NAME}`)\n${env.GIT_COMMIT_MSG}\n<${env.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>"
            script {
                if (env.BRANCH_NAME == 'main') {
                    sshagent(credentials: ['deploy-ssh-key']) {
                        sh """
                            ssh ${DEPLOY_USER}@211.117.60.189 '
                                PREV=\$(ls -1dt /home/webservice/api/releases/*/ | sed -n "2p" | xargs basename 2>/dev/null) &&
                                [ -n "\$PREV" ] && ln -sfn /home/webservice/api/releases/\$PREV /home/webservice/api/current &&
                                sudo systemctl reload php8.4-fpm
                            ' || true
                        """
                    }
                }
            }
        }
    }
}
```

> **참고:** Laravel은 런타임 .env를 사용하므로 Stage/Production 별도 빌드가 필요 없다.
> 각 환경의 shared/.env가 심링크로 연결된다.

### 수동 배포 절차 (API Production)

> **참고:** CI/CD Gitea는 `REQUIRE_SIGNIN_VIEW = true` 설정이므로,
> 수동 git clone 시 `https://사용자:비밀번호@git.sam.it.kr/...` 형식 또는
> CI/CD 서버에서 rsync로 전송하는 방식을 사용한다.

```bash
ssh sam-prod

# 1. 새 릴리즈 디렉토리 생성
RELEASE_ID=$(date +%Y%m%d_%H%M%S)
cd /home/webservice/api/releases
git clone --depth 1 --branch main https://git.sam.it.kr/SamProject/sam-api.git $RELEASE_ID

# 2. shared 심링크 연결
ln -sfn /home/webservice/api/shared/storage /home/webservice/api/releases/$RELEASE_ID/storage
ln -sfn /home/webservice/api/shared/.env /home/webservice/api/releases/$RELEASE_ID/.env

# 3. 필수 디렉토리 생성 (.gitignore에 의해 누락)
cd /home/webservice/api/releases/$RELEASE_ID
mkdir -p bootstrap/cache storage/framework/{views,cache/data,sessions} storage/logs

# 4. 의존성 설치
composer install --no-dev --optimize-autoloader --no-interaction

# 5. 캐시 생성
php artisan config:cache
php artisan route:cache
php artisan view:cache

# 6. 마이그레이션 (필요시)
php artisan migrate --force

# 7. 심링크 전환 (이 시점에 배포 적용)
ln -sfn /home/webservice/api/releases/$RELEASE_ID /home/webservice/api/current

# 8. 서비스 리로드
sudo systemctl reload php8.4-fpm
sudo supervisorctl restart sam-queue-worker:*

# 9. 오래된 릴리즈 정리 (최근 5개만 유지)
cd /home/webservice/api/releases
ls -1dt */ | tail -n +6 | xargs rm -rf 2>/dev/null || true
```

### 수동 배포 절차 (API Stage)

```bash
ssh sam-prod

RELEASE_ID=$(date +%Y%m%d_%H%M%S)
cd /home/webservice/api-stage/releases
git clone --depth 1 --branch stage https://git.sam.it.kr/SamProject/sam-api.git $RELEASE_ID

ln -sfn /home/webservice/api-stage/shared/storage /home/webservice/api-stage/releases/$RELEASE_ID/storage
ln -sfn /home/webservice/api-stage/shared/.env /home/webservice/api-stage/releases/$RELEASE_ID/.env

cd /home/webservice/api-stage/releases/$RELEASE_ID
mkdir -p bootstrap/cache storage/framework/{views,cache/data,sessions} storage/logs
composer install --no-dev --optimize-autoloader --no-interaction
php artisan config:cache
php artisan route:cache
php artisan view:cache
php artisan migrate --force

ln -sfn /home/webservice/api-stage/releases/$RELEASE_ID /home/webservice/api-stage/current
sudo systemctl reload php8.4-fpm

# 최근 3개만 유지
cd /home/webservice/api-stage/releases
ls -1dt */ | tail -n +4 | xargs rm -rf 2>/dev/null || true
```

---

## MNG (Laravel Admin) 배포

API와 동일한 releases/shared 구조. 차이점: npm build 추가, Queue Worker 재시작 불필요.

> **참고: storage/logs 심링크 (2026-02-26 변경)**
> MNG는 storage/logs를 shared로 심링크하여 배포 간 로그를 영속 보존한다.
> 이전에는 `mkdir`로 릴리즈 디렉토리에 생성하여 배포마다 로그가 유실되었음.
> 변경: `ln -sfn /home/webservice/mng/shared/storage/logs storage/logs`

### Jenkinsfile (mng/Jenkinsfile)

```groovy
pipeline {
    agent any

    options {
        disableConcurrentBuilds()
    }

    environment {
        DEPLOY_USER = 'hskwon'
        RELEASE_ID  = new Date().format('yyyyMMdd_HHmmss')
    }

    stages {
        stage('Checkout') {
            steps {
                checkout scm
                script {
                    env.GIT_COMMIT_MSG = sh(script: "git log -1 --pretty=format:'%s'", returnStdout: true).trim()
                }
                slackSend channel: '#product_infra', color: '#439FE0', tokenCredentialId: 'slack-token',
                    message: "🚀 *mng* 빌드 시작 (`${env.BRANCH_NAME}`)\n${env.GIT_COMMIT_MSG}\n<${env.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>"
            }
        }

        // ── main → 운영서버 Production ──
        stage('Deploy Production') {
            when { branch 'main' }
            steps {
                sshagent(credentials: ['deploy-ssh-key']) {
                    sh """
                        ssh ${DEPLOY_USER}@211.117.60.189 'mkdir -p /home/webservice/mng/releases/${RELEASE_ID}'
                        rsync -az --delete \
                            --exclude='.git' --exclude='.env' \
                            --exclude='storage/app' --exclude='storage/logs' \
                            --exclude='storage/framework/sessions' --exclude='storage/framework/cache' \
                            --exclude='node_modules' \
                            . ${DEPLOY_USER}@211.117.60.189:/home/webservice/mng/releases/${RELEASE_ID}/
                        ssh ${DEPLOY_USER}@211.117.60.189 '
                            cd /home/webservice/mng/releases/${RELEASE_ID} &&
                            mkdir -p bootstrap/cache storage/framework/{views,cache/data,sessions} &&
                            rm -rf storage/logs && ln -sfn /home/webservice/mng/shared/storage/logs storage/logs &&
                            ln -sfn /home/webservice/mng/shared/.env .env &&
                            sudo chmod 640 /home/webservice/mng/shared/.env &&
                            ln -sfn /home/webservice/mng/shared/storage/app storage/app &&
                            ln -sfn /home/webservice/mng/shared/storage/credentials storage/credentials &&
                            rm -rf storage/fonts && ln -sfn /home/webservice/mng/shared/storage/fonts storage/fonts &&
                            composer install --no-dev --optimize-autoloader --no-interaction &&
                            npm install --prefer-offline &&
                            npm run build &&
                            php artisan config:cache &&
                            php artisan route:cache &&
                            php artisan view:cache &&
                            php artisan migrate --force &&
                            ln -sfn /home/webservice/mng/releases/${RELEASE_ID} /home/webservice/mng/current &&
                            sudo systemctl reload php8.4-fpm &&
                            cd /home/webservice/mng/releases && ls -1dt */ | tail -n +6 | xargs rm -rf 2>/dev/null || true
                        '
                    """
                }
            }
        }

        // develop → Jenkins 관여 안함 (기존 post-update hook 유지)
    }

    post {
        success {
            slackSend channel: '#product_infra', color: 'good', tokenCredentialId: 'slack-token',
                message: "✅ *mng* 배포 성공 (`${env.BRANCH_NAME}`)\n${env.GIT_COMMIT_MSG}\n<${env.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>"
        }
        failure {
            slackSend channel: '#product_infra', color: 'danger', tokenCredentialId: 'slack-token',
                message: "❌ *mng* 배포 실패 (`${env.BRANCH_NAME}`)\n${env.GIT_COMMIT_MSG}\n<${env.BUILD_URL}|빌드 #${env.BUILD_NUMBER}>"
            script {
                if (env.BRANCH_NAME == 'main') {
                    sshagent(credentials: ['deploy-ssh-key']) {
                        sh """
                            ssh ${DEPLOY_USER}@211.117.60.189 '
                                PREV=\$(ls -1dt /home/webservice/mng/releases/*/ | sed -n "2p" | xargs basename) &&
                                [ -n "\$PREV" ] && ln -sfn /home/webservice/mng/releases/\$PREV /home/webservice/mng/current &&
                                sudo systemctl reload php8.4-fpm
                            '
                        """
                    }
                }
            }
        }
    }
}
```

### 수동 배포

```bash
ssh sam-prod

RELEASE_ID=$(date +%Y%m%d_%H%M%S)
cd /home/webservice/mng/releases
git clone --depth 1 --branch main https://git.sam.it.kr/SamProject/sam-manage.git $RELEASE_ID

ln -sfn /home/webservice/mng/shared/.env /home/webservice/mng/releases/$RELEASE_ID/.env
ln -sfn /home/webservice/mng/shared/storage/app /home/webservice/mng/releases/$RELEASE_ID/storage/app
rm -rf /home/webservice/mng/releases/$RELEASE_ID/storage/logs
ln -sfn /home/webservice/mng/shared/storage/logs /home/webservice/mng/releases/$RELEASE_ID/storage/logs
ln -sfn /home/webservice/mng/shared/storage/credentials /home/webservice/mng/releases/$RELEASE_ID/storage/credentials
rm -rf /home/webservice/mng/releases/$RELEASE_ID/storage/fonts
ln -sfn /home/webservice/mng/shared/storage/fonts /home/webservice/mng/releases/$RELEASE_ID/storage/fonts

cd /home/webservice/mng/releases/$RELEASE_ID
mkdir -p bootstrap/cache storage/framework/{views,cache/data,sessions}
composer install --no-dev --optimize-autoloader --no-interaction

# Vite 빌드 (Blade + Tailwind)
npm install --prefer-offline
npm run build

php artisan config:cache
php artisan route:cache
php artisan view:cache
php artisan migrate --force

ln -sfn /home/webservice/mng/releases/$RELEASE_ID /home/webservice/mng/current
sudo systemctl reload php8.4-fpm

# 오래된 릴리즈 정리
cd /home/webservice/mng/releases
ls -1dt */ | tail -n +6 | xargs rm -rf 2>/dev/null || true
```

---

## Sales (Plain PHP) 배포

레거시 PHP 애플리케이션. rsync 기반 배포.

### Jenkinsfile (sales/Jenkinsfile)

```groovy
pipeline {
    agent any
    environment { DEPLOY_USER = 'hskwon' }

    stages {
        stage('Checkout') {
            steps { checkout scm }
        }

        stage('Deploy Production') {
            when { branch 'main' }
            steps {
                sshagent(credentials: ['deploy-ssh-key']) {
                    sh """
                        rsync -az --delete \
                            --exclude='.git' --exclude='.env' --exclude='storage' \
                            . ${DEPLOY_USER}@211.117.60.189:/home/webservice/sales/
                        ssh ${DEPLOY_USER}@211.117.60.189 'cd /home/webservice/sales && echo "sales deployed"'
                    """
                }
            }
        }
        // develop → 개발서버는 기존 post-update hook 유지
    }

    post {
        success { echo '✅ sales 배포 완료 (' + env.BRANCH_NAME + ')' }
        failure { echo '❌ sales 배포 실패 (' + env.BRANCH_NAME + ')' }
    }
}
```

### 수동 배포

```bash
ssh sam-prod
cd /home/webservice/sales
git pull origin main
```

별도 캐시나 빌드 절차 없음. .env 변경 시에만 주의.

---

## Landing (정적 페이지) 배포

### Jenkinsfile (landing/Jenkinsfile)

```groovy
pipeline {
    agent any
    environment { DEPLOY_USER = 'hskwon' }

    stages {
        stage('Deploy Production') {
            when { branch 'main' }
            steps {
                sshagent(credentials: ['deploy-ssh-key']) {
                    sh "ssh ${DEPLOY_USER}@211.117.60.189 'cd /home/webservice/landing && git pull origin main'"
                }
            }
        }
    }
}
```

---

## 롤백

### React 롤백

```bash
# 이전 릴리즈 확인
ssh sam-prod "ls -lt /home/webservice/react/releases/"
ssh sam-prod "readlink /home/webservice/react/current"

# 롤백 실행
ssh sam-prod "
  PREV=\$(ls -1dt /home/webservice/react/releases/*/ | sed -n '2p' | xargs basename) &&
  echo \"롤백 대상: \$PREV\" &&
  ln -sfn /home/webservice/react/releases/\$PREV /home/webservice/react/current &&
  cd /home/webservice && pm2 reload sam-front
"
```

### API 롤백

```bash
ssh sam-prod "ls -1dt /home/webservice/api/releases/*/"

ssh sam-prod "
  PREV=\$(ls -1dt /home/webservice/api/releases/*/ | sed -n '2p' | xargs basename) &&
  echo \"롤백 대상: \$PREV\" &&
  ln -sfn /home/webservice/api/releases/\$PREV /home/webservice/api/current &&
  sudo systemctl reload php8.4-fpm &&
  sudo supervisorctl restart sam-queue-worker:*
"
```

---

## Jenkins 장애 시 수동 배포

### React 수동 배포

```bash
# CI/CD 서버에서 빌드
cd /tmp
git clone --depth 1 --branch main https://git.sam.it.kr/SamProject/sam-react-prod.git react-build
cd react-build
cp /var/lib/jenkins/env-files/react/.env.main .env.production
npm install --prefer-offline
npm run build

RELEASE_ID=$(date +%Y%m%d_%H%M%S)

# 운영서버로 전송
ssh sam-prod "mkdir -p /home/webservice/react/releases/${RELEASE_ID}"
rsync -az --delete \
  .next package.json next.config.ts public node_modules \
  hskwon@211.117.60.189:/home/webservice/react/releases/${RELEASE_ID}/
scp .env.production hskwon@211.117.60.189:/home/webservice/react/releases/${RELEASE_ID}/.env.production

# 심링크 전환 및 PM2 재시작
ssh sam-prod "
  ln -sfn /home/webservice/react/releases/${RELEASE_ID} /home/webservice/react/current &&
  cd /home/webservice && pm2 reload sam-front
"

# 빌드 디렉토리 정리
rm -rf /tmp/react-build
```

### API 수동 배포

```bash
RELEASE_ID=$(date +%Y%m%d_%H%M%S)

ssh sam-prod "
  cd /home/webservice/api/releases &&
  git clone --depth 1 --branch main https://git.sam.it.kr/SamProject/sam-api.git ${RELEASE_ID} &&
  ln -sfn /home/webservice/api/shared/storage /home/webservice/api/releases/${RELEASE_ID}/storage &&
  ln -sfn /home/webservice/api/shared/.env /home/webservice/api/releases/${RELEASE_ID}/.env &&
  cd /home/webservice/api/releases/${RELEASE_ID} &&
  mkdir -p bootstrap/cache storage/framework/{views,cache/data,sessions} storage/logs &&
  composer install --no-dev --optimize-autoloader --no-interaction &&
  php artisan config:cache &&
  php artisan route:cache &&
  php artisan view:cache &&
  php artisan migrate --force &&
  ln -sfn /home/webservice/api/releases/${RELEASE_ID} /home/webservice/api/current &&
  sudo systemctl reload php8.4-fpm &&
  sudo supervisorctl restart sam-queue-worker:*
"
```

---

## 배포 후 확인 사항

```bash
# 서비스 상태
sudo systemctl status nginx php8.4-fpm
pm2 status
sudo supervisorctl status

# 에러 로그
sudo tail -20 /var/log/nginx/api.sam.it.kr.error.log
sudo tail -20 /home/webservice/api/shared/storage/logs/laravel.log
sudo tail -20 /home/webservice/mng/shared/storage/logs/laravel.log

# HTTP 응답 확인
curl -sI https://api.sam.it.kr
curl -sI https://sam.it.kr
curl -sI https://mng.codebridge-x.com
```

---

## 빌드 아티팩트 관리

```bash
# Jenkins workspace 용량 확인
sudo du -sh /var/lib/jenkins/workspace/*

# 운영서버 릴리즈 정리
ssh sam-prod "cd /home/webservice/react/releases && ls -1dt */ | tail -n +6 | xargs rm -rf"
ssh sam-prod "cd /home/webservice/api/releases && ls -1dt */ | tail -n +6 | xargs rm -rf"

# Jenkins 빌드 보관 정책: Jenkins > Job > Configure > Discard old builds
```

---

## 빌드 실패 조사

```bash
# Jenkins 로그에서 최근 오류
sudo journalctl -u jenkins --since "30 minutes ago" | grep -i error

# Jenkins workspace 확인
ls -la /var/lib/jenkins/workspace/

# 웹 콘솔 로그 (권장)
# https://ci.sam.it.kr/job/<JOB_NAME>/<BUILD_NUMBER>/console
```

**빌드 실패 주요 원인:**

1. npm install 실패 -- node_modules 캐시, 네트워크
2. npm run build 실패 -- TypeScript 오류, 환경변수 누락
3. rsync 실패 -- SSH 키 문제, 디스크 공간 부족
4. composer install 실패 -- 네트워크, PHP 확장 누락
5. SSH 연결 실패 -- known_hosts 변경, 키 만료
6. Laravel `package:discover` 실패 -- `bootstrap/cache/` 디렉토리 누락 (`.gitignore`에 포함)
7. Blade view 캐시 실패 -- `storage/framework/views/` 디렉토리 누락
8. `Target class [request] does not exist` -- CLI 컨텍스트에서 `request()` 호출 (AppServiceProvider 확인)

> **Laravel 배포 필수:** `mkdir -p bootstrap/cache storage/framework/{views,cache/data,sessions} storage/logs`를
> `composer install` 전에 실행해야 함. `.gitignore`가 이 디렉토리들을 제외하므로 rsync/git clone 후 생성 필요.