# Database + MCP 가이드 ## 개요 데이터베이스 작업은 두 경로로 처리한다: - **MCP (Model Context Protocol)**: Claude Code가 DB를 직접 조회/탐색할 때 - **Spring Boot JPA**: 애플리케이션 코드에서 DB를 다룰 때 --- ## MCP 데이터베이스 설정 ### PostgreSQL MCP 설정 ```json // .claude/settings.json (프로젝트) { "mcpServers": { "postgres": { "command": "npx", "args": ["-y", "@modelcontextprotocol/server-postgres", "postgresql://localhost/myapp"], "env": { "PGPASSWORD": "${DB_PASSWORD}" } } } } ``` ### MySQL MCP 설정 ```json { "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) ```sql -- 모든 테이블에 포함 id BIGSERIAL PRIMARY KEY, created_at TIMESTAMP NOT NULL DEFAULT NOW(), updated_at TIMESTAMP NOT NULL DEFAULT NOW() ``` ### 사용자 테이블 표준 ```sql 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); ``` ### 관계 설계 ```sql -- 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 관계 ``` ### 마이그레이션 파일 작성 규칙 ```sql -- 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 ```java // 커스텀 조회 (인터페이스 + Impl 패턴) public interface UserRepositoryCustom { List findActiveUsersWithPosts(Pageable pageable); } @Repository public class UserRepositoryImpl implements UserRepositoryCustom { @PersistenceContext EntityManager em; @Override public List 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, UserRepositoryCustom { Optional findByEmail(String email); @Query("SELECT u FROM User u WHERE u.role = :role AND u.isActive = true") List findActiveByRole(@Param("role") String role); Page findByNameContaining(String name, Pageable pageable); } ``` --- ## DB 연결 설정 ### application.yml ```yaml 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 문제 해결 ```java // 잘못된 예 (N+1 발생) List users = userRepository.findAll(); users.forEach(u -> u.getPosts().size()); // 각 사용자마다 쿼리 발생 // 올바른 예 (fetch join) @Query("SELECT u FROM User u LEFT JOIN FETCH u.posts") List findAllWithPosts(); ``` ### 페이지네이션 ```java // 컨트롤러 @GetMapping public ResponseEntity> 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) ```yaml # 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: ``` ```bash # DB 시작 docker-compose up -d postgres # 접속 psql -h localhost -U postgres -d myapp ```