반응형
Spring Boot에 MariaDB 연결하고 JPA로 첫 CRUD 만들기

들어가며

지난 글에서 POST /members로 회원가입 API를 만들었다. 그런데 서버를 껐다 켜면 저장했던 회원이 전부 사라졌다. AtomicLong으로 id만 흉내 냈을 뿐, 진짜로 어딘가에 저장한 게 아니었기 때문이다.

결론만 이야기하면 이렇다. MariaDB를 설치하고, Spring Data JPA로 Member 엔티티 하나를 만들면, 회원 저장·조회·수정·삭제가 전부 DB에 그대로 남는다. 서버를 껐다 켜도 데이터가 살아있는 것을 확인할 수 있다.

curl -X POST http://localhost:8080/members \
  -H "Content-Type: application/json" \
  -d '{"name":"홍길동","email":"hong@example.com"}'
{"id":1,"name":"홍길동","email":"hong@example.com"}

여기까지는 지난 글과 응답 모양이 똑같다. 차이는 눈에 안 보이는 곳에 있다. 이번엔 이 응답이 메모리가 아니라 MariaDB 테이블에 실제로 INSERT된 결과다. 서버를 재시작해도 GET /members를 다시 호출하면 홍길동이 그대로 나온다.

순서는 MariaDB 설치·계정 만들기 → Spring Boot에 JPA·JDBC 드라이버 의존성 추가 → @Entity로 테이블 매핑 → JpaRepository로 CRUD 구현 → 조회/수정/삭제 엔드포인트 추가, 다섯 단계다.


준비물

항목 버전 비고
JDK 21 LTS 지난 글과 동일
Spring Boot 3.3.4 Spring Web + Validation + Data JPA 필요
MariaDB 10.11 Ubuntu 24.04 apt 기본 패키지 버전
MariaDB Connector/J 3.4.1 JDBC 드라이버
이전 프로젝트 지난 글demo 프로젝트 없다면 start.spring.io에서 새로 받아도 됨
참고 지난 글에서 만든 demo 프로젝트를 그대로 이어서 쓴다. 새로 시작한다면 start.spring.io에서 Spring Web, Validation, Spring Data JPA 세 개를 Dependencies에 추가해서 받으면 된다.

따라 하기

Step 1. MariaDB 설치하고 실행하기

우분투 계열이라면 apt로 바로 설치할 수 있다. (각자 환경에 맞게 macOS는 brew install mariadb, Windows는 공식 인스톨러를 쓰면 된다)

sudo apt-get update
sudo apt-get install -y mariadb-server mariadb-client

설치가 끝나면 서버를 실행한다. systemd가 있는 일반 환경이라면 sudo systemctl start mariadb로 충분하다. (컨테이너처럼 systemd가 없는 환경이라면 아래처럼 직접 백그라운드로 띄워도 된다)

sudo systemctl start mariadb   # 일반 환경
# 또는 systemd가 없는 경우
sudo -u mysql /usr/sbin/mysqld --pid-file=/var/run/mysqld/mysqld.pid &

살아있는지 확인한다.

mysqladmin ping
mysqld is alive

서버가 살아있는 것을 확인할 수 있다.


Step 2. 데이터베이스와 계정 만들기

root로 접속해서 이번 프로젝트 전용 데이터베이스와 계정을 만든다. (비밀번호는 실습용이니 그대로 써도 되고, 각자 편한 값으로 바꿔도 된다)

sudo mysql -u root
CREATE DATABASE demo CHARACTER SET utf8mb4;
CREATE USER 'demo'@'localhost' IDENTIFIED BY 'demo1234';
GRANT ALL PRIVILEGES ON demo.* TO 'demo'@'localhost';
FLUSH PRIVILEGES;

새로 만든 계정으로 접속되는지 확인한다.

mysql -u demo -pdemo1234 -e "SHOW DATABASES;"
Database
demo
information_schema

demo 데이터베이스가 보이는 것을 확인할 수 있다. root 계정을 애플리케이션에 그대로 쓰지 않고 전용 계정을 만드는 이유는, 나중에 이 계정의 권한을 딱 필요한 데이터베이스 하나로만 제한할 수 있어서다.


