🗄️ 데이터베이스

CRUD

Create, Read, Update, Delete

데이터의 기본 연산 4가지(생성, 읽기, 수정, 삭제)를 의미합니다. 대부분의 애플리케이션과 REST API가 CRUD 패턴을 기반으로 설계되며, 데이터베이스의 INSERT, SELECT, UPDATE, DELETE와 직접 매핑됩니다.

📖 상세 설명

Create (생성): 새로운 데이터를 생성합니다. SQL에서는 INSERT, HTTP에서는 POST 메서드에 해당합니다. 회원가입, 글 작성, 주문 생성 등이 대표적인 예입니다.

Read (읽기): 기존 데이터를 조회합니다. SQL에서는 SELECT, HTTP에서는 GET 메서드에 해당합니다. 단일 조회(getById)와 목록 조회(getAll, 검색, 필터링)로 구분됩니다.

Update (수정): 기존 데이터를 변경합니다. SQL에서는 UPDATE, HTTP에서는 PUT(전체 교체) 또는 PATCH(부분 수정)에 해당합니다. 프로필 수정, 상품 정보 변경 등이 있습니다.

Delete (삭제): 데이터를 제거합니다. SQL에서는 DELETE, HTTP에서는 DELETE 메서드에 해당합니다. 실제 삭제(Hard Delete)와 논리적 삭제(Soft Delete: is_deleted 플래그)로 구분됩니다.

CRUD와 REST API 매핑: RESTful API 설계의 기본입니다. 리소스 URL(예: /users)에 HTTP 메서드를 조합하여 CRUD를 표현합니다. GET /users(목록), GET /users/:id(상세), POST /users(생성), PUT /users/:id(수정), DELETE /users/:id(삭제).

💻 코드 예제

# CRUD API 예제 - Python FastAPI + SQLAlchemy
from fastapi import FastAPI, HTTPException, Depends
from sqlalchemy import create_engine, Column, Integer, String, Boolean, DateTime
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker, Session
from pydantic import BaseModel, EmailStr
from datetime import datetime
from typing import List, Optional

# 데이터베이스 설정
DATABASE_URL = "postgresql://user:pass@localhost/mydb"
engine = create_engine(DATABASE_URL)
SessionLocal = sessionmaker(bind=engine)
Base = declarative_base()


# 모델 정의
class User(Base):
    __tablename__ = "users"

    id = Column(Integer, primary_key=True, index=True)
    email = Column(String(255), unique=True, nullable=False)
    username = Column(String(100), nullable=False)
    is_active = Column(Boolean, default=True)
    created_at = Column(DateTime, default=datetime.utcnow)
    updated_at = Column(DateTime, onupdate=datetime.utcnow)


# Pydantic 스키마
class UserCreate(BaseModel):
    email: EmailStr
    username: str

class UserUpdate(BaseModel):
    email: Optional[EmailStr] = None
    username: Optional[str] = None
    is_active: Optional[bool] = None

class UserResponse(BaseModel):
    id: int
    email: str
    username: str
    is_active: bool
    created_at: datetime

    class Config:
        from_attributes = True


# FastAPI 앱
app = FastAPI()


def get_db():
    db = SessionLocal()
    try:
        yield db
    finally:
        db.close()


# ==============================================
# CREATE - 새 사용자 생성
# ==============================================
@app.post("/users", response_model=UserResponse, status_code=201)
def create_user(user: UserCreate, db: Session = Depends(get_db)):
    """
    Create: 새로운 리소스 생성
    - HTTP Method: POST
    - SQL: INSERT INTO users (email, username) VALUES (...)
    """
    # 중복 체크
    existing = db.query(User).filter(User.email == user.email).first()
    if existing:
        raise HTTPException(status_code=400, detail="Email already registered")

    db_user = User(email=user.email, username=user.username)
    db.add(db_user)
    db.commit()
    db.refresh(db_user)
    return db_user


# ==============================================
# READ - 사용자 조회
# ==============================================
@app.get("/users", response_model=List[UserResponse])
def get_users(
    skip: int = 0,
    limit: int = 100,
    is_active: Optional[bool] = None,
    db: Session = Depends(get_db)
):
    """
    Read (List): 목록 조회 + 페이지네이션 + 필터링
    - HTTP Method: GET
    - SQL: SELECT * FROM users WHERE ... LIMIT ... OFFSET ...
    """
    query = db.query(User)

    if is_active is not None:
        query = query.filter(User.is_active == is_active)

    return query.offset(skip).limit(limit).all()


