Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
node_modules
dist
.git
.github
coverage
temp
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.env
**/.env
6 changes: 5 additions & 1 deletion .env.sample
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ LOG_LEVEL=debug
# 베이스 URL (images)
BASE_URL=http://localhost:3000

# Socket.IO CORS 허용 도메인 목록 (쉼표로 구분)
# 예: http://localhost:5173,https://your-frontend.com
SOCKET_CORS_ORIGINS=

### Session ###
# 세션 스토어 전용 (DB명 반드시 포함, ?schema 제거)
SESSION_STORE_URL="postgresql://user:password@localhost:5432/<디비제목>"
Expand Down Expand Up @@ -54,4 +58,4 @@ GOOGLE_CLIENT_SECRET=
# Google Cloud Console → Authorized Redirect URIs에 등록 필수
#
# 2) JWT Secret은 실제 서비스에서는 더 긴 랜덤 문자열 사용
# 예: openssl rand -base64 32
# 예: openssl rand -base64 32
47 changes: 47 additions & 0 deletions .github/workflows/cd.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
name: CD

on:
push:
branches:
- main

jobs:
deploy:
runs-on: ubuntu-latest

steps:
- name: Deploy to EC2
uses: appleboy/ssh-action@v1.2.0
with:
host: ${{ secrets.EC2_HOST }}
username: ${{ secrets.EC2_USER }}
key: ${{ secrets.EC2_PRIVATE_KEY }}
port: 22
command_timeout: 30m
script: |
set -e
cd /home/ec2-user/sprint-5/6-sprint-mission-copy
export GIT_TERMINAL_PROMPT=0

# Prefer GitHub secret, fallback to EC2 .env.
if [ -n "${{ secrets.DATABASE_URL }}" ]; then
export DATABASE_URL='${{ secrets.DATABASE_URL }}'
fi

if [ -z "${DATABASE_URL:-}" ] && [ -f .env ]; then
set -a
. ./.env
set +a
fi

if [ -z "${DATABASE_URL:-}" ]; then
echo "DATABASE_URL is empty. Set GitHub secret DATABASE_URL or configure EC2 .env."
exit 1
fi

git pull --ff-only origin main
npm ci
npx prisma generate
npx prisma migrate deploy
npm run build
pm2 reload infra/ec2/ecosystem.config.cjs --update-env || pm2 start infra/ec2/ecosystem.config.cjs
58 changes: 58 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
name: CI

on:
pull_request:

jobs:
test:
runs-on: ubuntu-latest

services:
postgres:
image: postgres:16
ports:
- 5432:5432
env:
POSTGRES_USER: ci_user
POSTGRES_PASSWORD: ci_password
POSTGRES_DB: ci_test_db
options: >-
--health-cmd "pg_isready -U ci_user -d ci_test_db"
--health-interval 10s
--health-timeout 5s
--health-retries 5

env:
POSTGRES_USER: ci_user
POSTGRES_PASSWORD: ci_password
POSTGRES_DB: ci_test_db
DATABASE_URL: postgresql://ci_user:ci_password@localhost:5432/ci_test_db
JWT_ACCESS_SECRET: test-access-secret
JWT_REFRESH_SECRET: test-refresh-secret
DEBUG_MODE: "false"
LOG_LEVEL: error

steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 22
cache: npm

- name: Install dependencies
run: npm ci

- name: Generate Prisma Client
run: npx prisma generate

- name: Apply migrations
run: npx prisma migrate deploy

- name: Build
run: npm run build

- name: Test
run: npm test
21 changes: 21 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
FROM node:22-alpine

WORKDIR /app

COPY package.json package-lock.json ./
RUN npm ci

COPY prisma ./prisma
RUN npx prisma generate

COPY tsconfig.json ./
COPY src ./src
COPY public ./public

RUN npm run build

RUN mkdir -p /app/public/uploads

EXPOSE 3000

CMD ["sh", "-c", "npx prisma migrate deploy && node dist/app.js"]
44 changes: 44 additions & 0 deletions docker-compose.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
services:
app:
build:
context: .
dockerfile: Dockerfile
container_name: sprint11-app
depends_on:
postgres:
condition: service_healthy
environment:
PORT: 3000
NODE_ENV: production
DATABASE_URL: postgresql://sprint11:sprint11@postgres:5432/sprint11
JWT_ACCESS_SECRET: dev-access-secret
JWT_REFRESH_SECRET: dev-refresh-secret
DEBUG_MODE: "false"
LOG_LEVEL: info
ports:
- "3000:3000"
volumes:
- uploads_data:/app/public/uploads
restart: unless-stopped

