๐Ÿพ ๋ฝ€์‹œ๋ ˆ๊ธฐ 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:00ERD ์„ค๊ณ„๋ฐฑ์—”๋“œERD ๋‹ค์ด์–ด๊ทธ๋žจ
10:00-12:00AWS ๊ณ„์ • + IAM ์„ค์ •์ธํ”„๋ผIAM ์ •์ฑ…
13:00-15:00API ๋ช…์„ธ ์ž‘์„ฑ๋ฐฑ์—”๋“œOpenAPI Spec
13:00-15:00์•„ํ‚คํ…์ฒ˜ ๋‹ค์ด์–ด๊ทธ๋žจ์ธํ”„๋ผdraw.io
15:00-17:00Git ๋ ˆํฌ ๊ตฌ์„ฑ + ๋ธŒ๋žœ์น˜ ์ „๋žต์ „์ฒดGitHub Repo
17:00-18:00Day 1 ๋ฆฌ๋ทฐ + ๋ธ”๋กœ์ปค ๊ณต์œ ์ „์ฒดํšŒ์˜๋ก

D1-001: AWS ๊ณ„์ • ๋ฐ IAM ์„ค์ •

๐Ÿ“Œ ํƒœ์Šคํฌ ์ •๋ณด

ํ•ญ๋ชฉ๋‚ด์šฉ
IDD1-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 ํ™œ์„ฑํ™”

๐Ÿ“ ์ƒ์„ธ ๋ช…์„ธ

IAM ์ •์ฑ…: bbossiregi-terraform