@app.get("/users/{user_id}", response_model=UserResponse)
def get_user(user_id: int, db: Session = Depends(get_db)):
    """
    Read (Detail): 단일 리소스 조회
    - HTTP Method: GET
    - SQL: SELECT * FROM users WHERE id = ?
    """
    user = db.query(User).filter(User.id == user_id).first()
    if not user:
        raise HTTPException(status_code=404, detail="User not found")
    return user


# ==============================================
# UPDATE - 사용자 정보 수정
# ==============================================
@app.put("/users/{user_id}", response_model=UserResponse)
def update_user(user_id: int, user_update: UserUpdate, db: Session = Depends(get_db)):
    """
    Update: 리소스 수정
    - HTTP Method: PUT (전체 교체) / PATCH (부분 수정)
    - SQL: UPDATE users SET ... WHERE id = ?
    """
    db_user = db.query(User).filter(User.id == user_id).first()
    if not db_user:
        raise HTTPException(status_code=404, detail="User not found")

    # None이 아닌 필드만 업데이트
    update_data = user_update.dict(exclude_unset=True)
    for field, value in update_data.items():
        setattr(db_user, field, value)

    db.commit()
    db.refresh(db_user)
    return db_user


# ==============================================
# DELETE - 사용자 삭제
# ==============================================
@app.delete("/users/{user_id}", status_code=204)
def delete_user(user_id: int, db: Session = Depends(get_db)):
    """
    Delete: 리소스 삭제 (Hard Delete)
    - HTTP Method: DELETE
    - SQL: DELETE FROM users WHERE id = ?
    """
    db_user = db.query(User).filter(User.id == user_id).first()
    if not db_user:
        raise HTTPException(status_code=404, detail="User not found")

    db.delete(db_user)
    db.commit()
    return None  # 204 No Content


# Soft Delete (논리적 삭제) 버전
@app.delete("/users/{user_id}/soft")
def soft_delete_user(user_id: int, db: Session = Depends(get_db)):
    """
    Soft Delete: 실제 삭제하지 않고 비활성화
    - is_active = False 또는 deleted_at 타임스탬프 설정
    - 데이터 복구 가능, 감사 추적 용이
    """
    db_user = db.query(User).filter(User.id == user_id).first()
    if not db_user:
        raise HTTPException(status_code=404, detail="User not found")

    db_user.is_active = False
    db.commit()
    return {"message": "User deactivated"}
// CRUD API 예제 - Node.js Express + Prisma
const express = require('express');
const { PrismaClient } = require('@prisma/client');

const app = express();
const prisma = new PrismaClient();

app.use(express.json());

// ==============================================
// CREATE - 새 사용자 생성
// ==============================================
app.post('/users', async (req, res) => {
    /**
     * Create: POST /users
     * SQL: INSERT INTO users (email, username) VALUES ($1, $2)
     */
    try {
        const { email, username } = req.body;

        // 유효성 검사
        if (!email || !username) {
            return res.status(400).json({
                error: 'email and username are required'
            });
        }

        const user = await prisma.user.create({
            data: { email, username }
        });

        res.status(201).json(user);

    } catch (error) {
        if (error.code === 'P2002') {  // Unique constraint violation
            return res.status(400).json({ error: 'Email already exists' });
        }
        res.status(500).json({ error: error.message });
    }
});


// ==============================================
// READ - 사용자 목록 조회
// ==============================================
app.get('/users', async (req, res) => {
    /**
     * Read (List): GET /users?page=1&limit=10&search=john
     * SQL: SELECT * FROM users WHERE ... LIMIT ... OFFSET ...
     */
    try {
        const {
            page = 1,
            limit = 10,
            search,
            isActive
        } = req.query;

        const skip = (parseInt(page) - 1) * parseInt(limit);
        const take = parseInt(limit);

        // 필터 조건 구성
        const where = {};
        if (search) {
            where.OR = [
                { email: { contains: search, mode: 'insensitive' } },
                { username: { contains: search, mode: 'insensitive' } }
            ];
        }
        if (isActive !== undefined) {
            where.isActive = isActive === 'true';
        }

        // 병렬로 데이터와 총 개수 조회
        const [users, total] = await Promise.all([
            prisma.user.findMany({
                where,
                skip,
                take,
                orderBy: { createdAt: 'desc' }
            }),
            prisma.user.count({ where })
        ]);

        res.json({
            data: users,
            pagination: {
                page: parseInt(page),
                limit: parseInt(limit),
                total,
                totalPages: Math.ceil(total / take)
            }
        });

    } catch (error) {
        res.status(500).json({ error: error.message });
    }
});


// ==============================================
// READ - 단일 사용자 조회
// ==============================================
app.get('/users/:id', async (req, res) => {
    /**
     * Read (Detail): GET /users/:id
     * SQL: SELECT * FROM users WHERE id = $1
     */
    try {
        const { id } = req.params;

        const user = await prisma.user.findUnique({
            where: { id: parseInt(id) },
            include: {
                posts: true,     // 관계 데이터 포함
                profile: true
            }
        });

        if (!user) {
            return res.status(404).json({ error: 'User not found' });
        }

        res.json(user);

    } catch (error) {
        res.status(500).json({ error: error.message });
    }
});


// ==============================================
// UPDATE - 사용자 정보 수정
// ==============================================
app.put('/users/:id', async (req, res) => {
    /**
     * Update (Full): PUT /users/:id
     * SQL: UPDATE users SET email=$1, username=$2 WHERE id=$3
     */
    try {
        const { id } = req.params;
        const { email, username, isActive } = req.body;

        const user = await prisma.user.update({
            where: { id: parseInt(id) },
            data: { email, username, isActive }
        });

        res.json(user);

    } catch (error) {
        if (error.code === 'P2025') {  // Record not found
            return res.status(404).json({ error: 'User not found' });
        }
        res.status(500).json({ error: error.message });
    }
});

// PATCH - 부분 수정
app.patch('/users/:id', async (req, res) => {
    /**
     * Update (Partial): PATCH /users/:id
     * 전달된 필드만 업데이트
     */
    try {
        const { id } = req.params;

        // undefined 필드 제외
        const data = Object.fromEntries(
            Object.entries(req.body).filter(([_, v]) => v !== undefined)
        );

        const user = await prisma.user.update({
            where: { id: parseInt(id) },
            data
        });

        res.json(user);

    } catch (error) {
        if (error.code === 'P2025') {
            return res.status(404).json({ error: 'User not found' });
        }
        res.status(500).json({ error: error.message });
    }
});


// ==============================================
// DELETE - 사용자 삭제
// ==============================================
app.delete('/users/:id', async (req, res) => {
    /**
     * Delete (Hard): DELETE /users/:id
     * SQL: DELETE FROM users WHERE id = $1
     */
    try {
        const { id } = req.params;

        await prisma.user.delete({
            where: { id: parseInt(id) }
        });

        res.status(204).send();  // No Content

    } catch (error) {
        if (error.code === 'P2025') {
            return res.status(404).json({ error: 'User not found' });
        }
        res.status(500).json({ error: error.message });
    }
});

// Soft Delete
app.delete('/users/:id/soft', async (req, res) => {
    try {
        const { id } = req.params;

        const user = await prisma.user.update({
            where: { id: parseInt(id) },
            data: {
                isActive: false,
                deletedAt: new Date()
            }
        });

        res.json({ message: 'User soft deleted', user });

    } catch (error) {
        res.status(500).json({ error: error.message });
    }
});


app.listen(3000, () => console.log('Server running on port 3000'));
-- ============================================
-- CRUD SQL 예제 (PostgreSQL)
-- ============================================

-- 테이블 생성
CREATE TABLE users (
    id SERIAL PRIMARY KEY,
    email VARCHAR(255) UNIQUE NOT NULL,
    username VARCHAR(100) NOT NULL,
    is_active BOOLEAN DEFAULT TRUE,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    deleted_at TIMESTAMP  -- Soft Delete용
);


-- ============================================
-- CREATE (INSERT)
-- ============================================

-- 단일 레코드 삽입
INSERT INTO users (email, username)
VALUES ('john@example.com', 'john_doe')
RETURNING *;  -- 삽입된 레코드 반환 (PostgreSQL)

-- 다중 레코드 삽입
INSERT INTO users (email, username) VALUES
    ('alice@example.com', 'alice'),
    ('bob@example.com', 'bob'),
    ('charlie@example.com', 'charlie');

-- UPSERT (있으면 업데이트, 없으면 삽입)
INSERT INTO users (email, username)
VALUES ('john@example.com', 'john_updated')
ON CONFLICT (email) DO UPDATE SET
    username = EXCLUDED.username,
    updated_at = CURRENT_TIMESTAMP;


-- ============================================
-- READ (SELECT)
-- ============================================

-- 전체 조회
SELECT * FROM users;

-- 조건 조회
SELECT * FROM users WHERE is_active = TRUE;

-- 단일 레코드 조회
SELECT * FROM users WHERE id = 1;
SELECT * FROM users WHERE email = 'john@example.com';

-- 검색 (부분 일치)
SELECT * FROM users
WHERE username ILIKE '%john%'  -- 대소문자 무시 (PostgreSQL)
   OR email ILIKE '%john%';

-- 페이지네이션
SELECT * FROM users
ORDER BY created_at DESC
LIMIT 10 OFFSET 0;  -- 1페이지 (0~9)

SELECT * FROM users
ORDER BY created_at DESC
LIMIT 10 OFFSET 10;  -- 2페이지 (10~19)

-- 커서 기반 페이지네이션 (대용량에 효율적)
SELECT * FROM users
WHERE id > 100  -- 마지막 조회 ID
ORDER BY id ASC
LIMIT 10;

-- 집계와 함께 조회
SELECT
    COUNT(*) as total,
    COUNT(*) FILTER (WHERE is_active) as active_count
FROM users;


-- ============================================
-- UPDATE
-- ============================================

-- 단일 필드 업데이트
UPDATE users
SET username = 'john_updated'
WHERE id = 1
RETURNING *;

-- 다중 필드 업데이트
UPDATE users SET
    username = 'john_updated',
    is_active = FALSE,
    updated_at = CURRENT_TIMESTAMP
WHERE id = 1;

-- 조건부 업데이트
UPDATE users SET
    is_active = FALSE
WHERE created_at < NOW() - INTERVAL '1 year'
  AND is_active = TRUE;

-- CASE를 이용한 조건부 값 설정
UPDATE users SET
    is_active = CASE
        WHEN last_login_at < NOW() - INTERVAL '6 months' THEN FALSE
        ELSE is_active
    END;


-- ============================================
-- DELETE
-- ============================================

-- Hard Delete (물리적 삭제)
DELETE FROM users WHERE id = 1;

-- 조건부 삭제
DELETE FROM users
WHERE is_active = FALSE
  AND created_at < NOW() - INTERVAL '1 year';

-- 관련 데이터도 함께 삭제 (CASCADE 설정 필요)
DELETE FROM users WHERE id = 1;  -- posts, profiles도 삭제됨

-- Soft Delete (논리적 삭제)
UPDATE users SET
    is_active = FALSE,
    deleted_at = CURRENT_TIMESTAMP
WHERE id = 1;

-- Soft Delete된 데이터 제외하고 조회
SELECT * FROM users
WHERE deleted_at IS NULL;

-- Soft Delete 복구
UPDATE users SET
    is_active = TRUE,
    deleted_at = NULL
WHERE id = 1;


-- ============================================
-- CRUD 매핑 요약
-- ============================================
-- | CRUD   | SQL      | HTTP   | 설명           |
-- |--------|----------|--------|----------------|
-- | Create | INSERT   | POST   | 새 리소스 생성   |
-- | Read   | SELECT   | GET    | 리소스 조회      |
-- | Update | UPDATE   | PUT    | 전체 리소스 교체  |
-- | Update | UPDATE   | PATCH  | 부분 리소스 수정  |
-- | Delete | DELETE   | DELETE | 리소스 삭제      |

🗣️ 실무에서 이렇게 말하세요

💬 API 설계 회의에서
"이 리소스의 CRUD API부터 정의합시다. POST /products로 생성, GET /products/:id로 조회, PUT으로 전체 수정, PATCH로 부분 수정, DELETE로 삭제. RESTful 컨벤션 따라가면 됩니다."
💬 데이터 삭제 정책 논의에서
"사용자 삭제는 Soft Delete로 가죠. is_deleted 플래그 쓰면 나중에 복구도 가능하고, 감사 로그 추적도 쉽습니다. 실제 물리 삭제는 90일 후 배치로 처리하는 게 좋겠어요."
💬 성능 최적화 리뷰에서
"Read 비율이 90%가 넘으니까 읽기 최적화에 집중해야 해요. SELECT 쿼리에 적절한 인덱스 추가하고, 자주 조회되는 데이터는 캐싱 레이어 추가합시다. Write는 비동기로 처리하면 되고요."

⚠️ 주의사항 & 베스트 프랙티스

GET 요청으로 데이터 변경

GET은 조회 전용입니다. GET /users/1/delete처럼 GET으로 삭제하면 안 됩니다. 크롤러나 프리페치가 의도치 않은 삭제를 유발할 수 있습니다.

WHERE 없는 UPDATE/DELETE

WHERE 절 없이 UPDATE나 DELETE 실행 시 전체 데이터가 변경/삭제됩니다. 항상 조건절을 확인하고, 프로덕션에서는 트랜잭션으로 감싸세요.

N+1 문제

목록 조회 시 각 항목의 관계 데이터를 개별 쿼리로 조회하면 성능이 급격히 저하됩니다. JOIN이나 Eager Loading을 사용하세요.

CRUD 베스트 프랙티스

멱등성 보장(PUT, DELETE는 여러 번 호출해도 같은 결과), 적절한 HTTP 상태코드 반환, 페이지네이션 필수, Soft Delete 고려, 입력 유효성 검사.

🔗 관련 용어

📚 더 배우기