Step 3. 의존성 추가

build.gradle에 JPA와 MariaDB 드라이버를 추가한다.

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-starter-validation'
    // JPA로 엔티티를 테이블에 매핑하고 리포지토리를 자동 구현해주는 스타터
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    // MariaDB에 실제로 접속하는 JDBC 드라이버. 런타임에만 필요해서 runtimeOnly로 선언
    runtimeOnly 'org.mariadb.jdbc:mariadb-java-client:3.4.1'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
}

Step 4. 접속 정보 설정

application.yml(또는 application.properties)에 방금 만든 DB 접속 정보를 넣는다.

spring:
  datasource:
    url: jdbc:mariadb://localhost:3306/demo
    username: demo
    password: demo1234
  jpa:
    hibernate:
      ddl-auto: update
    show-sql: true
    properties:
      hibernate:
        format_sql: true

ddl-auto: update가 핵심이다. 애플리케이션이 뜰 때 @Entity 클래스를 보고 테이블이 없으면 만들고 컬럼이 부족하면 추가해준다. 편해서 입문 단계에선 쓰지만 운영 환경에서는 스키마를 예고 없이 바꿀 수 있어 위험하다. 실무에서는 Flyway나 Liquibase 같은 마이그레이션 도구로 스키마를 버전 관리하는 게 정석이다. show-sql: true는 Hibernate가 실제로 날리는 SQL을 콘솔에 그대로 찍어주는 옵션인데, 지금 단계에서는 뭐가 일어나는지 눈으로 보는 게 이해에 도움이 돼서 켜둔다.


Step 5. Member 엔티티 만들기

Member.java를 새로 만든다. 이전 글의 MemberRequest/MemberResponse 레코드와는 다른 개념이다. 레코드는 요청·응답을 담는 그릇이고 Member는 DB 테이블 한 행과 그대로 매핑되는 클래스다.

package com.example.demo;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;

@Entity
public class Member {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private String name;

    // unique = true : DB 레벨에서 같은 email이 두 번 저장되지 못하게 막는다
    @Column(nullable = false, unique = true)
    private String email;

    // JPA가 프록시 객체를 만들 때 쓰는 기본 생성자. 외부에서 직접 호출할 일은 없어 protected로 막는다
    protected Member() {
    }

    public Member(String name, String email) {
        this.name = name;
        this.email = email;
    }

    public Long getId() {
        return id;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public String getEmail() {
        return email;
    }

    public void setEmail(String email) {
        this.email = email;
    }
}

@Entity가 붙으면 JPA가 이 클래스를 테이블로 취급한다. 클래스 이름 Member가 그대로 테이블 이름 member가 되고 필드 하나하나가 컬럼이 된다. @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) 조합은 "id는 DB의 auto increment에 맡긴다"는 뜻이다. 지난 글에서 AtomicLong으로 직접 세던 걸 이제 DB가 대신 세준다.


Step 6. MemberRepository 만들기

이번 글에서 제일 놀라운 부분이다. 인터페이스 하나만 만들면 끝난다.

package com.example.demo;

import org.springframework.data.jpa.repository.JpaRepository;

public interface MemberRepository extends JpaRepository<Member, Long> {
}

JpaRepository<Member, Long>를 상속만 해도 save(), findById(), findAll(), deleteById(), existsById() 같은 메서드가 전부 자동으로 생긴다. 구현 클래스를 직접 작성하지 않아도 Spring이 애플리케이션을 띄울 때 뒤에서 구현체를 만들어 끼워 넣는다. SQL 한 줄 안 썼는데 CRUD가 다 된다는 게 처음엔 마법처럼 느껴지는데, 원리는 나중 글에서 따로 다룰 예정이다.


Step 7. 컨트롤러를 DB 기반으로 바꾸기

지난 글의 MemberController에서 AtomicLong 자리를 MemberRepository로 바꾸고, 조회·수정·삭제 엔드포인트를 추가한다.

package com.example.demo;

import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;

import java.util.List;

@RestController
public class MemberController {

    private final MemberRepository memberRepository;

    // 생성자가 하나뿐이면 @Autowired 없이도 스프링이 알아서 주입해준다
    public MemberController(MemberRepository memberRepository) {
        this.memberRepository = memberRepository;
    }

