๐พ ๋ฝ์๋ ๊ธฐ Sprint 1 - ๊ทนํ ๋ํ
์ผ ๊ธฐํ์
๊ธฐ๊ฐ : 2026.02.24 (์) ~ 02.28 (๊ธ) [5์ผ]
๋ชฉํ : ์ธํ๋ผ ๊ธฐ๋ฐ + ํต์ฌ API ์์ฑ + ์ฒซ ๋ฐฐํฌ
๐ Sprint 1 Overview
Day 1: ์ค๊ณ ํ์ + AWS ๊ธฐ๋ฐ
Day 2: ๋คํธ์ํฌ + DB ๊ตฌ์ถ
Day 3: ๋ฐฑ์๋ ํต์ฌ API
Day 4: ์ฃผ๋ฌธ ๋ก์ง + ์ปจํ
์ด๋ํ
Day 5: ๋ฐฐํฌ + HTTPS + ํ๊ณ
๐
Day 1 (2/24 ์) - ์ค๊ณ ํ์ + AWS ๊ธฐ๋ฐ
ํ์๋ผ์ธ
์๊ฐ ํ์คํฌ ๋ด๋น ์ฐ์ถ๋ฌผ 09:00-10:00 ๋ฐ์ผ๋ฆฌ ์คํ ๋์
+ ์คํ๋ฆฐํธ ํ๋๋ ์ ์ฒด ํ์๋ก 10:00-12:00 ERD ์ค๊ณ ๋ฐฑ์๋ ERD ๋ค์ด์ด๊ทธ๋จ 10:00-12:00 AWS ๊ณ์ + IAM ์ค์ ์ธํ๋ผ IAM ์ ์ฑ
13:00-15:00 API ๋ช
์ธ ์์ฑ ๋ฐฑ์๋ OpenAPI Spec 13:00-15:00 ์ํคํ
์ฒ ๋ค์ด์ด๊ทธ๋จ ์ธํ๋ผ draw.io 15:00-17:00 Git ๋ ํฌ ๊ตฌ์ฑ + ๋ธ๋์น ์ ๋ต ์ ์ฒด GitHub Repo 17:00-18:00 Day 1 ๋ฆฌ๋ทฐ + ๋ธ๋ก์ปค ๊ณต์ ์ ์ฒด ํ์๋ก
D1-001: AWS ๊ณ์ ๋ฐ IAM ์ค์
๐ ํ์คํฌ ์ ๋ณด
ํญ๋ชฉ ๋ด์ฉ ID D1-001 ์ฐ์ ์์ P0 (Blocker) ๋ด๋น ์ธํ๋ผ ๋ด๋น์ ์์ ์๊ฐ 2์๊ฐ ์ ํ ์์
์์ ํํ ์์
D2-001 (VPC ์์ฑ)
๐ ์ฒดํฌ๋ฆฌ์คํธ
[ ] AWS ๋ฃจํธ ๊ณ์ MFA ํ์ฑํ
[ ] IAM ์ฌ์ฉ์ ๊ทธ๋ฃน ์์ฑ
[ ] Admins - AdministratorAccess
[ ] Developers - PowerUserAccess + IAMReadOnlyAccess
[ ] ReadOnly - ReadOnlyAccess
[ ] IAM ์ฌ์ฉ์ ์์ฑ (ํ์๋ณ)
[ ] ์ด๋ํ - Admins ๊ทธ๋ฃน
[ ] ๋ฐ์งํ - Developers ๊ทธ๋ฃน
[ ] ๋ฐ๊ท์ - Developers ๊ทธ๋ฃน
[ ] ์์ฃผ์ - Developers ๊ทธ๋ฃน
[ ] ํ๋ก๊ทธ๋๋ฐ ๋ฐฉ์ ์ก์ธ์ค์ฉ ์ฌ์ฉ์ ์์ฑ
[ ] bbossiregi-terraform (Terraform์ฉ)
[ ] bbossiregi-github-actions (CI/CD์ฉ)
[ ] ๋น์ฉ ์๋ฆผ ์ค์
[ ] Budget: $50/์
[ ] ์๋ฆผ: 80%, 100% ๋๋ฌ ์
[ ] CloudTrail ํ์ฑํ
๐ ์์ธ ๋ช
์ธ
{
"Version" : "2012-10-17" ,
"Statement" : [
{
"Sid" : "TerraformFullAccess" ,
"Effect" : "Allow" ,
"Action" : [
"ec2:*" ,
"eks:*" ,
"rds:*" ,
"s3:*" ,
"iam:*" ,
"elasticloadbalancing:*" ,
"acm:*" ,
"route53:*" ,
"lambda:*" ,
"events:*" ,
"logs:*" ,
"ecr:*"
],
"Resource" : "*"
}
]
}
IAM ์ ์ฑ
: bbossiregi-github-actions
{
"Version" : "2012-10-17" ,
"Statement" : [
{
"Sid" : "ECRAccess" ,
"Effect" : "Allow" ,
"Action" : [
"ecr:GetAuthorizationToken" ,
"ecr:BatchCheckLayerAvailability" ,
"ecr:GetDownloadUrlForLayer" ,
"ecr:BatchGetImage" ,
"ecr:PutImage" ,
"ecr:InitiateLayerUpload" ,
"ecr:UploadLayerPart" ,
"ecr:CompleteLayerUpload"
],
"Resource" : "*"
},
{
"Sid" : "EKSAccess" ,
"Effect" : "Allow" ,
"Action" : [
"eks:DescribeCluster" ,
"eks:ListClusters"
],
"Resource" : "*"
}
]
}
โ
์๋ฃ ๊ธฐ์ค
1. ๋ชจ๋ ํ์์ด AWS ์ฝ์ ๋ก๊ทธ์ธ ๊ฐ๋ฅ
2. Terraform ์ฌ์ฉ์๋ก aws sts get-caller-identity ์ฑ๊ณต
3. GitHub Actions ์ฌ์ฉ์ ์ก์ธ์ค ํค ์์ฑ ์๋ฃ
4. ๋น์ฉ ์๋ฆผ ์ด๋ฉ์ผ ์์ ํ
์คํธ ์๋ฃ
๐งช ๊ฒ์ฆ ๋ช
๋ น์ด
# Terraform ์ฌ์ฉ์ ๊ฒ์ฆ
export AWS_ACCESS_KEY_ID = xxx
export AWS_SECRET_ACCESS_KEY = xxx
aws sts get-caller-identity
# ์์ ์ถ๋ ฅ
{
"UserId" : "AIDAXXXXXXXXXX",
"Account" : "123456789012",
"Arn" : "arn:aws:iam::123456789012:user/bbossiregi-terraform"
}
D1-002: ERD ์ค๊ณ ํ์
๐ ํ์คํฌ ์ ๋ณด
ํญ๋ชฉ ๋ด์ฉ ID D1-002 ์ฐ์ ์์ P0 (Blocker) ๋ด๋น ๋ฐฑ์๋ ๋ด๋น์ ์์ ์๊ฐ 2์๊ฐ ์ ํ ์์
์์ ํํ ์์
D2-006 (DB ์คํค๋ง ์์ฑ)
๐ ์ฒดํฌ๋ฆฌ์คํธ
[ ] ํ
์ด๋ธ ์ค๊ณ (6๊ฐ)
[ ] users
[ ] products
[ ] time_deals
[ ] orders
[ ] order_events
[ ] order_items (์ ํ)
[ ] ๊ด๊ณ ์ ์
[ ] 1:N ๊ด๊ณ ์๋ณ
[ ] FK ์ ์ฝ์กฐ๊ฑด ์ ์
[ ] ์ธ๋ฑ์ค ์ค๊ณ
[ ] ์กฐํ ๋น๋ ๋์ ์ปฌ๋ผ
[ ] FK ์ปฌ๋ผ
[ ] ERD ๋ค์ด์ด๊ทธ๋จ ์์ฑ
[ ] dbdiagram.io ๋๋ draw.io
[ ] ๋
ธ์
์ ์ฒจ๋ถ
[ ] DDL ์ด์ ์์ฑ
[ ] migrations/001_init.sql
๐ ์์ธ ๋ช
์ธ
ํ
์ด๋ธ: users
CREATE TABLE users (
-- PK
id SERIAL PRIMARY KEY ,
-- ์ธ์ฆ ์ ๋ณด
email VARCHAR ( 255 ) NOT NULL ,
password_hash VARCHAR ( 255 ) NOT NULL ,
-- ํ๋กํ
name VARCHAR ( 100 ) NOT NULL ,
phone VARCHAR ( 20 ),
-- ๊ถํ
role VARCHAR ( 20 ) NOT NULL DEFAULT 'user' ,
-- ENUM: 'user', 'admin'
-- ์ํ
status VARCHAR ( 20 ) NOT NULL DEFAULT 'active' ,
-- ENUM: 'active', 'inactive', 'banned'
-- ํ์์คํฌํ
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_login_at TIMESTAMP ,
-- ์ ์ฝ์กฐ๊ฑด
CONSTRAINT uk_users_email UNIQUE (email),
CONSTRAINT chk_users_role CHECK ( role IN ( 'user' , 'admin' )),
CONSTRAINT chk_users_status CHECK ( status IN ( 'active' , 'inactive' , 'banned' ))
);
-- ์ธ๋ฑ์ค
CREATE INDEX idx_users_email ON users(email);
CREATE INDEX idx_users_status ON users( status );
ํ
์ด๋ธ: products
CREATE TABLE products (
-- PK
id SERIAL PRIMARY KEY ,
-- ์ํ ์ ๋ณด
name VARCHAR ( 255 ) NOT NULL ,
description TEXT ,
price DECIMAL ( 10 , 2 ) NOT NULL ,
-- ์ด๋ฏธ์ง
image_url VARCHAR ( 500 ),
thumbnail_url VARCHAR ( 500 ),
-- ๋ถ๋ฅ
category VARCHAR ( 50 ) NOT NULL ,
-- ENUM: 'food', 'toy', 'health', 'fashion', 'living'
-- ์ํ
status VARCHAR ( 20 ) NOT NULL DEFAULT 'active' ,
-- ENUM: 'active', 'inactive', 'deleted'
-- ํ์์คํฌํ
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
-- ์ ์ฝ์กฐ๊ฑด
CONSTRAINT chk_products_price CHECK (price > 0 ),
CONSTRAINT chk_products_category CHECK (category IN ( 'food' , 'toy' , 'health' , 'fashion' , 'living' )),
CONSTRAINT chk_products_status CHECK ( status IN ( 'active' , 'inactive' , 'deleted' ))
);
-- ์ธ๋ฑ์ค
CREATE INDEX idx_products_category ON products(category);
CREATE INDEX idx_products_status ON products( status );
ํ
์ด๋ธ: time_deals
CREATE TABLE time_deals (
-- PK
id SERIAL PRIMARY KEY ,
-- FK
product_id INTEGER NOT NULL ,
-- ๊ฐ๊ฒฉ ์ ๋ณด
original_price DECIMAL ( 10 , 2 ) NOT NULL ,
deal_price DECIMAL ( 10 , 2 ) NOT NULL ,
discount_rate INTEGER GENERATED ALWAYS AS (
ROUND (( 1 - deal_price / original_price) * 100 )
) STORED,
-- ์ฌ๊ณ ๊ด๋ฆฌ (ํต์ฌ!)
stock_quantity INTEGER NOT NULL , -- ์ด ์ฌ๊ณ
reserved_quantity INTEGER NOT NULL DEFAULT 0 , -- ์์ฝ๋ ์๋
sold_quantity INTEGER NOT NULL DEFAULT 0 , -- ํ๋งค๋ ์๋
-- available = stock_quantity - reserved_quantity - sold_quantity
-- ์๊ฐ ์ ๋ณด
start_at TIMESTAMP NOT NULL ,
end_at TIMESTAMP NOT NULL ,
-- ์ํ
status VARCHAR ( 20 ) NOT NULL DEFAULT 'scheduled' ,
-- ENUM: 'scheduled', 'active', 'ended', 'soldout', 'canceled'
-- ํ์์คํฌํ
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
-- ์ ์ฝ์กฐ๊ฑด
CONSTRAINT fk_time_deals_product FOREIGN KEY (product_id)
REFERENCES products(id) ON DELETE RESTRICT,
CONSTRAINT chk_time_deals_price CHECK (deal_price > 0 AND deal_price < original_price),
CONSTRAINT chk_time_deals_stock CHECK (stock_quantity > 0 ),
CONSTRAINT chk_time_deals_reserved CHECK (reserved_quantity >= 0 ),
CONSTRAINT chk_time_deals_sold CHECK (sold_quantity >= 0 ),
CONSTRAINT chk_time_deals_total CHECK (reserved_quantity + sold_quantity <= stock_quantity),
CONSTRAINT chk_time_deals_time CHECK (end_at > start_at),
CONSTRAINT chk_time_deals_status CHECK ( status IN ( 'scheduled' , 'active' , 'ended' , 'soldout' , 'canceled' ))
);
-- ์ธ๋ฑ์ค
CREATE INDEX idx_time_deals_product_id ON time_deals(product_id);
CREATE INDEX idx_time_deals_status ON time_deals( status );
CREATE INDEX idx_time_deals_start_at ON time_deals(start_at);
CREATE INDEX idx_time_deals_end_at ON time_deals(end_at);
-- ๋ณตํฉ ์ธ๋ฑ์ค (ํ์ฑ ํ์๋ ์กฐํ์ฉ)
CREATE INDEX idx_time_deals_active ON time_deals( status , start_at, end_at)
WHERE status IN ( 'scheduled' , 'active' );
ํ
์ด๋ธ: orders
CREATE TABLE orders (
-- PK
id SERIAL PRIMARY KEY ,
-- FK
user_id INTEGER NOT NULL ,
time_deal_id INTEGER NOT NULL ,
-- ์ฃผ๋ฌธ ์ ๋ณด
quantity INTEGER NOT NULL DEFAULT 1 ,
unit_price DECIMAL ( 10 , 2 ) NOT NULL ,
total_price DECIMAL ( 10 , 2 ) NOT NULL ,
-- ์ํ (์ฌ๊ฐ ํจํด)
status VARCHAR ( 20 ) NOT NULL DEFAULT 'pending' ,
-- ์ํ ์ ์ด:
-- pending โ reserved โ confirmed (์ ์)
-- pending โ failed (์ฌ๊ณ ๋ถ์กฑ)
-- reserved โ canceled (์ทจ์/๋ณด์)
-- ํ์์คํฌํ
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
reserved_at TIMESTAMP ,
confirmed_at TIMESTAMP ,
canceled_at TIMESTAMP ,
-- ์ ์ฝ์กฐ๊ฑด
CONSTRAINT fk_orders_user FOREIGN KEY (user_id)
REFERENCES users(id) ON DELETE RESTRICT,
CONSTRAINT fk_orders_time_deal FOREIGN KEY (time_deal_id)
REFERENCES time_deals(id) ON DELETE RESTRICT,
CONSTRAINT chk_orders_quantity CHECK (quantity > 0 ),
CONSTRAINT chk_orders_price CHECK (unit_price > 0 AND total_price > 0 ),
CONSTRAINT chk_orders_total CHECK (total_price = unit_price * quantity),
CONSTRAINT chk_orders_status CHECK ( status IN ( 'pending' , 'reserved' , 'confirmed' , 'canceled' , 'failed' ))
);
-- ์ธ๋ฑ์ค
CREATE INDEX idx_orders_user_id ON orders(user_id);
CREATE INDEX idx_orders_time_deal_id ON orders(time_deal_id);
CREATE INDEX idx_orders_status ON orders( status );
CREATE INDEX idx_orders_created_at ON orders(created_at DESC );
ํ
์ด๋ธ: order_events (์ด๋ฒคํธ ์์ฑ)
CREATE TABLE order_events (
-- PK
id SERIAL PRIMARY KEY ,
-- FK
order_id INTEGER NOT NULL ,
-- ์ด๋ฒคํธ ์ ๋ณด
event_type VARCHAR ( 50 ) NOT NULL ,
-- ENUM: 'CREATED', 'STOCK_RESERVED', 'STOCK_RELEASED',
-- 'PAYMENT_REQUESTED', 'PAYMENT_COMPLETED', 'PAYMENT_FAILED',
-- 'CONFIRMED', 'CANCELED'
-- ์ด๋ฒคํธ ๋ฐ์ดํฐ
payload JSONB NOT NULL DEFAULT '{}' ,
-- ๋ฉํ ์ ๋ณด
actor_id INTEGER , -- ๋๊ฐ ๋ฐ์์์ผฐ๋์ง (user_id ๋๋ system)
actor_type VARCHAR ( 20 ) NOT NULL DEFAULT 'user' ,
-- ENUM: 'user', 'system', 'admin'
-- ํ์์คํฌํ
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
-- ์ ์ฝ์กฐ๊ฑด
CONSTRAINT fk_order_events_order FOREIGN KEY (order_id)
REFERENCES orders(id) ON DELETE CASCADE ,
CONSTRAINT chk_order_events_type CHECK (event_type IN (
'CREATED' , 'STOCK_RESERVED' , 'STOCK_RELEASED' ,
'PAYMENT_REQUESTED' , 'PAYMENT_COMPLETED' , 'PAYMENT_FAILED' ,
'CONFIRMED' , 'CANCELED'
))
);
-- ์ธ๋ฑ์ค
CREATE INDEX idx_order_events_order_id ON order_events(order_id);
CREATE INDEX idx_order_events_type ON order_events(event_type);
CREATE INDEX idx_order_events_created_at ON order_events(created_at);
-- JSONB ์ธ๋ฑ์ค (์ ํ)
CREATE INDEX idx_order_events_payload ON order_events USING GIN (payload);
ERD ๋ค์ด์ด๊ทธ๋จ
โโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโ
โ users โ โ products โ โ time_deals โ
โโโโโโโโโโโโโโโโค โโโโโโโโโโโโโโโโค โโโโโโโโโโโโโโโโค
โ id (PK) โ โ id (PK) โโโโโ โ id (PK) โ
โ email (UK) โ โ name โ โ โ product_id(FK)โโโโ
โ password_hashโ โ description โ โ โ original_priceโ
โ name โ โ price โ โ โ deal_price โ
โ phone โ โ image_url โ โ โ stock_qty โ
โ role โ โ category โ โ โ reserved_qty โ
โ status โ โ status โ โ โ sold_qty โ
โ created_at โ โ created_at โ โ โ start_at โ
โ updated_at โ โ updated_at โ โ โ end_at โ
โโโโโโโโฌโโโโโโโโ โโโโโโโโโโโโโโโโ โ โ status โ
โ โ โโโโโโโโฌโโโโโโโโ
โ 1:N โ โ 1:N
โ โ โ
โผ โ โผ
โโโโโโโโโโโโโโโโ โ โโโโโโโโโโโโโโโโ
โ orders โโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ order_events โ
โโโโโโโโโโโโโโโโค โโโโโโโโโโโโโโโโค
โ id (PK) โโโโโโโโโโโโโโโโโโโโโโโโโโโโโถ โ id (PK) โ
โ user_id (FK) โ โ order_id(FK) โ
โ time_deal_id โ โ event_type โ
โ quantity โ โ payload โ
โ unit_price โ โ actor_id โ
โ total_price โ โ actor_type โ
โ status โ โ created_at โ
โ created_at โ โโโโโโโโโโโโโโโโ
โ updated_at โ
โโโโโโโโโโโโโโโโ
โ
์๋ฃ ๊ธฐ์ค
1. ERD ๋ค์ด์ด๊ทธ๋จ ๋
ธ์
์ ์ฒจ๋ถ
2. ๋ชจ๋ ํ
์ด๋ธ DDL ํ์ผ ์์ฑ ์๋ฃ
3. ํ ๋ฆฌ๋ทฐ ์๋ฃ (ERD ํ์ )
4. ์ ์ฝ์กฐ๊ฑด ๋ฐ ์ธ๋ฑ์ค ์ ์ ์๋ฃ
D1-003: ์์คํ
์ํคํ
์ฒ ๋ค์ด์ด๊ทธ๋จ
๐ ํ์คํฌ ์ ๋ณด
ํญ๋ชฉ ๋ด์ฉ ID D1-003 ์ฐ์ ์์ P0 ๋ด๋น ์ธํ๋ผ ๋ด๋น์ ์์ ์๊ฐ 2์๊ฐ
๐ ์ฒดํฌ๋ฆฌ์คํธ
[ ] ์ ์ฒด ์ํคํ
์ฒ ๋ค์ด์ด๊ทธ๋จ
[ ] ๋คํธ์ํฌ ๊ตฌ์ฑ๋
[ ] ๋ฐ์ดํฐ ํ๋ฆ๋
[ ] ๋ฐฐํฌ ๊ตฌ์ฑ๋
๐ ์์ธ ๋ช
์ธ
์ ์ฒด ์ํคํ
์ฒ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ AWS Cloud โ
โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ VPC (10.0.0.0/16) โ โ
โ โ โ โ
โ โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ โ
โ โ โ Public Subnets (10.0.1.0/24, 10.0.2.0/24) โ โ โ
โ โ โ โ โ โ
โ โ โ โโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโ โ โ โ
โ โ โ โ IGW โ โ NAT โ โ ALB โ โ โ โ
โ โ โ โ Gateway โ โ Gateway โ โ โ โ โ โ
โ โ โ โโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโ โโโโโโโโฌโโโโโโโโ โ โ โ
โ โ โ โ โ โ โ
โ โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโผโโโโโโโโโโโ โ โ
โ โ โ โ โ
โ โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโผโโโโโโโโโโโโโ โ
โ โ โ Private Subnets (10.0.11.0/24, 10.0.12.0/24) โโ โ
โ โ โ โ โโ โ
โ โ โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโผโโโโโโโโโ โโ โ
โ โ โ โ EKS Cluster โ โโ โ
โ โ โ โ โ โโ โ
โ โ โ โ โโโโโโโโโโโโ โโโโโโโโโโโโ โโโโโโโโโโโโ โ โโ โ
โ โ โ โ โ Node 1 โ โ Node 2 โ โ Node 3 โ (Auto Scale) โ โโ โ
โ โ โ โ โ โ โ โ โ โ โ โโ โ
โ โ โ โ โโโโโโโโโโโโ โโโโโโโโโโโโ โโโโโโโโโโโโ โ โโ โ
โ โ โ โ โโBackend โโ โโBackend โโ โโBackend โโ โ โโ โ
โ โ โ โ โโ Pod โโ โโ Pod โโ โโ Pod โโ โ โโ โ
โ โ โ โ โโโโโโโโโโโโ โโโโโโโโโโโโ โโโโโโโโโโโโ โ โโ โ
โ โ โ โ โโโโโโโโโโโโ โโโโโโโโโโโโ โโโโโโโโโโโโ โ โโ โ
โ โ โ โ โ โโ โ
โ โ โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโฌโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โโ โ
โ โ โ โ โโ โ
โ โ โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโผโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ โ โ RDS โโโ โ
โ โ โ โ PostgreSQL (db.t3.micro) โโโ โ
โ โ โ โ Primary โโโ โ
โ โ โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ โ โโ โ
โ โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ โ โ
โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ
โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ Supporting Services โ โ
โ โ โ โ
โ โ โโโโโโโโโโโโ โโโโโโโโโโโโ โโโโโโโโโโโโ โโโโโโโโโโโโ โ โ
โ โ โ ECR โ โ Lambda โ โCloudWatchโ โ Route53 โ โ โ
โ โ โ Registry โ โScheduler โ โ Logs โ โ DNS โ โ โ
โ โ โโโโโโโโโโโโ โโโโโโโโโโโโ โโโโโโโโโโโโ โโโโโโโโโโโโ โ โ
โ โ โ โ
โ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
๋ฆฌ์์ค ๋ช
์ธ
๋ฆฌ์์ค ์คํ ์๋ ์์ ๋น์ฉ (์) EKS Cluster - 1 $72 EKS Node t3.medium 2-4 $60-120 RDS db.t3.micro 1 $15 ALB - 1 $20 NAT Gateway - 1 $32 ECR - 1 $1 Route53 - 1 $0.5 ํฉ๊ณ ~$200-260
D1-004: API ๋ช
์ธ ์์ฑ
๐ ํ์คํฌ ์ ๋ณด
ํญ๋ชฉ ๋ด์ฉ ID D1-004 ์ฐ์ ์์ P0 ๋ด๋น ๋ฐฑ์๋ ๋ด๋น์ ์์ ์๊ฐ 2์๊ฐ
๐ ์ฒดํฌ๋ฆฌ์คํธ
[ ] OpenAPI 3.0 ์คํ ํ์ผ ์์ฑ
[ ] ์ธ์ฆ API (2๊ฐ)
[ ] ์ํ API (3๊ฐ)
[ ] ํ์๋ API (3๊ฐ)
[ ] ์ฃผ๋ฌธ API (4๊ฐ)
[ ] ํฌ์ค์ฒดํฌ API (1๊ฐ)
[ ] ์๋ฌ ์๋ต ํ์ ์ ์
๐ ์์ธ ๋ช
์ธ
Base URL & ๊ณตํต
# openapi.yaml
openapi : 3.0.3
info :
title : ๋ฝ์๋ ๊ธฐ API
version : 1.0.0
description : ๋ฐ๋ ค๋๋ฌผ ํ์๋ ์ด์ปค๋จธ์ค API
servers :
- url : https://api.bbossiregi.com/api/v1
description : Production
- url : http://localhost:8080/api/v1
description : Local
security :
- BearerAuth : []
components :
securitySchemes :
BearerAuth :
type : http
scheme : bearer
bearerFormat : JWT
๊ณตํต ์๋ฌ ์๋ต
components :
schemas :
Error :
type : object
required :
- code
- message
properties :
code :
type : string
example : "INVALID_REQUEST"
message :
type : string
example : "์๋ชป๋ ์์ฒญ์
๋๋ค"
details :
type : object
additionalProperties : true
ValidationError :
type : object
properties :
code :
type : string
example : "VALIDATION_ERROR"
message :
type : string
example : "์ ํจ์ฑ ๊ฒ์ฌ ์คํจ"
errors :
type : array
items :
type : object
properties :
field :
type : string
message :
type : string
API 1: POST /auth/register
paths :
/auth/register :
post :
tags : [ Auth ]
summary : ํ์๊ฐ์
security : [] # ์ธ์ฆ ๋ถํ์
requestBody :
required : true
content :
application/json :
schema :
type : object
required :
- email
- password
- name
properties :
email :
type : string
format : email
maxLength : 255
example : "user@example.com"
password :
type : string
format : password
minLength : 8
maxLength : 100
example : "password123!"
name :
type : string
minLength : 2
maxLength : 100
example : "ํ๊ธธ๋"
phone :
type : string
pattern : "^01[0-9]-?[0-9]{3,4}-?[0-9]{4}$"
example : "010-1234-5678"
responses :
'201' :
description : ํ์๊ฐ์
์ฑ๊ณต
content :
application/json :
schema :
type : object
properties :
id :
type : integer
email :
type : string
name :
type : string
created_at :
type : string
format : date-time
'400' :
description : ์ ํจ์ฑ ๊ฒ์ฌ ์คํจ
content :
application/json :
schema :
$ref : '#/components/schemas/ValidationError'
examples :
invalid_email :
value :
code : "VALIDATION_ERROR"
message : "์ ํจ์ฑ ๊ฒ์ฌ ์คํจ"
errors :
- field : "email"
message : "์ฌ๋ฐ๋ฅธ ์ด๋ฉ์ผ ํ์์ด ์๋๋๋ค"
'409' :
description : ์ด๋ฉ์ผ ์ค๋ณต
content :
application/json :
schema :
$ref : '#/components/schemas/Error'
example :
code : "DUPLICATE_EMAIL"
message : "์ด๋ฏธ ์ฌ์ฉ ์ค์ธ ์ด๋ฉ์ผ์
๋๋ค"
API 2: POST /auth/login
/auth/login :
post :
tags : [ Auth ]
summary : ๋ก๊ทธ์ธ
security : []
requestBody :
required : true
content :
application/json :
schema :
type : object
required :
- email
- password
properties :
email :
type : string
format : email
password :
type : string
responses :
'200' :
description : ๋ก๊ทธ์ธ ์ฑ๊ณต
content :
application/json :
schema :
type : object
properties :
access_token :
type : string
example : "eyJhbGciOiJIUzI1NiIs..."
token_type :
type : string
example : "Bearer"
expires_in :
type : integer
example : 3600
user :
type : object
properties :
id :
type : integer
email :
type : string
name :
type : string
role :
type : string
'401' :
description : ์ธ์ฆ ์คํจ
content :
application/json :
example :
code : "INVALID_CREDENTIALS"
message : "์ด๋ฉ์ผ ๋๋ ๋น๋ฐ๋ฒํธ๊ฐ ์ฌ๋ฐ๋ฅด์ง ์์ต๋๋ค"
API 3: GET /timedeals
/timedeals :
get :
tags : [ TimeDeal ]
summary : ํ์๋ ๋ชฉ๋ก ์กฐํ
security : []
parameters :
- name : status
in : query
schema :
type : string
enum : [ scheduled , active , ended , soldout ]
description : ์ํ ํํฐ
- name : page
in : query
schema :
type : integer
default : 1
minimum : 1
- name : per_page
in : query
schema :
type : integer
default : 20
minimum : 1
maximum : 100
responses :
'200' :
description : ์ฑ๊ณต
content :
application/json :
schema :
type : object
properties :
data :
type : array
items :
$ref : '#/components/schemas/TimeDealSummary'
meta :
$ref : '#/components/schemas/Pagination'
example :
data :
- id : 1
product :
id : 1
name : "ํ๋ฆฌ๋ฏธ์ ์ฌ๋ฃ 3kg"
image_url : "https://..."
category : "food"
original_price : 45000
deal_price : 29900
discount_rate : 33
stock_quantity : 100
available_quantity : 87
start_at : "2026-02-27T14:00:00Z"
end_at : "2026-02-27T15:00:00Z"
status : "scheduled"
remaining_seconds : 3600
meta :
total : 50
page : 1
per_page : 20
total_pages : 3
API 4: GET /timedeals/{id}
/timedeals/{id} :
get :
tags : [ TimeDeal ]
summary : ํ์๋ ์์ธ ์กฐํ
security : []
parameters :
- name : id
in : path
required : true
schema :
type : integer
responses :
'200' :
description : ์ฑ๊ณต
content :
application/json :
schema :
$ref : '#/components/schemas/TimeDealDetail'
example :
id : 1
product :
id : 1
name : "ํ๋ฆฌ๋ฏธ์ ์ฌ๋ฃ 3kg"
description : "์ต๊ณ ๊ธ ์๋ฃ๋ก ๋ง๋ ํ๋ฆฌ๋ฏธ์ ์ฌ๋ฃ"
image_url : "https://..."
category : "food"
original_price : 45000
deal_price : 29900
discount_rate : 33
stock_quantity : 100
reserved_quantity : 10
sold_quantity : 3
available_quantity : 87
start_at : "2026-02-27T14:00:00Z"
end_at : "2026-02-27T15:00:00Z"
status : "active"
remaining_seconds : 1823
'404' :
description : ํ์๋ ์์
content :
application/json :
example :
code : "NOT_FOUND"
message : "ํ์๋์ ์ฐพ์ ์ ์์ต๋๋ค"
API 5: POST /orders (ํต์ฌ - ์ฌ๊ฐ ํจํด)
/orders :
post :
tags : [ Order ]
summary : ์ฃผ๋ฌธ ์์ฑ (์ฌ๊ณ ์์ฝ)
description : |
์ฌ๊ฐ ํจํด Step 1-2 ์ํ:
1. ์ฃผ๋ฌธ ์์ฑ (status: pending)
2. ์ฌ๊ณ ์์ฝ (reserved_quantity += quantity)
3. ์ฃผ๋ฌธ ์ํ ๋ณ๊ฒฝ (status: reserved)
requestBody :
required : true
content :
application/json :
schema :
type : object
required :
- time_deal_id
properties :
time_deal_id :
type : integer
example : 1
quantity :
type : integer
minimum : 1
maximum : 10
default : 1
example : 2
responses :
'201' :
description : ์ฃผ๋ฌธ ์์ฑ ๋ฐ ์ฌ๊ณ ์์ฝ ์ฑ๊ณต
content :
application/json :
schema :
$ref : '#/components/schemas/Order'
example :
id : 123
user_id : 1
time_deal_id : 1
quantity : 2
unit_price : 29900
total_price : 59800
status : "reserved"
created_at : "2026-02-27T14:05:00Z"
reserved_at : "2026-02-27T14:05:00Z"
'400' :
description : ์๋ชป๋ ์์ฒญ
content :
application/json :
examples :
deal_not_active :
value :
code : "DEAL_NOT_ACTIVE"
message : "ํ์๋์ด ์งํ ์ค์ด ์๋๋๋ค"
invalid_quantity :
value :
code : "INVALID_QUANTITY"
message : "์๋์ 1~10 ์ฌ์ด์ฌ์ผ ํฉ๋๋ค"
'409' :
description : ์ฌ๊ณ ๋ถ์กฑ
content :
application/json :
example :
code : "INSUFFICIENT_STOCK"
message : "์ฌ๊ณ ๊ฐ ๋ถ์กฑํฉ๋๋ค"
details :
available : 1
requested : 2
'401' :
description : ์ธ์ฆ ํ์
API 6: DELETE /orders/{id} (์ทจ์ - ๋ณด์ ํธ๋์ญ์
)
/orders/{id} :
delete :
tags : [ Order ]
summary : ์ฃผ๋ฌธ ์ทจ์ (๋ณด์ ํธ๋์ญ์
)
description : |
์ฌ๊ฐ ํจํด ๋ณด์ ํธ๋์ญ์
:
1. reserved_quantity -= quantity
2. order status โ canceled
parameters :
- name : id
in : path
required : true
schema :
type : integer
responses :
'200' :
description : ์ฃผ๋ฌธ ์ทจ์ ์ฑ๊ณต
content :
application/json :
example :
id : 123
status : "canceled"
canceled_at : "2026-02-27T14:10:00Z"
'400' :
description : ์ทจ์ ๋ถ๊ฐ
content :
application/json :
examples :
already_confirmed :
value :
code : "CANNOT_CANCEL"
message : "์ด๋ฏธ ํ์ ๋ ์ฃผ๋ฌธ์ ์ทจ์ํ ์ ์์ต๋๋ค"
already_canceled :
value :
code : "ALREADY_CANCELED"
message : "์ด๋ฏธ ์ทจ์๋ ์ฃผ๋ฌธ์
๋๋ค"
'404' :
description : ์ฃผ๋ฌธ ์์
API 7: GET /health
/health :
get :
tags : [ System ]
summary : ํฌ์ค์ฒดํฌ
security : []
responses :
'200' :
description : ์ ์
content :
application/json :
example :
status : "healthy"
timestamp : "2026-02-27T14:00:00Z"
version : "1.0.0"
checks :
database : "ok"
redis : "ok"
D1-005: Git ๋ ํฌ์งํ ๋ฆฌ ๊ตฌ์ฑ
๐ ํ์คํฌ ์ ๋ณด
ํญ๋ชฉ ๋ด์ฉ ID D1-005 ์ฐ์ ์์ P0 ๋ด๋น ์ ์ฒด (ํ์ฅ ์ฃผ๋) ์์ ์๊ฐ 1์๊ฐ
๐ ์ฒดํฌ๋ฆฌ์คํธ
[ ] GitHub Organization ์์ฑ (์ ํ)
[ ] ๋ ํฌ์งํ ๋ฆฌ ์์ฑ
[ ] ๋ธ๋์น ๋ณดํธ ๊ท์น ์ค์
[ ] ๋๋ ํ ๋ฆฌ ๊ตฌ์กฐ ์์ฑ
[ ] .gitignore ์์ฑ
[ ] README.md ์ด์ ์์ฑ
[ ] PR ํ
ํ๋ฆฟ ์์ฑ
[ ] Issue ํ
ํ๋ฆฟ ์์ฑ
๐ ์์ธ ๋ช
์ธ
๋๋ ํ ๋ฆฌ ๊ตฌ์กฐ
bbossiregi/
โโโ .github/
โ โโโ workflows/
โ โ โโโ ci.yml
โ โ โโโ cd.yml
โ โโโ PULL_REQUEST_TEMPLATE.md
โ โโโ ISSUE_TEMPLATE/
โ โโโ bug_report.md
โ โโโ feature_request.md
โ
โโโ backend/
โ โโโ cmd/
โ โ โโโ main.go
โ โโโ internal/
โ โ โโโ config/
โ โ โ โโโ config.go
โ โ โโโ handler/
โ โ โ โโโ auth.go
โ โ โ โโโ product.go
โ โ โ โโโ timedeal.go
โ โ โ โโโ order.go
โ โ โโโ service/
โ โ โ โโโ auth.go
โ โ โ โโโ stock.go
โ โ โ โโโ order.go
โ โ โโโ repository/
โ โ โ โโโ user.go
โ โ โ โโโ product.go
โ โ โ โโโ timedeal.go
โ โ โ โโโ order.go
โ โ โโโ model/
โ โ โ โโโ models.go
โ โ โโโ middleware/
โ โ โโโ auth.go
โ โ โโโ logger.go
โ โโโ pkg/
โ โ โโโ response/
โ โ โโโ response.go
โ โโโ migrations/
โ โ โโโ 001_init.sql
โ โโโ Dockerfile
โ โโโ go.mod
โ โโโ go.sum
โ
โโโ frontend/
โ โโโ src/
โ โ โโโ components/
โ โ โโโ pages/
โ โ โโโ hooks/
โ โ โโโ api/
โ โ โโโ store/
โ โ โโโ App.jsx
โ โ โโโ main.jsx
โ โโโ public/
โ โโโ Dockerfile
โ โโโ package.json
โ โโโ vite.config.js
โ
โโโ terraform/
โ โโโ modules/
โ โ โโโ vpc/
โ โ โโโ eks/
โ โ โโโ rds/
โ โ โโโ ecr/
โ โโโ environments/
โ โ โโโ dev/
โ โ โ โโโ main.tf
โ โ โ โโโ variables.tf
โ โ โ โโโ terraform.tfvars
โ โ โโโ prod/
โ โโโ main.tf
โ โโโ variables.tf
โ โโโ outputs.tf
โ
โโโ k8s/
โ โโโ base/
โ โ โโโ namespace.yaml
โ โ โโโ deployment.yaml
โ โ โโโ service.yaml
โ โ โโโ configmap.yaml
โ โโโ overlays/
โ โ โโโ dev/
โ โ โโโ prod/
โ โโโ kustomization.yaml
โ
โโโ lambda/
โ โโโ scheduler/
โ โ โโโ main.py
โ โ โโโ requirements.txt
โ โโโ notifier/
โ
โโโ docs/
โ โโโ adr/
โ โ โโโ 001-database-choice.md
โ โโโ api/
โ โ โโโ openapi.yaml
โ โโโ architecture/
โ โโโ system-architecture.png
โ
โโโ scripts/
โ โโโ setup-local.sh
โ โโโ deploy.sh
โ
โโโ .gitignore
โโโ .env.example
โโโ docker-compose.yml
โโโ Makefile
โโโ README.md
.gitignore
# Dependencies
node_modules/
vendor/
# Build outputs
dist/
build/
bin/
# IDE
.idea/
.vscode/
*.swp
*.swo
# Environment
.env
.env.local
*.tfvars
!*.tfvars.example
# Terraform
.terraform/
*.tfstate
*.tfstate.*
crash.log
# OS
.DS_Store
Thumbs.db
# Logs
*.log
logs/
# Test coverage
coverage/
*.out
# Compiled
*.exe
*.dll
*.so
*.dylib
PR ํ
ํ๋ฆฟ
<!-- .github/PULL_REQUEST_TEMPLATE.md -->
## ๐ ๋ณ๊ฒฝ ์ฌํญ
<!-- ๋ณ๊ฒฝ ๋ด์ฉ์ ๊ฐ๋ตํ ์ค๋ช
ํด์ฃผ์ธ์ -->
## ๐ ๊ด๋ จ ์ด์
<!-- Closes #123 -->
## โ
์ฒดํฌ๋ฆฌ์คํธ
- [ ] ์ฝ๋ ์ปจ๋ฒค์
์ค์
- [ ] ํ
์คํธ ์์ฑ/ํต๊ณผ
- [ ] ๋ฌธ์ ์
๋ฐ์ดํธ (ํ์์)
- [ ] ๋ก์ปฌ ํ
์คํธ ์๋ฃ
## ๐ธ ์คํฌ๋ฆฐ์ท (์ ํ)
<!-- UI ๋ณ๊ฒฝ์ด ์๋ค๋ฉด ์คํฌ๋ฆฐ์ท ์ฒจ๋ถ -->
## ๐งช ํ
์คํธ ๋ฐฉ๋ฒ
<!-- ๋ฆฌ๋ทฐ์ด๊ฐ ํ
์คํธํ ์ ์๋ ๋ฐฉ๋ฒ -->
๋ธ๋์น ๋ณดํธ ๊ท์น
# main ๋ธ๋์น
- Require pull request before merging : โ
- Required approving reviews : 1
- Dismiss stale reviews : โ
- Require status checks : โ
- Required checks : ci
- Require conversation resolution : โ
- Do not allow bypassing : โ
โ
์๋ฃ ๊ธฐ์ค
1. GitHub ๋ ํฌ์งํ ๋ฆฌ ์์ฑ ๋ฐ ํ์ ์ด๋ ์๋ฃ
2. main ๋ธ๋์น ๋ณดํธ ๊ท์น ์ ์ฉ
3. ๋๋ ํ ๋ฆฌ ๊ตฌ์กฐ ์์ฑ ์๋ฃ
4. README.md ์ด์ ์์ฑ ์๋ฃ
5. ๋ชจ๋ ํ์ clone ๋ฐ push ํ
์คํธ ์๋ฃ
๐
Day 2 (2/25 ํ) - ๋คํธ์ํฌ + DB ๊ตฌ์ถ
ํ์๋ผ์ธ
์๊ฐ ํ์คํฌ ๋ด๋น ์ฐ์ถ๋ฌผ 09:00-09:30 ๋ฐ์ผ๋ฆฌ ์คํ ๋์
์ ์ฒด - 09:30-12:00 VPC + ์๋ธ๋ท + NAT/IGW ์ธํ๋ผ Terraform ์ฝ๋ 09:30-12:00 Go ํ๋ก์ ํธ ์ด๊ธฐํ ๋ฐฑ์๋ main.go 13:00-15:00 ๋ณด์ ๊ทธ๋ฃน ์ค์ ์ธํ๋ผ Terraform ์ฝ๋ 13:00-15:00 DB ์ฐ๊ฒฐ ๋ชจ๋ ๋ฐฑ์๋ db/postgres.go 15:00-17:00 RDS ๊ตฌ์ถ ์ธํ๋ผ Terraform ์ฝ๋ 15:00-17:00 DDL ์คํ + ํ
์คํธ ๋ฐ์ดํฐ ๋ฐฑ์๋ SQL ํ์ผ 17:00-18:00 Day 2 ๋ฆฌ๋ทฐ + ์ฐ๊ฒฐ ํ
์คํธ ์ ์ฒด -
D2-001: VPC ์์ฑ
๐ ํ์คํฌ ์ ๋ณด
ํญ๋ชฉ ๋ด์ฉ ID D2-001 ์ฐ์ ์์ P0 ๋ด๋น ์ธํ๋ผ ์์ ์๊ฐ 1์๊ฐ ์ ํ ์์
D1-001 (IAM)
๐ ์ฒดํฌ๋ฆฌ์คํธ
[ ] VPC ์์ฑ (10.0.0.0/16)
[ ] DNS ํธ์คํธ์ด๋ฆ ํ์ฑํ
[ ] DNS ํด์ ํ์ฑํ
[ ] ํ๊ทธ ์ค์
# terraform/modules/vpc/main.tf
resource "aws_vpc" "main" {
cidr_block = var . vpc_cidr
enable_dns_hostnames = true
enable_dns_support = true
tags = {
Name = " ${ var . project_name } -vpc"
Project = var.project_name
Environment = var.environment
ManagedBy = "terraform"
}
}
# Internet Gateway
resource "aws_internet_gateway" "main" {
vpc_id = aws_vpc . main . id
tags = {
Name = " ${ var . project_name } -igw"
Project = var.project_name
Environment = var.environment
}
}
# terraform/modules/vpc/variables.tf
variable "project_name" {
type = string
description = "ํ๋ก์ ํธ ์ด๋ฆ"
}
variable "environment" {
type = string
description = "ํ๊ฒฝ (dev, staging, prod)"
}
variable "vpc_cidr" {
type = string
description = "VPC CIDR ๋ธ๋ก"
default = "10.0.0.0/16"
}
variable "azs" {
type = list ( string )
description = "๊ฐ์ฉ ์์ญ"
default = [ "ap-northeast-2a" , "ap-northeast-2c" ]
}
variable "public_subnet_cidrs" {
type = list ( string )
description = "ํผ๋ธ๋ฆญ ์๋ธ๋ท CIDR"
default = [ "10.0.1.0/24" , "10.0.2.0/24" ]
}
variable "private_subnet_cidrs" {
type = list ( string )
description = "ํ๋ผ์ด๋น ์๋ธ๋ท CIDR"
default = [ "10.0.11.0/24" , "10.0.12.0/24" ]
}
๐งช ๊ฒ์ฆ
# Terraform ์คํ
cd terraform/environments/dev
terraform init
terraform plan
terraform apply
# ๊ฒ์ฆ
aws ec2 describe-vpcs --filters "Name=tag:Name,Values=bbossiregi-vpc"
D2-002: ์๋ธ๋ท ๊ตฌ์ฑ
๐ ์ฒดํฌ๋ฆฌ์คํธ
[ ] Public ์๋ธ๋ท 2๊ฐ ์์ฑ (Multi-AZ)
[ ] Private ์๋ธ๋ท 2๊ฐ ์์ฑ (Multi-AZ)
[ ] ์๋ธ๋ท ํ๊ทธ ์ค์ (EKS์ฉ ํ์!)
# terraform/modules/vpc/subnets.tf
# Public Subnets
resource "aws_subnet" "public" {
count = length (var . public_subnet_cidrs)
vpc_id = aws_vpc . main . id
cidr_block = var . public_subnet_cidrs[count . index]
availability_zone = var . azs[count . index]
map_public_ip_on_launch = true
tags = {
Name = " ${ var . project_name } -public- ${ var . azs [ count . index ] } "
Project = var.project_name
Environment = var.environment
"kubernetes.io/role/elb" = "1" # EKS ALB์ฉ
"kubernetes.io/cluster/${var.project_name}-eks" = "shared"
}
}
# Private Subnets
resource "aws_subnet" "private" {
count = length (var . private_subnet_cidrs)
vpc_id = aws_vpc . main . id
cidr_block = var . private_subnet_cidrs[count . index]
availability_zone = var . azs[count . index]
tags = {
Name = " ${ var . project_name } -private- ${ var . azs [ count . index ] } "
Project = var.project_name
Environment = var.environment
"kubernetes.io/role/internal-elb" = "1" # EKS Internal LB์ฉ
"kubernetes.io/cluster/${var.project_name}-eks" = "shared"
}
}
D2-003: NAT Gateway
# terraform/modules/vpc/nat.tf
# Elastic IP for NAT
resource "aws_eip" "nat" {
domain = "vpc"
tags = {
Name = " ${ var . project_name } -nat-eip"
Project = var.project_name
Environment = var.environment
}
depends_on = [aws_internet_gateway . main]
}
# NAT Gateway (๋จ์ผ - ๋น์ฉ ์ ๊ฐ)
resource "aws_nat_gateway" "main" {
allocation_id = aws_eip . nat . id
subnet_id = aws_subnet . public[ 0 ] . id # ์ฒซ ๋ฒ์งธ ํผ๋ธ๋ฆญ ์๋ธ๋ท์ ๋ฐฐ์น
tags = {
Name = " ${ var . project_name } -nat"
Project = var.project_name
Environment = var.environment
}
depends_on = [aws_internet_gateway . main]
}
D2-004: ๋ผ์ฐํ
ํ
์ด๋ธ
# terraform/modules/vpc/routes.tf
# Public Route Table
resource "aws_route_table" "public" {
vpc_id = aws_vpc . main . id
route {
cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway . main . id
}
tags = {
Name = " ${ var . project_name } -public-rt"
Project = var.project_name
Environment = var.environment
}
}
# Public Subnet Association
resource "aws_route_table_association" "public" {
count = length (aws_subnet . public)
subnet_id = aws_subnet . public[count . index] . id
route_table_id = aws_route_table . public . id
}
# Private Route Table
resource "aws_route_table" "private" {
vpc_id = aws_vpc . main . id
route {
cidr_block = "0.0.0.0/0"
nat_gateway_id = aws_nat_gateway . main . id
}
tags = {
Name = " ${ var . project_name } -private-rt"
Project = var.project_name
Environment = var.environment
}
}
# Private Subnet Association
resource "aws_route_table_association" "private" {
count = length (aws_subnet . private)
subnet_id = aws_subnet . private[count . index] . id
route_table_id = aws_route_table . private . id
}
D2-005: ๋ณด์ ๊ทธ๋ฃน
# terraform/modules/vpc/security_groups.tf
# ALB Security Group
resource "aws_security_group" "alb" {
name = " ${ var . project_name } -alb-sg"
description = "Security group for ALB"
vpc_id = aws_vpc . main . id
ingress {
description = "HTTP"
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = [ "0.0.0.0/0" ]
}
ingress {
description = "HTTPS"
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = [ "0.0.0.0/0" ]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = [ "0.0.0.0/0" ]
}
tags = {
Name = " ${ var . project_name } -alb-sg"
Project = var.project_name
Environment = var.environment
}
}
# EKS Node Security Group
resource "aws_security_group" "eks_nodes" {
name = " ${ var . project_name } -eks-nodes-sg"
description = "Security group for EKS worker nodes"
vpc_id = aws_vpc . main . id
# ALB์์ ์ค๋ ํธ๋ํฝ ํ์ฉ
ingress {
description = "From ALB"
from_port = 0
to_port = 65535
protocol = "tcp"
security_groups = [aws_security_group . alb . id]
}
# ๋
ธ๋ ๊ฐ ํต์
ingress {
description = "Node to node"
from_port = 0
to_port = 65535
protocol = "-1"
self = true
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = [ "0.0.0.0/0" ]
}
tags = {
Name = " ${ var . project_name } -eks-nodes-sg"
Project = var.project_name
Environment = var.environment
}
}
# RDS Security Group
resource "aws_security_group" "rds" {
name = " ${ var . project_name } -rds-sg"
description = "Security group for RDS"
vpc_id = aws_vpc . main . id
# EKS์์ ์ค๋ ํธ๋ํฝ๋ง ํ์ฉ
ingress {
description = "PostgreSQL from EKS"
from_port = 5432
to_port = 5432
protocol = "tcp"
security_groups = [aws_security_group . eks_nodes . id]
}
# Bastion์์ ์ค๋ ํธ๋ํฝ (๋๋ฒ๊น
์ฉ)
ingress {
description = "PostgreSQL from Bastion"
from_port = 5432
to_port = 5432
protocol = "tcp"
cidr_blocks = [ "10.0.1.0/24" ] # Public ์๋ธ๋ท
}
tags = {
Name = " ${ var . project_name } -rds-sg"
Project = var.project_name
Environment = var.environment
}
}
D2-006: RDS PostgreSQL ๊ตฌ์ถ
๐ ์ฒดํฌ๋ฆฌ์คํธ
[ ] DB ์๋ธ๋ท ๊ทธ๋ฃน ์์ฑ
[ ] ํ๋ผ๋ฏธํฐ ๊ทธ๋ฃน ์์ฑ (์ ํ)
[ ] RDS ์ธ์คํด์ค ์์ฑ
[ ] ์ํฌ๋ฆฟ ๋งค๋์ ์ ์๊ฒฉ์ฆ๋ช
์ ์ฅ
[ ] ์ฐ๊ฒฐ ํ
์คํธ
# terraform/modules/rds/main.tf
# DB Subnet Group
resource "aws_db_subnet_group" "main" {
name = " ${ var . project_name } -db-subnet"
subnet_ids = var . private_subnet_ids
tags = {
Name = " ${ var . project_name } -db-subnet"
Project = var.project_name
Environment = var.environment
}
}
# RDS Instance
resource "aws_db_instance" "main" {
identifier = " ${ var . project_name } -db"
# ์์ง
engine = "postgres"
engine_version = "15.4"
instance_class = var . instance_class
# ์คํ ๋ฆฌ์ง
allocated_storage = 20
max_allocated_storage = 100 # Auto scaling
storage_type = "gp3"
storage_encrypted = true
# ๋ฐ์ดํฐ๋ฒ ์ด์ค
db_name = var . db_name
username = var . db_username
password = var . db_password
port = 5432
# ๋คํธ์ํฌ
db_subnet_group_name = aws_db_subnet_group . main . name
vpc_security_group_ids = [var . security_group_id]
publicly_accessible = false
# ๋ฐฑ์
backup_retention_period = 7
backup_window = "03:00-04:00"
maintenance_window = "Mon:04:00-Mon:05:00"
# ์ฑ๋ฅ
performance_insights_enabled = false # ๋น์ฉ ์ ๊ฐ
# ์ต์
skip_final_snapshot = var . environment != "prod"
deletion_protection = var . environment == "prod"
tags = {
Name = " ${ var . project_name } -db"
Project = var.project_name
Environment = var.environment
}
}
# Outputs
output "endpoint" {
value = aws_db_instance . main . endpoint
}
output "address" {
value = aws_db_instance . main . address
}
# terraform/modules/rds/variables.tf
variable "project_name" {
type = string
}
variable "environment" {
type = string
}
variable "private_subnet_ids" {
type = list ( string )
}
variable "security_group_id" {
type = string
}
variable "instance_class" {
type = string
default = "db.t3.micro"
}
variable "db_name" {
type = string
default = "bbossiregi"
}
variable "db_username" {
type = string
default = "bbossiregi"
}
variable "db_password" {
type = string
sensitive = true
}
D2-007: DB ์คํค๋ง ์์ฑ
๐ ์ฒดํฌ๋ฆฌ์คํธ
[ ] DDL ์คํ
[ ] ํ
์คํธ ๋ฐ์ดํฐ ์ฝ์
[ ] ์ฐ๊ฒฐ ํ
์คํธ
๐ ํ
์คํธ ๋ฐ์ดํฐ
-- migrations/002_seed.sql
-- ํ
์คํธ ์ ์
INSERT INTO users (email, password_hash, name , role ) VALUES
( 'admin@bbossiregi.com' , '$2a$10$xxxxx' , '๊ด๋ฆฌ์' , 'admin' ),
( 'user1@test.com' , '$2a$10$xxxxx' , 'ํ
์คํธ์ ์ 1' , 'user' ),
( 'user2@test.com' , '$2a$10$xxxxx' , 'ํ
์คํธ์ ์ 2' , 'user' );
-- ํ
์คํธ ์ํ
INSERT INTO products ( name , description , price, image_url, category) VALUES
( 'ํ๋ฆฌ๋ฏธ์ ๊ฐ์์ง ์ฌ๋ฃ 3kg' , '์ต๊ณ ๊ธ ์๋ฃ๋ก ๋ง๋ ํ๋ฆฌ๋ฏธ์ ์ฌ๋ฃ์
๋๋ค.' , 45000 , 'https://via.placeholder.com/300' , 'food' ),
( '๊ณ ์์ด ์บฃํ์ ๋ํ' , 'ํผํผํ ๊ตฌ์กฐ์ ๋ํ ์บฃํ์์
๋๋ค.' , 89000 , 'https://via.placeholder.com/300' , 'living' ),
( '๊ฐ์์ง ์ฅ๋๊ฐ ์ธํธ' , '๋ค์ํ ์ฅ๋๊ฐ์ด ํฌํจ๋ ์ธํธ์
๋๋ค.' , 25000 , 'https://via.placeholder.com/300' , 'toy' ),
( '๋ฐ๋ ค๋๋ฌผ ์์์ ' , '๋ฉด์ญ๋ ฅ ๊ฐํ์ ๋์์ด ๋๋ ์์์ ์
๋๋ค.' , 35000 , 'https://via.placeholder.com/300' , 'health' ),
( '๊ฐ์์ง ํจ๋ฉ ์กฐ๋ผ' , '๋ฐ๋ปํ ๊ฒจ์ธ์ฉ ํจ๋ฉ ์กฐ๋ผ์
๋๋ค.' , 55000 , 'https://via.placeholder.com/300' , 'fashion' );
-- ํ
์คํธ ํ์๋ (์ค๋ + ๋ด์ผ)
INSERT INTO time_deals (product_id, original_price, deal_price, stock_quantity, start_at, end_at, status ) VALUES
-- ์งํ ์ค
( 1 , 45000 , 29900 , 100 , NOW () - INTERVAL '30 minutes' , NOW () + INTERVAL '30 minutes' , 'active' ),
-- ์์
( 2 , 89000 , 59900 , 50 , NOW () + INTERVAL '1 hour' , NOW () + INTERVAL '2 hours' , 'scheduled' ),
( 3 , 25000 , 15900 , 200 , NOW () + INTERVAL '3 hours' , NOW () + INTERVAL '4 hours' , 'scheduled' ),
-- ๋ด์ผ
( 4 , 35000 , 24900 , 150 , NOW () + INTERVAL '1 day' , NOW () + INTERVAL '1 day' + INTERVAL '1 hour' , 'scheduled' ),
( 5 , 55000 , 35900 , 80 , NOW () + INTERVAL '1 day' + INTERVAL '2 hours' , NOW () + INTERVAL '1 day' + INTERVAL '3 hours' , 'scheduled' );
๐งช ๊ฒ์ฆ
# RDS ์ฐ๊ฒฐ ํ
์คํธ (Bastion ๋๋ Port Forwarding)
psql -h < rds-endpoin t > -U bbossiregi -d bbossiregi
# ํ
์ด๋ธ ํ์ธ
\dt
# ๋ฐ์ดํฐ ํ์ธ
SELECT * FROM users ;
SELECT * FROM products ;
SELECT * FROM time_deals ;
๐
Day 3 (2/26 ์) - ๋ฐฑ์๋ ํต์ฌ API ๊ฐ๋ฐ
ํ์๋ผ์ธ
์๊ฐ ํ์คํฌ ๋ด๋น ์ฐ์ถ๋ฌผ 09:00-09:30 ๋ฐ์ผ๋ฆฌ ์คํ ๋์
์ ์ฒด ํ์๋ก 09:30-10:30 Go ํ๋ก์ ํธ ๊ตฌ์กฐ ์ธํ
๋ฐฑ์๋ ๋๋ ํ ๋ฆฌ ๊ตฌ์กฐ 10:30-12:00 DB ์ฐ๊ฒฐ + ์ค์ ๋ชจ๋ ๋ฐฑ์๋ config/, db/ 13:00-14:30 ์ธ์ฆ API (ํ์๊ฐ์
/๋ก๊ทธ์ธ) ๋ฐฑ์๋ handler/auth.go 14:30-16:00 ์ํ/ํ์๋ ์กฐํ API ๋ฐฑ์๋ handler/product.go, timedeal.go 16:00-17:30 ํ์๋ ์ค์ผ์ค๋ฌ ๋ก์ง ๋ฐฑ์๋ service/scheduler.go 17:30-18:00 Day 3 ๋ฆฌ๋ทฐ + API ํ
์คํธ ์ ์ฒด Postman Collection
D3-001: Go ํ๋ก์ ํธ ์ด๊ธฐํ
๐ ํ์คํฌ ์ ๋ณด
ํญ๋ชฉ ๋ด์ฉ ID D3-001 ์ฐ์ ์์ P0 (Blocker) ๋ด๋น ๋ฐฑ์๋ ์์ ์๊ฐ 1์๊ฐ ์ ํ ์์
D2-006 (RDS ๊ตฌ์ถ) ํํ ์์
D3-002 (DB ์ฐ๊ฒฐ)
๐ ์ฒดํฌ๋ฆฌ์คํธ
[ ] go mod init
[ ] ๋๋ ํ ๋ฆฌ ๊ตฌ์กฐ ์์ฑ
[ ] ์์กด์ฑ ์ค์น
[ ] gin-gonic/gin (์น ํ๋ ์์ํฌ)
[ ] lib/pq (PostgreSQL ๋๋ผ์ด๋ฒ)
[ ] golang-jwt/jwt (JWT)
[ ] joho/godotenv (ํ๊ฒฝ๋ณ์)
[ ] go-playground/validator (์ ํจ์ฑ ๊ฒ์ฌ)
[ ] golang.org/x/crypto (bcrypt)
[ ] main.go ๊ธฐ๋ณธ ๊ตฌ์กฐ
[ ] Makefile ์์ฑ
[ ] .env.example ์์ฑ
๐ ์์ธ ๋ช
์ธ
go.mod
// backend/go.mod
module github.com / bbossiregi / backend
go 1.22
require (
github.com / gin - gonic / gin v1. 9.1
github.com / lib / pq v1. 10.9
github.com / golang - jwt / jwt / v5 v5. 2.0
github.com / joho / godotenv v1. 5.1
github.com /go- playground / validator / v10 v10. 17.0
golang.org / x / crypto v0. 18.0
)
๋๋ ํ ๋ฆฌ ๊ตฌ์กฐ (์ต์ข
)
backend/
โโโ cmd/
โ โโโ api/
โ โโโ main.go # ์ํธ๋ฆฌํฌ์ธํธ
โ
โโโ internal/
โ โโโ config/
โ โ โโโ config.go # ํ๊ฒฝ ์ค์
โ โ
โ โโโ database/
โ โ โโโ postgres.go # DB ์ฐ๊ฒฐ
โ โ
โ โโโ model/
โ โ โโโ user.go # User ๋ชจ๋ธ
โ โ โโโ product.go # Product ๋ชจ๋ธ
โ โ โโโ timedeal.go # TimeDeal ๋ชจ๋ธ
โ โ โโโ order.go # Order ๋ชจ๋ธ
โ โ โโโ response.go # ๊ณตํต ์๋ต ๊ตฌ์กฐ์ฒด
โ โ
โ โโโ repository/
โ โ โโโ user_repo.go # User DB ์์
โ โ โโโ product_repo.go # Product DB ์์
โ โ โโโ timedeal_repo.go # TimeDeal DB ์์
โ โ โโโ order_repo.go # Order DB ์์
โ โ
โ โโโ service/
โ โ โโโ auth_service.go # ์ธ์ฆ ๋น์ฆ๋์ค ๋ก์ง
โ โ โโโ stock_service.go # ์ฌ๊ณ ๊ด๋ฆฌ (์ฌ๊ฐ ํจํด)
โ โ โโโ order_service.go # ์ฃผ๋ฌธ ๋น์ฆ๋์ค ๋ก์ง
โ โ โโโ scheduler_service.go # ํ์๋ ์ค์ผ์ค๋ฌ
โ โ
โ โโโ handler/
โ โ โโโ auth_handler.go # ์ธ์ฆ API ํธ๋ค๋ฌ
โ โ โโโ product_handler.go # ์ํ API ํธ๋ค๋ฌ
โ โ โโโ timedeal_handler.go # ํ์๋ API ํธ๋ค๋ฌ
โ โ โโโ order_handler.go # ์ฃผ๋ฌธ API ํธ๋ค๋ฌ
โ โ โโโ health_handler.go # ํฌ์ค์ฒดํฌ ํธ๋ค๋ฌ
โ โ
โ โโโ middleware/
โ โ โโโ auth.go # JWT ์ธ์ฆ ๋ฏธ๋ค์จ์ด
โ โ โโโ logger.go # ๋ก๊น
๋ฏธ๋ค์จ์ด
โ โ โโโ cors.go # CORS ๋ฏธ๋ค์จ์ด
โ โ โโโ recovery.go # ํจ๋ ๋ณต๊ตฌ
โ โ
โ โโโ router/
โ โโโ router.go # ๋ผ์ฐํฐ ์ค์
โ
โโโ pkg/
โ โโโ response/
โ โ โโโ response.go # ์๋ต ํฌํผ
โ โโโ validator/
โ โ โโโ validator.go # ์ปค์คํ
์ ํจ์ฑ ๊ฒ์ฌ
โ โโโ utils/
โ โโโ hash.go # ๋น๋ฐ๋ฒํธ ํด์ฑ
โ โโโ jwt.go # JWT ์ ํธ
โ
โโโ migrations/
โ โโโ 001_init.sql
โ โโโ 002_seed.sql
โ
โโโ .env.example
โโโ Dockerfile
โโโ Makefile
โโโ go.mod
main.go
// backend/cmd/api/main.go
package main
import (
" log "
" os "
" github.com/bbossiregi/backend/internal/config "
" github.com/bbossiregi/backend/internal/database "
" github.com/bbossiregi/backend/internal/router "
" github.com/bbossiregi/backend/internal/service "
" github.com/joho/godotenv "
)
func main () {
// ํ๊ฒฝ๋ณ์ ๋ก๋
if err := godotenv. Load (); err != nil {
log. Println ( "No .env file found, using environment variables" )
}
// ์ค์ ๋ก๋
cfg := config. Load ()
// DB ์ฐ๊ฒฐ
db, err := database. NewPostgresDB (cfg.Database)
if err != nil {
log. Fatalf ( "Failed to connect to database: %v " , err)
}
defer db. Close ()
log. Println ( "โ
Database connected successfully" )
// ์ค์ผ์ค๋ฌ ์์
scheduler := service. NewSchedulerService (db)
go scheduler. Start ()
log. Println ( "โ
Scheduler started" )
// ๋ผ์ฐํฐ ์ค์
r := router. Setup (db, cfg)
// ์๋ฒ ์์
port := cfg.Server.Port
if port == "" {
port = "8080"
}
log. Printf ( "๐ Server starting on port %s " , port)
if err := r. Run ( ":" + port); err != nil {
log. Fatalf ( "Failed to start server: %v " , err)
}
}
Makefile
# backend/Makefile
.PHONY : run build test clean docker-build docker-run
# ๋ณ์
APP_NAME=bbossiregi-api
GO=go
MAIN_PATH=./cmd/api
# ๊ฐ๋ฐ
run :
$( GO ) run $( MAIN_PATH ) /main.go
# ๋น๋
build :
CGO_ENABLED=0 GOOS=linux $( GO ) build -o bin/ $( APP_NAME ) $( MAIN_PATH ) /main.go
# ํ
์คํธ
test :
$( GO ) test -v ./...
test-coverage :
$( GO ) test -v -coverprofile=coverage.out ./...
$( GO ) tool cover -html=coverage.out -o coverage.html
# ๋ฆฐํธ
lint :
golangci-lint run
# ์ ๋ฆฌ
clean :
rm -rf bin/
rm -f coverage.out coverage.html
# Docker
docker-build :
docker build -t $( APP_NAME ) :latest .
docker-run :
docker run -p 8080:8080 --env-file .env $( APP_NAME ) :latest
# ๋ง์ด๊ทธ๋ ์ด์
migrate-up :
psql $( DATABASE_URL ) -f migrations/001_init.sql
migrate-seed :
psql $( DATABASE_URL ) -f migrations/002_seed.sql
# ์์กด์ฑ
deps :
$( GO ) mod download
$( GO ) mod tidy
.env.example
# backend/.env.example
# Server
PORT = 8080
GIN_MODE = debug # debug, release, test
# Database
DB_HOST = localhost
DB_PORT = 5432
DB_USER = bbossiregi
DB_PASSWORD = your_password_here
DB_NAME = bbossiregi
DB_SSLMODE = disable # disable, require, verify-full
# JWT
JWT_SECRET = your_jwt_secret_here_min_32_chars
JWT_EXPIRES_IN = 3600 # seconds
# App
APP_ENV = development # development, staging, production
โ
์๋ฃ ๊ธฐ์ค
1. go mod init ์๋ฃ
2. ๋ชจ๋ ๋๋ ํ ๋ฆฌ ๊ตฌ์กฐ ์์ฑ
3. go mod tidy๋ก ์์กด์ฑ ์ค์น ์๋ฃ
4. make run์ผ๋ก ์๋ฒ ์์ ๊ฐ๋ฅ (์๋ฌ ์์)
๐งช ๊ฒ์ฆ
cd backend
# ์์กด์ฑ ์ค์น
go mod tidy
# ๋น๋ ํ
์คํธ
go build ./...
# ์คํ ํ
์คํธ (DB ์ฐ๊ฒฐ ์ ์ด๋ฏ๋ก ์๋ฌ ์์)
make run
D3-002: ์ค์ ๋ฐ DB ์ฐ๊ฒฐ ๋ชจ๋
๐ ํ์คํฌ ์ ๋ณด
ํญ๋ชฉ ๋ด์ฉ ID D3-002 ์ฐ์ ์์ P0 ๋ด๋น ๋ฐฑ์๋ ์์ ์๊ฐ 1.5์๊ฐ ์ ํ ์์
D3-001 ํํ ์์
D3-003 (์ธ์ฆ API)
๐ ์ฒดํฌ๋ฆฌ์คํธ
[ ] config.go ์์ฑ
[ ] postgres.go ์์ฑ
[ ] Connection pool ์ค์
[ ] Health check ์ฟผ๋ฆฌ
[ ] ์ฐ๊ฒฐ ํ
์คํธ
๐ ์์ธ ๋ช
์ธ
config.go
// backend/internal/config/config.go
package config
import (
" os "
" strconv "
" time "
)
type Config struct {
Server ServerConfig
Database DatabaseConfig
JWT JWTConfig
App AppConfig
}
type ServerConfig struct {
Port string
GinMode string
}
type DatabaseConfig struct {
Host string
Port string
User string
Password string
DBName string
SSLMode string
// Connection pool
MaxOpenConns int
MaxIdleConns int
ConnMaxLifetime time . Duration
ConnMaxIdleTime time . Duration
}
type JWTConfig struct {
Secret string
ExpiresIn time . Duration
}
type AppConfig struct {
Env string
}
func Load () * Config {
return & Config {
Server: ServerConfig {
Port: getEnv ( "PORT" , "8080" ),
GinMode: getEnv ( "GIN_MODE" , "debug" ),
},
Database: DatabaseConfig {
Host: getEnv ( "DB_HOST" , "localhost" ),
Port: getEnv ( "DB_PORT" , "5432" ),
User: getEnv ( "DB_USER" , "bbossiregi" ),
Password: getEnv ( "DB_PASSWORD" , "" ),
DBName: getEnv ( "DB_NAME" , "bbossiregi" ),
SSLMode: getEnv ( "DB_SSLMODE" , "disable" ),
MaxOpenConns: getEnvInt ( "DB_MAX_OPEN_CONNS" , 25 ),
MaxIdleConns: getEnvInt ( "DB_MAX_IDLE_CONNS" , 5 ),
ConnMaxLifetime: getEnvDuration ( "DB_CONN_MAX_LIFETIME" , 5 * time.Minute),
ConnMaxIdleTime: getEnvDuration ( "DB_CONN_MAX_IDLE_TIME" , 1 * time.Minute),
},
JWT: JWTConfig {
Secret: getEnv ( "JWT_SECRET" , "" ),
ExpiresIn: getEnvDuration ( "JWT_EXPIRES_IN" , 3600 * time.Second),
},
App: AppConfig {
Env: getEnv ( "APP_ENV" , "development" ),
},
}
}
// DSN ์์ฑ
func ( c * DatabaseConfig ) DSN () string {
return "host=" + c.Host +
" port=" + c.Port +
" user=" + c.User +
" password=" + c.Password +
" dbname=" + c.DBName +
" sslmode=" + c.SSLMode
}
// ํฌํผ ํจ์๋ค
func getEnv ( key , defaultValue string ) string {
if value := os. Getenv (key); value != "" {
return value
}
return defaultValue
}
func getEnvInt ( key string , defaultValue int ) int {
if value := os. Getenv (key); value != "" {
if intVal, err := strconv. Atoi (value); err == nil {
return intVal
}
}
return defaultValue
}
func getEnvDuration ( key string , defaultValue time . Duration ) time . Duration {
if value := os. Getenv (key); value != "" {
if intVal, err := strconv. Atoi (value); err == nil {
return time. Duration (intVal) * time.Second
}
}
return defaultValue
}
postgres.go
// backend/internal/database/postgres.go
package database
import (
" database/sql "
" fmt "
" log "
" time "
_ " github.com/lib/pq "
" github.com/bbossiregi/backend/internal/config "
)
func NewPostgresDB ( cfg config . DatabaseConfig ) ( * sql . DB , error ) {
// ์ฐ๊ฒฐ
db, err := sql. Open ( "postgres" , cfg. DSN ())
if err != nil {
return nil , fmt. Errorf ( "failed to open database: %w " , err)
}
// Connection pool ์ค์
db. SetMaxOpenConns (cfg.MaxOpenConns)
db. SetMaxIdleConns (cfg.MaxIdleConns)
db. SetConnMaxLifetime (cfg.ConnMaxLifetime)
db. SetConnMaxIdleTime (cfg.ConnMaxIdleTime)
// Ping ํ
์คํธ
if err := db. Ping (); err != nil {
return nil , fmt. Errorf ( "failed to ping database: %w " , err)
}
// ์ฐ๊ฒฐ ์ ๋ณด ๋ก๊น
log. Printf ( "๐ฆ Database connected: host= %s , port= %s , db= %s " ,
cfg.Host, cfg.Port, cfg.DBName)
log. Printf ( "๐ฆ Connection pool: maxOpen= %d , maxIdle= %d " ,
cfg.MaxOpenConns, cfg.MaxIdleConns)
return db, nil
}
// HealthCheck - DB ์ํ ํ์ธ
func HealthCheck ( db * sql . DB ) error {
ctx, cancel := context. WithTimeout (context. Background (), 5 * time.Second)
defer cancel ()
var result int
err := db. QueryRowContext (ctx, "SELECT 1" ). Scan ( & result)
if err != nil {
return fmt. Errorf ( "database health check failed: %w " , err)
}
return nil
}
โ
์๋ฃ ๊ธฐ์ค
1. make run ์คํ ์ "Database connected" ๋ก๊ทธ ์ถ๋ ฅ
2. Connection pool ์ค์ ์ ์ฉ ํ์ธ
3. ์๋ชป๋ DB ์ ๋ณด๋ก ์คํ ์ ์ ์ ํ ์๋ฌ ๋ฉ์์ง
๐งช ๊ฒ์ฆ
# .env ํ์ผ ์์ฑ (RDS ์ ๋ณด๋ก)
cp .env.example .env
vi .env # DB ์ ๋ณด ์
๋ ฅ
# ์คํ
make run
# ์์ ์ถ๋ ฅ
# โ
Database connected successfully
# โ
Scheduler started
# ๐ Server starting on port 8080
D3-003: ๊ณตํต ๋ชจ๋ธ ๋ฐ ์๋ต ๊ตฌ์กฐ์ฒด
๐ ํ์คํฌ ์ ๋ณด
ํญ๋ชฉ ๋ด์ฉ ID D3-003 ์ฐ์ ์์ P0 ๋ด๋น ๋ฐฑ์๋ ์์ ์๊ฐ 30๋ถ
๐ ์์ธ ๋ช
์ธ
๊ณตํต ์๋ต ๊ตฌ์กฐ์ฒด
// backend/internal/model/response.go
package model
import " time "
// ์ฑ๊ณต ์๋ต
type Response struct {
Success bool `json:"success"`
Data interface {} `json:"data,omitempty"`
Meta * Meta `json:"meta,omitempty"`
}
// ์๋ฌ ์๋ต
type ErrorResponse struct {
Success bool `json:"success"`
Error ErrorDetail `json:"error"`
}
type ErrorDetail struct {
Code string `json:"code"`
Message string `json:"message"`
Details interface {} `json:"details,omitempty"`
}
// ํ์ด์ง๋ค์ด์
type Meta struct {
Total int `json:"total"`
Page int `json:"page"`
PerPage int `json:"per_page"`
TotalPages int `json:"total_pages"`
}
// ์ ํจ์ฑ ๊ฒ์ฌ ์๋ฌ
type ValidationError struct {
Field string `json:"field"`
Message string `json:"message"`
}
User ๋ชจ๋ธ
// backend/internal/model/user.go
package model
import " time "
type User struct {
ID int `json:"id"`
Email string `json:"email"`
PasswordHash string `json:"-"` // JSON์์ ์ ์ธ
Name string `json:"name"`
Phone * string `json:"phone,omitempty"`
Role string `json:"role"`
Status string `json:"status"`
CreatedAt time . Time `json:"created_at"`
UpdatedAt time . Time `json:"updated_at"`
LastLoginAt * time . Time `json:"last_login_at,omitempty"`
}
// ํ์๊ฐ์
์์ฒญ
type RegisterRequest struct {
Email string `json:"email" validate:"required,email,max=255"`
Password string `json:"password" validate:"required,min=8,max=100"`
Name string `json:"name" validate:"required,min=2,max=100"`
Phone * string `json:"phone,omitempty" validate:"omitempty,len=13"`
}
// ๋ก๊ทธ์ธ ์์ฒญ
type LoginRequest struct {
Email string `json:"email" validate:"required,email"`
Password string `json:"password" validate:"required"`
}
// ๋ก๊ทธ์ธ ์๋ต
type LoginResponse struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
User User `json:"user"`
}
// JWT Claims
type JWTClaims struct {
UserID int `json:"user_id"`
Email string `json:"email"`
Role string `json:"role"`
jwt . RegisteredClaims
}
Product ๋ชจ๋ธ
// backend/internal/model/product.go
package model
import " time "
type Product struct {
ID int `json:"id"`
Name string `json:"name"`
Description * string `json:"description,omitempty"`
Price float64 `json:"price"`
ImageURL * string `json:"image_url,omitempty"`
ThumbnailURL * string `json:"thumbnail_url,omitempty"`
Category string `json:"category"`
Status string `json:"status"`
CreatedAt time . Time `json:"created_at"`
UpdatedAt time . Time `json:"updated_at"`
}
// ์ํ ์์ฝ (ํ์๋ ๋ชฉ๋ก์ฉ)
type ProductSummary struct {
ID int `json:"id"`
Name string `json:"name"`
ImageURL * string `json:"image_url,omitempty"`
Category string `json:"category"`
}
TimeDeal ๋ชจ๋ธ
// backend/internal/model/timedeal.go
package model
import " time "
type TimeDeal struct {
ID int `json:"id"`
ProductID int `json:"product_id"`
OriginalPrice float64 `json:"original_price"`
DealPrice float64 `json:"deal_price"`
DiscountRate int `json:"discount_rate"`
StockQuantity int `json:"stock_quantity"`
ReservedQuantity int `json:"reserved_quantity"`
SoldQuantity int `json:"sold_quantity"`
StartAt time . Time `json:"start_at"`
EndAt time . Time `json:"end_at"`
Status string `json:"status"`
CreatedAt time . Time `json:"created_at"`
UpdatedAt time . Time `json:"updated_at"`
}
// ํ์๋ ๋ชฉ๋ก ์๋ต
type TimeDealSummary struct {
ID int `json:"id"`
Product ProductSummary `json:"product"`
OriginalPrice float64 `json:"original_price"`
DealPrice float64 `json:"deal_price"`
DiscountRate int `json:"discount_rate"`
StockQuantity int `json:"stock_quantity"`
AvailableQuantity int `json:"available_quantity"`
StartAt time . Time `json:"start_at"`
EndAt time . Time `json:"end_at"`
Status string `json:"status"`
RemainingSeconds int `json:"remaining_seconds"`
}
// ํ์๋ ์์ธ ์๋ต
type TimeDealDetail struct {
ID int `json:"id"`
Product Product `json:"product"`
OriginalPrice float64 `json:"original_price"`
DealPrice float64 `json:"deal_price"`
DiscountRate int `json:"discount_rate"`
StockQuantity int `json:"stock_quantity"`
ReservedQuantity int `json:"reserved_quantity"`
SoldQuantity int `json:"sold_quantity"`
AvailableQuantity int `json:"available_quantity"`
StartAt time . Time `json:"start_at"`
EndAt time . Time `json:"end_at"`
Status string `json:"status"`
RemainingSeconds int `json:"remaining_seconds"`
}
// ๊ฐ์ฉ ์ฌ๊ณ ๊ณ์ฐ
func ( t * TimeDeal ) AvailableQuantity () int {
return t.StockQuantity - t.ReservedQuantity - t.SoldQuantity
}
// ๋จ์ ์๊ฐ ๊ณ์ฐ (์ด)
func ( t * TimeDeal ) RemainingSeconds () int {
if t.Status == "ended" || t.Status == "soldout" {
return 0
}
now := time. Now ()
if t.Status == "scheduled" {
return int (t.StartAt. Sub (now). Seconds ())
}
// active
remaining := int (t.EndAt. Sub (now). Seconds ())
if remaining < 0 {
return 0
}
return remaining
}
Order ๋ชจ๋ธ
// backend/internal/model/order.go
package model
import " time "
type Order struct {
ID int `json:"id"`
UserID int `json:"user_id"`
TimeDealID int `json:"time_deal_id"`
Quantity int `json:"quantity"`
UnitPrice float64 `json:"unit_price"`
TotalPrice float64 `json:"total_price"`
Status string `json:"status"`
CreatedAt time . Time `json:"created_at"`
UpdatedAt time . Time `json:"updated_at"`
ReservedAt * time . Time `json:"reserved_at,omitempty"`
ConfirmedAt * time . Time `json:"confirmed_at,omitempty"`
CanceledAt * time . Time `json:"canceled_at,omitempty"`
}
// ์ฃผ๋ฌธ ์์ฑ ์์ฒญ
type CreateOrderRequest struct {
TimeDealID int `json:"time_deal_id" validate:"required,gt=0"`
Quantity int `json:"quantity" validate:"required,min=1,max=10"`
}
// ์ฃผ๋ฌธ ์ํ
const (
OrderStatusPending = "pending"
OrderStatusReserved = "reserved"
OrderStatusConfirmed = "confirmed"
OrderStatusCanceled = "canceled"
OrderStatusFailed = "failed"
)
// ์ฃผ๋ฌธ ์ด๋ฒคํธ
type OrderEvent struct {
ID int `json:"id"`
OrderID int `json:"order_id"`
EventType string `json:"event_type"`
Payload string `json:"payload"` // JSON string
ActorID *int `json:"actor_id,omitempty"`
ActorType string `json:"actor_type"`
CreatedAt time . Time `json:"created_at"`
}
// ์ด๋ฒคํธ ํ์
const (
EventOrderCreated = "CREATED"
EventStockReserved = "STOCK_RESERVED"
EventStockReleased = "STOCK_RELEASED"
EventPaymentRequested = "PAYMENT_REQUESTED"
EventPaymentCompleted = "PAYMENT_COMPLETED"
EventPaymentFailed = "PAYMENT_FAILED"
EventOrderConfirmed = "CONFIRMED"
EventOrderCanceled = "CANCELED"
)
D3-004: ์๋ต ํฌํผ ๋ฐ ๋ฏธ๋ค์จ์ด
๐ ํ์คํฌ ์ ๋ณด
ํญ๋ชฉ ๋ด์ฉ ID D3-004 ์ฐ์ ์์ P0 ๋ด๋น ๋ฐฑ์๋ ์์ ์๊ฐ 1์๊ฐ
๐ ์์ธ ๋ช
์ธ
์๋ต ํฌํผ
// backend/pkg/response/response.go
package response
import (
" net/http "
" github.com/gin-gonic/gin "
" github.com/bbossiregi/backend/internal/model "
)
// ์ฑ๊ณต ์๋ต
func Success ( c * gin . Context , data interface {}) {
c. JSON (http.StatusOK, model . Response {
Success: true ,
Data: data,
})
}
// ์ฑ๊ณต ์๋ต (ํ์ด์ง๋ค์ด์
)
func SuccessWithMeta ( c * gin . Context , data interface {}, meta * model . Meta ) {
c. JSON (http.StatusOK, model . Response {
Success: true ,
Data: data,
Meta: meta,
})
}
// ์์ฑ ์ฑ๊ณต
func Created ( c * gin . Context , data interface {}) {
c. JSON (http.StatusCreated, model . Response {
Success: true ,
Data: data,
})
}
// ์๋ฌ ์๋ต
func Error ( c * gin . Context , statusCode int , code , message string ) {
c. JSON (statusCode, model . ErrorResponse {
Success: false ,
Error: model . ErrorDetail {
Code: code,
Message: message,
},
})
}
// ์๋ฌ ์๋ต (์์ธ)
func ErrorWithDetails ( c * gin . Context , statusCode int , code , message string , details interface {}) {
c. JSON (statusCode, model . ErrorResponse {
Success: false ,
Error: model . ErrorDetail {
Code: code,
Message: message,
Details: details,
},
})
}
// 400 Bad Request
func BadRequest ( c * gin . Context , message string ) {
Error (c, http.StatusBadRequest, "BAD_REQUEST" , message)
}
// 401 Unauthorized
func Unauthorized ( c * gin . Context , message string ) {
Error (c, http.StatusUnauthorized, "UNAUTHORIZED" , message)
}
// 403 Forbidden
func Forbidden ( c * gin . Context , message string ) {
Error (c, http.StatusForbidden, "FORBIDDEN" , message)
}
// 404 Not Found
func NotFound ( c * gin . Context , message string ) {
Error (c, http.StatusNotFound, "NOT_FOUND" , message)
}
// 409 Conflict
func Conflict ( c * gin . Context , code , message string ) {
Error (c, http.StatusConflict, code, message)
}
// 500 Internal Server Error
func InternalError ( c * gin . Context , message string ) {
Error (c, http.StatusInternalServerError, "INTERNAL_ERROR" , message)
}
// ์ ํจ์ฑ ๊ฒ์ฌ ์๋ฌ
func ValidationError ( c * gin . Context , errors [] model . ValidationError ) {
c. JSON (http.StatusBadRequest, model . ErrorResponse {
Success: false ,
Error: model . ErrorDetail {
Code: "VALIDATION_ERROR" ,
Message: "์ ํจ์ฑ ๊ฒ์ฌ ์คํจ" ,
Details: errors,
},
})
}
์ ํธ: ๋น๋ฐ๋ฒํธ ํด์ฑ
// backend/pkg/utils/hash.go
package utils
import (
" golang.org/x/crypto/bcrypt "
)
const bcryptCost = 10
// ๋น๋ฐ๋ฒํธ ํด์ฑ
func HashPassword ( password string ) ( string , error ) {
bytes, err := bcrypt. GenerateFromPassword ([] byte (password), bcryptCost)
return string (bytes), err
}
// ๋น๋ฐ๋ฒํธ ๊ฒ์ฆ
func CheckPassword ( password , hash string ) bool {
err := bcrypt. CompareHashAndPassword ([] byte (hash), [] byte (password))
return err == nil
}
์ ํธ: JWT
// backend/pkg/utils/jwt.go
package utils
import (
" errors "
" time "
" github.com/golang-jwt/jwt/v5 "
" github.com/bbossiregi/backend/internal/model "
)
var (
ErrInvalidToken = errors. New ( "invalid token" )
ErrExpiredToken = errors. New ( "token has expired" )
)
// JWT ์์ฑ
func GenerateToken ( userID int , email , role , secret string , expiresIn time . Duration ) ( string , error ) {
claims := model . JWTClaims {
UserID: userID,
Email: email,
Role: role,
RegisteredClaims: jwt . RegisteredClaims {
ExpiresAt: jwt. NewNumericDate (time. Now (). Add (expiresIn)),
IssuedAt: jwt. NewNumericDate (time. Now ()),
Issuer: "bbossiregi" ,
},
}
token := jwt. NewWithClaims (jwt.SigningMethodHS256, claims)
return token. SignedString ([] byte (secret))
}
// JWT ๊ฒ์ฆ
func ValidateToken ( tokenString , secret string ) ( * model . JWTClaims , error ) {
token, err := jwt. ParseWithClaims (tokenString, & model . JWTClaims {}, func ( token * jwt . Token ) ( interface {}, error ) {
if _, ok := token.Method.( * jwt . SigningMethodHMAC ); ! ok {
return nil , ErrInvalidToken
}
return [] byte (secret), nil
})
if err != nil {
return nil , err
}
if claims, ok := token.Claims.( * model . JWTClaims ); ok && token.Valid {
return claims, nil
}
return nil , ErrInvalidToken
}
๋ฏธ๋ค์จ์ด: JWT ์ธ์ฆ
// backend/internal/middleware/auth.go
package middleware
import (
" strings "
" github.com/gin-gonic/gin "
" github.com/bbossiregi/backend/internal/config "
" github.com/bbossiregi/backend/pkg/response "
" github.com/bbossiregi/backend/pkg/utils "
)
func AuthMiddleware ( cfg * config . Config ) gin . HandlerFunc {
return func ( c * gin . Context ) {
// Authorization ํค๋ ํ์ธ
authHeader := c. GetHeader ( "Authorization" )
if authHeader == "" {
response. Unauthorized (c, "์ธ์ฆ ํ ํฐ์ด ํ์ํฉ๋๋ค" )
c. Abort ()
return
}
// Bearer ํ ํฐ ์ถ์ถ
parts := strings. Split (authHeader, " " )
if len (parts) != 2 || parts[ 0 ] != "Bearer" {
response. Unauthorized (c, "์๋ชป๋ ์ธ์ฆ ํ์์
๋๋ค" )
c. Abort ()
return
}
tokenString := parts[ 1 ]
// ํ ํฐ ๊ฒ์ฆ
claims, err := utils. ValidateToken (tokenString, cfg.JWT.Secret)
if err != nil {
response. Unauthorized (c, "์ ํจํ์ง ์์ ํ ํฐ์
๋๋ค" )
c. Abort ()
return
}
// Context์ ์ฌ์ฉ์ ์ ๋ณด ์ ์ฅ
c. Set ( "userID" , claims.UserID)
c. Set ( "userEmail" , claims.Email)
c. Set ( "userRole" , claims.Role)
c. Next ()
}
}
// Admin ๊ถํ ํ์ธ
func AdminOnly () gin . HandlerFunc {
return func ( c * gin . Context ) {
role, exists := c. Get ( "userRole" )
if ! exists || role != "admin" {
response. Forbidden (c, "๊ด๋ฆฌ์ ๊ถํ์ด ํ์ํฉ๋๋ค" )
c. Abort ()
return
}
c. Next ()
}
}
๋ฏธ๋ค์จ์ด: ๋ก๊ฑฐ
// backend/internal/middleware/logger.go
package middleware
import (
" log "
" time "
" github.com/gin-gonic/gin "
)
func LoggerMiddleware () gin . HandlerFunc {
return func ( c * gin . Context ) {
start := time. Now ()
path := c.Request.URL.Path
method := c.Request.Method
// ์์ฒญ ์ฒ๋ฆฌ
c. Next ()
// ๋ก๊ทธ ๊ธฐ๋ก
latency := time. Since (start)
statusCode := c.Writer. Status ()
clientIP := c. ClientIP ()
log. Printf ( "[ %s ] %s %s %d %v %s " ,
method,
path,
clientIP,
statusCode,
latency,
c.Errors. String (),
)
}
}
๋ฏธ๋ค์จ์ด: CORS
// backend/internal/middleware/cors.go
package middleware
import (
" github.com/gin-gonic/gin "
)
func CORSMiddleware () gin . HandlerFunc {
return func ( c * gin . Context ) {
c.Writer. Header (). Set ( "Access-Control-Allow-Origin" , "*" )
c.Writer. Header (). Set ( "Access-Control-Allow-Credentials" , "true" )
c.Writer. Header (). Set ( "Access-Control-Allow-Headers" , "Content-Type, Content-Length, Authorization, Accept, X-Requested-With" )
c.Writer. Header (). Set ( "Access-Control-Allow-Methods" , "GET, POST, PUT, PATCH, DELETE, OPTIONS" )
if c.Request.Method == "OPTIONS" {
c. AbortWithStatus ( 204 )
return
}
c. Next ()
}
}
D3-005: ์ธ์ฆ API (ํ์๊ฐ์
/๋ก๊ทธ์ธ)
๐ ํ์คํฌ ์ ๋ณด
ํญ๋ชฉ ๋ด์ฉ ID D3-005 ์ฐ์ ์์ P1 ๋ด๋น ๋ฐฑ์๋ ์์ ์๊ฐ 1.5์๊ฐ
๐ ์ฒดํฌ๋ฆฌ์คํธ
[ ] User Repository ๊ตฌํ
[ ] Auth Service ๊ตฌํ
[ ] Auth Handler ๊ตฌํ
[ ] POST /auth/register
[ ] POST /auth/login
[ ] ์ ํจ์ฑ ๊ฒ์ฌ
[ ] ์๋ฌ ์ฒ๋ฆฌ
[ ] ํ
์คํธ
๐ ์์ธ ๋ช
์ธ
User Repository
// backend/internal/repository/user_repo.go
package repository
import (
" context "
" database/sql "
" errors "
" time "
" github.com/bbossiregi/backend/internal/model "
)
var (
ErrUserNotFound = errors. New ( "user not found" )
ErrDuplicateEmail = errors. New ( "email already exists" )
)
type UserRepository struct {
db * sql . DB
}
func NewUserRepository ( db * sql . DB ) * UserRepository {
return & UserRepository {db: db}
}
// ์ด๋ฉ์ผ๋ก ์ฌ์ฉ์ ์กฐํ
func ( r * UserRepository ) FindByEmail ( ctx context . Context , email string ) ( * model . User , error ) {
query := `
SELECT id, email, password_hash, name, phone, role, status,
created_at, updated_at, last_login_at
FROM users
WHERE email = $1 AND status = 'active'
`
var user model . User
var phone, lastLoginAt sql . NullString
var lastLogin sql . NullTime
err := r.db. QueryRowContext (ctx, query, email). Scan (
& user.ID,
& user.Email,
& user.PasswordHash,
& user.Name,
& phone,
& user.Role,
& user.Status,
& user.CreatedAt,
& user.UpdatedAt,
& lastLogin,
)
if err == sql.ErrNoRows {
return nil , ErrUserNotFound
}
if err != nil {
return nil , err
}
if phone.Valid {
user.Phone = & phone.String
}
if lastLogin.Valid {
user.LastLoginAt = & lastLogin.Time
}
return & user, nil
}
// ์ฌ์ฉ์ ์์ฑ
func ( r * UserRepository ) Create ( ctx context . Context , user * model . User ) error {
query := `
INSERT INTO users (email, password_hash, name, phone, role, status)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, created_at, updated_at
`
err := r.db. QueryRowContext (ctx, query,
user.Email,
user.PasswordHash,
user.Name,
user.Phone,
user.Role,
user.Status,
). Scan ( & user.ID, & user.CreatedAt, & user.UpdatedAt)
if err != nil {
// ์ค๋ณต ์ด๋ฉ์ผ ์ฒดํฌ (PostgreSQL unique violation)
if isPgUniqueViolation (err) {
return ErrDuplicateEmail
}
return err
}
return nil
}
// ๋ง์ง๋ง ๋ก๊ทธ์ธ ์๊ฐ ์
๋ฐ์ดํธ
func ( r * UserRepository ) UpdateLastLogin ( ctx context . Context , userID int ) error {
query := `UPDATE users SET last_login_at = $1 WHERE id = $2`
_, err := r.db. ExecContext (ctx, query, time. Now (), userID)
return err
}
// ์ด๋ฉ์ผ ์กด์ฌ ์ฌ๋ถ ํ์ธ
func ( r * UserRepository ) ExistsByEmail ( ctx context . Context , email string ) ( bool , error ) {
query := `SELECT EXISTS(SELECT 1 FROM users WHERE email = $1)`
var exists bool
err := r.db. QueryRowContext (ctx, query, email). Scan ( & exists)
return exists, err
}
// PostgreSQL unique violation ์ฒดํฌ
func isPgUniqueViolation ( err error ) bool {
return strings. Contains (err. Error (), "duplicate key" ) ||
strings. Contains (err. Error (), "unique constraint" )
}
Auth Service
// backend/internal/service/auth_service.go
package service
import (
" context "
" errors "
" github.com/bbossiregi/backend/internal/config "
" github.com/bbossiregi/backend/internal/model "
" github.com/bbossiregi/backend/internal/repository "
" github.com/bbossiregi/backend/pkg/utils "
)
var (
ErrInvalidCredentials = errors. New ( "invalid email or password" )
ErrEmailAlreadyExists = errors. New ( "email already exists" )
)
type AuthService struct {
userRepo * repository . UserRepository
cfg * config . Config
}
func NewAuthService ( userRepo * repository . UserRepository , cfg * config . Config ) * AuthService {
return & AuthService {
userRepo: userRepo,
cfg: cfg,
}
}
// ํ์๊ฐ์
func ( s * AuthService ) Register ( ctx context . Context , req * model . RegisterRequest ) ( * model . User , error ) {
// ์ด๋ฉ์ผ ์ค๋ณต ํ์ธ
exists, err := s.userRepo. ExistsByEmail (ctx, req.Email)
if err != nil {
return nil , err
}
if exists {
return nil , ErrEmailAlreadyExists
}
// ๋น๋ฐ๋ฒํธ ํด์ฑ
hashedPassword, err := utils. HashPassword (req.Password)
if err != nil {
return nil , err
}
// ์ฌ์ฉ์ ์์ฑ
user := & model . User {
Email: req.Email,
PasswordHash: hashedPassword,
Name: req.Name,
Phone: req.Phone,
Role: "user" ,
Status: "active" ,
}
if err := s.userRepo. Create (ctx, user); err != nil {
if errors. Is (err, repository.ErrDuplicateEmail) {
return nil , ErrEmailAlreadyExists
}
return nil , err
}
return user, nil
}
// ๋ก๊ทธ์ธ
func ( s * AuthService ) Login ( ctx context . Context , req * model . LoginRequest ) ( * model . LoginResponse , error ) {
// ์ฌ์ฉ์ ์กฐํ
user, err := s.userRepo. FindByEmail (ctx, req.Email)
if err != nil {
if errors. Is (err, repository.ErrUserNotFound) {
return nil , ErrInvalidCredentials
}
return nil , err
}
// ๋น๋ฐ๋ฒํธ ๊ฒ์ฆ
if ! utils. CheckPassword (req.Password, user.PasswordHash) {
return nil , ErrInvalidCredentials
}
// JWT ์์ฑ
token, err := utils. GenerateToken (
user.ID,
user.Email,
user.Role,
s.cfg.JWT.Secret,
s.cfg.JWT.ExpiresIn,
)
if err != nil {
return nil , err
}
// ๋ง์ง๋ง ๋ก๊ทธ์ธ ์๊ฐ ์
๋ฐ์ดํธ
_ = s.userRepo. UpdateLastLogin (ctx, user.ID)
return & model . LoginResponse {
AccessToken: token,
TokenType: "Bearer" ,
ExpiresIn: int (s.cfg.JWT.ExpiresIn. Seconds ()),
User: * user,
}, nil
}
Auth Handler
// backend/internal/handler/auth_handler.go
package handler
import (
" errors "
" net/http "
" github.com/gin-gonic/gin "
" github.com/go-playground/validator/v10 "
" github.com/bbossiregi/backend/internal/model "
" github.com/bbossiregi/backend/internal/service "
" github.com/bbossiregi/backend/pkg/response "
)
type AuthHandler struct {
authService * service . AuthService
validate * validator . Validate
}
func NewAuthHandler ( authService * service . AuthService ) * AuthHandler {
return & AuthHandler {
authService: authService,
validate: validator. New (),
}
}
// POST /api/v1/auth/register
func ( h * AuthHandler ) Register ( c * gin . Context ) {
var req model . RegisterRequest
// JSON ํ์ฑ
if err := c. ShouldBindJSON ( & req); err != nil {
response. BadRequest (c, "์๋ชป๋ ์์ฒญ ํ์์
๋๋ค" )
return
}
// ์ ํจ์ฑ ๊ฒ์ฌ
if err := h.validate. Struct ( & req); err != nil {
validationErrors := translateValidationErrors (err)
response. ValidationError (c, validationErrors)
return
}
// ํ์๊ฐ์
์ฒ๋ฆฌ
user, err := h.authService. Register (c.Request. Context (), & req)
if err != nil {
if errors. Is (err, service.ErrEmailAlreadyExists) {
response. Error (c, http.StatusConflict, "DUPLICATE_EMAIL" , "์ด๋ฏธ ์ฌ์ฉ ์ค์ธ ์ด๋ฉ์ผ์
๋๋ค" )
return
}
response. InternalError (c, "ํ์๊ฐ์
์ฒ๋ฆฌ ์ค ์ค๋ฅ๊ฐ ๋ฐ์ํ์ต๋๋ค" )
return
}
response. Created (c, user)
}
// POST /api/v1/auth/login
func ( h * AuthHandler ) Login ( c * gin . Context ) {
var req model . LoginRequest
// JSON ํ์ฑ
if err := c. ShouldBindJSON ( & req); err != nil {
response. BadRequest (c, "์๋ชป๋ ์์ฒญ ํ์์
๋๋ค" )
return
}
// ์ ํจ์ฑ ๊ฒ์ฌ
if err := h.validate. Struct ( & req); err != nil {
validationErrors := translateValidationErrors (err)
response. ValidationError (c, validationErrors)
return
}
// ๋ก๊ทธ์ธ ์ฒ๋ฆฌ
result, err := h.authService. Login (c.Request. Context (), & req)
if err != nil {
if errors. Is (err, service.ErrInvalidCredentials) {
response. Unauthorized (c, "์ด๋ฉ์ผ ๋๋ ๋น๋ฐ๋ฒํธ๊ฐ ์ฌ๋ฐ๋ฅด์ง ์์ต๋๋ค" )
return
}
response. InternalError (c, "๋ก๊ทธ์ธ ์ฒ๋ฆฌ ์ค ์ค๋ฅ๊ฐ ๋ฐ์ํ์ต๋๋ค" )
return
}
response. Success (c, result)
}
// ์ ํจ์ฑ ๊ฒ์ฌ ์๋ฌ ๋ฒ์ญ
func translateValidationErrors ( err error ) [] model . ValidationError {
var errors [] model . ValidationError
for _, e := range err.( validator . ValidationErrors ) {
errors = append (errors, model . ValidationError {
Field: e. Field (),
Message: getValidationMessage (e),
})
}
return errors
}
func getValidationMessage ( e validator . FieldError ) string {
switch e. Tag () {
case "required" :
return "ํ์ ์
๋ ฅ ํญ๋ชฉ์
๋๋ค"
case "email" :
return "์ฌ๋ฐ๋ฅธ ์ด๋ฉ์ผ ํ์์ด ์๋๋๋ค"
case "min" :
return "์ต์ " + e. Param () + "์ ์ด์์ด์ด์ผ ํฉ๋๋ค"
case "max" :
return "์ต๋ " + e. Param () + "์๊น์ง ๊ฐ๋ฅํฉ๋๋ค"
default :
return "์ ํจํ์ง ์์ ๊ฐ์
๋๋ค"
}
}
๐งช ๊ฒ์ฆ
# ์๋ฒ ์คํ
make run
# ํ์๊ฐ์
ํ
์คํธ
curl -X POST http://localhost:8080/api/v1/auth/register \
-H "Content-Type: application/json" \
-d '{
"email": "test@example.com",
"password": "password123",
"name": "ํ
์คํธ์ ์ "
}'
# ์์ ์๋ต (201)
{
"success" : true ,
"data" : {
"id" : 1,
"email" : "test@example.com",
"name" : "ํ
์คํธ์ ์ ",
"role" : "user",
"status" : "active",
"created_at" : "2026-02-26T10:00:00Z"
}
}
# ๋ก๊ทธ์ธ ํ
์คํธ
curl -X POST http://localhost:8080/api/v1/auth/login \
-H "Content-Type: application/json" \
-d '{
"email": "test@example.com",
"password": "password123"
}'
# ์์ ์๋ต (200)
{
"success" : true ,
"data" : {
"access_token" : "eyJhbGciOiJIUzI1NiIs...",
"token_type" : "Bearer",
"expires_in" : 3600,
"user" : { ... }
}
}
# ์ค๋ณต ์ด๋ฉ์ผ ํ
์คํธ (409)
# ์๋ชป๋ ๋น๋ฐ๋ฒํธ ํ
์คํธ (401)
D3-006: ํ์๋ ์กฐํ API
๐ ํ์คํฌ ์ ๋ณด
ํญ๋ชฉ ๋ด์ฉ ID D3-006 ์ฐ์ ์์ P1 ๋ด๋น ๋ฐฑ์๋ ์์ ์๊ฐ 1.5์๊ฐ
๐ ์ฒดํฌ๋ฆฌ์คํธ
[ ] TimeDeal Repository ๊ตฌํ
[ ] FindAll (๋ชฉ๋ก)
[ ] FindByID (์์ธ)
[ ] TimeDeal Handler ๊ตฌํ
[ ] GET /timedeals
[ ] GET /timedeals/:id
[ ] ํ์ด์ง๋ค์ด์
[ ] ํ
์คํธ
๐ ์์ธ ๋ช
์ธ
TimeDeal Repository
// backend/internal/repository/timedeal_repo.go
package repository
import (
" context "
" database/sql "
" errors "
" github.com/bbossiregi/backend/internal/model "
)
var (
ErrTimeDealNotFound = errors. New ( "time deal not found" )
)
type TimeDealRepository struct {
db * sql . DB
}
func NewTimeDealRepository ( db * sql . DB ) * TimeDealRepository {
return & TimeDealRepository {db: db}
}
// ํ์๋ ๋ชฉ๋ก ์กฐํ
func ( r * TimeDealRepository ) FindAll ( ctx context . Context , status string , page , perPage int ) ([] model . TimeDealSummary , int , error ) {
// ์ ์ฒด ๊ฐ์ ์กฐํ
countQuery := `SELECT COUNT(*) FROM time_deals WHERE ($1 = '' OR status = $1)`
var total int
if err := r.db. QueryRowContext (ctx, countQuery, status). Scan ( & total); err != nil {
return nil , 0 , err
}
// ๋ชฉ๋ก ์กฐํ
query := `
SELECT
td.id, td.original_price, td.deal_price,
td.stock_quantity, td.reserved_quantity, td.sold_quantity,
td.start_at, td.end_at, td.status,
p.id, p.name, p.image_url, p.category
FROM time_deals td
JOIN products p ON td.product_id = p.id
WHERE ($1 = '' OR td.status = $1)
ORDER BY
CASE td.status
WHEN 'active' THEN 1
WHEN 'scheduled' THEN 2
ELSE 3
END,
td.start_at ASC
LIMIT $2 OFFSET $3
`
offset := (page - 1 ) * perPage
rows, err := r.db. QueryContext (ctx, query, status, perPage, offset)
if err != nil {
return nil , 0 , err
}
defer rows. Close ()
var deals [] model . TimeDealSummary
for rows. Next () {
var deal model . TimeDealSummary
var product model . ProductSummary
var imageURL sql . NullString
var reservedQty, soldQty int
err := rows. Scan (
& deal.ID, & deal.OriginalPrice, & deal.DealPrice,
& deal.StockQuantity, & reservedQty, & soldQty,
& deal.StartAt, & deal.EndAt, & deal.Status,
& product.ID, & product.Name, & imageURL, & product.Category,
)
if err != nil {
return nil , 0 , err
}
if imageURL.Valid {
product.ImageURL = & imageURL.String
}
deal.Product = product
deal.AvailableQuantity = deal.StockQuantity - reservedQty - soldQty
deal.DiscountRate = int (( 1 - deal.DealPrice / deal.OriginalPrice) * 100 )
deal.RemainingSeconds = calculateRemainingSeconds (deal.Status, deal.StartAt, deal.EndAt)
deals = append (deals, deal)
}
return deals, total, nil
}
// ํ์๋ ์์ธ ์กฐํ
func ( r * TimeDealRepository ) FindByID ( ctx context . Context , id int ) ( * model . TimeDealDetail , error ) {
query := `
SELECT
td.id, td.original_price, td.deal_price,
td.stock_quantity, td.reserved_quantity, td.sold_quantity,
td.start_at, td.end_at, td.status,
p.id, p.name, p.description, p.price, p.image_url, p.category, p.status
FROM time_deals td
JOIN products p ON td.product_id = p.id
WHERE td.id = $1
`
var deal model . TimeDealDetail
var product model . Product
var description, imageURL sql . NullString
err := r.db. QueryRowContext (ctx, query, id). Scan (
& deal.ID, & deal.OriginalPrice, & deal.DealPrice,
& deal.StockQuantity, & deal.ReservedQuantity, & deal.SoldQuantity,
& deal.StartAt, & deal.EndAt, & deal.Status,
& product.ID, & product.Name, & description, & product.Price, & imageURL, & product.Category, & product.Status,
)
if err == sql.ErrNoRows {
return nil , ErrTimeDealNotFound
}
if err != nil {
return nil , err
}
if description.Valid {
product.Description = & description.String
}
if imageURL.Valid {
product.ImageURL = & imageURL.String
}
deal.Product = product
deal.AvailableQuantity = deal.StockQuantity - deal.ReservedQuantity - deal.SoldQuantity
deal.DiscountRate = int (( 1 - deal.DealPrice / deal.OriginalPrice) * 100 )
deal.RemainingSeconds = calculateRemainingSeconds (deal.Status, deal.StartAt, deal.EndAt)
return & deal, nil
}
// ๋จ์ ์๊ฐ ๊ณ์ฐ
func calculateRemainingSeconds ( status string , startAt , endAt time . Time ) int {
now := time. Now ()
switch status {
case "scheduled" :
return int (startAt. Sub (now). Seconds ())
case "active" :
remaining := int (endAt. Sub (now). Seconds ())
if remaining < 0 {
return 0
}
return remaining
default :
return 0
}
}
TimeDeal Handler
// backend/internal/handler/timedeal_handler.go
package handler
import (
" errors "
" strconv "
" github.com/gin-gonic/gin "
" github.com/bbossiregi/backend/internal/model "
" github.com/bbossiregi/backend/internal/repository "
" github.com/bbossiregi/backend/pkg/response "
)
type TimeDealHandler struct {
timeDealRepo * repository . TimeDealRepository
}
func NewTimeDealHandler ( timeDealRepo * repository . TimeDealRepository ) * TimeDealHandler {
return & TimeDealHandler {timeDealRepo: timeDealRepo}
}
// GET /api/v1/timedeals
func ( h * TimeDealHandler ) List ( c * gin . Context ) {
// ์ฟผ๋ฆฌ ํ๋ผ๋ฏธํฐ
status := c. DefaultQuery ( "status" , "" )
page, _ := strconv. Atoi (c. DefaultQuery ( "page" , "1" ))
perPage, _ := strconv. Atoi (c. DefaultQuery ( "per_page" , "20" ))
// ์ ํจ์ฑ ๊ฒ์ฌ
if page < 1 {
page = 1
}
if perPage < 1 || perPage > 100 {
perPage = 20
}
// ์ํ ์ ํจ์ฑ ๊ฒ์ฌ
validStatuses := map [ string ] bool {
"" : true , "scheduled" : true , "active" : true , "ended" : true , "soldout" : true ,
}
if ! validStatuses[status] {
response. BadRequest (c, "์ ํจํ์ง ์์ ์ํ ๊ฐ์
๋๋ค" )
return
}
// ์กฐํ
deals, total, err := h.timeDealRepo. FindAll (c.Request. Context (), status, page, perPage)
if err != nil {
response. InternalError (c, "ํ์๋ ๋ชฉ๋ก ์กฐํ ์ค ์ค๋ฅ๊ฐ ๋ฐ์ํ์ต๋๋ค" )
return
}
// ํ์ด์ง๋ค์ด์
๋ฉํ
totalPages := (total + perPage - 1 ) / perPage
meta := & model . Meta {
Total: total,
Page: page,
PerPage: perPage,
TotalPages: totalPages,
}
response. SuccessWithMeta (c, deals, meta)
}
// GET /api/v1/timedeals/:id
func ( h * TimeDealHandler ) Get ( c * gin . Context ) {
// ๊ฒฝ๋ก ํ๋ผ๋ฏธํฐ
idStr := c. Param ( "id" )
id, err := strconv. Atoi (idStr)
if err != nil {
response. BadRequest (c, "์ ํจํ์ง ์์ ID์
๋๋ค" )
return
}
// ์กฐํ
deal, err := h.timeDealRepo. FindByID (c.Request. Context (), id)
if err != nil {
if errors. Is (err, repository.ErrTimeDealNotFound) {
response. NotFound (c, "ํ์๋์ ์ฐพ์ ์ ์์ต๋๋ค" )
return
}
response. InternalError (c, "ํ์๋ ์กฐํ ์ค ์ค๋ฅ๊ฐ ๋ฐ์ํ์ต๋๋ค" )
return
}
response. Success (c, deal)
}
๐งช ๊ฒ์ฆ
# ํ์๋ ๋ชฉ๋ก ์กฐํ
curl http://localhost:8080/api/v1/timedeals
# ์ํ ํํฐ
curl "http://localhost:8080/api/v1/timedeals?status=active"
# ํ์ด์ง๋ค์ด์
curl "http://localhost:8080/api/v1/timedeals?page=1&per_page=10"
# ํ์๋ ์์ธ ์กฐํ
curl http://localhost:8080/api/v1/timedeals/1
D3-007: ํ์๋ ์ค์ผ์ค๋ฌ
๐ ํ์คํฌ ์ ๋ณด
ํญ๋ชฉ ๋ด์ฉ ID D3-007 ์ฐ์ ์์ P0 ๋ด๋น ๋ฐฑ์๋ ์์ ์๊ฐ 1.5์๊ฐ
๐ ์ฒดํฌ๋ฆฌ์คํธ
[ ] Scheduler Service ๊ตฌํ
[ ] ์ํ ์ ์ด ๋ก์ง
[ ] scheduled โ active
[ ] active โ ended
[ ] active โ soldout
[ ] 1๋ถ ์ฃผ๊ธฐ ์คํ
[ ] ๋ก๊น
[ ] ํ
์คํธ
๐ ์์ธ ๋ช
์ธ
Scheduler Service
// backend/internal/service/scheduler_service.go
package service
import (
" context "
" database/sql "
" log "
" time "
)
type SchedulerService struct {
db * sql . DB
interval time . Duration
stopCh chan struct {}
}
func NewSchedulerService ( db * sql . DB ) * SchedulerService {
return & SchedulerService {
db: db,
interval: 1 * time.Minute,
stopCh: make ( chan struct {}),
}
}
// ์ค์ผ์ค๋ฌ ์์
func ( s * SchedulerService ) Start () {
log. Println ( "โฐ Scheduler started, interval:" , s.interval)
// ์์ ์ ์ฆ์ ํ ๋ฒ ์คํ
s. run ()
ticker := time. NewTicker (s.interval)
defer ticker. Stop ()
for {
select {
case <- ticker.C:
s. run ()
case <- s.stopCh:
log. Println ( "โฐ Scheduler stopped" )
return
}
}
}
// ์ค์ผ์ค๋ฌ ์ค์ง
func ( s * SchedulerService ) Stop () {
close (s.stopCh)
}
// ์ํ ์
๋ฐ์ดํธ ์คํ
func ( s * SchedulerService ) run () {
ctx, cancel := context. WithTimeout (context. Background (), 30 * time.Second)
defer cancel ()
// ํธ๋์ญ์
์์
tx, err := s.db. BeginTx (ctx, nil )
if err != nil {
log. Printf ( "โ Scheduler: failed to begin transaction: %v " , err)
return
}
defer tx. Rollback ()
now := time. Now ()
// 1. scheduled โ active
activated, err := s. activateDeals (ctx, tx, now)
if err != nil {
log. Printf ( "โ Scheduler: failed to activate deals: %v " , err)
return
}
// 2. active โ ended (์๊ฐ ๋ง๋ฃ)
ended, err := s. expireDeals (ctx, tx, now)
if err != nil {
log. Printf ( "โ Scheduler: failed to expire deals: %v " , err)
return
}
// 3. active โ soldout (์ฌ๊ณ ์์ง)
soldout, err := s. markSoldOut (ctx, tx)
if err != nil {
log. Printf ( "โ Scheduler: failed to mark soldout: %v " , err)
return
}
// ์ปค๋ฐ
if err := tx. Commit (); err != nil {
log. Printf ( "โ Scheduler: failed to commit: %v " , err)
return
}
// ๋ณ๊ฒฝ ์ฌํญ ๋ก๊น
if activated > 0 || ended > 0 || soldout > 0 {
log. Printf ( "โฐ Scheduler: activated= %d , ended= %d , soldout= %d " ,
activated, ended, soldout)
}
}
// scheduled โ active
func ( s * SchedulerService ) activateDeals ( ctx context . Context , tx * sql . Tx , now time . Time ) ( int64 , error ) {
query := `
UPDATE time_deals
SET status = 'active', updated_at = $1
WHERE status = 'scheduled'
AND start_at <= $1
AND end_at > $1
`
result, err := tx. ExecContext (ctx, query, now)
if err != nil {
return 0 , err
}
return result. RowsAffected ()
}
// active โ ended
func ( s * SchedulerService ) expireDeals ( ctx context . Context , tx * sql . Tx , now time . Time ) ( int64 , error ) {
query := `
UPDATE time_deals
SET status = 'ended', updated_at = $1
WHERE status = 'active'
AND end_at <= $1
`
result, err := tx. ExecContext (ctx, query, now)
if err != nil {
return 0 , err
}
return result. RowsAffected ()
}
// active โ soldout
func ( s * SchedulerService ) markSoldOut ( ctx context . Context , tx * sql . Tx ) ( int64 , error ) {
query := `
UPDATE time_deals
SET status = 'soldout', updated_at = CURRENT_TIMESTAMP
WHERE status = 'active'
AND stock_quantity <= (reserved_quantity + sold_quantity)
`
result, err := tx. ExecContext (ctx, query)
if err != nil {
return 0 , err
}
return result. RowsAffected ()
}
D3-008: ๋ผ์ฐํฐ ์ค์
๐ ํ์คํฌ ์ ๋ณด
ํญ๋ชฉ ๋ด์ฉ ID D3-008 ์ฐ์ ์์ P0 ๋ด๋น ๋ฐฑ์๋ ์์ ์๊ฐ 30๋ถ
๐ ์์ธ ๋ช
์ธ
// backend/internal/router/router.go
package router
import (
" database/sql "
" github.com/gin-gonic/gin "
" github.com/bbossiregi/backend/internal/config "
" github.com/bbossiregi/backend/internal/handler "
" github.com/bbossiregi/backend/internal/middleware "
" github.com/bbossiregi/backend/internal/repository "
" github.com/bbossiregi/backend/internal/service "
)
func Setup ( db * sql . DB , cfg * config . Config ) * gin . Engine {
// Gin ๋ชจ๋ ์ค์
gin. SetMode (cfg.Server.GinMode)
r := gin. New ()
// ๊ธ๋ก๋ฒ ๋ฏธ๋ค์จ์ด
r. Use (gin. Recovery ())
r. Use (middleware. LoggerMiddleware ())
r. Use (middleware. CORSMiddleware ())
// Repositories
userRepo := repository. NewUserRepository (db)
timeDealRepo := repository. NewTimeDealRepository (db)
// orderRepo := repository.NewOrderRepository(db) // Day 4
// Services
authService := service. NewAuthService (userRepo, cfg)
// stockService := service.NewStockService(db) // Day 4
// orderService := service.NewOrderService(...) // Day 4
// Handlers
authHandler := handler. NewAuthHandler (authService)
timeDealHandler := handler. NewTimeDealHandler (timeDealRepo)
healthHandler := handler. NewHealthHandler (db)
// orderHandler := handler.NewOrderHandler(...) // Day 4
// API v1
v1 := r. Group ( "/api/v1" )
{
// Health
v1. GET ( "/health" , healthHandler.Check)
// Auth (์ธ์ฆ ๋ถํ์)
auth := v1. Group ( "/auth" )
{
auth. POST ( "/register" , authHandler.Register)
auth. POST ( "/login" , authHandler.Login)
}
// TimeDeals (์ธ์ฆ ๋ถํ์ - ์กฐํ)
timedeals := v1. Group ( "/timedeals" )
{
timedeals. GET ( "" , timeDealHandler.List)
timedeals. GET ( "/:id" , timeDealHandler.Get)
}
// Orders (์ธ์ฆ ํ์) - Day 4
// orders := v1.Group("/orders")
// orders.Use(middleware.AuthMiddleware(cfg))
// {
// orders.POST("", orderHandler.Create)
// orders.GET("", orderHandler.List)
// orders.GET("/:id", orderHandler.Get)
// orders.DELETE("/:id", orderHandler.Cancel)
// }
// Admin (๊ด๋ฆฌ์ ์ ์ฉ) - ์ ํ
// admin := v1.Group("/admin")
// admin.Use(middleware.AuthMiddleware(cfg))
// admin.Use(middleware.AdminOnly())
// {
// admin.POST("/products", ...)
// admin.POST("/timedeals", ...)
// }
}
return r
}
Health Handler
// backend/internal/handler/health_handler.go
package handler
import (
" database/sql "
" time "
" github.com/gin-gonic/gin "
" github.com/bbossiregi/backend/pkg/response "
)
type HealthHandler struct {
db * sql . DB
}
func NewHealthHandler ( db * sql . DB ) * HealthHandler {
return & HealthHandler {db: db}
}
// GET /api/v1/health
func ( h * HealthHandler ) Check ( c * gin . Context ) {
// DB ์ฒดํฌ
dbStatus := "ok"
if err := h.db. Ping (); err != nil {
dbStatus = "error"
}
response. Success (c, gin . H {
"status" : "healthy" ,
"timestamp" : time. Now (). Format (time.RFC3339),
"version" : "1.0.0" ,
"checks" : gin . H {
"database" : dbStatus,
},
})
}
D3-009: Day 3 ๊ฒ์ฆ ๋ฐ ๋ฆฌ๋ทฐ
๐ Day 3 ์๋ฃ ๊ธฐ์ค ์ฒดํฌ๋ฆฌ์คํธ
[ ] Go ํ๋ก์ ํธ ๊ตฌ์กฐ ์์ฑ
[ ] DB ์ฐ๊ฒฐ ์ฑ๊ณต
[ ] ํ์๊ฐ์
API ๋์
[ ] ๋ก๊ทธ์ธ API ๋์ + JWT ๋ฐ๊ธ
[ ] ํ์๋ ๋ชฉ๋ก ์กฐํ ๋์
[ ] ํ์๋ ์์ธ ์กฐํ ๋์
[ ] ์ค์ผ์ค๋ฌ ๋์ (๋ก๊ทธ ํ์ธ)
[ ] ํฌ์ค์ฒดํฌ API ๋์
[ ] Postman Collection
๐
Day 4 (2/27 ๋ชฉ) - ์ฃผ๋ฌธ ๋ก์ง + ์ปจํ
์ด๋ํ
ํ์๋ผ์ธ
์๊ฐ ํ์คํฌ ๋ด๋น ์ฐ์ถ๋ฌผ 09:00-09:30 ๋ฐ์ผ๋ฆฌ ์คํ ๋์
์ ์ฒด ํ์๋ก 09:30-12:00 ์ฃผ๋ฌธ API (์ฌ๊ฐ ํจํด) ๋ฐฑ์๋ order_handler.go 09:30-11:00 ECR ๋ ํฌ์งํ ๋ฆฌ ์์ฑ ์ธํ๋ผ Terraform ์ฝ๋ 11:00-12:00 Dockerfile ์์ฑ ์ธํ๋ผ Dockerfile 13:00-15:00 ์ฌ๊ณ ๊ด๋ฆฌ ์๋น์ค (๋์์ฑ) ๋ฐฑ์๋ stock_service.go 13:00-15:00 EKS ํด๋ฌ์คํฐ ์์ฑ ์ธํ๋ผ Terraform ์ฝ๋ 15:00-17:00 ์ฃผ๋ฌธ ์ทจ์ (๋ณด์ ํธ๋์ญ์
) ๋ฐฑ์๋ ๋ณด์ ๋ก์ง 15:00-17:00 K8s ๋งค๋ํ์คํธ ์์ฑ ์ธํ๋ผ k8s/*.yaml 17:00-18:00 Day 4 ๋ฆฌ๋ทฐ + ํตํฉ ํ
์คํธ ์ ์ฒด ํ
์คํธ ๊ฒฐ๊ณผ
D4-001: ์ฃผ๋ฌธ Repository ๊ตฌํ
๐ ํ์คํฌ ์ ๋ณด
ํญ๋ชฉ ๋ด์ฉ ID D4-001 ์ฐ์ ์์ P0 (Blocker) ๋ด๋น ๋ฐฑ์๋ ์์ ์๊ฐ 1์๊ฐ ์ ํ ์์
D3-006 (ํ์๋ API) ํํ ์์
D4-002 (์ฌ๊ณ ์๋น์ค)
๐ ์ฒดํฌ๋ฆฌ์คํธ
[ ] Order Repository ๊ตฌํ
[ ] Create (์ฃผ๋ฌธ ์์ฑ)
[ ] FindByID (์ฃผ๋ฌธ ์กฐํ)
[ ] FindByUserID (์ฌ์ฉ์๋ณ ์ฃผ๋ฌธ ๋ชฉ๋ก)
[ ] UpdateStatus (์ํ ๋ณ๊ฒฝ)
[ ] OrderEvent Repository ๊ตฌํ
[ ] Create (์ด๋ฒคํธ ๊ธฐ๋ก)
[ ] FindByOrderID (์ด๋ฒคํธ ์กฐํ)
[ ] ํธ๋์ญ์
์ฒ๋ฆฌ ์ค๋น
๐ ์์ธ ๋ช
์ธ
Order Repository
// backend/internal/repository/order_repo.go
package repository
import (
" context "
" database/sql "
" encoding/json "
" errors "
" time "
" github.com/bbossiregi/backend/internal/model "
)
var (
ErrOrderNotFound = errors. New ( "order not found" )
)
type OrderRepository struct {
db * sql . DB
}
func NewOrderRepository ( db * sql . DB ) * OrderRepository {
return & OrderRepository {db: db}
}
// ์ฃผ๋ฌธ ์์ฑ (ํธ๋์ญ์
๋ด์์ ํธ์ถ)
func ( r * OrderRepository ) CreateTx ( ctx context . Context , tx * sql . Tx , order * model . Order ) error {
query := `
INSERT INTO orders (user_id, time_deal_id, quantity, unit_price, total_price, status)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, created_at, updated_at
`
err := tx. QueryRowContext (ctx, query,
order.UserID,
order.TimeDealID,
order.Quantity,
order.UnitPrice,
order.TotalPrice,
order.Status,
). Scan ( & order.ID, & order.CreatedAt, & order.UpdatedAt)
return err
}
// ์ฃผ๋ฌธ ์ํ ๋ณ๊ฒฝ (ํธ๋์ญ์
๋ด์์ ํธ์ถ)
func ( r * OrderRepository ) UpdateStatusTx ( ctx context . Context , tx * sql . Tx , orderID int , status string ) error {
var query string
var args [] interface {}
now := time. Now ()
switch status {
case model.OrderStatusReserved:
query = `UPDATE orders SET status = $1, reserved_at = $2, updated_at = $2 WHERE id = $3`
args = [] interface {}{status, now, orderID}
case model.OrderStatusConfirmed:
query = `UPDATE orders SET status = $1, confirmed_at = $2, updated_at = $2 WHERE id = $3`
args = [] interface {}{status, now, orderID}
case model.OrderStatusCanceled:
query = `UPDATE orders SET status = $1, canceled_at = $2, updated_at = $2 WHERE id = $3`
args = [] interface {}{status, now, orderID}
default :
query = `UPDATE orders SET status = $1, updated_at = $2 WHERE id = $3`
args = [] interface {}{status, now, orderID}
}
result, err := tx. ExecContext (ctx, query, args ... )
if err != nil {
return err
}
rowsAffected, _ := result. RowsAffected ()
if rowsAffected == 0 {
return ErrOrderNotFound
}
return nil
}
// ID๋ก ์ฃผ๋ฌธ ์กฐํ
func ( r * OrderRepository ) FindByID ( ctx context . Context , id int ) ( * model . Order , error ) {
query := `
SELECT id, user_id, time_deal_id, quantity, unit_price, total_price,
status, created_at, updated_at, reserved_at, confirmed_at, canceled_at
FROM orders
WHERE id = $1
`
var order model . Order
var reservedAt, confirmedAt, canceledAt sql . NullTime
err := r.db. QueryRowContext (ctx, query, id). Scan (
& order.ID,
& order.UserID,
& order.TimeDealID,
& order.Quantity,
& order.UnitPrice,
& order.TotalPrice,
& order.Status,
& order.CreatedAt,
& order.UpdatedAt,
& reservedAt,
& confirmedAt,
& canceledAt,
)
if err == sql.ErrNoRows {
return nil , ErrOrderNotFound
}
if err != nil {
return nil , err
}
if reservedAt.Valid {
order.ReservedAt = & reservedAt.Time
}
if confirmedAt.Valid {
order.ConfirmedAt = & confirmedAt.Time
}
if canceledAt.Valid {
order.CanceledAt = & canceledAt.Time
}
return & order, nil
}
// ID๋ก ์ฃผ๋ฌธ ์กฐํ (ํธ๋์ญ์
๋ด, FOR UPDATE)
func ( r * OrderRepository ) FindByIDForUpdate ( ctx context . Context , tx * sql . Tx , id int ) ( * model . Order , error ) {
query := `
SELECT id, user_id, time_deal_id, quantity, unit_price, total_price,
status, created_at, updated_at, reserved_at, confirmed_at, canceled_at
FROM orders
WHERE id = $1
FOR UPDATE
`
var order model . Order
var reservedAt, confirmedAt, canceledAt sql . NullTime
err := tx. QueryRowContext (ctx, query, id). Scan (
& order.ID,
& order.UserID,
& order.TimeDealID,
& order.Quantity,
& order.UnitPrice,
& order.TotalPrice,
& order.Status,
& order.CreatedAt,
& order.UpdatedAt,
& reservedAt,
& confirmedAt,
& canceledAt,
)
if err == sql.ErrNoRows {
return nil , ErrOrderNotFound
}
if err != nil {
return nil , err
}
if reservedAt.Valid {
order.ReservedAt = & reservedAt.Time
}
if confirmedAt.Valid {
order.ConfirmedAt = & confirmedAt.Time
}
if canceledAt.Valid {
order.CanceledAt = & canceledAt.Time
}
return & order, nil
}
// ์ฌ์ฉ์๋ณ ์ฃผ๋ฌธ ๋ชฉ๋ก ์กฐํ
func ( r * OrderRepository ) FindByUserID ( ctx context . Context , userID , page , perPage int ) ([] model . Order , int , error ) {
// ์ ์ฒด ๊ฐ์ ์กฐํ
countQuery := `SELECT COUNT(*) FROM orders WHERE user_id = $1`
var total int
if err := r.db. QueryRowContext (ctx, countQuery, userID). Scan ( & total); err != nil {
return nil , 0 , err
}
// ๋ชฉ๋ก ์กฐํ
query := `
SELECT id, user_id, time_deal_id, quantity, unit_price, total_price,
status, created_at, updated_at, reserved_at, confirmed_at, canceled_at
FROM orders
WHERE user_id = $1
ORDER BY created_at DESC
LIMIT $2 OFFSET $3
`
offset := (page - 1 ) * perPage
rows, err := r.db. QueryContext (ctx, query, userID, perPage, offset)
if err != nil {
return nil , 0 , err
}
defer rows. Close ()
var orders [] model . Order
for rows. Next () {
var order model . Order
var reservedAt, confirmedAt, canceledAt sql . NullTime
err := rows. Scan (
& order.ID,
& order.UserID,
& order.TimeDealID,
& order.Quantity,
& order.UnitPrice,
& order.TotalPrice,
& order.Status,
& order.CreatedAt,
& order.UpdatedAt,
& reservedAt,
& confirmedAt,
& canceledAt,
)
if err != nil {
return nil , 0 , err
}
if reservedAt.Valid {
order.ReservedAt = & reservedAt.Time
}
if confirmedAt.Valid {
order.ConfirmedAt = & confirmedAt.Time
}
if canceledAt.Valid {
order.CanceledAt = & canceledAt.Time
}
orders = append (orders, order)
}
return orders, total, nil
}
OrderEvent Repository
// backend/internal/repository/order_event_repo.go
package repository
import (
" context "
" database/sql "
" encoding/json "
" github.com/bbossiregi/backend/internal/model "
)
type OrderEventRepository struct {
db * sql . DB
}
func NewOrderEventRepository ( db * sql . DB ) * OrderEventRepository {
return & OrderEventRepository {db: db}
}
// ์ด๋ฒคํธ ์์ฑ (ํธ๋์ญ์
๋ด์์ ํธ์ถ)
func ( r * OrderEventRepository ) CreateTx ( ctx context . Context , tx * sql . Tx , event * model . OrderEvent ) error {
query := `
INSERT INTO order_events (order_id, event_type, payload, actor_id, actor_type)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, created_at
`
return tx. QueryRowContext (ctx, query,
event.OrderID,
event.EventType,
event.Payload,
event.ActorID,
event.ActorType,
). Scan ( & event.ID, & event.CreatedAt)
}
// ์ฃผ๋ฌธ๋ณ ์ด๋ฒคํธ ์กฐํ
func ( r * OrderEventRepository ) FindByOrderID ( ctx context . Context , orderID int ) ([] model . OrderEvent , error ) {
query := `
SELECT id, order_id, event_type, payload, actor_id, actor_type, created_at
FROM order_events
WHERE order_id = $1
ORDER BY created_at ASC
`
rows, err := r.db. QueryContext (ctx, query, orderID)
if err != nil {
return nil , err
}
defer rows. Close ()
var events [] model . OrderEvent
for rows. Next () {
var event model . OrderEvent
var actorID sql . NullInt64
err := rows. Scan (
& event.ID,
& event.OrderID,
& event.EventType,
& event.Payload,
& actorID,
& event.ActorType,
& event.CreatedAt,
)
if err != nil {
return nil , err
}
if actorID.Valid {
id := int (actorID.Int64)
event.ActorID = & id
}
events = append (events, event)
}
return events, nil
}
// ์ด๋ฒคํธ Payload ์์ฑ ํฌํผ
func CreateEventPayload ( data map [ string ] interface {}) string {
if data == nil {
return "{}"
}
bytes, _ := json. Marshal (data)
return string (bytes)
}
D4-002: ์ฌ๊ณ ๊ด๋ฆฌ ์๋น์ค (๋์์ฑ ์ ์ด ํต์ฌ!)
๐ ํ์คํฌ ์ ๋ณด
ํญ๋ชฉ ๋ด์ฉ ID D4-002 ์ฐ์ ์์ P0 (Blocker) ๋ด๋น ๋ฐฑ์๋ ์์ ์๊ฐ 2์๊ฐ ์ ํ ์์
D4-001 ํํ ์์
D4-003 (์ฃผ๋ฌธ ์๋น์ค)
๐ ์ฒดํฌ๋ฆฌ์คํธ
[ ] Stock Service ๊ตฌํ
[ ] ReserveStock (์ฌ๊ณ ์์ฝ - ๋น๊ด์ ๋ฝ)
[ ] ReleaseStock (์ฌ๊ณ ํด์ - ๋ณด์)
[ ] ConfirmStock (์ฌ๊ณ ํ์ )
[ ] ๋์์ฑ ์ ์ด (SELECT FOR UPDATE)
[ ] ์ฌ๊ณ ๋ถ์กฑ ์์ธ ์ฒ๋ฆฌ
[ ] ๋ฐ๋๋ฝ ๋ฐฉ์ง
๐ ์์ธ ๋ช
์ธ
Stock Service (ํต์ฌ!)
// backend/internal/service/stock_service.go
package service
import (
" context "
" database/sql "
" errors "
" fmt "
" time "
)
var (
ErrInsufficientStock = errors. New ( "insufficient stock" )
ErrDealNotActive = errors. New ( "deal is not active" )
ErrDealNotFound = errors. New ( "deal not found" )
)
type StockService struct {
db * sql . DB
}
func NewStockService ( db * sql . DB ) * StockService {
return & StockService {db: db}
}
// TimeDeal ์ ๋ณด (์ฌ๊ณ ๊ด๋ฆฌ์ฉ)
type TimeDealStock struct {
ID int
ProductID int
DealPrice float64
StockQuantity int
ReservedQuantity int
SoldQuantity int
Status string
StartAt time . Time
EndAt time . Time
}
// ๊ฐ์ฉ ์ฌ๊ณ ๊ณ์ฐ
func ( t * TimeDealStock ) AvailableQuantity () int {
return t.StockQuantity - t.ReservedQuantity - t.SoldQuantity
}
// ์ฌ๊ณ ์์ฝ (์ฌ๊ฐ ํจํด Step 2)
// ํธ๋์ญ์
๋ด์์ ํธ์ถ๋์ด์ผ ํจ
func ( s * StockService ) ReserveStock ( ctx context . Context , tx * sql . Tx , timeDealID , quantity int ) ( * TimeDealStock , error ) {
// 1. SELECT FOR UPDATE๋ก ํ์๋ ๋ฝ ํ๋
deal, err := s. getTimeDealForUpdate (ctx, tx, timeDealID)
if err != nil {
return nil , err
}
// 2. ํ์๋ ์ํ ํ์ธ
if deal.Status != "active" {
return nil , ErrDealNotActive
}
// 3. ์ฌ๊ณ ํ์ธ
available := deal. AvailableQuantity ()
if available < quantity {
return nil , fmt. Errorf ( " %w : available= %d , requested= %d " ,
ErrInsufficientStock, available, quantity)
}
// 4. ์ฌ๊ณ ์์ฝ (reserved_quantity ์ฆ๊ฐ)
err = s. updateReservedQuantity (ctx, tx, timeDealID, quantity)
if err != nil {
return nil , err
}
// 5. ์
๋ฐ์ดํธ๋ ์ ๋ณด ๋ฐํ
deal.ReservedQuantity += quantity
return deal, nil
}
// ์ฌ๊ณ ํด์ (๋ณด์ ํธ๋์ญ์
)
// ์ฃผ๋ฌธ ์ทจ์ ์ ํธ์ถ
func ( s * StockService ) ReleaseStock ( ctx context . Context , tx * sql . Tx , timeDealID , quantity int ) error {
// 1. SELECT FOR UPDATE๋ก ํ์๋ ๋ฝ ํ๋
deal, err := s. getTimeDealForUpdate (ctx, tx, timeDealID)
if err != nil {
return err
}
// 2. ์์ฝ๋ ์๋ ํ์ธ (์์ ๋ฐฉ์ง)
if deal.ReservedQuantity < quantity {
return fmt. Errorf ( "cannot release more than reserved: reserved= %d , release= %d " ,
deal.ReservedQuantity, quantity)
}
// 3. ์ฌ๊ณ ํด์ (reserved_quantity ๊ฐ์)
query := `
UPDATE time_deals
SET reserved_quantity = reserved_quantity - $1,
updated_at = CURRENT_TIMESTAMP
WHERE id = $2
`
_, err = tx. ExecContext (ctx, query, quantity, timeDealID)
return err
}
// ์ฌ๊ณ ํ์ (๊ฒฐ์ ์๋ฃ ํ)
// reserved โ sold ์ด๋
func ( s * StockService ) ConfirmStock ( ctx context . Context , tx * sql . Tx , timeDealID , quantity int ) error {
// 1. SELECT FOR UPDATE๋ก ํ์๋ ๋ฝ ํ๋
deal, err := s. getTimeDealForUpdate (ctx, tx, timeDealID)
if err != nil {
return err
}
// 2. ์์ฝ๋ ์๋ ํ์ธ
if deal.ReservedQuantity < quantity {
return fmt. Errorf ( "cannot confirm more than reserved: reserved= %d , confirm= %d " ,
deal.ReservedQuantity, quantity)
}
// 3. reserved โ sold ์ด๋
query := `
UPDATE time_deals
SET reserved_quantity = reserved_quantity - $1,
sold_quantity = sold_quantity + $1,
updated_at = CURRENT_TIMESTAMP
WHERE id = $2
`
_, err = tx. ExecContext (ctx, query, quantity, timeDealID)
return err
}
// SELECT FOR UPDATE - ๋น๊ด์ ๋ฝ
func ( s * StockService ) getTimeDealForUpdate ( ctx context . Context , tx * sql . Tx , timeDealID int ) ( * TimeDealStock , error ) {
query := `
SELECT id, product_id, deal_price, stock_quantity, reserved_quantity,
sold_quantity, status, start_at, end_at
FROM time_deals
WHERE id = $1
FOR UPDATE
`
var deal TimeDealStock
err := tx. QueryRowContext (ctx, query, timeDealID). Scan (
& deal.ID,
& deal.ProductID,
& deal.DealPrice,
& deal.StockQuantity,
& deal.ReservedQuantity,
& deal.SoldQuantity,
& deal.Status,
& deal.StartAt,
& deal.EndAt,
)
if err == sql.ErrNoRows {
return nil , ErrDealNotFound
}
if err != nil {
return nil , err
}
return & deal, nil
}
// reserved_quantity ์ฆ๊ฐ
func ( s * StockService ) updateReservedQuantity ( ctx context . Context , tx * sql . Tx , timeDealID , quantity int ) error {
query := `
UPDATE time_deals
SET reserved_quantity = reserved_quantity + $1,
updated_at = CURRENT_TIMESTAMP
WHERE id = $2
`
_, err := tx. ExecContext (ctx, query, quantity, timeDealID)
return err
}
// ํ์๋ ์กฐํ (ํธ๋์ญ์
์์ด)
func ( s * StockService ) GetTimeDeal ( ctx context . Context , timeDealID int ) ( * TimeDealStock , error ) {
query := `
SELECT id, product_id, deal_price, stock_quantity, reserved_quantity,
sold_quantity, status, start_at, end_at
FROM time_deals
WHERE id = $1
`
var deal TimeDealStock
err := s.db. QueryRowContext (ctx, query, timeDealID). Scan (
& deal.ID,
& deal.ProductID,
& deal.DealPrice,
& deal.StockQuantity,
& deal.ReservedQuantity,
& deal.SoldQuantity,
& deal.Status,
& deal.StartAt,
& deal.EndAt,
)
if err == sql.ErrNoRows {
return nil , ErrDealNotFound
}
if err != nil {
return nil , err
}
return & deal, nil
}
D4-003: ์ฃผ๋ฌธ ์๋น์ค (์ฌ๊ฐ ํจํด ๊ตฌํ)
๐ ํ์คํฌ ์ ๋ณด
ํญ๋ชฉ ๋ด์ฉ ID D4-003 ์ฐ์ ์์ P0 (Blocker) ๋ด๋น ๋ฐฑ์๋ ์์ ์๊ฐ 2์๊ฐ ์ ํ ์์
D4-002 ํํ ์์
D4-004 (์ฃผ๋ฌธ ํธ๋ค๋ฌ)
๐ ์ฒดํฌ๋ฆฌ์คํธ
[ ] Order Service ๊ตฌํ
[ ] CreateOrder (์ฌ๊ฐ ํจํด ์คํ)
[ ] CancelOrder (๋ณด์ ํธ๋์ญ์
)
[ ] GetOrder
[ ] ListOrders
[ ] ํธ๋์ญ์
๊ด๋ฆฌ
[ ] ์ด๋ฒคํธ ๊ธฐ๋ก
[ ] ์๋ฌ ์ฒ๋ฆฌ ๋ฐ ๋กค๋ฐฑ
๐ ์์ธ ๋ช
์ธ
Order Service
// backend/internal/service/order_service.go
package service
import (
" context "
" database/sql "
" errors "
" fmt "
" log "
" github.com/bbossiregi/backend/internal/model "
" github.com/bbossiregi/backend/internal/repository "
)
var (
ErrOrderAlreadyCanceled = errors. New ( "order already canceled" )
ErrOrderAlreadyConfirmed = errors. New ( "order already confirmed" )
ErrCannotCancelOrder = errors. New ( "cannot cancel order in current status" )
ErrUnauthorized = errors. New ( "unauthorized to access this order" )
)
type OrderService struct {
db * sql . DB
orderRepo * repository . OrderRepository
orderEventRepo * repository . OrderEventRepository
stockService * StockService
}
func NewOrderService (
db * sql . DB ,
orderRepo * repository . OrderRepository ,
orderEventRepo * repository . OrderEventRepository ,
stockService * StockService ,
) * OrderService {
return & OrderService {
db: db,
orderRepo: orderRepo,
orderEventRepo: orderEventRepo,
stockService: stockService,
}
}
// ์ฃผ๋ฌธ ์์ฑ - ์ฌ๊ฐ ํจํด ๊ตฌํ
// Step 1: ์ฃผ๋ฌธ ์์ฑ (pending)
// Step 2: ์ฌ๊ณ ์์ฝ (reserved_quantity += quantity)
// Step 3: ์ฃผ๋ฌธ ์ํ ๋ณ๊ฒฝ (reserved)
func ( s * OrderService ) CreateOrder ( ctx context . Context , userID int , req * model . CreateOrderRequest ) ( * model . Order , error ) {
// ํธ๋์ญ์
์์
tx, err := s.db. BeginTx (ctx, & sql . TxOptions {
Isolation: sql.LevelSerializable, // ์ง๋ ฌํ ๊ฒฉ๋ฆฌ ์์ค
})
if err != nil {
return nil , fmt. Errorf ( "failed to begin transaction: %w " , err)
}
defer tx. Rollback () // ์๋ฌ ์ ์๋ ๋กค๋ฐฑ
// ===== Step 1: ํ์๋ ์ ๋ณด ์กฐํ ๋ฐ ๊ฒ์ฆ =====
deal, err := s.stockService. getTimeDealForUpdate (ctx, tx, req.TimeDealID)
if err != nil {
if errors. Is (err, ErrDealNotFound) {
return nil , fmt. Errorf ( "time deal not found: %d " , req.TimeDealID)
}
return nil , err
}
// ํ์๋ ์ํ ํ์ธ
if deal.Status != "active" {
return nil , ErrDealNotActive
}
// ์ฌ๊ณ ์ฌ์ ํ์ธ
available := deal. AvailableQuantity ()
if available < req.Quantity {
return nil , fmt. Errorf ( " %w : available= %d , requested= %d " ,
ErrInsufficientStock, available, req.Quantity)
}
// ===== Step 2: ์ฃผ๋ฌธ ์์ฑ (pending) =====
order := & model . Order {
UserID: userID,
TimeDealID: req.TimeDealID,
Quantity: req.Quantity,
UnitPrice: deal.DealPrice,
TotalPrice: deal.DealPrice * float64 (req.Quantity),
Status: model.OrderStatusPending,
}
if err := s.orderRepo. CreateTx (ctx, tx, order); err != nil {
return nil , fmt. Errorf ( "failed to create order: %w " , err)
}
// ์ด๋ฒคํธ ๊ธฐ๋ก: CREATED
err = s. recordEvent (ctx, tx, order.ID, model.EventOrderCreated, userID, map [ string ] interface {}{
"time_deal_id" : req.TimeDealID,
"quantity" : req.Quantity,
"unit_price" : deal.DealPrice,
"total_price" : order.TotalPrice,
})
if err != nil {
return nil , fmt. Errorf ( "failed to record event: %w " , err)
}
// ===== Step 3: ์ฌ๊ณ ์์ฝ =====
_, err = s.stockService. ReserveStock (ctx, tx, req.TimeDealID, req.Quantity)
if err != nil {
// ์ฌ๊ณ ๋ถ์กฑ ๋๋ ํ์๋ ๋นํ์ฑ - ์ฃผ๋ฌธ ์คํจ ์ฒ๋ฆฌ
order.Status = model.OrderStatusFailed
s.orderRepo. UpdateStatusTx (ctx, tx, order.ID, model.OrderStatusFailed)
// ์ด๋ฒคํธ ๊ธฐ๋ก: FAILED
s. recordEvent (ctx, tx, order.ID, "FAILED" , userID, map [ string ] interface {}{
"reason" : err. Error (),
})
// ํธ๋์ญ์
์ปค๋ฐ (์คํจ ์ํ๋ก)
tx. Commit ()
return nil , err
}
// ์ด๋ฒคํธ ๊ธฐ๋ก: STOCK_RESERVED
err = s. recordEvent (ctx, tx, order.ID, model.EventStockReserved, userID, map [ string ] interface {}{
"quantity" : req.Quantity,
})
if err != nil {
return nil , fmt. Errorf ( "failed to record event: %w " , err)
}
// ===== Step 4: ์ฃผ๋ฌธ ์ํ ๋ณ๊ฒฝ (reserved) =====
if err := s.orderRepo. UpdateStatusTx (ctx, tx, order.ID, model.OrderStatusReserved); err != nil {
return nil , fmt. Errorf ( "failed to update order status: %w " , err)
}
order.Status = model.OrderStatusReserved
// ํธ๋์ญ์
์ปค๋ฐ
if err := tx. Commit (); err != nil {
return nil , fmt. Errorf ( "failed to commit transaction: %w " , err)
}
log. Printf ( "โ
Order created: id= %d , user= %d , deal= %d , qty= %d , total= %.2f " ,
order.ID, userID, req.TimeDealID, req.Quantity, order.TotalPrice)
return order, nil
}
// ์ฃผ๋ฌธ ์ทจ์ - ๋ณด์ ํธ๋์ญ์
func ( s * OrderService ) CancelOrder ( ctx context . Context , userID , orderID int ) ( * model . Order , error ) {
// ํธ๋์ญ์
์์
tx, err := s.db. BeginTx (ctx, nil )
if err != nil {
return nil , fmt. Errorf ( "failed to begin transaction: %w " , err)
}
defer tx. Rollback ()
// ์ฃผ๋ฌธ ์กฐํ (FOR UPDATE)
order, err := s.orderRepo. FindByIDForUpdate (ctx, tx, orderID)
if err != nil {
return nil , err
}
// ๊ถํ ํ์ธ
if order.UserID != userID {
return nil , ErrUnauthorized
}
// ์ํ ํ์ธ
switch order.Status {
case model.OrderStatusCanceled:
return nil , ErrOrderAlreadyCanceled
case model.OrderStatusConfirmed:
return nil , ErrOrderAlreadyConfirmed
case model.OrderStatusPending, model.OrderStatusReserved:
// ์ทจ์ ๊ฐ๋ฅ
default :
return nil , ErrCannotCancelOrder
}
// ===== ๋ณด์ ํธ๋์ญ์
: ์ฌ๊ณ ํด์ =====
if order.Status == model.OrderStatusReserved {
err = s.stockService. ReleaseStock (ctx, tx, order.TimeDealID, order.Quantity)
if err != nil {
return nil , fmt. Errorf ( "failed to release stock: %w " , err)
}
// ์ด๋ฒคํธ ๊ธฐ๋ก: STOCK_RELEASED
err = s. recordEvent (ctx, tx, order.ID, model.EventStockReleased, userID, map [ string ] interface {}{
"quantity" : order.Quantity,
})
if err != nil {
return nil , fmt. Errorf ( "failed to record event: %w " , err)
}
}
// ์ฃผ๋ฌธ ์ํ ๋ณ๊ฒฝ (canceled)
if err := s.orderRepo. UpdateStatusTx (ctx, tx, order.ID, model.OrderStatusCanceled); err != nil {
return nil , fmt. Errorf ( "failed to update order status: %w " , err)
}
order.Status = model.OrderStatusCanceled
// ์ด๋ฒคํธ ๊ธฐ๋ก: CANCELED
err = s. recordEvent (ctx, tx, order.ID, model.EventOrderCanceled, userID, map [ string ] interface {}{
"reason" : "user_requested" ,
})
if err != nil {
return nil , fmt. Errorf ( "failed to record event: %w " , err)
}
// ์ปค๋ฐ
if err := tx. Commit (); err != nil {
return nil , fmt. Errorf ( "failed to commit transaction: %w " , err)
}
log. Printf ( "โ
Order canceled: id= %d , user= %d " , order.ID, userID)
return order, nil
}
// ์ฃผ๋ฌธ ์กฐํ
func ( s * OrderService ) GetOrder ( ctx context . Context , userID , orderID int ) ( * model . Order , error ) {
order, err := s.orderRepo. FindByID (ctx, orderID)
if err != nil {
return nil , err
}
// ๊ถํ ํ์ธ
if order.UserID != userID {
return nil , ErrUnauthorized
}
return order, nil
}
// ์ฃผ๋ฌธ ๋ชฉ๋ก ์กฐํ
func ( s * OrderService ) ListOrders ( ctx context . Context , userID , page , perPage int ) ([] model . Order , int , error ) {
return s.orderRepo. FindByUserID (ctx, userID, page, perPage)
}
// ์ด๋ฒคํธ ๊ธฐ๋ก ํฌํผ
func ( s * OrderService ) recordEvent ( ctx context . Context , tx * sql . Tx , orderID int , eventType string , actorID int , data map [ string ] interface {}) error {
event := & model . OrderEvent {
OrderID: orderID,
EventType: eventType,
Payload: repository. CreateEventPayload (data),
ActorID: & actorID,
ActorType: "user" ,
}
return s.orderEventRepo. CreateTx (ctx, tx, event)
}
D4-004: ์ฃผ๋ฌธ API ํธ๋ค๋ฌ
๐ ํ์คํฌ ์ ๋ณด
ํญ๋ชฉ ๋ด์ฉ ID D4-004 ์ฐ์ ์์ P0 ๋ด๋น ๋ฐฑ์๋ ์์ ์๊ฐ 1์๊ฐ ์ ํ ์์
D4-003
๐ ์ฒดํฌ๋ฆฌ์คํธ
[ ] Order Handler ๊ตฌํ
[ ] POST /orders (์ฃผ๋ฌธ ์์ฑ)
[ ] GET /orders (๋ชฉ๋ก ์กฐํ)
[ ] GET /orders/:id (์์ธ ์กฐํ)
[ ] DELETE /orders/:id (์ฃผ๋ฌธ ์ทจ์)
[ ] ์๋ฌ ์๋ต ์ฒ๋ฆฌ
[ ] ํ
์คํธ
๐ ์์ธ ๋ช
์ธ
Order Handler
// backend/internal/handler/order_handler.go
package handler
import (
" errors "
" net/http "
" strconv "
" github.com/gin-gonic/gin "
" github.com/go-playground/validator/v10 "
" github.com/bbossiregi/backend/internal/model "
" github.com/bbossiregi/backend/internal/repository "
" github.com/bbossiregi/backend/internal/service "
" github.com/bbossiregi/backend/pkg/response "
)
type OrderHandler struct {
orderService * service . OrderService
validate * validator . Validate
}
func NewOrderHandler ( orderService * service . OrderService ) * OrderHandler {
return & OrderHandler {
orderService: orderService,
validate: validator. New (),
}
}
// POST /api/v1/orders
func ( h * OrderHandler ) Create ( c * gin . Context ) {
// ์ฌ์ฉ์ ID ์ถ์ถ (JWT ๋ฏธ๋ค์จ์ด์์ ์ค์ )
userID, exists := c. Get ( "userID" )
if ! exists {
response. Unauthorized (c, "์ธ์ฆ์ด ํ์ํฉ๋๋ค" )
return
}
var req model . CreateOrderRequest
// JSON ํ์ฑ
if err := c. ShouldBindJSON ( & req); err != nil {
response. BadRequest (c, "์๋ชป๋ ์์ฒญ ํ์์
๋๋ค" )
return
}
// ๊ธฐ๋ณธ๊ฐ ์ค์
if req.Quantity == 0 {
req.Quantity = 1
}
// ์ ํจ์ฑ ๊ฒ์ฌ
if err := h.validate. Struct ( & req); err != nil {
validationErrors := translateValidationErrors (err)
response. ValidationError (c, validationErrors)
return
}
// ์๋ ๋ฒ์ ๊ฒ์ฌ
if req.Quantity < 1 || req.Quantity > 10 {
response. Error (c, http.StatusBadRequest, "INVALID_QUANTITY" , "์๋์ 1~10 ์ฌ์ด์ฌ์ผ ํฉ๋๋ค" )
return
}
// ์ฃผ๋ฌธ ์์ฑ
order, err := h.orderService. CreateOrder (c.Request. Context (), userID.( int ), & req)
if err != nil {
// ์๋ฌ ํ์
๋ณ ์๋ต
switch {
case errors. Is (err, service.ErrDealNotFound):
response. NotFound (c, "ํ์๋์ ์ฐพ์ ์ ์์ต๋๋ค" )
case errors. Is (err, service.ErrDealNotActive):
response. Error (c, http.StatusBadRequest, "DEAL_NOT_ACTIVE" , "ํ์๋์ด ์งํ ์ค์ด ์๋๋๋ค" )
case errors. Is (err, service.ErrInsufficientStock):
// ์ฌ๊ณ ๋ถ์กฑ - ์์ธ ์ ๋ณด ํฌํจ
response. ErrorWithDetails (c, http.StatusConflict, "INSUFFICIENT_STOCK" , "์ฌ๊ณ ๊ฐ ๋ถ์กฑํฉ๋๋ค" , map [ string ] interface {}{
"message" : err. Error (),
})
default :
response. InternalError (c, "์ฃผ๋ฌธ ์์ฑ ์ค ์ค๋ฅ๊ฐ ๋ฐ์ํ์ต๋๋ค" )
}
return
}
response. Created (c, order)
}
// GET /api/v1/orders
func ( h * OrderHandler ) List ( c * gin . Context ) {
userID, exists := c. Get ( "userID" )
if ! exists {
response. Unauthorized (c, "์ธ์ฆ์ด ํ์ํฉ๋๋ค" )
return
}
// ์ฟผ๋ฆฌ ํ๋ผ๋ฏธํฐ
page, _ := strconv. Atoi (c. DefaultQuery ( "page" , "1" ))
perPage, _ := strconv. Atoi (c. DefaultQuery ( "per_page" , "20" ))
if page < 1 {
page = 1
}
if perPage < 1 || perPage > 100 {
perPage = 20
}
// ์กฐํ
orders, total, err := h.orderService. ListOrders (c.Request. Context (), userID.( int ), page, perPage)
if err != nil {
response. InternalError (c, "์ฃผ๋ฌธ ๋ชฉ๋ก ์กฐํ ์ค ์ค๋ฅ๊ฐ ๋ฐ์ํ์ต๋๋ค" )
return
}
// ํ์ด์ง๋ค์ด์
๋ฉํ
totalPages := (total + perPage - 1 ) / perPage
meta := & model . Meta {
Total: total,
Page: page,
PerPage: perPage,
TotalPages: totalPages,
}
response. SuccessWithMeta (c, orders, meta)
}
// GET /api/v1/orders/:id
func ( h * OrderHandler ) Get ( c * gin . Context ) {
userID, exists := c. Get ( "userID" )
if ! exists {
response. Unauthorized (c, "์ธ์ฆ์ด ํ์ํฉ๋๋ค" )
return
}
// ๊ฒฝ๋ก ํ๋ผ๋ฏธํฐ
idStr := c. Param ( "id" )
orderID, err := strconv. Atoi (idStr)
if err != nil {
response. BadRequest (c, "์ ํจํ์ง ์์ ์ฃผ๋ฌธ ID์
๋๋ค" )
return
}
// ์กฐํ
order, err := h.orderService. GetOrder (c.Request. Context (), userID.( int ), orderID)
if err != nil {
switch {
case errors. Is (err, repository.ErrOrderNotFound):
response. NotFound (c, "์ฃผ๋ฌธ์ ์ฐพ์ ์ ์์ต๋๋ค" )
case errors. Is (err, service.ErrUnauthorized):
response. Forbidden (c, "์ด ์ฃผ๋ฌธ์ ์ ๊ทผํ ๊ถํ์ด ์์ต๋๋ค" )
default :
response. InternalError (c, "์ฃผ๋ฌธ ์กฐํ ์ค ์ค๋ฅ๊ฐ ๋ฐ์ํ์ต๋๋ค" )
}
return
}
response. Success (c, order)
}
// DELETE /api/v1/orders/:id
func ( h * OrderHandler ) Cancel ( c * gin . Context ) {
userID, exists := c. Get ( "userID" )
if ! exists {
response. Unauthorized (c, "์ธ์ฆ์ด ํ์ํฉ๋๋ค" )
return
}
// ๊ฒฝ๋ก ํ๋ผ๋ฏธํฐ
idStr := c. Param ( "id" )
orderID, err := strconv. Atoi (idStr)
if err != nil {
response. BadRequest (c, "์ ํจํ์ง ์์ ์ฃผ๋ฌธ ID์
๋๋ค" )
return
}
// ์ทจ์
order, err := h.orderService. CancelOrder (c.Request. Context (), userID.( int ), orderID)
if err != nil {
switch {
case errors. Is (err, repository.ErrOrderNotFound):
response. NotFound (c, "์ฃผ๋ฌธ์ ์ฐพ์ ์ ์์ต๋๋ค" )
case errors. Is (err, service.ErrUnauthorized):
response. Forbidden (c, "์ด ์ฃผ๋ฌธ์ ์ ๊ทผํ ๊ถํ์ด ์์ต๋๋ค" )
case errors. Is (err, service.ErrOrderAlreadyCanceled):
response. Error (c, http.StatusBadRequest, "ALREADY_CANCELED" , "์ด๋ฏธ ์ทจ์๋ ์ฃผ๋ฌธ์
๋๋ค" )
case errors. Is (err, service.ErrOrderAlreadyConfirmed):
response. Error (c, http.StatusBadRequest, "CANNOT_CANCEL" , "์ด๋ฏธ ํ์ ๋ ์ฃผ๋ฌธ์ ์ทจ์ํ ์ ์์ต๋๋ค" )
case errors. Is (err, service.ErrCannotCancelOrder):
response. Error (c, http.StatusBadRequest, "CANNOT_CANCEL" , "ํ์ฌ ์ํ์์๋ ์ฃผ๋ฌธ์ ์ทจ์ํ ์ ์์ต๋๋ค" )
default :
response. InternalError (c, "์ฃผ๋ฌธ ์ทจ์ ์ค ์ค๋ฅ๊ฐ ๋ฐ์ํ์ต๋๋ค" )
}
return
}
response. Success (c, order)
}
D4-005: ๋ผ์ฐํฐ ์
๋ฐ์ดํธ
๐ ์์ธ ๋ช
์ธ
// backend/internal/router/router.go (์
๋ฐ์ดํธ)
package router
import (
" database/sql "
" github.com/gin-gonic/gin "
" github.com/bbossiregi/backend/internal/config "
" github.com/bbossiregi/backend/internal/handler "
" github.com/bbossiregi/backend/internal/middleware "
" github.com/bbossiregi/backend/internal/repository "
" github.com/bbossiregi/backend/internal/service "
)
func Setup ( db * sql . DB , cfg * config . Config ) * gin . Engine {
gin. SetMode (cfg.Server.GinMode)
r := gin. New ()
// ๊ธ๋ก๋ฒ ๋ฏธ๋ค์จ์ด
r. Use (gin. Recovery ())
r. Use (middleware. LoggerMiddleware ())
r. Use (middleware. CORSMiddleware ())
// Repositories
userRepo := repository. NewUserRepository (db)
timeDealRepo := repository. NewTimeDealRepository (db)
orderRepo := repository. NewOrderRepository (db)
orderEventRepo := repository. NewOrderEventRepository (db)
// Services
authService := service. NewAuthService (userRepo, cfg)
stockService := service. NewStockService (db)
orderService := service. NewOrderService (db, orderRepo, orderEventRepo, stockService)
// Handlers
authHandler := handler. NewAuthHandler (authService)
timeDealHandler := handler. NewTimeDealHandler (timeDealRepo)
orderHandler := handler. NewOrderHandler (orderService)
healthHandler := handler. NewHealthHandler (db)
// API v1
v1 := r. Group ( "/api/v1" )
{
// Health
v1. GET ( "/health" , healthHandler.Check)
// Auth (์ธ์ฆ ๋ถํ์)
auth := v1. Group ( "/auth" )
{
auth. POST ( "/register" , authHandler.Register)
auth. POST ( "/login" , authHandler.Login)
}
// TimeDeals (์ธ์ฆ ๋ถํ์)
timedeals := v1. Group ( "/timedeals" )
{
timedeals. GET ( "" , timeDealHandler.List)
timedeals. GET ( "/:id" , timeDealHandler.Get)
}
// Orders (์ธ์ฆ ํ์)
orders := v1. Group ( "/orders" )
orders. Use (middleware. AuthMiddleware (cfg))
{
orders. POST ( "" , orderHandler.Create)
orders. GET ( "" , orderHandler.List)
orders. GET ( "/:id" , orderHandler.Get)
orders. DELETE ( "/:id" , orderHandler.Cancel)
}
}
return r
}
D4-006: ECR ๋ ํฌ์งํ ๋ฆฌ ์์ฑ
๐ ํ์คํฌ ์ ๋ณด
ํญ๋ชฉ ๋ด์ฉ ID D4-006 ์ฐ์ ์์ P0 ๋ด๋น ์ธํ๋ผ ์์ ์๊ฐ 30๋ถ ์ ํ ์์
D2-005 (๋ณด์ ๊ทธ๋ฃน) ํํ ์์
D4-007 (Dockerfile)
๐ ์ฒดํฌ๋ฆฌ์คํธ
[ ] ECR ๋ ํฌ์งํ ๋ฆฌ ์์ฑ
[ ] ์ด๋ฏธ์ง ์ค์บ ํ์ฑํ
[ ] ๋ผ์ดํ์ฌ์ดํด ์ ์ฑ
์ค์
[ ] ํธ์ ํ
์คํธ
# terraform/modules/ecr/main.tf
resource "aws_ecr_repository" "backend" {
name = " ${ var . project_name } -backend"
image_tag_mutability = "MUTABLE"
image_scanning_configuration {
scan_on_push = true
}
encryption_configuration {
encryption_type = "AES256"
}
tags = {
Name = " ${ var . project_name } -backend"
Project = var.project_name
Environment = var.environment
}
}
# ๋ผ์ดํ์ฌ์ดํด ์ ์ฑ
(์ด๋ฏธ์ง ์ ๋ฆฌ)
resource "aws_ecr_lifecycle_policy" "backend" {
repository = aws_ecr_repository . backend . name
policy = jsonencode ({
rules = [
{
rulePriority = 1
description = "Keep last 10 images"
selection = {
tagStatus = "any"
countType = "imageCountMoreThan"
countNumber = 10
}
action = {
type = "expire"
}
}
]
})
}
# Frontend ๋ ํฌ์งํ ๋ฆฌ (์ ํ)
resource "aws_ecr_repository" "frontend" {
name = " ${ var . project_name } -frontend"
image_tag_mutability = "MUTABLE"
image_scanning_configuration {
scan_on_push = true
}
tags = {
Name = " ${ var . project_name } -frontend"
Project = var.project_name
Environment = var.environment
}
}
output "backend_repository_url" {
value = aws_ecr_repository . backend . repository_url
}
output "frontend_repository_url" {
value = aws_ecr_repository . frontend . repository_url
}
๐งช ๊ฒ์ฆ
# ECR ๋ก๊ทธ์ธ
aws ecr get-login-password --region ap-northeast-2 | \
docker login --username AWS --password-stdin \
< account-i d > .dkr.ecr.ap-northeast-2.amazonaws.com
# ๋ ํฌ์งํ ๋ฆฌ ํ์ธ
aws ecr describe-repositories
D4-007: Dockerfile ์์ฑ
๐ ํ์คํฌ ์ ๋ณด
ํญ๋ชฉ ๋ด์ฉ ID D4-007 ์ฐ์ ์์ P0 ๋ด๋น ์ธํ๋ผ/๋ฐฑ์๋ ์์ ์๊ฐ 30๋ถ
๐ ์ฒดํฌ๋ฆฌ์คํธ
[ ] ๋ฉํฐ ์คํ
์ด์ง ๋น๋
[ ] ์ต์ ์ด๋ฏธ์ง (scratch/alpine)
[ ] ๋น๋ ํ
์คํธ
[ ] ๋ก์ปฌ ์คํ ํ
์คํธ
๐ Dockerfile
# backend/Dockerfile
# ===== Build Stage =====
FROM golang:1.22-alpine AS builder
# ๋น๋ ์์กด์ฑ ์ค์น
RUN apk add --no-cache git ca-certificates tzdata
# ์์
๋๋ ํ ๋ฆฌ ์ค์
WORKDIR /app
# ์์กด์ฑ ํ์ผ ๋ณต์ฌ ๋ฐ ๋ค์ด๋ก๋ (์บ์ฑ ํ์ฉ)
COPY go.mod go.sum ./
RUN go mod download
# ์์ค ์ฝ๋ ๋ณต์ฌ
COPY . .
# ๋น๋
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
go build -ldflags= "-w -s" -o /app/server ./cmd/api/main.go
# ===== Runtime Stage =====
FROM alpine:3.19
# ํ์์กด ๋ฐ ์ธ์ฆ์
RUN apk --no-cache add ca-certificates tzdata
# ๋น๋ฃจํธ ์ฌ์ฉ์ ์์ฑ
RUN adduser -D -g '' appuser
WORKDIR /app
# ๋น๋๋ ๋ฐ์ด๋๋ฆฌ ๋ณต์ฌ
COPY --from=builder /app/server .
# ๋น๋ฃจํธ ์ฌ์ฉ์๋ก ์คํ
USER appuser
# ํฌํธ ๋
ธ์ถ
EXPOSE 8080
# ํฌ์ค์ฒดํฌ
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:8080/api/v1/health || exit 1
# ์คํ
ENTRYPOINT [ "./server" ]
๐งช ๊ฒ์ฆ
# ๋น๋
cd backend
docker build -t bbossiregi-backend:latest .
# ์ด๋ฏธ์ง ํฌ๊ธฐ ํ์ธ (๋ชฉํ: < 30MB)
docker images bbossiregi-backend
# ๋ก์ปฌ ์คํ ํ
์คํธ
docker run -p 8080:8080 \
-e DB_HOST=host.docker.internal \
-e DB_PORT= 5432 \
-e DB_USER=bbossiregi \
-e DB_PASSWORD=xxx \
-e DB_NAME=bbossiregi \
-e JWT_SECRET=xxx \
bbossiregi-backend:latest
# ํฌ์ค์ฒดํฌ
curl http://localhost:8080/api/v1/health
D4-008: EKS ํด๋ฌ์คํฐ ์์ฑ
๐ ํ์คํฌ ์ ๋ณด
ํญ๋ชฉ ๋ด์ฉ ID D4-008 ์ฐ์ ์์ P0 (Blocker) ๋ด๋น ์ธํ๋ผ ์์ ์๊ฐ 2์๊ฐ (ํด๋ฌ์คํฐ ์์ฑ ~15๋ถ) ์ ํ ์์
D2-004 (๋ผ์ฐํ
ํ
์ด๋ธ) ํํ ์์
D4-009 (K8s ๋งค๋ํ์คํธ)
๐ ์ฒดํฌ๋ฆฌ์คํธ
[ ] EKS ํด๋ฌ์คํฐ ์์ฑ
[ ] Node Group ์์ฑ
[ ] IAM ์ญํ ๋ฐ ์ ์ฑ
[ ] kubectl ์ฐ๊ฒฐ ์ค์
[ ] ๊ธฐ๋ณธ ๋ค์์คํ์ด์ค ์์ฑ
# terraform/modules/eks/main.tf
# EKS ํด๋ฌ์คํฐ IAM ์ญํ
resource "aws_iam_role" "eks_cluster" {
name = " ${ var . project_name } -eks-cluster-role"
assume_role_policy = jsonencode ({
Version = "2012-10-17"
Statement = [
{
Action = "sts:AssumeRole"
Effect = "Allow"
Principal = {
Service = "eks.amazonaws.com"
}
}
]
})
tags = {
Name = " ${ var . project_name } -eks-cluster-role"
Project = var.project_name
Environment = var.environment
}
}
resource "aws_iam_role_policy_attachment" "eks_cluster_policy" {
policy_arn = "arn:aws:iam::aws:policy/AmazonEKSClusterPolicy"
role = aws_iam_role . eks_cluster . name
}
# EKS ํด๋ฌ์คํฐ
resource "aws_eks_cluster" "main" {
name = " ${ var . project_name } -eks"
role_arn = aws_iam_role . eks_cluster . arn
version = "1.29"
vpc_config {
subnet_ids = var . private_subnet_ids
endpoint_private_access = true
endpoint_public_access = true
security_group_ids = [var . cluster_security_group_id]
}
enabled_cluster_log_types = [ "api" , "audit" , "authenticator" ]
tags = {
Name = " ${ var . project_name } -eks"
Project = var.project_name
Environment = var.environment
}
depends_on = [
aws_iam_role_policy_attachment . eks_cluster_policy
]
}
# Node Group IAM ์ญํ
resource "aws_iam_role" "eks_nodes" {
name = " ${ var . project_name } -eks-nodes-role"
assume_role_policy = jsonencode ({
Version = "2012-10-17"
Statement = [
{
Action = "sts:AssumeRole"
Effect = "Allow"
Principal = {
Service = "ec2.amazonaws.com"
}
}
]
})
tags = {
Name = " ${ var . project_name } -eks-nodes-role"
Project = var.project_name
Environment = var.environment
}
}
resource "aws_iam_role_policy_attachment" "eks_worker_node_policy" {
policy_arn = "arn:aws:iam::aws:policy/AmazonEKSWorkerNodePolicy"
role = aws_iam_role . eks_nodes . name
}
resource "aws_iam_role_policy_attachment" "eks_cni_policy" {
policy_arn = "arn:aws:iam::aws:policy/AmazonEKS_CNI_Policy"
role = aws_iam_role . eks_nodes . name
}
resource "aws_iam_role_policy_attachment" "eks_container_registry" {
policy_arn = "arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryReadOnly"
role = aws_iam_role . eks_nodes . name
}
# Node Group
resource "aws_eks_node_group" "main" {
cluster_name = aws_eks_cluster . main . name
node_group_name = " ${ var . project_name } -node-group"
node_role_arn = aws_iam_role . eks_nodes . arn
subnet_ids = var . private_subnet_ids
capacity_type = "ON_DEMAND" # ๋๋ SPOT (๋น์ฉ ์ ๊ฐ)
instance_types = [ "t3.medium" ]
scaling_config {
desired_size = 2
max_size = 4
min_size = 1
}
update_config {
max_unavailable = 1
}
labels = {
role = "worker"
}
tags = {
Name = " ${ var . project_name } -node-group"
Project = var.project_name
Environment = var.environment
}
depends_on = [
aws_iam_role_policy_attachment . eks_worker_node_policy,
aws_iam_role_policy_attachment . eks_cni_policy,
aws_iam_role_policy_attachment . eks_container_registry,
]
}
# Outputs
output "cluster_endpoint" {
value = aws_eks_cluster . main . endpoint
}
output "cluster_name" {
value = aws_eks_cluster . main . name
}
output "cluster_certificate_authority" {
value = aws_eks_cluster . main . certificate_authority[ 0 ] . data
}
# terraform/modules/eks/variables.tf
variable "project_name" {
type = string
}
variable "environment" {
type = string
}
variable "private_subnet_ids" {
type = list ( string )
}
variable "cluster_security_group_id" {
type = string
}
๐งช ๊ฒ์ฆ
# kubeconfig ์
๋ฐ์ดํธ
aws eks update-kubeconfig --name bbossiregi-eks --region ap-northeast-2
# ํด๋ฌ์คํฐ ํ์ธ
kubectl cluster-info
# ๋
ธ๋ ํ์ธ
kubectl get nodes
# ์์ ์ถ๋ ฅ
NAME STATUS ROLES AGE VERSION
ip-10-0-11-xxx.ap-northeast-2.compute.internal Ready < non e > 5m v1.29.x
ip-10-0-12-xxx.ap-northeast-2.compute.internal Ready < non e > 5m v1.29.x
D4-009: Kubernetes ๋งค๋ํ์คํธ ์์ฑ
๐ ํ์คํฌ ์ ๋ณด
ํญ๋ชฉ ๋ด์ฉ ID D4-009 ์ฐ์ ์์ P0 ๋ด๋น ์ธํ๋ผ ์์ ์๊ฐ 1.5์๊ฐ ์ ํ ์์
D4-008
๐ ์ฒดํฌ๋ฆฌ์คํธ
[ ] Namespace
[ ] ConfigMap
[ ] Secret
[ ] Deployment
[ ] Service
[ ] HPA (์ ํ)
[ ] Ingress (Day 5)
๐ ์์ธ ๋ช
์ธ
Namespace
# k8s/base/namespace.yaml
apiVersion : v1
kind : Namespace
metadata :
name : bbossiregi
labels :
app : bbossiregi
env : production
ConfigMap
# k8s/base/configmap.yaml
apiVersion : v1
kind : ConfigMap
metadata :
name : backend-config
namespace : bbossiregi
data :
GIN_MODE : "release"
DB_SSLMODE : "require"
APP_ENV : "production"
Secret
# k8s/base/secret.yaml
# ์ฃผ์: ์ค์ ๊ฐ์ base64 ์ธ์ฝ๋ฉ ํ์
# kubectl create secret์ผ๋ก ์์ฑ ๊ถ์ฅ
apiVersion : v1
kind : Secret
metadata :
name : backend-secret
namespace : bbossiregi
type : Opaque
data :
DB_HOST : <base64-encoded>
DB_PORT : NTQzMg== # 5432
DB_USER : <base64-encoded>
DB_PASSWORD : <base64-encoded>
DB_NAME : <base64-encoded>
JWT_SECRET : <base64-encoded>
Secret ์์ฑ ์คํฌ๋ฆฝํธ
# scripts/create-secrets.sh
#!/bin/bash
# ํ๊ฒฝ๋ณ์ ์ค์
DB_HOST = "your-rds-endpoint.ap-northeast-2.rds.amazonaws.com"
DB_PORT = "5432"
DB_USER = "bbossiregi"
DB_PASSWORD = "your-password"
DB_NAME = "bbossiregi"
JWT_SECRET = "your-jwt-secret-min-32-characters"
# Secret ์์ฑ
kubectl create secret generic backend-secret \
--namespace=bbossiregi \
--from-literal=DB_HOST= $DB_HOST \
--from-literal=DB_PORT= $DB_PORT \
--from-literal=DB_USER= $DB_USER \
--from-literal=DB_PASSWORD= $DB_PASSWORD \
--from-literal=DB_NAME= $DB_NAME \
--from-literal=JWT_SECRET= $JWT_SECRET \
--dry-run=client -o yaml | kubectl apply -f -
Deployment
# k8s/base/deployment.yaml
apiVersion : apps/v1
kind : Deployment
metadata :
name : backend
namespace : bbossiregi
labels :
app : backend
spec :
replicas : 2
selector :
matchLabels :
app : backend
strategy :
type : RollingUpdate
rollingUpdate :
maxSurge : 1
maxUnavailable : 0
template :
metadata :
labels :
app : backend
spec :
containers :
- name : backend
image : <account-id>.dkr.ecr.ap-northeast-2.amazonaws.com/bbossiregi-backend:latest
imagePullPolicy : Always
ports :
- containerPort : 8080
protocol : TCP
envFrom :
- configMapRef :
name : backend-config
- secretRef :
name : backend-secret
resources :
requests :
cpu : "100m"
memory : "128Mi"
limits :
cpu : "500m"
memory : "512Mi"
livenessProbe :
httpGet :
path : /api/v1/health
port : 8080
initialDelaySeconds : 15
periodSeconds : 20
timeoutSeconds : 5
failureThreshold : 3
readinessProbe :
httpGet :
path : /api/v1/health
port : 8080
initialDelaySeconds : 5
periodSeconds : 10
timeoutSeconds : 3
failureThreshold : 3
terminationGracePeriodSeconds : 30
Service
# k8s/base/service.yaml
apiVersion : v1
kind : Service
metadata :
name : backend-service
namespace : bbossiregi
labels :
app : backend
spec :
type : ClusterIP
selector :
app : backend
ports :
- name : http
port : 80
targetPort : 8080
protocol : TCP
HPA (Horizontal Pod Autoscaler)
# k8s/base/hpa.yaml
apiVersion : autoscaling/v2
kind : HorizontalPodAutoscaler
metadata :
name : backend-hpa
namespace : bbossiregi
spec :
scaleTargetRef :
apiVersion : apps/v1
kind : Deployment
name : backend
minReplicas : 2
maxReplicas : 10
metrics :
- type : Resource
resource :
name : cpu
target :
type : Utilization
averageUtilization : 70
- type : Resource
resource :
name : memory
target :
type : Utilization
averageUtilization : 80
behavior :
scaleDown :
stabilizationWindowSeconds : 300
policies :
- type : Pods
value : 1
periodSeconds : 60
scaleUp :
stabilizationWindowSeconds : 0
policies :
- type : Pods
value : 2
periodSeconds : 60
Kustomization
# k8s/base/kustomization.yaml
apiVersion : kustomize.config.k8s.io/v1beta1
kind : Kustomization
namespace : bbossiregi
resources :
- namespace.yaml
- configmap.yaml
- deployment.yaml
- service.yaml
- hpa.yaml
commonLabels :
app.kubernetes.io/name : bbossiregi
app.kubernetes.io/component : backend
๐งช ๊ฒ์ฆ
# ๋ค์์คํ์ด์ค ์์ฑ
kubectl apply -f k8s/base/namespace.yaml
# Secret ์์ฑ
./scripts/create-secrets.sh
# ์ ์ฒด ๋ฐฐํฌ (kustomize)
kubectl apply -k k8s/base/
# ๋๋ ๊ฐ๋ณ ์ ์ฉ
kubectl apply -f k8s/base/configmap.yaml
kubectl apply -f k8s/base/deployment.yaml
kubectl apply -f k8s/base/service.yaml
# ์ํ ํ์ธ
kubectl get all -n bbossiregi
# Pod ๋ก๊ทธ ํ์ธ
kubectl logs -f deployment/backend -n bbossiregi
# ์์ ์ถ๋ ฅ
NAME READY STATUS RESTARTS AGE
pod/backend-xxx-xxx 1/1 Running 0 2m
pod/backend-xxx-yyy 1/1 Running 0 2m
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT ( S ) AGE
service/backend-service ClusterIP 172.20.xxx.xxx < non e > 80/TCP 2m
NAME READY UP-TO-DATE AVAILABLE AGE
deployment.apps/backend 2/2 2 2 2m
D4-010: Day 4 ๊ฒ์ฆ - ์ฃผ๋ฌธ ํ๋ก์ฐ ํ
์คํธ
๐ Day 4 ์๋ฃ ๊ธฐ์ค ์ฒดํฌ๋ฆฌ์คํธ
[ ] ์ฃผ๋ฌธ API 4๊ฐ ์์ฑ
[ ] POST /orders (์ฃผ๋ฌธ ์์ฑ)
[ ] GET /orders (๋ชฉ๋ก ์กฐํ)
[ ] GET /orders/:id (์์ธ ์กฐํ)
[ ] DELETE /orders/:id (์ทจ์)
[ ] ์ฌ๊ฐ ํจํด ๋์ ํ์ธ
[ ] ์ฃผ๋ฌธ ์์ฑ ์ ์ฌ๊ณ ์์ฝ
[ ] ์ฃผ๋ฌธ ์ทจ์ ์ ์ฌ๊ณ ํด์
[ ] ๋์์ฑ ํ
์คํธ (์ ํ)
[ ] ECR ์ด๋ฏธ์ง ํธ์ ์๋ฃ
[ ] EKS ํด๋ฌ์คํฐ ์์ฑ ์๋ฃ
[ ] K8s ๋งค๋ํ์คํธ ์์ฑ ์๋ฃ
[ ] Pod ์ ์ ์คํ ํ์ธ
๐งช ์ฃผ๋ฌธ ํ๋ก์ฐ ํ
์คํธ
# 1. ๋ก๊ทธ์ธํ์ฌ ํ ํฐ ํ๋
TOKEN = $( curl -s -X POST http://localhost:8080/api/v1/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"user1@test.com","password":"password123"}' | jq -r '.data.access_token' )
echo "Token: $TOKEN "
# 2. ํ์ฑ ํ์๋ ํ์ธ
curl -s http://localhost:8080/api/v1/timedeals?status=active | jq
# 3. ์ฃผ๋ฌธ ์์ฑ (์ฌ๊ณ ์์ฝ)
curl -s -X POST http://localhost:8080/api/v1/orders \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN " \
-d '{
"time_deal_id": 1,
"quantity": 2
}' | jq
# ์์ ์๋ต
{
"success" : true ,
"data" : {
"id" : 1,
"user_id" : 2,
"time_deal_id" : 1,
"quantity" : 2,
"unit_price" : 29900,
"total_price" : 59800,
"status" : "reserved",
"created_at" : "2026-02-27T10:00:00Z",
"reserved_at" : "2026-02-27T10:00:00Z"
}
}
# 4. ํ์๋ ์ฌ๊ณ ํ์ธ (reserved_quantity ์ฆ๊ฐ)
curl -s http://localhost:8080/api/v1/timedeals/1 | jq '.data | {stock_quantity, reserved_quantity, sold_quantity, available_quantity}'
# 5. ์ฃผ๋ฌธ ๋ชฉ๋ก ์กฐํ
curl -s http://localhost:8080/api/v1/orders \
-H "Authorization: Bearer $TOKEN " | jq
# 6. ์ฃผ๋ฌธ ์์ธ ์กฐํ
curl -s http://localhost:8080/api/v1/orders/1 \
-H "Authorization: Bearer $TOKEN " | jq
# 7. ์ฃผ๋ฌธ ์ทจ์ (์ฌ๊ณ ํด์ )
curl -s -X DELETE http://localhost:8080/api/v1/orders/1 \
-H "Authorization: Bearer $TOKEN " | jq
# ์์ ์๋ต
{
"success" : true ,
"data" : {
"id" : 1,
"status" : "canceled",
"canceled_at" : "2026-02-27T10:05:00Z"
}
}
# 8. ํ์๋ ์ฌ๊ณ ํ์ธ (reserved_quantity ๊ฐ์)
curl -s http://localhost:8080/api/v1/timedeals/1 | jq '.data | {stock_quantity, reserved_quantity, sold_quantity, available_quantity}'
๐งช ๋์์ฑ ํ
์คํธ (์ ํ)
# ๋์์ 10๊ฐ ์ฃผ๋ฌธ ์์ฒญ (์ฌ๊ณ 100๊ฐ ๊ธฐ์ค)
# Apache Benchmark ๋๋ hey ์ฌ์ฉ
# hey ์ค์น (Go)
go install github.com/rakyll/hey@latest
# ๋์์ฑ ํ
์คํธ (10๊ฐ ๋์, ์ด 50๊ฐ ์์ฒญ)
hey -n 50 -c 10 \
-m POST \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN " \
-d '{"time_deal_id":1,"quantity":1}' \
http://localhost:8080/api/v1/orders
# ๊ฒฐ๊ณผ ํ์ธ
# - ์ฑ๊ณต ์ฃผ๋ฌธ ์ ํ์ธ
# - ์ฌ๊ณ ์ ํฉ์ฑ ํ์ธ (reserved_quantity + sold_quantity = ์ฑ๊ณต ์ฃผ๋ฌธ ์)
๐งช ์ด๋ฒคํธ ๊ธฐ๋ก ํ์ธ
-- order_events ํ
์ด๋ธ ํ์ธ
SELECT
oe . id ,
oe . order_id ,
oe . event_type ,
oe . payload ,
oe . created_at
FROM order_events oe
JOIN orders o ON oe . order_id = o . id
WHERE o . user_id = 2
ORDER BY oe . created_at DESC ;
-- ์์ ๊ฒฐ๊ณผ
-- id | order_id | event_type | payload | created_at
-- 4 | 1 | CANCELED | {"reason":"user_requested"} | ...
-- 3 | 1 | STOCK_RELEASED | {"quantity":2} | ...
-- 2 | 1 | STOCK_RESERVED | {"quantity":2} | ...
-- 1 | 1 | CREATED | {"time_deal_id":1,"quantity":2} | ...
D4-011: CI/CD ํ์ดํ๋ผ์ธ ์ด์
๐ GitHub Actions Workflow
# .github/workflows/ci.yml
name : CI
on :
push :
branches : [ main , develop ]
pull_request :
branches : [ main ]
jobs :
test :
runs-on : ubuntu-latest
steps :
- uses : actions/checkout@v4
- name : Set up Go
uses : actions/setup-go@v5
with :
go-version : '1.22'
- name : Cache Go modules
uses : actions/cache@v4
with :
path : ~/go/pkg/mod
key : ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
restore-keys : |
${{ runner.os }}-go-
- name : Download dependencies
working-directory : ./backend
run : go mod download
- name : Run tests
working-directory : ./backend
run : go test -v -race -coverprofile=coverage.out ./...
- name : Upload coverage
uses : codecov/codecov-action@v4
with :
files : ./backend/coverage.out
flags : backend
lint :
runs-on : ubuntu-latest
steps :
- uses : actions/checkout@v4
- name : Set up Go
uses : actions/setup-go@v5
with :
go-version : '1.22'
- name : Run golangci-lint
uses : golangci/golangci-lint-action@v4
with :
version : latest
working-directory : ./backend
build :
needs : [ test , lint ]
runs-on : ubuntu-latest
steps :
- uses : actions/checkout@v4
- name : Set up Docker Buildx
uses : docker/setup-buildx-action@v3
- name : Build Docker image
uses : docker/build-push-action@v5
with :
context : ./backend
push : false
tags : bbossiregi-backend:${{ github.sha }}
cache-from : type=gha
cache-to : type=gha,mode=max
# .github/workflows/cd.yml
name : CD
on :
push :
branches : [ main ]
paths :
- 'backend/**'
env :
AWS_REGION : ap-northeast-2
ECR_REPOSITORY : bbossiregi-backend
EKS_CLUSTER_NAME : bbossiregi-eks
jobs :
deploy :
runs-on : ubuntu-latest
steps :
- uses : actions/checkout@v4
- name : Configure AWS credentials
uses : aws-actions/configure-aws-credentials@v4
with :
aws-access-key-id : ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key : ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region : ${{ env.AWS_REGION }}
- name : Login to Amazon ECR
id : login-ecr
uses : aws-actions/amazon-ecr-login@v2
- name : Build, tag, and push image to Amazon ECR
id : build-image
env :
ECR_REGISTRY : ${{ steps.login-ecr.outputs.registry }}
IMAGE_TAG : ${{ github.sha }}
run : |
docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG ./backend
docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG
echo "image=$ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG" >> $GITHUB_OUTPUT
- name : Update kubeconfig
run : |
aws eks update-kubeconfig --name ${{ env.EKS_CLUSTER_NAME }} --region ${{ env.AWS_REGION }}
- name : Deploy to EKS
env :
IMAGE : ${{ steps.build-image.outputs.image }}
run : |
kubectl set image deployment/backend \
backend=$IMAGE \
-n bbossiregi
kubectl rollout status deployment/backend -n bbossiregi --timeout=5m
Day 4 ์๋ฃ! ๐
Day 4 ์ฃผ์ ๋ฌ์ฑ ์ฌํญ:
โ
์ฃผ๋ฌธ API (์ฌ๊ฐ ํจํด) ์์ฑ
โ
์ฌ๊ณ ๊ด๋ฆฌ ์๋น์ค (๋์์ฑ ์ ์ด)
โ
๋ณด์ ํธ๋์ญ์
๊ตฌํ
โ
ECR ๋ ํฌ์งํ ๋ฆฌ ์์ฑ
โ
Dockerfile ์์ฑ
โ
EKS ํด๋ฌ์คํฐ ์์ฑ
โ
K8s ๋งค๋ํ์คํธ ์์ฑ
๐
Day 5 (2/28 ๊ธ) - ๋ฐฐํฌ + HTTPS + ํ๊ณ
ํ์๋ผ์ธ
์๊ฐ ํ์คํฌ ๋ด๋น ์ฐ์ถ๋ฌผ 09:00-09:30 ๋ฐ์ผ๋ฆฌ ์คํ ๋์
์ ์ฒด ํ์๋ก 09:30-11:00 ALB Ingress Controller ์ค์น ์ธํ๋ผ Ingress ๋ฆฌ์์ค 09:30-11:00 ๋ฐฑ์๋ ์ต์ข
์ ๊ฒ ๋ฐ ๋ฒ๊ทธ ์์ ๋ฐฑ์๋ ์์ ๋ ์ฝ๋ 11:00-12:00 ACM ์ธ์ฆ์ + Route53 ์ค์ ์ธํ๋ผ HTTPS ํ์ฑํ 13:00-14:30 ์ด๋ฏธ์ง ๋น๋ + ECR ํธ์ + EKS ๋ฐฐํฌ ์ ์ฒด ์คํ ์ค์ธ ์๋น์ค 14:30-16:00 E2E ํ
์คํธ + ๋ฒ๊ทธ ์์ ์ ์ฒด ํ
์คํธ ๊ฒฐ๊ณผ 16:00-17:00 ๋ชจ๋ํฐ๋ง ์ค์ (CloudWatch) ์ธํ๋ผ ๋์๋ณด๋ 17:00-18:00 ์คํ๋ฆฐํธ ํ๊ณ + ๋ฌธ์ํ ์ ์ฒด ํ๊ณ ๋ก, README
D5-001: ALB Ingress Controller ์ค์น
๐ ํ์คํฌ ์ ๋ณด
ํญ๋ชฉ ๋ด์ฉ ID D5-001 ์ฐ์ ์์ P0 (Blocker) ๋ด๋น ์ธํ๋ผ ์์ ์๊ฐ 1.5์๊ฐ ์ ํ ์์
D4-008 (EKS ํด๋ฌ์คํฐ) ํํ ์์
D5-002 (ACM ์ธ์ฆ์)
๐ ์ฒดํฌ๋ฆฌ์คํธ
[ ] IAM OIDC Provider ์์ฑ
[ ] IAM Policy ์์ฑ (ALB Controller์ฉ)
[ ] IAM Role ์์ฑ (ServiceAccount์ฉ)
[ ] AWS Load Balancer Controller ์ค์น
[ ] Ingress ๋ฆฌ์์ค ์์ฑ
[ ] ALB ์์ฑ ํ์ธ
๐ ์์ธ ๋ช
์ธ
IAM OIDC Provider ์์ฑ
# OIDC Provider ํ์ธ
aws eks describe-cluster --name bbossiregi-eks --query "cluster.identity.oidc.issuer" --output text
# eksctl๋ก OIDC Provider ์์ฑ
eksctl utils associate-iam-oidc-provider \
--region ap-northeast-2 \
--cluster bbossiregi-eks \
--approve
IAM Policy ์์ฑ
# AWS Load Balancer Controller IAM Policy ๋ค์ด๋ก๋
curl -O https://raw.githubusercontent.com/kubernetes-sigs/aws-load-balancer-controller/v2.7.1/docs/install/iam_policy.json
# IAM Policy ์์ฑ
aws iam create-policy \
--policy-name AWSLoadBalancerControllerIAMPolicy \
--policy-document file://iam_policy.json
# terraform/modules/eks/alb_controller.tf
# OIDC Provider ๋ฐ์ดํฐ
data "aws_eks_cluster" "main" {
name = aws_eks_cluster . main . name
}
data "tls_certificate" "eks" {
url = data . aws_eks_cluster . main . identity[ 0 ] . oidc[ 0 ] . issuer
}
resource "aws_iam_openid_connect_provider" "eks" {
client_id_list = [ "sts.amazonaws.com" ]
thumbprint_list = [data . tls_certificate . eks . certificates[ 0 ] . sha1_fingerprint]
url = data . aws_eks_cluster . main . identity[ 0 ] . oidc[ 0 ] . issuer
}
# ALB Controller IAM Role
resource "aws_iam_role" "alb_controller" {
name = " ${ var . project_name } -alb-controller-role"
assume_role_policy = jsonencode ({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Principal = {
Federated = aws_iam_openid_connect_provider.eks.arn
}
Action = "sts:AssumeRoleWithWebIdentity"
Condition = {
StringEquals = {
"${replace(aws_iam_openid_connect_provider.eks.url, "https://", "")}:aud" = "sts.amazonaws.com"
"${replace(aws_iam_openid_connect_provider.eks.url, "https://", "")}:sub" = "system:serviceaccount:kube-system:aws-load-balancer-controller"
}
}
}
]
})
tags = {
Name = " ${ var . project_name } -alb-controller-role"
Project = var.project_name
Environment = var.environment
}
}
resource "aws_iam_role_policy_attachment" "alb_controller" {
policy_arn = "arn:aws:iam:: ${ data . aws_caller_identity . current . account_id } :policy/AWSLoadBalancerControllerIAMPolicy"
role = aws_iam_role . alb_controller . name
}
data "aws_caller_identity" "current" {}
output "alb_controller_role_arn" {
value = aws_iam_role . alb_controller . arn
}
AWS Load Balancer Controller ์ค์น (Helm)
# Helm ์ค์น (์๋ ๊ฒฝ์ฐ)
curl https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash
# EKS Helm ์ฐจํธ ๋ฆฌํฌ์งํ ๋ฆฌ ์ถ๊ฐ
helm repo add eks https://aws.github.io/eks-charts
helm repo update
# AWS Load Balancer Controller ์ค์น
helm install aws-load-balancer-controller eks/aws-load-balancer-controller \
-n kube-system \
--set clusterName=bbossiregi-eks \
--set serviceAccount.create= true \
--set serviceAccount.name=aws-load-balancer-controller \
--set serviceAccount.annotations."eks\.amazonaws\.com/role-arn"=arn:aws:iam:: < ACCOUNT_I D > :role/bbossiregi-alb-controller-role \
--set region=ap-northeast-2 \
--set vpcId= < VPC_I D >
# ์ค์น ํ์ธ
kubectl get deployment -n kube-system aws-load-balancer-controller
Ingress ๋ฆฌ์์ค ์์ฑ
# k8s/base/ingress.yaml
apiVersion : networking.k8s.io/v1
kind : Ingress
metadata :
name : backend-ingress
namespace : bbossiregi
annotations :
kubernetes.io/ingress.class : alb
alb.ingress.kubernetes.io/scheme : internet-facing
alb.ingress.kubernetes.io/target-type : ip
alb.ingress.kubernetes.io/listen-ports : '[{"HTTP": 80}]'
alb.ingress.kubernetes.io/healthcheck-path : /api/v1/health
alb.ingress.kubernetes.io/healthcheck-interval-seconds : '15'
alb.ingress.kubernetes.io/healthcheck-timeout-seconds : '5'
alb.ingress.kubernetes.io/healthy-threshold-count : '2'
alb.ingress.kubernetes.io/unhealthy-threshold-count : '2'
alb.ingress.kubernetes.io/tags : Project=bbossiregi,Environment=production
spec :
rules :
- http :
paths :
- path : /
pathType : Prefix
backend :
service :
name : backend-service
port :
number : 80
๐งช ๊ฒ์ฆ
# Ingress ์ ์ฉ
kubectl apply -f k8s/base/ingress.yaml
# Ingress ์ํ ํ์ธ
kubectl get ingress -n bbossiregi
# ALB ์ฃผ์ ํ์ธ (์์ฑ๊น์ง 2-3๋ถ ์์)
kubectl get ingress backend-ingress -n bbossiregi -o jsonpath='{.status.loadBalancer.ingress[0].hostname}'
# ์์ ์ถ๋ ฅ
# k8s-bbossire-backendi-xxxxxxxxxx-xxxxxxxxxx.ap-northeast-2.elb.amazonaws.com
# ํฌ์ค์ฒดํฌ ํ
์คํธ
ALB_URL = $( kubectl get ingress backend-ingress -n bbossiregi -o jsonpath='{.status.loadBalancer.ingress[0].hostname}' )
curl http:// $ALB_URL /api/v1/health
# ์์ ์๋ต
{
"success" : true ,
"data" : {
"status" : "healthy",
"timestamp" : "2026-02-28T09:30:00Z",
"version" : "1.0.0",
"checks" : {
"database" : "ok"
}
}
}
D5-002: ACM ์ธ์ฆ์ ๋ฐ๊ธ
๐ ํ์คํฌ ์ ๋ณด
ํญ๋ชฉ ๋ด์ฉ ID D5-002 ์ฐ์ ์์ P1 ๋ด๋น ์ธํ๋ผ ์์ ์๊ฐ 30๋ถ ์ ํ ์์
D5-001 ํํ ์์
D5-003 (Route53)
๐ ์ฒดํฌ๋ฆฌ์คํธ
[ ] ACM ์ธ์ฆ์ ์์ฒญ
[ ] DNS ๊ฒ์ฆ ๋ ์ฝ๋ ์์ฑ
[ ] ์ธ์ฆ์ ๋ฐ๊ธ ํ์ธ
[ ] Ingress์ ์ธ์ฆ์ ์ฐ๊ฒฐ
# terraform/modules/acm/main.tf
# ACM ์ธ์ฆ์
resource "aws_acm_certificate" "main" {
domain_name = var . domain_name
subject_alternative_names = [ "*. ${ var . domain_name } " ]
validation_method = "DNS"
lifecycle {
create_before_destroy = true
}
tags = {
Name = " ${ var . project_name } -cert"
Project = var.project_name
Environment = var.environment
}
}
# Route53 Zone (์ด๋ฏธ ์กด์ฌํ๋ ๊ฒฝ์ฐ data source ์ฌ์ฉ)
data "aws_route53_zone" "main" {
name = var . domain_name
private_zone = false
}
# DNS ๊ฒ์ฆ ๋ ์ฝ๋
resource "aws_route53_record" "cert_validation" {
for_each = {
for dvo in aws_acm_certificate . main . domain_validation_options : dvo . domain_name => {
name = dvo.resource_record_name
record = dvo.resource_record_value
type = dvo.resource_record_type
}
}
allow_overwrite = true
name = each . value . name
records = [each . value . record]
ttl = 60
type = each . value . type
zone_id = data . aws_route53_zone . main . zone_id
}
# ์ธ์ฆ์ ๊ฒ์ฆ ์๋ฃ ๋๊ธฐ
resource "aws_acm_certificate_validation" "main" {
certificate_arn = aws_acm_certificate . main . arn
validation_record_fqdns = [ for record in aws_route53_record . cert_validation : record . fqdn]
}
output "certificate_arn" {
value = aws_acm_certificate . main . arn
}
# terraform/modules/acm/variables.tf
variable "project_name" {
type = string
}
variable "environment" {
type = string
}
variable "domain_name" {
type = string
description = "๋๋ฉ์ธ ์ด๋ฆ (์: bbossiregi.com)"
}
๐งช ๊ฒ์ฆ
# ์ธ์ฆ์ ์ํ ํ์ธ
aws acm describe-certificate \
--certificate-arn < certificate-ar n > \
--query 'Certificate.Status'
# ์์ ์ถ๋ ฅ: "ISSUED"
D5-003: Route53 DNS ์ค์
๐ ํ์คํฌ ์ ๋ณด
ํญ๋ชฉ ๋ด์ฉ ID D5-003 ์ฐ์ ์์ P1 ๋ด๋น ์ธํ๋ผ ์์ ์๊ฐ 30๋ถ ์ ํ ์์
D5-002
๐ ์ฒดํฌ๋ฆฌ์คํธ
[ ] Hosted Zone ํ์ธ/์์ฑ
[ ] ALB Alias ๋ ์ฝ๋ ์์ฑ
[ ] DNS ์ ํ ํ์ธ
# terraform/modules/route53/main.tf
# Hosted Zone (์ด๋ฏธ ์กด์ฌํ๋ ๊ฒฝ์ฐ data source)
data "aws_route53_zone" "main" {
name = var . domain_name
private_zone = false
}
# API ์๋ธ๋๋ฉ์ธ โ ALB
resource "aws_route53_record" "api" {
zone_id = data . aws_route53_zone . main . zone_id
name = "api. ${ var . domain_name } "
type = "A"
alias {
name = var . alb_dns_name
zone_id = var . alb_zone_id
evaluate_target_health = true
}
}
# ๋ฃจํธ ๋๋ฉ์ธ (์ ํ - ํ๋ก ํธ์๋์ฉ)
resource "aws_route53_record" "root" {
zone_id = data . aws_route53_zone . main . zone_id
name = var . domain_name
type = "A"
alias {
name = var . alb_dns_name
zone_id = var . alb_zone_id
evaluate_target_health = true
}
}
# www ์๋ธ๋๋ฉ์ธ (์ ํ)
resource "aws_route53_record" "www" {
zone_id = data . aws_route53_zone . main . zone_id
name = "www. ${ var . domain_name } "
type = "CNAME"
ttl = 300
records = [var . domain_name]
}
output "api_domain" {
value = aws_route53_record . api . fqdn
}
# terraform/modules/route53/variables.tf
variable "domain_name" {
type = string
}
variable "alb_dns_name" {
type = string
description = "ALB DNS ์ด๋ฆ"
}
variable "alb_zone_id" {
type = string
description = "ALB Hosted Zone ID"
}
๐งช ๊ฒ์ฆ
# DNS ๋ ์ฝ๋ ํ์ธ
dig api.bbossiregi.com
# ์์ ์๋ต (A ๋ ์ฝ๋๊ฐ ALB IP๋ก ํด์)
;; ANSWER SECTION:
api.bbossiregi.com. 60 IN A xx.xx.xx.xx
# HTTP ํ
์คํธ
curl http://api.bbossiregi.com/api/v1/health
D5-004: HTTPS ํ์ฑํ
๐ ํ์คํฌ ์ ๋ณด
ํญ๋ชฉ ๋ด์ฉ ID D5-004 ์ฐ์ ์์ P1 ๋ด๋น ์ธํ๋ผ ์์ ์๊ฐ 30๋ถ ์ ํ ์์
D5-002, D5-003
๐ ์ฒดํฌ๋ฆฌ์คํธ
[ ] Ingress์ HTTPS ์ค์ ์ถ๊ฐ
[ ] HTTP โ HTTPS ๋ฆฌ๋ค์ด๋ ํธ ์ค์
[ ] HTTPS ์ ์ ํ
์คํธ
๐ Ingress ์
๋ฐ์ดํธ (HTTPS)
# k8s/base/ingress-https.yaml
apiVersion : networking.k8s.io/v1
kind : Ingress
metadata :
name : backend-ingress
namespace : bbossiregi
annotations :
kubernetes.io/ingress.class : alb
alb.ingress.kubernetes.io/scheme : internet-facing
alb.ingress.kubernetes.io/target-type : ip
# HTTPS ์ค์
alb.ingress.kubernetes.io/listen-ports : '[{"HTTP": 80}, {"HTTPS": 443}]'
alb.ingress.kubernetes.io/ssl-redirect : '443'
alb.ingress.kubernetes.io/certificate-arn : arn:aws:acm:ap-northeast-2:<ACCOUNT_ID>:certificate/<CERT_ID>
# ํฌ์ค์ฒดํฌ
alb.ingress.kubernetes.io/healthcheck-path : /api/v1/health
alb.ingress.kubernetes.io/healthcheck-interval-seconds : '15'
alb.ingress.kubernetes.io/healthcheck-timeout-seconds : '5'
alb.ingress.kubernetes.io/healthy-threshold-count : '2'
alb.ingress.kubernetes.io/unhealthy-threshold-count : '2'
# ๋ณด์ ํค๋
alb.ingress.kubernetes.io/ssl-policy : ELBSecurityPolicy-TLS13-1-2-2021-06
# ํ๊ทธ
alb.ingress.kubernetes.io/tags : Project=bbossiregi,Environment=production
spec :
rules :
- host : api.bbossiregi.com
http :
paths :
- path : /
pathType : Prefix
backend :
service :
name : backend-service
port :
number : 80
๐งช ๊ฒ์ฆ
# Ingress ์
๋ฐ์ดํธ
kubectl apply -f k8s/base/ingress-https.yaml
# HTTPS ํ
์คํธ
curl https://api.bbossiregi.com/api/v1/health
# HTTP โ HTTPS ๋ฆฌ๋ค์ด๋ ํธ ํ์ธ
curl -I http://api.bbossiregi.com/api/v1/health
# ์์: 301 Moved Permanently, Location: https://...
# SSL ์ธ์ฆ์ ํ์ธ
openssl s_client -connect api.bbossiregi.com:443 -servername api.bbossiregi.com < /dev/null 2> /dev/null | openssl x509 -noout -dates
D5-005: ์ต์ข
์ด๋ฏธ์ง ๋น๋ ๋ฐ ๋ฐฐํฌ
๐ ํ์คํฌ ์ ๋ณด
ํญ๋ชฉ ๋ด์ฉ ID D5-005 ์ฐ์ ์์ P0 (Blocker) ๋ด๋น ์ ์ฒด ์์ ์๊ฐ 1์๊ฐ ์ ํ ์์
D4-007 (Dockerfile), D5-001
๐ ์ฒดํฌ๋ฆฌ์คํธ
[ ] ์ต์ข
์ฝ๋ ์ ๊ฒ
[ ] Docker ์ด๋ฏธ์ง ๋น๋
[ ] ECR ํธ์
[ ] EKS ๋ฐฐํฌ
[ ] Pod ์ํ ํ์ธ
[ ] ์๋น์ค ์ ์ ๋์ ํ์ธ
๐ ๋ฐฐํฌ ์คํฌ๋ฆฝํธ
#!/bin/bash
# scripts/deploy.sh
set -e
# ๋ณ์ ์ค์
AWS_ACCOUNT_ID = $( aws sts get-caller-identity --query Account --output text )
AWS_REGION = "ap-northeast-2"
ECR_REGISTRY = "${ AWS_ACCOUNT_ID }.dkr.ecr.${ AWS_REGION }.amazonaws.com"
IMAGE_NAME = "bbossiregi-backend"
IMAGE_TAG = $( git rev-parse --short HEAD )
NAMESPACE = "bbossiregi"
echo "=========================================="
echo "๐ ๋ฝ์๋ ๊ธฐ ๋ฐฑ์๋ ๋ฐฐํฌ ์์"
echo "=========================================="
echo "Account: $AWS_ACCOUNT_ID "
echo "Region: $AWS_REGION "
echo "Image: $ECR_REGISTRY / $IMAGE_NAME : $IMAGE_TAG "
echo "=========================================="
# 1. ECR ๋ก๊ทธ์ธ
echo "๐ฆ ECR ๋ก๊ทธ์ธ..."
aws ecr get-login-password --region $AWS_REGION | \
docker login --username AWS --password-stdin $ECR_REGISTRY
# 2. Docker ์ด๋ฏธ์ง ๋น๋
echo "๐จ Docker ์ด๋ฏธ์ง ๋น๋..."
cd backend
docker build -t $IMAGE_NAME : $IMAGE_TAG .
docker tag $IMAGE_NAME : $IMAGE_TAG $ECR_REGISTRY / $IMAGE_NAME : $IMAGE_TAG
docker tag $IMAGE_NAME : $IMAGE_TAG $ECR_REGISTRY / $IMAGE_NAME :latest
# 3. ECR ํธ์
echo "๐ค ECR ํธ์..."
docker push $ECR_REGISTRY / $IMAGE_NAME : $IMAGE_TAG
docker push $ECR_REGISTRY / $IMAGE_NAME :latest
# 4. kubeconfig ์
๋ฐ์ดํธ
echo "โ๏ธ kubeconfig ์
๋ฐ์ดํธ..."
aws eks update-kubeconfig --name bbossiregi-eks --region $AWS_REGION
# 5. ๋ฐฐํฌ
echo "๐ EKS ๋ฐฐํฌ..."
kubectl set image deployment/backend \
backend= $ECR_REGISTRY / $IMAGE_NAME : $IMAGE_TAG \
-n $NAMESPACE
# 6. ๋กค์์ ์ํ ํ์ธ
echo "โณ ๋กค์์ ์ํ ํ์ธ..."
kubectl rollout status deployment/backend -n $NAMESPACE --timeout=5m
# 7. Pod ์ํ ํ์ธ
echo "โ
Pod ์ํ ํ์ธ..."
kubectl get pods -n $NAMESPACE -l app=backend
# 8. ์๋น์ค ํ
์คํธ
echo "๐งช ์๋น์ค ํ
์คํธ..."
sleep 10 # ALB ํฌ์ค์ฒดํฌ ๋๊ธฐ
curl -s https://api.bbossiregi.com/api/v1/health | jq
echo "=========================================="
echo "โ
๋ฐฐํฌ ์๋ฃ!"
echo "=========================================="
๐งช ๊ฒ์ฆ
# ๋ฐฐํฌ ์คํฌ๋ฆฝํธ ์คํ
chmod +x scripts/deploy.sh
./scripts/deploy.sh
# ์๋ ํ์ธ
kubectl get pods -n bbossiregi
kubectl get svc -n bbossiregi
kubectl get ingress -n bbossiregi
# Pod ๋ก๊ทธ ํ์ธ
kubectl logs -f deployment/backend -n bbossiregi
# ์ด๋ฒคํธ ํ์ธ
kubectl get events -n bbossiregi --sort-by= '.lastTimestamp'
D5-006: E2E ํ
์คํธ
๐ ํ์คํฌ ์ ๋ณด
ํญ๋ชฉ ๋ด์ฉ ID D5-006 ์ฐ์ ์์ P0 ๋ด๋น ์ ์ฒด ์์ ์๊ฐ 1.5์๊ฐ
๐ ์ฒดํฌ๋ฆฌ์คํธ
[ ] ํฌ์ค์ฒดํฌ API
[ ] ํ์๊ฐ์
API
[ ] ๋ก๊ทธ์ธ API
[ ] ํ์๋ ๋ชฉ๋ก ์กฐํ
[ ] ํ์๋ ์์ธ ์กฐํ
[ ] ์ฃผ๋ฌธ ์์ฑ (์ฌ๊ณ ์์ฝ)
[ ] ์ฃผ๋ฌธ ์กฐํ
[ ] ์ฃผ๋ฌธ ์ทจ์ (์ฌ๊ณ ํด์ )
[ ] ์๋ฌ ์ผ์ด์ค ํ
์คํธ
๐ E2E ํ
์คํธ ์คํฌ๋ฆฝํธ
#!/bin/bash
# scripts/e2e-test.sh
set -e
BASE_URL = "https://api.bbossiregi.com/api/v1"
# ๋ก์ปฌ ํ
์คํธ: BASE_URL="http://localhost:8080/api/v1"
echo "=========================================="
echo "๐งช ๋ฝ์๋ ๊ธฐ E2E ํ
์คํธ ์์"
echo "=========================================="
echo "Base URL: $BASE_URL "
echo "=========================================="
# ํ
์คํธ ์นด์ดํฐ
PASSED = 0
FAILED = 0
# ํ
์คํธ ํฌํผ ํจ์
test_api () {
local name = $1
local method = $2
local endpoint = $3
local data = $4
local expected_status = $5
local auth_header = $6
echo -n "Testing: $name ... "
if [ -n " $auth_header " ]; then
response = $( curl -s -w "\n%{http_code}" -X $method " $BASE_URL$endpoint " \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $auth_header " \
-d " $data " )
else
response = $( curl -s -w "\n%{http_code}" -X $method " $BASE_URL$endpoint " \
-H "Content-Type: application/json" \
-d " $data " )
fi
status_code = $( echo " $response " | tail -n 1 )
body = $( echo " $response " | sed '$d' )
if [ " $status_code " -eq " $expected_status " ]; then
echo "โ
PASSED (HTTP $status_code )"
((PASSED ++ ))
echo " $body " | jq -c '.' 2> /dev/null || echo " $body "
else
echo "โ FAILED (Expected: $expected_status , Got: $status_code )"
((FAILED ++ ))
echo " $body " | jq -c '.' 2> /dev/null || echo " $body "
fi
echo ""
}
# ========================================
# 1. ํฌ์ค์ฒดํฌ
# ========================================
echo "=== 1. ํฌ์ค์ฒดํฌ ==="
test_api "Health Check" "GET" "/health" "" 200
# ========================================
# 2. ์ธ์ฆ ํ
์คํธ
# ========================================
echo "=== 2. ์ธ์ฆ ํ
์คํธ ==="
# ํ์๊ฐ์
(์ ์ ์ )
RANDOM_EMAIL = "test_$( date +%s)@example.com"
test_api "Register (New User)" "POST" "/auth/register" \
"{ \" email \" : \" $RANDOM_EMAIL \" , \" password \" : \" password123 \" , \" name \" : \" E2Eํ
์คํฐ \" }" 201
# ํ์๊ฐ์
(์ค๋ณต ์ด๋ฉ์ผ)
test_api "Register (Duplicate Email)" "POST" "/auth/register" \
"{ \" email \" : \" $RANDOM_EMAIL \" , \" password \" : \" password123 \" , \" name \" : \" ์ค๋ณตํ
์คํธ \" }" 409
# ๋ก๊ทธ์ธ (์ฑ๊ณต)
LOGIN_RESPONSE = $( curl -s -X POST " $BASE_URL /auth/login" \
-H "Content-Type: application/json" \
-d "{ \" email \" : \" $RANDOM_EMAIL \" , \" password \" : \" password123 \" }" )
TOKEN = $( echo $LOGIN_RESPONSE | jq -r '.data.access_token' )
echo "Token acquired: ${ TOKEN : 0 : 20 }..."
test_api "Login (Success)" "POST" "/auth/login" \
"{ \" email \" : \" $RANDOM_EMAIL \" , \" password \" : \" password123 \" }" 200
# ๋ก๊ทธ์ธ (์๋ชป๋ ๋น๋ฐ๋ฒํธ)
test_api "Login (Wrong Password)" "POST" "/auth/login" \
"{ \" email \" : \" $RANDOM_EMAIL \" , \" password \" : \" wrongpassword \" }" 401
# ========================================
# 3. ํ์๋ ํ
์คํธ
# ========================================
echo "=== 3. ํ์๋ ํ
์คํธ ==="
# ํ์๋ ๋ชฉ๋ก ์กฐํ
test_api "TimeDeal List (All)" "GET" "/timedeals" "" 200
# ํ์๋ ๋ชฉ๋ก ์กฐํ (์ํ ํํฐ)
test_api "TimeDeal List (Active)" "GET" "/timedeals?status=active" "" 200
# ํ์๋ ์์ธ ์กฐํ
test_api "TimeDeal Detail (ID: 1)" "GET" "/timedeals/1" "" 200
# ํ์๋ ์์ธ ์กฐํ (์๋ ID)
test_api "TimeDeal Detail (Not Found)" "GET" "/timedeals/99999" "" 404
# ========================================
# 4. ์ฃผ๋ฌธ ํ
์คํธ
# ========================================
echo "=== 4. ์ฃผ๋ฌธ ํ
์คํธ ==="
# ์ฃผ๋ฌธ ์์ฑ ์ ์ฌ๊ณ ํ์ธ
echo "๐ฆ ์ฃผ๋ฌธ ์ ํ์๋ ์ฌ๊ณ ํ์ธ..."
curl -s " $BASE_URL /timedeals/1" | jq '.data | {stock_quantity, reserved_quantity, sold_quantity}'
# ์ฃผ๋ฌธ ์์ฑ (์ฑ๊ณต)
ORDER_RESPONSE = $( curl -s -X POST " $BASE_URL /orders" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN " \
-d '{"time_deal_id":1,"quantity":1}' )
ORDER_ID = $( echo $ORDER_RESPONSE | jq -r '.data.id' )
test_api "Create Order (Success)" "POST" "/orders" \
'{"time_deal_id":1,"quantity":1}' 201 " $TOKEN "
# ์ฃผ๋ฌธ ์์ฑ ํ ์ฌ๊ณ ํ์ธ
echo "๐ฆ ์ฃผ๋ฌธ ํ ํ์๋ ์ฌ๊ณ ํ์ธ (reserved_quantity +1)..."
curl -s " $BASE_URL /timedeals/1" | jq '.data | {stock_quantity, reserved_quantity, sold_quantity}'
# ์ฃผ๋ฌธ ์์ฑ (์ธ์ฆ ์์)
test_api "Create Order (No Auth)" "POST" "/orders" \
'{"time_deal_id":1,"quantity":1}' 401
# ์ฃผ๋ฌธ ์์ฑ (์๋ชป๋ ์๋)
test_api "Create Order (Invalid Quantity)" "POST" "/orders" \
'{"time_deal_id":1,"quantity":100}' 400 " $TOKEN "
# ์ฃผ๋ฌธ ๋ชฉ๋ก ์กฐํ
test_api "Order List" "GET" "/orders" "" 200 " $TOKEN "
# ์ฃผ๋ฌธ ์์ธ ์กฐํ
test_api "Order Detail" "GET" "/orders/ $ORDER_ID " "" 200 " $TOKEN "
# ์ฃผ๋ฌธ ์ทจ์
test_api "Cancel Order" "DELETE" "/orders/ $ORDER_ID " "" 200 " $TOKEN "
# ์ฃผ๋ฌธ ์ทจ์ ํ ์ฌ๊ณ ํ์ธ
echo "๐ฆ ์ฃผ๋ฌธ ์ทจ์ ํ ํ์๋ ์ฌ๊ณ ํ์ธ (reserved_quantity -1)..."
curl -s " $BASE_URL /timedeals/1" | jq '.data | {stock_quantity, reserved_quantity, sold_quantity}'
# ์ด๋ฏธ ์ทจ์๋ ์ฃผ๋ฌธ ์ฌ์ทจ์
test_api "Cancel Order (Already Canceled)" "DELETE" "/orders/ $ORDER_ID " "" 400 " $TOKEN "
# ========================================
# ๊ฒฐ๊ณผ ์์ฝ
# ========================================
echo "=========================================="
echo "๐ E2E ํ
์คํธ ๊ฒฐ๊ณผ"
echo "=========================================="
echo "โ
Passed: $PASSED "
echo "โ Failed: $FAILED "
echo "=========================================="
if [ $FAILED -gt 0 ]; then
echo "โ ๏ธ ์ผ๋ถ ํ
์คํธ ์คํจ!"
exit 1
else
echo "๐ ๋ชจ๋ ํ
์คํธ ํต๊ณผ!"
exit 0
fi
๐งช ํ
์คํธ ์คํ
# ํ
์คํธ ์คํฌ๋ฆฝํธ ์คํ
chmod +x scripts/e2e-test.sh
./scripts/e2e-test.sh
# ์์ ์ถ๋ ฅ
==========================================
๐งช ๋ฝ์๋ ๊ธฐ E2E ํ
์คํธ ์์
==========================================
Base URL: https://api.bbossiregi.com/api/v1
==========================================
=== 1. ํฌ์ค์ฒดํฌ ===
Testing: Health Check... โ
PASSED (HTTP 200 )
{ "success" :true, "data" : { " status ":" healthy ",...}}
=== 2. ์ธ์ฆ ํ
์คํธ ===
Testing: Register (New User)... โ
PASSED (HTTP 201)
...
==========================================
๐ E2E ํ
์คํธ ๊ฒฐ๊ณผ
==========================================
โ
Passed: 15
โ Failed: 0
==========================================
๐ ๋ชจ๋ ํ
์คํธ ํต๊ณผ!
D5-007: CloudWatch ๋ชจ๋ํฐ๋ง ์ค์
๐ ํ์คํฌ ์ ๋ณด
ํญ๋ชฉ ๋ด์ฉ ID D5-007 ์ฐ์ ์์ P2 ๋ด๋น ์ธํ๋ผ ์์ ์๊ฐ 1์๊ฐ
๐ ์ฒดํฌ๋ฆฌ์คํธ
[ ] Container Insights ํ์ฑํ
[ ] ๋ก๊ทธ ๊ทธ๋ฃน ์ค์
[ ] ๋์๋ณด๋ ์์ฑ
[ ] ์๋ ์ค์ (์ ํ)
๐ Container Insights ํ์ฑํ
# CloudWatch Container Insights ์ค์น
# FluentBit DaemonSet์ผ๋ก ๋ก๊ทธ ์์ง
# CloudWatch ์์ด์ ํธ ๋ค์์คํ์ด์ค ์์ฑ
kubectl apply -f https://raw.githubusercontent.com/aws-samples/amazon-cloudwatch-container-insights/latest/k8s-deployment-manifest-templates/deployment-mode/daemonset/container-insights-monitoring/cloudwatch-namespace.yaml
# CloudWatch ์์ด์ ํธ ConfigMap
kubectl apply -f https://raw.githubusercontent.com/aws-samples/amazon-cloudwatch-container-insights/latest/k8s-deployment-manifest-templates/deployment-mode/daemonset/container-insights-monitoring/cwagent-configmap.yaml
# FluentBit ConfigMap (ํด๋ฌ์คํฐ ์ด๋ฆ ์์ ํ์)
ClusterName = bbossiregi-eks
RegionName = ap-northeast-2
FluentBitHttpPort = '2020'
FluentBitReadFromHead = 'Off'
[[ ${FluentBitReadFromHead} = 'On' ]] && FluentBitReadFromTail = 'Off' || FluentBitReadFromTail = 'On'
[[ -z ${FluentBitHttpPort} ]] && FluentBitHttpServer = 'Off' || FluentBitHttpServer = 'On'
kubectl create configmap fluent-bit-cluster-info \
--from-literal=cluster.name=${ ClusterName } \
--from-literal=http.server=${ FluentBitHttpServer } \
--from-literal=http.port=${ FluentBitHttpPort } \
--from-literal=read.head=${ FluentBitReadFromHead } \
--from-literal=read.tail=${ FluentBitReadFromTail } \
--from-literal=logs.region=${ RegionName } -n amazon-cloudwatch
# FluentBit DaemonSet ๋ฐฐํฌ
kubectl apply -f https://raw.githubusercontent.com/aws-samples/amazon-cloudwatch-container-insights/latest/k8s-deployment-manifest-templates/deployment-mode/daemonset/container-insights-monitoring/fluent-bit/fluent-bit.yaml
๐ CloudWatch ๋์๋ณด๋
// cloudwatch-dashboard.json
{
"widgets" : [
{
"type" : "metric" ,
"x" : 0 ,
"y" : 0 ,
"width" : 12 ,
"height" : 6 ,
"properties" : {
"title" : "Pod CPU Utilization" ,
"metrics" : [
[ "ContainerInsights" , "pod_cpu_utilization" , "ClusterName" , "bbossiregi-eks" , "Namespace" , "bbossiregi" , "PodName" , "backend" ]
],
"view" : "timeSeries" ,
"region" : "ap-northeast-2" ,
"period" : 60
}
},
{
"type" : "metric" ,
"x" : 12 ,
"y" : 0 ,
"width" : 12 ,
"height" : 6 ,
"properties" : {
"title" : "Pod Memory Utilization" ,
"metrics" : [
[ "ContainerInsights" , "pod_memory_utilization" , "ClusterName" , "bbossiregi-eks" , "Namespace" , "bbossiregi" , "PodName" , "backend" ]
],
"view" : "timeSeries" ,
"region" : "ap-northeast-2" ,
"period" : 60
}
},
{
"type" : "metric" ,
"x" : 0 ,
"y" : 6 ,
"width" : 12 ,
"height" : 6 ,
"properties" : {
"title" : "ALB Request Count" ,
"metrics" : [
[ "AWS/ApplicationELB" , "RequestCount" , "LoadBalancer" , "app/k8s-bbossire-backendi/xxxxx" ]
],
"view" : "timeSeries" ,
"region" : "ap-northeast-2" ,
"stat" : "Sum" ,
"period" : 60
}
},
{
"type" : "metric" ,
"x" : 12 ,
"y" : 6 ,
"width" : 12 ,
"height" : 6 ,
"properties" : {
"title" : "ALB Target Response Time" ,
"metrics" : [
[ "AWS/ApplicationELB" , "TargetResponseTime" , "LoadBalancer" , "app/k8s-bbossire-backendi/xxxxx" ]
],
"view" : "timeSeries" ,
"region" : "ap-northeast-2" ,
"stat" : "Average" ,
"period" : 60
}
},
{
"type" : "metric" ,
"x" : 0 ,
"y" : 12 ,
"width" : 12 ,
"height" : 6 ,
"properties" : {
"title" : "RDS CPU Utilization" ,
"metrics" : [
[ "AWS/RDS" , "CPUUtilization" , "DBInstanceIdentifier" , "bbossiregi-db" ]
],
"view" : "timeSeries" ,
"region" : "ap-northeast-2" ,
"period" : 60
}
},
{
"type" : "metric" ,
"x" : 12 ,
"y" : 12 ,
"width" : 12 ,
"height" : 6 ,
"properties" : {
"title" : "RDS Database Connections" ,
"metrics" : [
[ "AWS/RDS" , "DatabaseConnections" , "DBInstanceIdentifier" , "bbossiregi-db" ]
],
"view" : "timeSeries" ,
"region" : "ap-northeast-2" ,
"period" : 60
}
}
]
}
# terraform/modules/monitoring/alarms.tf
# ๋์ CPU ์ฌ์ฉ๋ฅ ์๋
resource "aws_cloudwatch_metric_alarm" "high_cpu" {
alarm_name = " ${ var . project_name } -high-cpu"
comparison_operator = "GreaterThanThreshold"
evaluation_periods = 2
metric_name = "pod_cpu_utilization"
namespace = "ContainerInsights"
period = 300
statistic = "Average"
threshold = 80
alarm_description = "CPU utilization is above 80%"
dimensions = {
ClusterName = " ${ var . project_name } -eks"
Namespace = var.project_name
}
alarm_actions = [aws_sns_topic . alerts . arn]
ok_actions = [aws_sns_topic . alerts . arn]
tags = {
Project = var.project_name
Environment = var.environment
}
}
# 5xx ์๋ฌ ์๋
resource "aws_cloudwatch_metric_alarm" "alb_5xx_errors" {
alarm_name = " ${ var . project_name } -alb-5xx"
comparison_operator = "GreaterThanThreshold"
evaluation_periods = 1
metric_name = "HTTPCode_Target_5XX_Count"
namespace = "AWS/ApplicationELB"
period = 300
statistic = "Sum"
threshold = 10
alarm_description = "ALB 5xx errors exceeded threshold"
dimensions = {
LoadBalancer = var.alb_arn_suffix
}
alarm_actions = [aws_sns_topic . alerts . arn]
tags = {
Project = var.project_name
Environment = var.environment
}
}
# SNS Topic
resource "aws_sns_topic" "alerts" {
name = " ${ var . project_name } -alerts"
}
# ์ด๋ฉ์ผ ๊ตฌ๋
(์๋์ผ๋ก ํ์ธ ํ์)
resource "aws_sns_topic_subscription" "email" {
topic_arn = aws_sns_topic . alerts . arn
protocol = "email"
endpoint = var . alert_email
}
D5-008: ๋ฌธ์ํ
๐ ํ์คํฌ ์ ๋ณด
ํญ๋ชฉ ๋ด์ฉ ID D5-008 ์ฐ์ ์์ P1 ๋ด๋น ์ ์ฒด ์์ ์๊ฐ 30๋ถ
๐ README.md ์
๋ฐ์ดํธ
# ๐พ ๋ฝ์๋ ๊ธฐ (BBossiregi)
> ๋ฐ๋ ค๋๋ฌผ ํ์๋ ์ด์ปค๋จธ์ค ํ๋ซํผ
## ๐ ํ๋ก์ ํธ ๊ฐ์
๋ฝ์๋ ๊ธฐ๋ ๋ฐ๋ ค๋๋ฌผ ์ฉํ์ ํ์๋ ๋ฐฉ์์ผ๋ก ํ๋งคํ๋ ์ด์ปค๋จธ์ค ํ๋ซํผ์
๋๋ค.
์ฌ๊ฐ ํจํด์ ํ์ฉํ ๋ถ์ฐ ํธ๋์ญ์
์ผ๋ก ์ฌ๊ณ ์ ํฉ์ฑ์ ๋ณด์ฅํฉ๋๋ค.
### ์ฃผ์ ๊ธฐ๋ฅ
- ๐ **์ฌ์ฉ์ ์ธ์ฆ** : JWT ๊ธฐ๋ฐ ํ์๊ฐ์
/๋ก๊ทธ์ธ
- ๐ **ํ์๋** : ์๊ฐ ์ ํ ํน๊ฐ ์ํ ํ๋งค
- ๐ฆ **์ฃผ๋ฌธ ๊ด๋ฆฌ** : ์ฌ๊ฐ ํจํด ๊ธฐ๋ฐ ์ฃผ๋ฌธ ์ฒ๋ฆฌ
- ๐ **์ฌ๊ณ ๊ด๋ฆฌ** : ๋น๊ด์ ๋ฝ์ ํตํ ๋์์ฑ ์ ์ด
## ๐ ์ํคํ
์ฒ
โโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโ
โ Client โโโโโถโ ALB โโโโโถโ EKS โ
โโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโ โโโโโโโโฌโโโโโโโ
โ
โผ
โโโโโโโโโโโโโโโ
โ RDS โ
โ (PostgreSQL)โ
โโโโโโโโโโโโโโโ
## ๐ ๊ธฐ์ ์คํ
### Backend
- **Language**: Go 1.22
- **Framework**: Gin
- **Database**: PostgreSQL 15
- **ORM**: database/sql + Raw SQL
### Infrastructure
- **Cloud**: AWS (EKS, RDS, ALB, ECR)
- **IaC**: Terraform
- **Container**: Docker, Kubernetes
- **CI/CD**: GitHub Actions
## ๐ ์์ํ๊ธฐ
### ์ฌ์ ์๊ตฌ์ฌํญ
- Go 1.22+
- Docker
- kubectl
- AWS CLI
- Terraform 1.5+
### ๋ก์ปฌ ๊ฐ๋ฐ
```bash
# ๋ ํฌ์งํ ๋ฆฌ ํด๋ก
git clone https://github.com/your-org/bbossiregi.git
cd bbossiregi
# ํ๊ฒฝ๋ณ์ ์ค์
cp backend/.env.example backend/.env
# .env ํ์ผ ํธ์ง
# ์์กด์ฑ ์ค์น
cd backend
go mod download
# ๋ก์ปฌ ์คํ
make run
๋ฐฐํฌ
# ๋ฐฐํฌ ์คํฌ๋ฆฝํธ ์คํ
./scripts/deploy.sh
๐ก API ์๋ํฌ์ธํธ
Method Endpoint ์ค๋ช
์ธ์ฆ GET /api/v1/health ํฌ์ค์ฒดํฌ โ POST /api/v1/auth/register ํ์๊ฐ์
โ POST /api/v1/auth/login ๋ก๊ทธ์ธ โ GET /api/v1/timedeals ํ์๋ ๋ชฉ๋ก โ GET /api/v1/timedeals/:id ํ์๋ ์์ธ โ POST /api/v1/orders ์ฃผ๋ฌธ ์์ฑ โ
GET /api/v1/orders ์ฃผ๋ฌธ ๋ชฉ๋ก โ
GET /api/v1/orders/:id ์ฃผ๋ฌธ ์์ธ โ
DELETE /api/v1/orders/:id ์ฃผ๋ฌธ ์ทจ์ โ
๐ ํ๋ก์ ํธ ๊ตฌ์กฐ
bbossiregi/
โโโ backend/ # Go ๋ฐฑ์๋
โ โโโ cmd/ # ์ํธ๋ฆฌํฌ์ธํธ
โ โโโ internal/ # ๋ด๋ถ ํจํค์ง
โ โโโ migrations/ # DB ๋ง์ด๊ทธ๋ ์ด์
โโโ terraform/ # IaC
โโโ k8s/ # Kubernetes ๋งค๋ํ์คํธ
โโโ scripts/ # ๋ฐฐํฌ ์คํฌ๋ฆฝํธ
โโโ docs/ # ๋ฌธ์
๐ฅ ํ
์ด๋ํ - ํ์ฅ / ์ธํ๋ผ
๋ฐ์งํ - ๋ฐฑ์๋
๋ฐ๊ท์ - ๋ฐฑ์๋
์์ฃผ์ - ๋ฐฑ์๋
๐ ๋ผ์ด์ ์ค
MIT License
---
## D5-009: ์คํ๋ฆฐํธ ํ๊ณ
### ๐ ํ์คํฌ ์ ๋ณด
| ํญ๋ชฉ | ๋ด์ฉ |
|------|------|
| ID | D5-009 |
| ์ฐ์ ์์ | P0 |
| ๋ด๋น | ์ ์ฒด |
| ์์ ์๊ฐ | 1์๊ฐ |
### ๐ ํ๊ณ ์์ ๋ค
Sprint 1 ๋ชฉํ ๋ฌ์ฑ ํํฉ ๊ฒํ (10๋ถ)
์ํ ์ (Keep) ๊ณต์ (15๋ถ)
๊ฐ์ ํ ์ (Problem) ๊ณต์ (15๋ถ)
์๋ํ ๊ฒ (Try) ๋
ผ์ (15๋ถ)
Sprint 2 ๊ณํ ๋ฏธ๋ฆฌ๋ณด๊ธฐ (5๋ถ)
### ๐ ํ๊ณ ํ
ํ๋ฆฟ
```markdown
# ๐ Sprint 1 ํ๊ณ ๋ก
## ๐
๊ธฐ๊ฐ
2026.02.24 (์) ~ 02.28 (๊ธ)
## ๐ฏ ๋ชฉํ ๋ฌ์ฑ ํํฉ
### ์๋ฃ๋ ํญ๋ชฉ โ
- [ ] AWS ์ธํ๋ผ ๊ตฌ์ถ (VPC, RDS, EKS)
- [ ] ๋ฐฑ์๋ API ๊ฐ๋ฐ (์ธ์ฆ, ํ์๋, ์ฃผ๋ฌธ)
- [ ] ์ฌ๊ฐ ํจํด ๊ตฌํ (์ฌ๊ณ ์์ฝ/ํด์ )
- [ ] ์ปจํ
์ด๋ํ ๋ฐ ๋ฐฐํฌ
- [ ] HTTPS ์ค์
### ๋ฏธ์๋ฃ ํญ๋ชฉ โ
- (์๋ค๋ฉด ๊ธฐ์ฌ)
## ๐ Keep (์ํ ์ )
### ๊ธฐ์ ์
-
### ํ์
-
## ๐ Problem (๊ฐ์ ํ ์ )
### ๊ธฐ์ ์
-
### ํ์
-
## ๐ก Try (์๋ํ ๊ฒ)
### Sprint 2์์ ์๋
-
## ๐ ์์น ์งํ
| ์งํ | ๋ชฉํ | ๋ฌ์ฑ |
|------|------|------|
| ๊ณํ๋ ํ์คํฌ | 30๊ฐ | ?๊ฐ |
| ์๋ฃ๋ ํ์คํฌ | 30๊ฐ | ?๊ฐ |
| ๋ฒ๊ทธ ๋ฐ์ | 0๊ฐ | ?๊ฐ |
| E2E ํ
์คํธ ํต๊ณผ์จ | 100% | ?% |
## ๐ Sprint 2 ๋ฏธ๋ฆฌ๋ณด๊ธฐ
1. ํ๋ก ํธ์๋ ๊ฐ๋ฐ ์์
2. ๊ฒฐ์ ๋ชจ๋ ์ฐ๋
3. ์ค์๊ฐ ์ฌ๊ณ ์๋ฆผ
4. ์ฑ๋ฅ ํ
์คํธ ๋ฐ ์ต์ ํ
5. ๊ด๋ฆฌ์ ๋์๋ณด๋
## ๐ ๊ฐ์ธ๋ณ ํ๋ง๋
### ์ด๋ํ (์ธํ๋ผ)
>
### ๋ฐ์งํ (๋ฐฑ์๋)
>
### ๋ฐ๊ท์ (๋ฐฑ์๋)
>
### ์์ฃผ์ (๋ฐฑ์๋)
>
D5-010: Day 5 ์๋ฃ ์ฒดํฌ๋ฆฌ์คํธ
๐ ์ต์ข
์๋ฃ ๊ธฐ์ค
[ ] ALB Ingress Controller ์ค์น ์๋ฃ
[ ] ACM ์ธ์ฆ์ ๋ฐ๊ธ ์๋ฃ
[ ] Route53 DNS ์ค์ ์๋ฃ
[ ] HTTPS ์ ์ ๊ฐ๋ฅ
[ ] ์ต์ข
์ด๋ฏธ์ง ECR ํธ์ ์๋ฃ
[ ] EKS ๋ฐฐํฌ ์๋ฃ (Pod Running)
[ ] E2E ํ
์คํธ ์ ์ฒด ํต๊ณผ
[ ] CloudWatch ๋ก๊ทธ ์์ง ํ์ธ
[ ] README.md ์
๋ฐ์ดํธ ์๋ฃ
[ ] ์คํ๋ฆฐํธ ํ๊ณ ์๋ฃ
๐งช ์ต์ข
๊ฒ์ฆ
# 1. ์๋น์ค ์ํ ํ์ธ
kubectl get all -n bbossiregi
# ์์ ์ถ๋ ฅ
NAME READY STATUS RESTARTS AGE
pod/backend-xxx-xxx 1/1 Running 0 1h
pod/backend-xxx-yyy 1/1 Running 0 1h
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT ( S ) AGE
service/backend-service ClusterIP 172.20.xxx.xxx < non e > 80/TCP 1h
NAME READY UP-TO-DATE AVAILABLE AGE
deployment.apps/backend 2/2 2 2 1h
# 2. HTTPS ํฌ์ค์ฒดํฌ
curl https://api.bbossiregi.com/api/v1/health
# 3. E2E ํ
์คํธ
./scripts/e2e-test.sh
# 4. ๋ก๊ทธ ํ์ธ
kubectl logs -f deployment/backend -n bbossiregi --tail=100
๐ Sprint 1 ์๋ฃ!
๐ Sprint 1 ๋ฌ์ฑ ์์ฝ
์นดํ
๊ณ ๋ฆฌ ํญ๋ชฉ ์ํ ์ธํ๋ผ VPC + ์๋ธ๋ท + NAT โ
RDS PostgreSQL โ
EKS ํด๋ฌ์คํฐ โ
ALB + HTTPS โ
Route53 DNS โ
๋ฐฑ์๋ ์ธ์ฆ API โ
ํ์๋ API โ
์ฃผ๋ฌธ API (์ฌ๊ฐ ํจํด) โ
์ฌ๊ณ ๊ด๋ฆฌ (๋์์ฑ) โ
DevOps Dockerfile โ
K8s ๋งค๋ํ์คํธ โ
CI/CD ํ์ดํ๋ผ์ธ โ
๋ฌธ์ํ API ๋ช
์ธ โ
README โ
ํ๊ณ ๋ก โ
๐ Sprint 2 ์๊ณ
Week 2: ํ๋ก ํธ์๋ + ๊ณ ๊ธ ๊ธฐ๋ฅ
Day 6-7: React ํ๋ก ํธ์๋ ๊ฐ๋ฐ
- ํ์๋ ๋ชฉ๋ก/์์ธ ํ์ด์ง
- ๋ก๊ทธ์ธ/ํ์๊ฐ์
- ์ฃผ๋ฌธํ๊ธฐ
Day 8-9: ๊ณ ๊ธ ๊ธฐ๋ฅ
- ์ค์๊ฐ ์ฌ๊ณ (WebSocket)
- ๊ฒฐ์ ๋ชจ๋ ์ฐ๋
- ์๋ฆผ ๊ธฐ๋ฅ
Day 10: ์ฑ๋ฅ ํ
์คํธ + ์ต์ ํ
- ๋ถํ ํ
์คํธ (k6)
- ์ฟผ๋ฆฌ ์ต์ ํ
- ์บ์ฑ (Redis)
๐พ ๋ฝ์๋ ๊ธฐ Sprint 1 ์๋ฃ! ์๊ณ ํ์
จ์ต๋๋ค! ๐