harness/plugins/zio-harness/skills/zio-harness/references/database.md
ythong cc46c4528e feat: split zio-harness into a standalone plugin
Move skills/zio-harness into plugins/zio-harness/ as a self-contained
plugin with its own .claude-plugin/plugin.json. Add a second marketplace
entry (zio-harness@1.0.0) and bump the harness plugin to 1.3.0 since it no
longer bundles zio-harness. Single ownership: harness ships only the
harness skill, zio-harness ships only the zio-harness skill.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 16:54:17 +09:00

6.4 KiB

Database + MCP 가이드

개요

데이터베이스 작업은 두 경로로 처리한다:

  • MCP (Model Context Protocol): Claude Code가 DB를 직접 조회/탐색할 때
  • Spring Boot JPA: 애플리케이션 코드에서 DB를 다룰 때

MCP 데이터베이스 설정

PostgreSQL MCP 설정

// .claude/settings.json (프로젝트)
{
  "mcpServers": {
    "postgres": {
      "command": "npx",
      "args": ["-y", "@modelcontextprotocol/server-postgres", "postgresql://localhost/myapp"],
      "env": {
        "PGPASSWORD": "${DB_PASSWORD}"
      }
    }
  }
}

MySQL MCP 설정

{
  "mcpServers": {
    "mysql": {
      "command": "npx",
      "args": ["-y", "@benborla29/mcp-server-mysql"],
      "env": {
        "MYSQL_HOST": "localhost",
        "MYSQL_PORT": "3306",
        "MYSQL_USER": "root",
        "MYSQL_PASS": "${DB_PASSWORD}",
        "MYSQL_DB": "myapp"
      }
    }
  }
}

MCP로 DB 탐색하는 방법

MCP가 활성화되면 Claude Code에서 직접 DB를 조회한다:

  • 스키마 확인: "현재 DB 테이블 목록 보여줘"
  • 데이터 조회: "users 테이블에서 최근 가입한 10명 조회해줘"
  • 스키마 설계: "이 요구사항에 맞는 테이블 구조 제안해줘"

데이터베이스 설계 패턴

공통 컬럼 (BaseEntity)

-- 모든 테이블에 포함
id          BIGSERIAL PRIMARY KEY,
created_at  TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at  TIMESTAMP NOT NULL DEFAULT NOW()

사용자 테이블 표준

CREATE TABLE users (
    id          BIGSERIAL PRIMARY KEY,
    email       VARCHAR(255) NOT NULL UNIQUE,
    password    VARCHAR(255) NOT NULL,  -- bcrypt 해시
    name        VARCHAR(100) NOT NULL,
    role        VARCHAR(20) NOT NULL DEFAULT 'USER',
    is_active   BOOLEAN NOT NULL DEFAULT TRUE,
    created_at  TIMESTAMP NOT NULL DEFAULT NOW(),
    updated_at  TIMESTAMP NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_users_email ON users(email);

관계 설계

-- 1:N 관계 (posts → users)
CREATE TABLE posts (
    id          BIGSERIAL PRIMARY KEY,
    user_id     BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    title       VARCHAR(500) NOT NULL,
    content     TEXT,
    created_at  TIMESTAMP NOT NULL DEFAULT NOW(),
    updated_at  TIMESTAMP NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_posts_user_id ON posts(user_id);

-- N:M 관계 (users ↔ tags)
CREATE TABLE user_tags (
    user_id BIGINT REFERENCES users(id) ON DELETE CASCADE,
    tag_id  BIGINT REFERENCES tags(id) ON DELETE CASCADE,
    PRIMARY KEY (user_id, tag_id)
);

Flyway 마이그레이션 패턴

src/main/resources/db/migration/
├── V1__init_schema.sql          # 초기 스키마
├── V2__add_posts_table.sql      # 새 테이블
├── V3__add_user_avatar.sql      # 컬럼 추가
└── V4__create_tags.sql          # N:M 관계

마이그레이션 파일 작성 규칙

-- V2__add_posts_table.sql
-- 항상 트랜잭션 안에서 실행됨 (PostgreSQL)
CREATE TABLE posts (
    id          BIGSERIAL PRIMARY KEY,
    user_id     BIGINT NOT NULL REFERENCES users(id),
    title       VARCHAR(500) NOT NULL,
    created_at  TIMESTAMP NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_posts_user_id ON posts(user_id);
  • 파일명: V{숫자}__{설명}.sql (밑줄 2개)
  • 한 번 적용된 파일은 수정 금지 (새 버전 파일 추가)
  • 롤백 스크립트: R{버전}__{설명}.sql (선택)

JPA Repository 고급 패턴

QueryDSL / JPQL

// 커스텀 조회 (인터페이스 + Impl 패턴)
public interface UserRepositoryCustom {
    List<User> findActiveUsersWithPosts(Pageable pageable);
}

@Repository
public class UserRepositoryImpl implements UserRepositoryCustom {
    @PersistenceContext EntityManager em;

    @Override
    public List<User> findActiveUsersWithPosts(Pageable pageable) {
        return em.createQuery(
            "SELECT DISTINCT u FROM User u JOIN FETCH u.posts WHERE u.isActive = true",
            User.class)
            .setFirstResult((int) pageable.getOffset())
            .setMaxResults(pageable.getPageSize())
            .getResultList();
    }
}

// Spring Data JPA 인터페이스
public interface UserRepository extends JpaRepository<User, Long>, UserRepositoryCustom {
    Optional<User> findByEmail(String email);
    
    @Query("SELECT u FROM User u WHERE u.role = :role AND u.isActive = true")
    List<User> findActiveByRole(@Param("role") String role);
    
    Page<User> findByNameContaining(String name, Pageable pageable);
}

DB 연결 설정

application.yml

spring:
  datasource:
    url: ${DB_URL:jdbc:postgresql://localhost:5432/myapp}
    username: ${DB_USERNAME:postgres}
    password: ${DB_PASSWORD:password}
    hikari:
      maximum-pool-size: 20
      minimum-idle: 5
      connection-timeout: 30000

  jpa:
    hibernate:
      ddl-auto: validate   # Flyway 사용 시 validate (create/update 금지)
    properties:
      hibernate:
        dialect: org.hibernate.dialect.PostgreSQLDialect
        format_sql: true
    open-in-view: false    # 성능: 트랜잭션 외부 지연 로딩 비활성화

  flyway:
    enabled: true
    locations: classpath:db/migration

성능 최적화

N+1 문제 해결

// 잘못된 예 (N+1 발생)
List<User> users = userRepository.findAll();
users.forEach(u -> u.getPosts().size()); // 각 사용자마다 쿼리 발생

// 올바른 예 (fetch join)
@Query("SELECT u FROM User u LEFT JOIN FETCH u.posts")
List<User> findAllWithPosts();

페이지네이션

// 컨트롤러
@GetMapping
public ResponseEntity<Page<UserResponse>> getUsers(
    @RequestParam(defaultValue = "0") int page,
    @RequestParam(defaultValue = "20") int size,
    @RequestParam(defaultValue = "createdAt") String sort) {
    
    Pageable pageable = PageRequest.of(page, size, Sort.by(sort).descending());
    return ResponseEntity.ok(userService.findAll(pageable).map(UserResponse::from));
}

로컬 개발 환경 (Docker)

# docker-compose.yml
services:
  postgres:
    image: postgres:16
    environment:
      POSTGRES_DB: myapp
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: password
    ports:
      - "5432:5432"
    volumes:
      - postgres_data:/var/lib/postgresql/data

volumes:
  postgres_data:
# DB 시작
docker-compose up -d postgres

# 접속
psql -h localhost -U postgres -d myapp