    @PostMapping("/members")
    public MemberResponse create(@Valid @RequestBody MemberRequest request) {
        Member saved = memberRepository.save(new Member(request.name(), request.email()));
        return MemberResponse.from(saved);
    }

    @GetMapping("/members")
    public List<MemberResponse> findAll() {
        return memberRepository.findAll().stream()
                .map(MemberResponse::from)
                .toList();
    }

    @GetMapping("/members/{id}")
    public MemberResponse findOne(@PathVariable Long id) {
        Member member = memberRepository.findById(id)
                .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "member " + id + " not found"));
        return MemberResponse.from(member);
    }

    @PutMapping("/members/{id}")
    public MemberResponse update(@PathVariable Long id, @Valid @RequestBody MemberRequest request) {
        Member member = memberRepository.findById(id)
                .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "member " + id + " not found"));
        member.setName(request.name());
        member.setEmail(request.email());
        // findById로 가져온 시점엔 이미 트랜잭션이 끝나 detached 상태라, 변경 후 save를 명시적으로 호출해야 반영된다
        Member saved = memberRepository.save(member);
        return MemberResponse.from(saved);
    }

    @DeleteMapping("/members/{id}")
    @ResponseStatus(HttpStatus.NO_CONTENT)
    public void delete(@PathVariable Long id) {
        if (!memberRepository.existsById(id)) {
            throw new ResponseStatusException(HttpStatus.NOT_FOUND, "member " + id + " not found");
        }
        memberRepository.deleteById(id);
    }
}

ResponseStatusException은 스프링이 기본 제공하는 클래스라 별도 예외 클래스를 만들지 않고도 "이 상황이면 404를 던진다"처럼 상태 코드와 메시지를 그 자리에서 바로 지정할 수 있다. 없는 id를 조회·수정·삭제하려고 하면 전부 404로 응답한다.

주의 update()에서 save(member)를 빼먹기 쉬운데, 반드시 있어야 한다. findById()로 가져온 시점에는 이미 리포지토리 메서드의 트랜잭션이 끝나 있어서 member는 영속성 컨텍스트와 연결이 끊긴(detached) 상태다. setName(), setEmail()로 값만 바꿔놓고 save()를 안 부르면 DB에는 아무 변화도 반영되지 않는다.

Step 8. email 중복 저장 예외 처리 추가

Member.emailunique = true를 걸어놨기 때문에, 같은 email로 두 번 가입을 시도하면 DB가 제약 조건 위반 예외를 던진다. GlobalExceptionHandler에 처리 코드를 추가한다.

package com.example.demo;

import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;

import java.util.HashMap;
import java.util.Map;

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(MethodArgumentNotValidException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public Map<String, String> handleValidation(MethodArgumentNotValidException ex) {
        Map<String, String> errors = new HashMap<>();
        ex.getBindingResult().getFieldErrors().forEach(error ->
                errors.put(error.getField(), error.getDefaultMessage())
        );
        return errors;
    }

    // email unique 제약 위반 시 스프링이 던지는 예외. 이 핸들러가 없으면 그대로 500으로 나간다
    @ExceptionHandler(DataIntegrityViolationException.class)
    @ResponseStatus(HttpStatus.CONFLICT)
    public Map<String, String> handleDuplicate(DataIntegrityViolationException ex) {
        return Map.of("error", "이미 등록된 email 입니다");
    }
}

이 핸들러를 추가하기 전에 직접 중복 email로 요청을 보내봤는데, 500 Internal Server Error가 그대로 나갔다. DB 제약 조건 위반이 클라이언트 잘못(400계열)인데도 서버 에러(500)로 보이는 건 사용자 입장에서 혼란스럽다. DataIntegrityViolationException을 잡아서 409 Conflict로 바꿔주면 훨씬 명확해지는 것을 확인할 수 있다.


전체 코드 (복붙용)

build.gradle

plugins {
    id 'java'
    id 'org.springframework.boot' version '3.3.4'
    id 'io.spring.dependency-management' version '1.1.6'
}

group = 'com.example'
version = '0.0.1-SNAPSHOT'

java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(21)
    }
}