{
  "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 ์„ค๊ณ„ ํ™•์ •

๐Ÿ“Œ ํƒœ์Šคํฌ ์ •๋ณด

ํ•ญ๋ชฉ๋‚ด์šฉ
IDD1-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: ์‹œ์Šคํ…œ ์•„ํ‚คํ…์ฒ˜ ๋‹ค์ด์–ด๊ทธ๋žจ

๐Ÿ“Œ ํƒœ์Šคํฌ ์ •๋ณด

ํ•ญ๋ชฉ๋‚ด์šฉ
IDD1-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 Nodet3.medium2-4$60-120
RDSdb.t3.micro1$15
ALB-1$20
NAT Gateway-1$32
ECR-1$1
Route53-1$0.5
ํ•ฉ๊ณ„~$200-260

D1-004: API ๋ช…์„ธ ์ž‘์„ฑ

๐Ÿ“Œ ํƒœ์Šคํฌ ์ •๋ณด

ํ•ญ๋ชฉ๋‚ด์šฉ
IDD1-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 ๋ ˆํฌ์ง€ํ† ๋ฆฌ ๊ตฌ์„ฑ

๐Ÿ“Œ ํƒœ์Šคํฌ ์ •๋ณด

ํ•ญ๋ชฉ๋‚ด์šฉ
IDD1-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:00VPC + ์„œ๋ธŒ๋„ท + NAT/IGW์ธํ”„๋ผTerraform ์ฝ”๋“œ
09:30-12:00Go ํ”„๋กœ์ ํŠธ ์ดˆ๊ธฐํ™”๋ฐฑ์—”๋“œmain.go
13:00-15:00๋ณด์•ˆ ๊ทธ๋ฃน ์„ค์ •์ธํ”„๋ผTerraform ์ฝ”๋“œ
13:00-15:00DB ์—ฐ๊ฒฐ ๋ชจ๋“ˆ๋ฐฑ์—”๋“œdb/postgres.go
15:00-17:00RDS ๊ตฌ์ถ•์ธํ”„๋ผTerraform ์ฝ”๋“œ
15:00-17:00DDL ์‹คํ–‰ + ํ…Œ์ŠคํŠธ ๋ฐ์ดํ„ฐ๋ฐฑ์—”๋“œSQL ํŒŒ์ผ
17:00-18:00Day 2 ๋ฆฌ๋ทฐ + ์—ฐ๊ฒฐ ํ…Œ์ŠคํŠธ์ „์ฒด-

D2-001: VPC ์ƒ์„ฑ

๐Ÿ“Œ ํƒœ์Šคํฌ ์ •๋ณด

ํ•ญ๋ชฉ๋‚ด์šฉ
IDD2-001
์šฐ์„ ์ˆœ์œ„P0
๋‹ด๋‹น์ธํ”„๋ผ
์˜ˆ์ƒ ์‹œ๊ฐ„1์‹œ๊ฐ„
์„ ํ–‰ ์ž‘์—…D1-001 (IAM)

๐Ÿ“‹ ์ฒดํฌ๋ฆฌ์ŠคํŠธ

[ ] VPC ์ƒ์„ฑ (10.0.0.0/16)
[ ] DNS ํ˜ธ์ŠคํŠธ์ด๋ฆ„ ํ™œ์„ฑํ™”
[ ] DNS ํ•ด์„ ํ™œ์„ฑํ™”
[ ] ํƒœ๊ทธ ์„ค์ •

๐Ÿ“ Terraform ์ฝ”๋“œ

# 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 ์ฝ”๋“œ

# 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 ์ฝ”๋“œ

# 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 ์ฝ”๋“œ

# 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 ์ฝ”๋“œ

# 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 ์ฝ”๋“œ

# 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-endpoint> -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:30Go ํ”„๋กœ์ ํŠธ ๊ตฌ์กฐ ์„ธํŒ…๋ฐฑ์—”๋“œ๋””๋ ‰ํ† ๋ฆฌ ๊ตฌ์กฐ
10:30-12:00DB ์—ฐ๊ฒฐ + ์„ค์ • ๋ชจ๋“ˆ๋ฐฑ์—”๋“œ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:00Day 3 ๋ฆฌ๋ทฐ + API ํ…Œ์ŠคํŠธ์ „์ฒดPostman Collection

D3-001: Go ํ”„๋กœ์ ํŠธ ์ดˆ๊ธฐํ™”

๐Ÿ“Œ ํƒœ์Šคํฌ ์ •๋ณด

ํ•ญ๋ชฉ๋‚ด์šฉ
IDD3-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 ์—ฐ๊ฒฐ ๋ชจ๋“ˆ

๐Ÿ“Œ ํƒœ์Šคํฌ ์ •๋ณด

ํ•ญ๋ชฉ๋‚ด์šฉ
IDD3-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: ๊ณตํ†ต ๋ชจ๋ธ ๋ฐ ์‘๋‹ต ๊ตฌ์กฐ์ฒด

๐Ÿ“Œ ํƒœ์Šคํฌ ์ •๋ณด

ํ•ญ๋ชฉ๋‚ด์šฉ
IDD3-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: ์‘๋‹ต ํ—ฌํผ ๋ฐ ๋ฏธ๋“ค์›จ์–ด

๐Ÿ“Œ ํƒœ์Šคํฌ ์ •๋ณด

ํ•ญ๋ชฉ๋‚ด์šฉ
IDD3-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 (ํšŒ์›๊ฐ€์ž…/๋กœ๊ทธ์ธ)

๐Ÿ“Œ ํƒœ์Šคํฌ ์ •๋ณด

ํ•ญ๋ชฉ๋‚ด์šฉ
IDD3-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

๐Ÿ“Œ ํƒœ์Šคํฌ ์ •๋ณด

ํ•ญ๋ชฉ๋‚ด์šฉ
IDD3-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: ํƒ€์ž„๋”œ ์Šค์ผ€์ค„๋Ÿฌ

๐Ÿ“Œ ํƒœ์Šคํฌ ์ •๋ณด

ํ•ญ๋ชฉ๋‚ด์šฉ
IDD3-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: ๋ผ์šฐํ„ฐ ์„ค์ •

๐Ÿ“Œ ํƒœ์Šคํฌ ์ •๋ณด

ํ•ญ๋ชฉ๋‚ด์šฉ
IDD3-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:00ECR ๋ ˆํฌ์ง€ํ† ๋ฆฌ ์ƒ์„ฑ์ธํ”„๋ผTerraform ์ฝ”๋“œ
11:00-12:00Dockerfile ์ž‘์„ฑ์ธํ”„๋ผDockerfile
13:00-15:00์žฌ๊ณ  ๊ด€๋ฆฌ ์„œ๋น„์Šค (๋™์‹œ์„ฑ)๋ฐฑ์—”๋“œstock_service.go
13:00-15:00EKS ํด๋Ÿฌ์Šคํ„ฐ ์ƒ์„ฑ์ธํ”„๋ผTerraform ์ฝ”๋“œ
15:00-17:00์ฃผ๋ฌธ ์ทจ์†Œ (๋ณด์ƒ ํŠธ๋žœ์žญ์…˜)๋ฐฑ์—”๋“œ๋ณด์ƒ ๋กœ์ง
15:00-17:00K8s ๋งค๋‹ˆํŽ˜์ŠคํŠธ ์ž‘์„ฑ์ธํ”„๋ผk8s/*.yaml
17:00-18:00Day 4 ๋ฆฌ๋ทฐ + ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ์ „์ฒดํ…Œ์ŠคํŠธ ๊ฒฐ๊ณผ

D4-001: ์ฃผ๋ฌธ Repository ๊ตฌํ˜„

๐Ÿ“Œ ํƒœ์Šคํฌ ์ •๋ณด

ํ•ญ๋ชฉ๋‚ด์šฉ
IDD4-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: ์žฌ๊ณ  ๊ด€๋ฆฌ ์„œ๋น„์Šค (๋™์‹œ์„ฑ ์ œ์–ด ํ•ต์‹ฌ!)

๐Ÿ“Œ ํƒœ์Šคํฌ ์ •๋ณด

ํ•ญ๋ชฉ๋‚ด์šฉ
IDD4-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: ์ฃผ๋ฌธ ์„œ๋น„์Šค (์‚ฌ๊ฐ€ ํŒจํ„ด ๊ตฌํ˜„)

๐Ÿ“Œ ํƒœ์Šคํฌ ์ •๋ณด

ํ•ญ๋ชฉ๋‚ด์šฉ
IDD4-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 ํ•ธ๋“ค๋Ÿฌ

๐Ÿ“Œ ํƒœ์Šคํฌ ์ •๋ณด

ํ•ญ๋ชฉ๋‚ด์šฉ
IDD4-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 ๋ ˆํฌ์ง€ํ† ๋ฆฌ ์ƒ์„ฑ

๐Ÿ“Œ ํƒœ์Šคํฌ ์ •๋ณด

ํ•ญ๋ชฉ๋‚ด์šฉ
IDD4-006
์šฐ์„ ์ˆœ์œ„P0
๋‹ด๋‹น์ธํ”„๋ผ
์˜ˆ์ƒ ์‹œ๊ฐ„30๋ถ„
์„ ํ–‰ ์ž‘์—…D2-005 (๋ณด์•ˆ ๊ทธ๋ฃน)
ํ›„ํ–‰ ์ž‘์—…D4-007 (Dockerfile)

๐Ÿ“‹ ์ฒดํฌ๋ฆฌ์ŠคํŠธ

[ ] ECR ๋ ˆํฌ์ง€ํ† ๋ฆฌ ์ƒ์„ฑ
[ ] ์ด๋ฏธ์ง€ ์Šค์บ” ํ™œ์„ฑํ™”
[ ] ๋ผ์ดํ”„์‚ฌ์ดํด ์ •์ฑ… ์„ค์ •
[ ] ํ‘ธ์‹œ ํ…Œ์ŠคํŠธ

๐Ÿ“ Terraform ์ฝ”๋“œ

# 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-id>.dkr.ecr.ap-northeast-2.amazonaws.com
 
# ๋ ˆํฌ์ง€ํ† ๋ฆฌ ํ™•์ธ
aws ecr describe-repositories

D4-007: Dockerfile ์ž‘์„ฑ

๐Ÿ“Œ ํƒœ์Šคํฌ ์ •๋ณด

ํ•ญ๋ชฉ๋‚ด์šฉ
IDD4-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 ํด๋Ÿฌ์Šคํ„ฐ ์ƒ์„ฑ

๐Ÿ“Œ ํƒœ์Šคํฌ ์ •๋ณด

ํ•ญ๋ชฉ๋‚ด์šฉ
IDD4-008
์šฐ์„ ์ˆœ์œ„P0 (Blocker)
๋‹ด๋‹น์ธํ”„๋ผ
์˜ˆ์ƒ ์‹œ๊ฐ„2์‹œ๊ฐ„ (ํด๋Ÿฌ์Šคํ„ฐ ์ƒ์„ฑ ~15๋ถ„)
์„ ํ–‰ ์ž‘์—…D2-004 (๋ผ์šฐํŒ… ํ…Œ์ด๋ธ”)
ํ›„ํ–‰ ์ž‘์—…D4-009 (K8s ๋งค๋‹ˆํŽ˜์ŠคํŠธ)

๐Ÿ“‹ ์ฒดํฌ๋ฆฌ์ŠคํŠธ

[ ] EKS ํด๋Ÿฌ์Šคํ„ฐ ์ƒ์„ฑ
[ ] Node Group ์ƒ์„ฑ
[ ] IAM ์—ญํ•  ๋ฐ ์ •์ฑ…
[ ] kubectl ์—ฐ๊ฒฐ ์„ค์ •
[ ] ๊ธฐ๋ณธ ๋„ค์ž„์ŠคํŽ˜์ด์Šค ์ƒ์„ฑ

๐Ÿ“ Terraform ์ฝ”๋“œ

# 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    <none>   5m    v1.29.x
ip-10-0-12-xxx.ap-northeast-2.compute.internal   Ready    <none>   5m    v1.29.x

D4-009: Kubernetes ๋งค๋‹ˆํŽ˜์ŠคํŠธ ์ž‘์„ฑ

๐Ÿ“Œ ํƒœ์Šคํฌ ์ •๋ณด

ํ•ญ๋ชฉ๋‚ด์šฉ
IDD4-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  <none>        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 ์ฃผ์š” ๋‹ฌ์„ฑ ์‚ฌํ•ญ:

  1. โœ… ์ฃผ๋ฌธ API (์‚ฌ๊ฐ€ ํŒจํ„ด) ์™„์„ฑ
  2. โœ… ์žฌ๊ณ  ๊ด€๋ฆฌ ์„œ๋น„์Šค (๋™์‹œ์„ฑ ์ œ์–ด)
  3. โœ… ๋ณด์ƒ ํŠธ๋žœ์žญ์…˜ ๊ตฌํ˜„
  4. โœ… ECR ๋ ˆํฌ์ง€ํ† ๋ฆฌ ์ƒ์„ฑ
  5. โœ… Dockerfile ์ž‘์„ฑ
  6. โœ… EKS ํด๋Ÿฌ์Šคํ„ฐ ์ƒ์„ฑ
  7. โœ… K8s ๋งค๋‹ˆํŽ˜์ŠคํŠธ ์ž‘์„ฑ

๐Ÿ“… Day 5 (2/28 ๊ธˆ) - ๋ฐฐํฌ + HTTPS + ํšŒ๊ณ 

ํƒ€์ž„๋ผ์ธ

์‹œ๊ฐ„ํƒœ์Šคํฌ๋‹ด๋‹น์‚ฐ์ถœ๋ฌผ
09:00-09:30๋ฐ์ผ๋ฆฌ ์Šคํƒ ๋“œ์—…์ „์ฒดํšŒ์˜๋ก
09:30-11:00ALB Ingress Controller ์„ค์น˜์ธํ”„๋ผIngress ๋ฆฌ์†Œ์Šค
09:30-11:00๋ฐฑ์—”๋“œ ์ตœ์ข… ์ ๊ฒ€ ๋ฐ ๋ฒ„๊ทธ ์ˆ˜์ •๋ฐฑ์—”๋“œ์ˆ˜์ •๋œ ์ฝ”๋“œ
11:00-12:00ACM ์ธ์ฆ์„œ + Route53 ์„ค์ •์ธํ”„๋ผHTTPS ํ™œ์„ฑํ™”
13:00-14:30์ด๋ฏธ์ง€ ๋นŒ๋“œ + ECR ํ‘ธ์‹œ + EKS ๋ฐฐํฌ์ „์ฒด์‹คํ–‰ ์ค‘์ธ ์„œ๋น„์Šค
14:30-16:00E2E ํ…Œ์ŠคํŠธ + ๋ฒ„๊ทธ ์ˆ˜์ •์ „์ฒดํ…Œ์ŠคํŠธ ๊ฒฐ๊ณผ
16:00-17:00๋ชจ๋‹ˆํ„ฐ๋ง ์„ค์ • (CloudWatch)์ธํ”„๋ผ๋Œ€์‹œ๋ณด๋“œ
17:00-18:00์Šคํ”„๋ฆฐํŠธ ํšŒ๊ณ  + ๋ฌธ์„œํ™”์ „์ฒดํšŒ๊ณ ๋ก, README

D5-001: ALB Ingress Controller ์„ค์น˜

๐Ÿ“Œ ํƒœ์Šคํฌ ์ •๋ณด

ํ•ญ๋ชฉ๋‚ด์šฉ
IDD5-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์œผ๋กœ IAM Role ์ƒ์„ฑ

# 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_ID>:role/bbossiregi-alb-controller-role \
  --set region=ap-northeast-2 \
  --set vpcId=<VPC_ID>
 
# ์„ค์น˜ ํ™•์ธ
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 ์ธ์ฆ์„œ ๋ฐœ๊ธ‰

๐Ÿ“Œ ํƒœ์Šคํฌ ์ •๋ณด

ํ•ญ๋ชฉ๋‚ด์šฉ
IDD5-002
์šฐ์„ ์ˆœ์œ„P1
๋‹ด๋‹น์ธํ”„๋ผ
์˜ˆ์ƒ ์‹œ๊ฐ„30๋ถ„
์„ ํ–‰ ์ž‘์—…D5-001
ํ›„ํ–‰ ์ž‘์—…D5-003 (Route53)

๐Ÿ“‹ ์ฒดํฌ๋ฆฌ์ŠคํŠธ

[ ] ACM ์ธ์ฆ์„œ ์š”์ฒญ
[ ] DNS ๊ฒ€์ฆ ๋ ˆ์ฝ”๋“œ ์ƒ์„ฑ
[ ] ์ธ์ฆ์„œ ๋ฐœ๊ธ‰ ํ™•์ธ
[ ] Ingress์— ์ธ์ฆ์„œ ์—ฐ๊ฒฐ

๐Ÿ“ Terraform ์ฝ”๋“œ

# 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-arn> \
  --query 'Certificate.Status'
 
# ์˜ˆ์ƒ ์ถœ๋ ฅ: "ISSUED"

D5-003: Route53 DNS ์„ค์ •

๐Ÿ“Œ ํƒœ์Šคํฌ ์ •๋ณด

ํ•ญ๋ชฉ๋‚ด์šฉ
IDD5-003
์šฐ์„ ์ˆœ์œ„P1
๋‹ด๋‹น์ธํ”„๋ผ
์˜ˆ์ƒ ์‹œ๊ฐ„30๋ถ„
์„ ํ–‰ ์ž‘์—…D5-002

๐Ÿ“‹ ์ฒดํฌ๋ฆฌ์ŠคํŠธ

[ ] Hosted Zone ํ™•์ธ/์ƒ์„ฑ
[ ] ALB Alias ๋ ˆ์ฝ”๋“œ ์ƒ์„ฑ
[ ] DNS ์ „ํŒŒ ํ™•์ธ

๐Ÿ“ Terraform ์ฝ”๋“œ

# 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 ํ™œ์„ฑํ™”

๐Ÿ“Œ ํƒœ์Šคํฌ ์ •๋ณด

ํ•ญ๋ชฉ๋‚ด์šฉ
IDD5-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: ์ตœ์ข… ์ด๋ฏธ์ง€ ๋นŒ๋“œ ๋ฐ ๋ฐฐํฌ

๐Ÿ“Œ ํƒœ์Šคํฌ ์ •๋ณด

ํ•ญ๋ชฉ๋‚ด์šฉ
IDD5-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 ํ…Œ์ŠคํŠธ

๐Ÿ“Œ ํƒœ์Šคํฌ ์ •๋ณด

ํ•ญ๋ชฉ๋‚ด์šฉ
IDD5-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 ๋ชจ๋‹ˆํ„ฐ๋ง ์„ค์ •

๐Ÿ“Œ ํƒœ์Šคํฌ ์ •๋ณด

ํ•ญ๋ชฉ๋‚ด์šฉ
IDD5-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)

# 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: ๋ฌธ์„œํ™”

๐Ÿ“Œ ํƒœ์Šคํฌ ์ •๋ณด

ํ•ญ๋ชฉ๋‚ด์šฉ
IDD5-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 ์—”๋“œํฌ์ธํŠธ

MethodEndpoint์„ค๋ช…์ธ์ฆ
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์‹œ๊ฐ„ |

### ๐Ÿ“‹ ํšŒ๊ณ  ์•„์  ๋‹ค

  1. Sprint 1 ๋ชฉํ‘œ ๋‹ฌ์„ฑ ํ˜„ํ™ฉ ๊ฒ€ํ†  (10๋ถ„)
  2. ์ž˜ํ•œ ์  (Keep) ๊ณต์œ  (15๋ถ„)
  3. ๊ฐœ์„ ํ•  ์  (Problem) ๊ณต์œ  (15๋ถ„)
  4. ์‹œ๋„ํ•  ๊ฒƒ (Try) ๋…ผ์˜ (15๋ถ„)
  5. 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  <none>        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 (์‚ฌ๊ฐ€ ํŒจํ„ด)โœ…
์žฌ๊ณ  ๊ด€๋ฆฌ (๋™์‹œ์„ฑ)โœ…
DevOpsDockerfileโœ…
K8s ๋งค๋‹ˆํŽ˜์ŠคํŠธโœ…
CI/CD ํŒŒ์ดํ”„๋ผ์ธโœ…
๋ฌธ์„œํ™”API ๋ช…์„ธโœ…
READMEโœ…
ํšŒ๊ณ ๋กโœ…

๐Ÿ”œ Sprint 2 ์˜ˆ๊ณ 

Week 2: ํ”„๋ก ํŠธ์—”๋“œ + ๊ณ ๊ธ‰ ๊ธฐ๋Šฅ

Day 6-7: React ํ”„๋ก ํŠธ์—”๋“œ ๊ฐœ๋ฐœ
- ํƒ€์ž„๋”œ ๋ชฉ๋ก/์ƒ์„ธ ํŽ˜์ด์ง€
- ๋กœ๊ทธ์ธ/ํšŒ์›๊ฐ€์ž…
- ์ฃผ๋ฌธํ•˜๊ธฐ

Day 8-9: ๊ณ ๊ธ‰ ๊ธฐ๋Šฅ
- ์‹ค์‹œ๊ฐ„ ์žฌ๊ณ  (WebSocket)
- ๊ฒฐ์ œ ๋ชจ๋“ˆ ์—ฐ๋™
- ์•Œ๋ฆผ ๊ธฐ๋Šฅ

Day 10: ์„ฑ๋Šฅ ํ…Œ์ŠคํŠธ + ์ตœ์ ํ™”
- ๋ถ€ํ•˜ ํ…Œ์ŠคํŠธ (k6)
- ์ฟผ๋ฆฌ ์ตœ์ ํ™”
- ์บ์‹ฑ (Redis)

๐Ÿพ ๋ฝ€์‹œ๋ ˆ๊ธฐ Sprint 1 ์™„๋ฃŒ! ์ˆ˜๊ณ ํ•˜์…จ์Šต๋‹ˆ๋‹ค! ๐ŸŽŠ