postgres:
image: postgres:16-alpine
container_name: sprint11-postgres
environment:
POSTGRES_USER: sprint11
POSTGRES_PASSWORD: sprint11
POSTGRES_DB: sprint11
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"]
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped

volumes:
postgres_data:
uploads_data:
11 changes: 11 additions & 0 deletions infra/ec2/ecosystem.config.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
module.exports = {
apps: [
{
script: 'npm',
args: 'start',
env: {
NODE_ENV: 'production',
},
},
],
};
15 changes: 15 additions & 0 deletions infra/ec2/nginx.conf
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
server {
listen 80;
server_name _;

location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
Binary file added infra/ec2/secure-group-inbound.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added infra/ec2/secure-group-outbound.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
8 changes: 8 additions & 0 deletions infra/ec2/start.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
#!/bin/bash

npm install
npx prisma generate
npx prisma migrate deploy
npm run build

pm2 start ecosystem.config.cjs
Binary file added infra/rds/secure-group-inbound.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added infra/rds/secure-group-outbound.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added infra/s3/policy.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion src/__tests__/article.auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { app } from '../app.js';
import { createUserWithToken, resetDb } from '../test/test-helpers.js';

describe('Article API (인증 필요)', () => {
beforeEach(async () => {
beforeAll(async () => {
await resetDb();
});

Expand Down
2 changes: 1 addition & 1 deletion src/__tests__/article.public.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import prisma from '../config/prisma.js';
import { createUserWithToken, resetDb } from '../test/test-helpers.js';

describe('Article API (public)', () => {
beforeEach(async () => {
beforeAll(async () => {
await resetDb();
});

Expand Down
43 changes: 43 additions & 0 deletions src/__tests__/article.service.unit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { jest } from '@jest/globals';
import { ValidationError } from '../core/error/error-handler.js';
import { articleRepo } from '../repositories/article-repository.js';
import { articleService } from '../services/article-service.js';

describe('Article service (유닛 단위 테스트)', () => {
beforeEach(() => {
jest.restoreAllMocks();
});

test('목록 조회는 정렬/검색 조건을 저장소에 전달한다', async () => {
const spy = jest.spyOn(articleRepo, 'findArticles').mockResolvedValue([]);

await articleService.list({
q: '테스트',
offset: '0',
limit: '10',
order: 'recent',
});

expect(spy).toHaveBeenCalledTimes(1);
const [where, orderBy, skip, take] = spy.mock.calls[0] as Parameters<
typeof articleRepo.findArticles
>;

expect(where).toEqual(
expect.objectContaining({
OR: expect.any(Array),
})
);
expect(orderBy).toEqual({ createdAt: 'desc' });
expect(skip).toBe(0);
expect(take).toBe(10);
});

test('지원하지 않는 정렬 키면 ValidationError를 던진다', async () => {
await expect(
articleService.list({
order: 'unknown-order',
})
).rejects.toBeInstanceOf(ValidationError);
});
});
2 changes: 1 addition & 1 deletion src/__tests__/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { app } from '../app.js';
import { resetDb } from '../test/test-helpers.js';

describe('Auth API', () => {
beforeEach(async () => {
beforeAll(async () => {
await resetDb();
});

Expand Down
2 changes: 1 addition & 1 deletion src/__tests__/product.auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import prisma from '../config/prisma.js';
import { createUserWithToken, resetDb } from '../test/test-helpers.js';

describe('Product API (인증 필요)', () => {
beforeEach(async () => {
beforeAll(async () => {
await resetDb();
});

Expand Down
2 changes: 1 addition & 1 deletion src/__tests__/product.public.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import prisma from '../config/prisma.js';
import { createUserWithToken, resetDb } from '../test/test-helpers.js';

describe('Product API (public)', () => {
beforeEach(async () => {
beforeAll(async () => {
await resetDb();
});

Expand Down
11 changes: 10 additions & 1 deletion src/controllers/notification-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,16 @@ export const notificationController = {

// 2) 미읽음 개수 조회
async unreadCount(req: Request, res: Response) {
const count = await notificationService.countUnread(req.user!.id);
const userId = req.user?.id;

if (!userId) {
return res.status(401).json({
success: false,
message: '인증 정보가 없습니다',
});
}

const count = await notificationService.countUnread(userId);

res.status(200).json({
success: true,
Expand Down
Loading