repositories {
    mavenCentral()
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-starter-validation'
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    runtimeOnly 'org.mariadb.jdbc:mariadb-java-client:3.4.1'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
}

tasks.named('test') {
    useJUnitPlatform()
}

src/main/resources/application.yml

spring:
  datasource:
    url: jdbc:mariadb://localhost:3306/demo
    username: demo
    password: demo1234
  jpa:
    hibernate:
      ddl-auto: update
    show-sql: true
    properties:
      hibernate:
        format_sql: true

Member.java

package com.example.demo;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;

@Entity
public class Member {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private String name;

    @Column(nullable = false, unique = true)
    private String email;

    protected Member() {
    }

    public Member(String name, String email) {
        this.name = name;
        this.email = email;
    }

    public Long getId() {
        return id;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public String getEmail() {
        return email;
    }

    public void setEmail(String email) {
        this.email = email;
    }
}

MemberRepository.java

package com.example.demo;

import org.springframework.data.jpa.repository.JpaRepository;

public interface MemberRepository extends JpaRepository<Member, Long> {
}

MemberRequest.java

package com.example.demo;

import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;

public record MemberRequest(
        @NotBlank(message = "name은 비어 있을 수 없습니다") String name,
        @NotBlank(message = "email은 비어 있을 수 없습니다")
        @Email(message = "email 형식이 올바르지 않습니다") String email
) {
}

MemberResponse.java

package com.example.demo;

public record MemberResponse(Long id, String name, String email) {
    public static MemberResponse from(Member member) {
        return new MemberResponse(member.getId(), member.getName(), member.getEmail());
    }
}

MemberController.java

package com.example.demo;

import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;

import java.util.List;

@RestController
public class MemberController {

    private final MemberRepository memberRepository;

    public MemberController(MemberRepository memberRepository) {
        this.memberRepository = memberRepository;
    }

    @PostMapping("/members")
    public MemberResponse create(@Valid @RequestBody MemberRequest request) {
        Member saved = memberRepository.save(new Member(request.name(), request.email()));
        return MemberResponse.from(saved);
    }

    @GetMapping("/members")
    public List<MemberResponse> findAll() {
        return memberRepository.findAll().stream()
                .map(MemberResponse::from)
                .toList();
    }

    @GetMapping("/members/{id}")
    public MemberResponse findOne(@PathVariable Long id) {
        Member member = memberRepository.findById(id)
                .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "member " + id + " not found"));
        return MemberResponse.from(member);
    }

    @PutMapping("/members/{id}")
    public MemberResponse update(@PathVariable Long id, @Valid @RequestBody MemberRequest request) {
        Member member = memberRepository.findById(id)
                .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "member " + id + " not found"));
        member.setName(request.name());
        member.setEmail(request.email());
        Member saved = memberRepository.save(member);
        return MemberResponse.from(saved);
    }

    @DeleteMapping("/members/{id}")
    @ResponseStatus(HttpStatus.NO_CONTENT)
    public void delete(@PathVariable Long id) {
        if (!memberRepository.existsById(id)) {
            throw new ResponseStatusException(HttpStatus.NOT_FOUND, "member " + id + " not found");
        }
        memberRepository.deleteById(id);
    }
}

GlobalExceptionHandler.java

package com.example.demo;

import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;

import java.util.HashMap;
import java.util.Map;

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(MethodArgumentNotValidException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public Map<String, String> handleValidation(MethodArgumentNotValidException ex) {
        Map<String, String> errors = new HashMap<>();
        ex.getBindingResult().getFieldErrors().forEach(error ->
                errors.put(error.getField(), error.getDefaultMessage())
        );
        return errors;
    }

    @ExceptionHandler(DataIntegrityViolationException.class)
    @ResponseStatus(HttpStatus.CONFLICT)
    public Map<String, String> handleDuplicate(DataIntegrityViolationException ex) {
        return Map.of("error", "이미 등록된 email 입니다");
    }
}

요청 처리 흐름

graph LR
    A["클라이언트 요청<br/>POST/GET/PUT/DELETE"] --> B["MemberController"]
    B --> C["MemberRepository<br/>(JpaRepository)"]
    C --> D["Hibernate가 SQL 생성"]
    D --> E[("MariaDB<br/>member 테이블")]
    E --> D
    D --> C
    C --> B
    B --> F["MemberResponse → JSON"]

실행 결과 확인

실제로 서버를 띄우고 curl로 순서대로 호출해서 나온 결과를 그대로 옮겨왔다.

먼저 실행 로그에서 Hibernate가 테이블을 자동으로 만드는 것부터 확인할 수 있다.

Hibernate:
    create table member (
        id bigint not null auto_increment,
        email varchar(255) not null,
        name varchar(255) not null,
        primary key (id)
    ) engine=InnoDB
Hibernate:
    alter table if exists member
       add constraint UKmbmcqelty0fbrvxp1q58dn57t unique (email)

ddl-auto: update 덕분에 CREATE TABLE도, email에 unique 제약을 거는 것도 코드 한 줄 안 쓰고 자동으로 실행되는 것을 확인할 수 있다.

서버는 껐다 켜져도, MariaDB에 저장된 데이터는 그대로 남는다
순서 요청 결과
1POST /members (홍길동)200, {"id":1,"name":"홍길동","email":"hong@example.com"}
2POST /members (김철수)200, {"id":2,"name":"김철수","email":"kim@example.com"}
3GET /members200, 두 명이 배열로 그대로 조회됨
4GET /members/999 (없는 id)404, {"timestamp":"...","status":404,...,"path":"/members/999"}
5PUT /members/1 (이름/이메일 변경)200, {"id":1,"name":"홍길동2","email":"hong2@example.com"}
6DELETE /members/2204, 본문 없음
7GET /members (삭제 후)200, [{"id":1,"name":"홍길동2","email":"hong2@example.com"}]

5번(수정) 뒤에 다시 GET /members/1을 호출해도 홍길동2, hong2@example.com이 그대로 나온다. save(member)를 명시적으로 호출한 게 실제로 반영됐다는 뜻이다.

curl -i -X PUT http://localhost:8080/members/1 \
  -H "Content-Type: application/json" \
  -d '{"name":"홍길동2","email":"hong2@example.com"}'
HTTP/1.1 200
{"id":1,"name":"홍길동2","email":"hong2@example.com"}

가장 중요한 확인은 이거다. 이 상태에서 애플리케이션을 완전히 종료하고 ./gradlew bootRun으로 다시 실행한 다음 GET /members를 호출해도 홍길동2 데이터가 그대로 남아있는 것을 확인할 수 있다. 지난 글의 AtomicLong 버전이었다면 재시작하는 순간 전부 사라졌을 데이터다.

[TODO: 본인 환경에서 실행한 터미널 화면을 여기에 캡처해서 추가하세요]


자주 만나는 에러와 해결

에러 1: 계정/비밀번호가 틀려서 애플리케이션이 아예 안 뜸

application.ymlpassword를 일부러 틀리게 넣고 실행해보면 이렇게 뜬다.

o.m.jdbc.message.server.ErrorPacket : Error: 1045-28000: Access denied for user 'demo'@'localhost' (using password: YES)
org.hibernate.exception.GenericJDBCException: unable to obtain isolated JDBC connection
Caused by: java.sql.SQLInvalidAuthorizationSpecException: (conn=27) Access denied for user 'demo'@'localhost' (using password: YES)

원인: spring.datasource 아래 username/password가 실제 MariaDB 계정 정보와 다르다. 애플리케이션 자체가 컨텍스트 초기화 단계에서 죽어버리기 때문에, 엔드포인트를 만들었는지 여부와는 무관하게 서버가 뜨질 않는다.

해결: mysql -u demo -pdemo1234 -e "SELECT 1;"처럼 같은 계정 정보로 터미널에서 직접 접속이 되는지부터 확인한다. 여기서 안 되면 애플리케이션 설정이 아니라 MariaDB 계정 문제다.

에러 2: MariaDB 자체가 안 떠 있는 경우

spring.datasource.url의 포트로 아무도 듣고 있지 않으면 연결 자체가 안 된다. mysqladmin ping으로 서버 상태부터 확인하는 습관을 들이면 좋다.

