diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..5f30612f --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +node_modules +coverage +dist +.git +.env* \ No newline at end of file diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md deleted file mode 100644 index ec85f6f1..00000000 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ /dev/null @@ -1,21 +0,0 @@ - -## 요구사항 - -### 기본 -- [x] 기본 항목 1 -- [ ] 기본 항목 2 - -### 심화 -- [ ] 심화 항목 1 -- [ ] 심화 항목 2 - -## 주요 변경사항 -- -- - -## 스크린샷 -![image](이미지url) - -## 멘토에게 -- 셀프 코드 리뷰를 통해 질문 이어가겠습니다. -- diff --git a/.github/workflows/depoly.yml b/.github/workflows/depoly.yml new file mode 100644 index 00000000..31783c14 --- /dev/null +++ b/.github/workflows/depoly.yml @@ -0,0 +1,27 @@ +name: Deploy to EC2 + +on: + push: + branches: + - main + +jobs: + deploy: + runs-on: ubuntu-latest + + steps: + - name: Deploy to EC2 + uses: appleboy/ssh-action@v0.1.6 + with: + host: ${{secrets.EC2_HOST}} + username: ${{secrets.EC2_USER}} + key: ${{secrets.EC2_PRIVATE_KEY}} + port: 22 + script: | + cd ~/1-sprint-mission/5-sprint-mission # EC2 인스턴스 내 프로젝트 경로로 진입 + git reset --hard HEAD + git pull origin main # 코드 최신화 + cd ~/1-sprint-mission + chmod +x ./5-sprint-mission/infra/ec2/start.sh + ./5-sprint-mission/infra/ec2/start.sh + echo "Deployment successful!" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 00000000..49f1f650 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,67 @@ +# 이 github actions 워크플로우는 main 브랜치로의 푸시 또는 pull request가 생기면 자동으로 실행 +# 통합 테스트를 위해 postgresql 데이터베이스를 함께 실행한다. + +name: Test + +on: + push: + branches: + - main + pull_request: + branches: + - main + +jobs: + test: + runs-on: ubuntu-latest # github에서 제공하는 ubuntu 리눅스 머신에서 실행 + + defaults: + run: + working-directory: 5-sprint-mission + + # Postgresql 서비스를 docker 컨테이너로 함께 실행 + services: + postgres: + image: postgres:15 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: testdb + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready --health-interval 10s --health-timeout 8s --health-retries 5 + env: + DATABASE_URL: postgres://postgres:postgres@localhost:5432/testdb + BASE_URL: http://localhost:3000 + PORT: 3000 + + JWT_ACCESS_TOKEN_SECRET: ${{secrets.JWT_ACCESS_TOKEN_SECRET}} + JWT_REFRESH_TOKEN_SECRET: ${{secrets.JWT_REFRESH_TOKEN_SECRET}} + + AWS_REGION: ap-northeast-2 + AWS_ACCESS_KEY_ID: ${{secrets.AWS_ACCESS_KEY_ID}} + AWS_SECRET_ACCESS_KEY: ${{secrets.AWS_SECRET_ACCESS_KEY}} + AWS_S3_BUCKET_NAME: sprintmission10 + + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Set up Node.js + uses: actions/setup-node@v3 + with: + node-version: "22.13.0" + + - name: Install dependencies + + run: npm install + + - name: Apply DB Schema + run: npm run prisma:migrate #Prisma 마이그레이션 적용 + + - name: Type Check + run: npm run typecheck #TypeScript 타입 검사 수행 + + - name: Run Tests + run: npm run test:ci #테스트 실행 diff --git a/5-sprint-mission/.gitignore b/5-sprint-mission/.gitignore index 3af0a7c7..2706065f 100644 --- a/5-sprint-mission/.gitignore +++ b/5-sprint-mission/.gitignore @@ -1,10 +1,11 @@ node_modules -.env +.env* ERD.MD study.MD tests dist/ README.MD request/ -.env.test -coverage/ \ No newline at end of file +coverage/ +test1.yml + diff --git a/5-sprint-mission/infra/ec2/ecosystem.config.js b/5-sprint-mission/infra/ec2/ecosystem.config.js index de82de41..ee63f178 100644 --- a/5-sprint-mission/infra/ec2/ecosystem.config.js +++ b/5-sprint-mission/infra/ec2/ecosystem.config.js @@ -1,9 +1,8 @@ module.exports = { apps: [ { - name: 'mission10', - script: 'npx', - args: 'ts-node src/main.ts', + name: 'mission11', + script: 'dist/main.js', env: { NODE_ENV: 'production', }, diff --git a/5-sprint-mission/infra/ec2/nginx.conf b/5-sprint-mission/infra/ec2/nginx.conf index c592cb28..edea0992 100644 --- a/5-sprint-mission/infra/ec2/nginx.conf +++ b/5-sprint-mission/infra/ec2/nginx.conf @@ -1,12 +1,20 @@ -server { +events {} + +http { + upstream express_app { + server express_app:3000; + } + server { listen 80; location / { - proxy_pass http://localhost:3000; + proxy_pass http://express_app; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection 'upgrade'; proxy_set_header Host $host; proxy_cache_bypass $http_upgrade; } -} \ No newline at end of file + } +} + diff --git a/5-sprint-mission/infra/ec2/start.sh b/5-sprint-mission/infra/ec2/start.sh index 9c7ea6f2..1e38baff 100644 --- a/5-sprint-mission/infra/ec2/start.sh +++ b/5-sprint-mission/infra/ec2/start.sh @@ -1,6 +1,33 @@ -sudo npm install -g pm2 -pm2 -v -pm2 start "npm run dev" --name mission-app -pm2 list -pm2 startup -pm2 save \ No newline at end of file +#!/bin/bash +set -eux + +cd "$(dirname "$0")/../../.." + +echo "Docker 설치" +sudo yum update -y +sudo dnf install -y docker +sudo systemctl enable --now docker +sudo usermod -aG docker $USER + +echo "Docker Compose 설치" +sudo curl -L "https://github.com/docker/compose/releases/download/v2.24.6/docker-compose-linux-x86_64" -o /usr/local/bin/docker-compose +sudo chmod +x /usr/local/bin/docker-compose +docker-compose version + +echo "AWS CLI + jq 설치" +sudo yum install -y awscli jq + +echo "SSM 파리미터로 .env.production 생성" +aws ssm get-parameters-by-path \ + --path "/MyApp/prod/" \ + --with-decryption \ + --query "Parameters[*].{Name:Name,Value:Value}" \ + --output json > /tmp/env.json + +cat /tmp/env.json \ + | jq -r '.[] | (.Name | split("/")[-1]) + "=" + .Value' \ + > .env.production + +echo "Docker Compose 실행" +docker-compose -f docker-compose.prod.yml down || true +docker-compose -f docker-compose.prod.yml up -d --build \ No newline at end of file diff --git a/5-sprint-mission/package.json b/5-sprint-mission/package.json index fad57511..97c128ba 100644 --- a/5-sprint-mission/package.json +++ b/5-sprint-mission/package.json @@ -22,12 +22,13 @@ }, "scripts": { "dev": "nodemon --watch src --ext ts --exec ts-node src/main.ts", - "start": "node dist/app.js", + "start": "node dist/main.js", "build": "tsc", - "test": "dotenv -e .env.test -- prisma migrate dev && dotenv -e .env.test -- jest -i --coverage" - }, - "prisma": { - "seed": "ts-node --esm prisma/seed.ts" + "typecheck": "tsc --noEmit", + "prisma:migrate": "npx prisma migrate dev", + "prisma:deploy": "npx prisma migrate deploy && npx prisma generate", + "test:local": "dotenv -e .env.test -- prisma migrate dev && dotenv -e .env.test -- jest -i --coverage", + "test:ci": "jest --runInBand --forceExit" }, "devDependencies": { "@types/cookie-parser": "^1.4.8", diff --git a/5-sprint-mission/request/user.http b/5-sprint-mission/request/user.http index 3f357dc6..30d82e7a 100644 --- a/5-sprint-mission/request/user.http +++ b/5-sprint-mission/request/user.http @@ -3,7 +3,7 @@ POST http://localhost:3000/users Content-Type: application/json { - "email": "you12@example.com", + "email": "you123@example.com", "password": "password1234", "nickname": "도준", "image": "https://example.com/profile.jpg" diff --git a/5-sprint-mission/src/controller/imagesController.ts b/5-sprint-mission/src/controller/imagesController.ts index b292a77e..4fe2e078 100644 --- a/5-sprint-mission/src/controller/imagesController.ts +++ b/5-sprint-mission/src/controller/imagesController.ts @@ -8,12 +8,15 @@ import { uploadImageToS3 } from '../lib/upload'; const ALLOWED_MIME_TYPES = ['image/png', 'image/jpg', 'image/jpeg']; const FILE_SIZE_LIMIT = 5 * 1024 * 1024; +// 허용할 이미지 타입 설정 +// 업로드 최대 용량은 5MB export const upload = multer({ storage: NODE_ENV === 'production' - ? multer.memoryStorage() + ? multer.memoryStorage() //운영 환경에 사용 : multer.diskStorage({ + // 개발 환경이면 사용 destination(req: Request, file: Express.Multer.File, cb: Function) { cb(null, PUBLIC_PATH); }, @@ -32,7 +35,7 @@ export const upload = multer({ if (!ALLOWED_MIME_TYPES.includes(file.mimetype)) { const err = new BadRequestError('Only png, jpeg, and jpg are allowed'); return cb(err); - } + } //파일 타입 필터링 PNG, JPG, JPEG만 허용 cb(null, true); }, @@ -48,8 +51,8 @@ export async function uploadImage(req: Request, res: Response) { if (NODE_ENV === 'production') { try { - const url = await uploadImageToS3(file); - res.status(200).json({ url }); + const url = await uploadImageToS3(file); //S3 업로드 + res.status(200).json({ url }); //S3 URL 반환 return; } catch (err) { console.error(err); @@ -58,7 +61,7 @@ export async function uploadImage(req: Request, res: Response) { } } - const url = `${BASE_URL}/uploads/${file.filename}`; + const url = `${BASE_URL}/uploads/${file.filename}`; //개발 환경에서는 로컬에 저장된 이미지 경로를 URL로 반환 res.status(200).send({ url }); return; } diff --git a/5-sprint-mission/src/lib/constants.ts b/5-sprint-mission/src/lib/constants.ts index d55368c8..466871fb 100644 --- a/5-sprint-mission/src/lib/constants.ts +++ b/5-sprint-mission/src/lib/constants.ts @@ -1,6 +1,8 @@ import path from 'path'; import dotenv from 'dotenv'; -dotenv.config(); + +const envPath = `.env.${process.env.NODE_ENV || 'development'}`; +dotenv.config({ path: envPath }); if (!process.env.JWT_ACCESS_TOKEN_SECRET) { throw new Error('JWT_ACCESS_TOKEN_SECRET 환경 변수가 설정되지 않았습니다.'); @@ -15,7 +17,6 @@ export const REFRESH_TOKEN_COOKIE_NAME: string = 'refreshToken'; export const DATABASE_URL: string | undefined = process.env.DATABASE_URL; export const JWT_ACCESS_TOKEN_SECRET = process.env.JWT_ACCESS_TOKEN_SECRET; export const JWT_REFRESH_TOKEN_SECRET = process.env.JWT_REFRESH_TOKEN_SECRET; -export const PORT: number = process.env.PORT ? parseInt(process.env.PORT) : 3000; export const NODE_ENV: string = process.env.NODE_ENV || 'development'; export const PUBLIC_PATH: string = path.resolve('public'); export const STATIC_PATH: string = '/uploads'; diff --git a/5-sprint-mission/src/lib/upload.ts b/5-sprint-mission/src/lib/upload.ts index 29ffd7e8..49aa317b 100644 --- a/5-sprint-mission/src/lib/upload.ts +++ b/5-sprint-mission/src/lib/upload.ts @@ -1,20 +1,23 @@ import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3'; +//AWS SDK의 S3 클라이언트와 파일 업로드 명령어를 사용하기 위한 임포트 import { v4 as uuidv4 } from 'uuid'; +// 고유한 파일명을 만들기 위해 UUID를 사용 import path from 'path'; import { AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_REGION, S3_BUCKET_NAME } from './constants'; - +//AWS 인증 정보 및 환경 설정을 상수로 불러온다. export const s3Client = new S3Client({ region: AWS_REGION, credentials: { accessKeyId: AWS_ACCESS_KEY_ID, secretAccessKey: AWS_SECRET_ACCESS_KEY, }, -}); +}); //AWS S3와 통신할 클라이언트를 생성 REGION, credentials를 설정하여 인증된 상태로 사용 function generateKey(file: Express.Multer.File): string { const ext = path.extname(file.originalname); return `uploads/${uuidv4()}${ext}`; -} +} // 파일 이름이 중복되지 않도록 UUID를 사용해서 S3의 파일 키를 생성 +// 업로드 폴더 아래에 저장되도록 경로를 지정 export async function uploadImageToS3(file: Express.Multer.File): Promise { const key = generateKey(file); @@ -24,7 +27,9 @@ export async function uploadImageToS3(file: Express.Multer.File): Promise { - console.log('Server is running on port 3000'); +const port = Number(process.env.SERVER_PORT) || 3000; + +app.listen(port, () => { + console.log(`Server is running on port ${port}`); }); diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..1a9223ff --- /dev/null +++ b/Dockerfile @@ -0,0 +1,16 @@ +#빌드 스테이지 +FROM node:20 AS build-stage +WORKDIR /build +COPY 5-sprint-mission/package*.json ./ +RUN npm ci +COPY 5-sprint-mission ./ +RUN npm run build + +#실행 스테이지 +FROM node:20-slim +WORKDIR /app +COPY --from=build-stage /build/dist ./dist +COPY --from=build-stage /build/package*.json ./ +RUN npm ci --omit=dev +ENV SERVER_PORT=3000 +ENTRYPOINT [ "npm","run","start" ] \ No newline at end of file diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 00000000..1f6d11e3 --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,38 @@ +name: express-app-test +services: + server: + image: ksot1233/express + build: + context: . + dockerfile: Dockerfile + args: + - NODE_VERSION=20.12.1 + tags: + - ksot1233/express:latest + pull: true + + container_name: express-app + env_file: + - .env + environment: + - SERVER_PORT=3000 + ports: + - "3000:3000" + networks: + - network-a + volumes: + - volume-a:/app/logs:rw + db: + image: postgres + env_file: + - .env.db + ports: + - "5434:5432" + networks: + - network-a + +networks: + network-a: + +volumes: + volume-a: diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 00000000..626165a2 --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,43 @@ +name: express-app-test +services: + nginx: + image: nginx:latest + container_name: nginx-reverse-proxy + ports: + - "80:80" + volumes: + - ./infra/ec2/nginx.conf:/etc/nginx/nginx.conf:ro + depends_on: + - server + networks: + - network-a + restart: always + server: + image: ksot1233/express + build: + context: . + dockerfile: Dockerfile + args: + - NODE_VERSION=20.12.1 + tags: + - ksot1233/express:latest + pull: true + + container_name: express-app + restart: always + env_file: + - .env.production + environment: + - SERVER_PORT=3000 + expose: + - "3000" + networks: + - network-a + volumes: + - volume-a:/app/logs:rw + +networks: + network-a: + +volumes: + volume-a: