Files
sam-docs/deploys/ops-manual/05-deployment.md
권혁성 dbcfe65692 docs:MNG storage/logs 심링크 변경 및 E-Sign PDF 트러블슈팅 추가
- 05-deployment: MNG Jenkinsfile/수동배포 storage/logs 심링크 방식 반영
- 08-troubleshooting: 전자계약 PDF 서명 합성 오류 진단/조치 가이드 추가
- 08-troubleshooting: MNG 500 에러 섹션 로그 경로 shared로 업데이트

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 14:39:50 +09:00

40 KiB

5. 배포 가이드

목차로 돌아가기


파이프라인 개요

전체 흐름

개발자 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)

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 수동 재시작

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)

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로 전송하는 방식을 사용한다.

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)

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)

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} &&
                            ln -sfn /home/webservice/mng/shared/.env .env &&
                            ln -sfn /home/webservice/mng/shared/storage/app storage/app &&
                            ln -sfn /home/webservice/mng/shared/storage/logs storage/logs &&
                            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
                            '
                        """
                    }
                }
            }
        }
    }
}

수동 배포

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
ln -sfn /home/webservice/mng/shared/storage/logs /home/webservice/mng/releases/$RELEASE_ID/storage/logs

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)

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 + ')' }
    }
}

수동 배포

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

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


Landing (정적 페이지) 배포

Jenkinsfile (landing/Jenkinsfile)

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 롤백

# 이전 릴리즈 확인
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 롤백

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 수동 배포

# 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 수동 배포

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:*
"

배포 후 확인 사항

# 서비스 상태
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

빌드 아티팩트 관리

# 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

빌드 실패 조사

# 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/logscomposer install 전에 실행해야 함. .gitignore가 이 디렉토리들을 제외하므로 rsync/git clone 후 생성 필요.