mysqladmin ping
mysqladmin: connect to server at 'localhost' failed
error: 'Can't connect to local server through socket '/run/mysqld/mysqld.sock' (2)'

해결: sudo systemctl start mariadb(또는 systemd가 없는 환경이면 Step 1의 직접 실행 명령)로 서버를 다시 띄운다.

에러 3: 중복 email 저장 시 500이 나옴 (핸들러 추가 전)

GlobalExceptionHandlerDataIntegrityViolationException 핸들러를 추가하기 전에 같은 email로 두 번 저장해보면 이렇게 나온다.

DataIntegrityViolationException: could not execute statement
[(conn=6) Duplicate entry 'hong2@example.com' for key 'UKmbmcqelty0fbrvxp1q58dn57t']

HTTP 응답은 그냥 500 Internal Server Error.

원인: Member.email에 걸어둔 unique = true 제약을 DB가 그대로 지킨 것뿐인데, 이 예외를 컨트롤러 쪽에서 처리하지 않으면 스프링의 기본 에러 처리기가 무조건 500으로 응답한다. 클라이언트 입력이 원인인데 서버 탓처럼 보이는 대표적인 케이스다.

해결: Step 8에서 추가한 @ExceptionHandler(DataIntegrityViolationException.class)409 Conflict로 바꿔준다.

에러 4: ddl-auto: update인데 컬럼이 안 생김

엔티티에 필드를 추가했는데 테이블에 컬럼이 안 생기는 경우가 있다. 보통은 오타나 @Column 위치 문제가 아니라, 이전에 수동으로 만든 테이블이 있어서 Hibernate가 "이미 있다"고 판단하고 건너뛰는 경우다.

해결: 개발 초기 단계라 데이터를 지워도 상관없다면 DROP TABLE member; 후 애플리케이션을 재시작해서 처음부터 다시 만들게 한다. 운영 환경이라면 ddl-auto: update를 아예 쓰지 않고 Flyway/Liquibase 마이그레이션 스크립트로 직접 ALTER TABLE을 관리해야 한다.


핵심 요약

  • @Entity + @Id/@GeneratedValue로 자바 클래스를 DB 테이블에 매핑한다
  • JpaRepository만 상속하면 save/findById/findAll/deleteById가 자동 구현된다
  • ddl-auto: update는 편하지만 운영에서는 Flyway/Liquibase로 대체해야 한다
  • findById()로 가져온 엔티티는 detached 상태라 수정 후 save()를 명시적으로 호출해야 반영된다
  • DB 제약 조건 위반(unique 등)은 기본으로 500이 나가므로 @ExceptionHandler로 적절한 상태 코드로 바꿔줘야 한다

마무리

AtomicLong으로 흉내만 내던 저장을 진짜 MariaDB로 옮겼다. @Entity 하나, JpaRepository 인터페이스 하나만 추가했을 뿐인데 저장·조회·수정·삭제가 재시작해도 살아남는 것까지 확인했다. SQL을 한 줄도 안 썼다는 게 편하기도 하고, 동시에 뒤에서 무슨 일이 일어나는지 감이 잘 안 잡히는 느낌도 있었다.

다음 단계로 해볼 것들.

  • JPA가 실제로 날리는 쿼리 확인: show-sql로 보이는 SQL을 하나씩 뜯어보면서 save(), findById()가 각각 어떤 쿼리로 변환되는지 확인하기
  • 연관관계 매핑: 회원 하나가 여러 개의 주문을 갖는 @OneToMany 관계 만들어보기
  • findBy 쿼리 메서드: findByEmail(String email)처럼 메서드 이름만으로 조건 검색이 되는 Spring Data JPA의 쿼리 메서드 규칙
  • 트랜잭션 경계: 지금은 컨트롤러가 리포지토리를 직접 부르고 있는데, 서비스 계층을 분리하고 @Transactional을 어디에 붙여야 하는지

엔티티 하나로 CRUD가 통째로 되는 건 신기했지만, ddl-auto: update에 계속 의존해도 되는 건지, 컨트롤러가 리포지토리를 직접 호출해도 되는 건지 같은 의문이 남았다. 다음 글에서 서비스 계층 분리와 실제 쿼리 확인부터 이어서 다룬다.

반응형