React에서 Spring으로

메모리에서 데이터베이스로

JPA를 붙이다

6장 · TASKBOARD의 저장소를 갈아 끼우기

목차 · 진행 0%

    서버를 잘 띄워 두고 TaskBoard에 할 일을 차곡차곡 쌓았다고 해 보자. “장보기”, “운동하기”, “이 장 끝내기”까지 정성껏 넣었다. 그런데 코드를 한 줄 고쳐 서버를 다시 띄우는 순간, 방금 넣은 할 일들이 흔적도 없이 사라진다. Ctrl+C 한 번에 모든 것이 백지가 된다. 처음 겪으면 허탈하다. 분명히 잘 동작했는데 데이터가 증발했다.

    왜 이런 일이 벌어질까? 지금까지 할 일 목록은 자바 객체로서 서버 프로세스의 메모리(RAM) 안에 살고 있었기 때문이다. 메모리는 빠르지만 휘발성이다. 프로세스가 죽으면 그 안의 모든 것이 함께 사라진다. 프런트로 치면 새로고침 한 번에 날아가는 useState 값, 화면 안에서만 잠깐 사는 상태와 같다. 우리에게 필요한 건 서버를 껐다 켜도, 심지어 서버가 통째로 죽었다 살아나도 남아 있는 데이터다. 그러려면 데이터를 메모리 바깥의 영구적인 저장소, 즉 데이터베이스에 눌러 담아야 한다.

    자, 그렇다면 가장 중요한 질문을 던져 보자. 5장까지 공들여 만든 그 API를, 데이터베이스를 붙이느라 다 갈아엎어야 할까? 컨트롤러도, DTO도, 검증 규칙도 처음부터 다시? 다행히 그렇지 않다. 이 장의 핵심이 여기 있다. 바깥에서 보이는 API 계약은 그대로 둔 채, 안쪽 저장소만 메모리에서 데이터베이스로 갈아 끼우는 것. 클라이언트(React 앱)는 달라진 점을 전혀 느끼지 못하는데, 데이터는 이제 영영 사라지지 않는다. 이 점진적 부착이 오늘의 주인공이다.

    객체와 표(table)는 원래 사이가 나쁘다

    데이터베이스 이야기를 꺼내면 곧장 SQL이 떠오를 것이다. INSERT INTO tasks ..., SELECT * FROM tasks WHERE ... 같은 것들. 그런데 잠깐 멈추고 생각해 보자. 자바에서 다루는 건 Task라는 객체다. 필드를 갖고, 메서드를 갖고, 다른 객체를 참조한다. 반면 관계형 데이터베이스가 다루는 건 행과 열로 이루어진 표다. 이 둘은 생긴 게 너무 다르다.

    객체의 세계에는 상속이 있고, 객체가 객체를 직접 가리키는 참조가 있다. 하지만 표의 세계에서는 외래 키(foreign key)라는 숫자 하나로 다른 표를 가리킬 뿐이다. 객체는 메모리 주소로 “같은 객체인지”를 따지지만, 표는 기본 키(primary key) 값으로 “같은 행인지”를 따진다. 이렇게 객체 모델과 관계형 모델 사이에 벌어진 근본적인 간극을, 어려운 말로 임피던스 불일치(impedance mismatch)라고 부른다. 이름은 거창하지만 뜻은 단순하다. “객체랑 표는 원래 사이가 안 좋다”는 것이다.

    그래서 옛날 개발자들은 이 간극을 손으로 메웠다. SELECT로 행을 읽어 와 칼럼을 하나씩 꺼내 객체 필드에 일일이 옮겨 담고, 저장할 땐 객체 필드를 다시 INSERT 문자열로 조립했다. 이 지루하고 실수투성이인 변환 작업을 자동으로 해 주겠다고 나선 도구가 ORM(Object-Relational Mapping), 객체-관계 매핑이다. 자바 진영의 ORM 표준이 JPA(Jakarta Persistence API)이고, 그 구현체로 가장 널리 쓰이는 것이 Hibernate다. Spring Data JPA는 이 위에 한 겹을 더 씌워 우리 손을 한층 덜어 준다.

    여기서 한 가지는 솔직하게 못 박아 두자. ORM은 마법이 아니다. ORM은 우리가 객체를 다루면 그에 맞는 SQL을 대신 생성해 데이터베이스에 보내 주는 도구일 뿐이다. SQL을 없애 주지 않고, 우리 대신 SQL을 써 준다. 이 차이가 결정적이다. 편할 땐 한없이 편하지만, 어느 순간 ORM이 만들어 낸 SQL이 기대와 다르게 동작해 발목을 잡는 날이 온다. 테드 네워드(Ted Neward)는 일찍이 ORM의 이런 양면성을 두고 “컴퓨터 과학의 베트남”이라 부르기도 했다(2006). 편의를 좇아 들어갔다가 빠져나오기 힘든 수렁이 될 수 있다는 경고다.

    그러니 가져야 할 자세는 분명하다. ORM이 생성하는 SQL을 끝까지 볼 줄 알아야 한다. 이건 협박이 아니라 약속이다. 이 장에서는 일단 ORM의 편안함을 마음껏 누리되, 그 아래에서 어떤 SQL이 만들어지는지 곁눈질하는 습관부터 들이자. 그 SQL이 본격적으로 우리를 괴롭히는 이야기는 8장에서 다룬다.

    프런트 다리 · 원문

    프런트에서 Prisma나 Drizzle을 써 본 적이 있다면 JPA가 그리 낯설지만은 않을 것이다. 셋 다 “쿼리를 직접 안 쓰고 객체/모델로 데이터를 다룬다”는 같은 약속을 한다. 다만 결의 차이는 짚어 두자. Prisma는 어떤 동작을 할지 비교적 또렷하게 드러내는 편이고, JPA는 영속성 컨텍스트라는 보이지 않는 장치(7장에서 다룬다)가 더 많은 일을 알아서 처리한다. 편한 만큼 “내가 시키지 않은 SQL이 나가는” 순간이 더 잦다는 뜻이기도 하다. 프런트↔︎Spring 대조는 부록 A 대조표도 함께 보자.

    객체에 “이건 테이블이다”라고 표식 붙이기 — @Entity

    이론은 이쯤 해 두고 손을 움직이자. 지금 할 일(Task)은 평범한 자바 객체다. 이 객체에게 “너는 데이터베이스의 한 행(row)에 대응한다”고 알려 주는 일부터 시작한다. 그 표식이 @Entity 어노테이션이다.

    3장에서는 com.example.taskboard 패키지에서 출발했다. 그 자리에 Task라는 엔티티 클래스를 만들어 보자.

    package com.example.taskboard;
    
    import jakarta.persistence.Entity;
    import jakarta.persistence.GeneratedValue;
    import jakarta.persistence.GenerationType;
    import jakarta.persistence.Id;
    
    @Entity
    public class Task {
    
        @Id
        @GeneratedValue(strategy = GenerationType.IDENTITY)
        private Long id;
    
        private String title;
    
        private boolean done;
    
        protected Task() {
            // JPA가 내부적으로 쓰는 기본 생성자
        }
    
        public Task(String title) {
            this.title = title;
            this.done = false;
        }
    
        // getter / 상태 변경 메서드는 지면 관계상 생략
        public Long getId() { return id; }
        public String getTitle() { return title; }
        public boolean isDone() { return done; }
    
        public void markDone() { this.done = true; }
    }
    예측 · 앱 보충먼저 답해 보세요

    @Entity class Task에 테이블 이름을 따로 적지 않았다. 생기는 테이블 이름은?

    @Id가 붙은 id 필드는 “이 필드가 이 테이블의 기본 키(primary key)다”라는 뜻이다. 표의 세계에서는 기본 키 값으로 행을 구별하니, 모든 엔티티에는 반드시 기본 키가 있어야 한다. 그 아래 @GeneratedValue(strategy = GenerationType.IDENTITY)는 “이 키 값은 내가 정하지 않을 테니, 데이터베이스가 행을 넣을 때 자동으로 1, 2, 3… 하고 채워 달라”는 부탁이다. 프런트로 치면, 새 항목을 만들 때 id를 직접 만들지 않고 서버가 채워 주길 기대하던 감각과 같다.

    예측 · 앱 보충먼저 답해 보세요

    오래된 블로그의 import javax.persistence.Entity;를 Spring Boot 3.x 프로젝트에 그대로 붙이면?

    그런데 왜 protected Task()라는 빈 생성자가 필요할까? JPA가 데이터베이스에서 읽어 온 행을 객체로 되살릴 때, 일단 빈 객체를 하나 만든 뒤 값을 채워 넣기 때문이다. 그래서 인자 없는 기본 생성자가 반드시 필요하다. 다만 우리 코드에서 실수로 빈 Task()를 만들지 못하도록 protected로 살짝 잠가 두었다. 지금은 “JPA가 요구하는 약속” 정도로만 알아 두면 충분하다.

    인터페이스 한 줄로 CRUD가 끝난다 — JpaRepository

    엔티티를 정의했으니, 이제 이 객체를 데이터베이스에 저장하고 다시 꺼내 올 통로가 필요하다. 직접 SQL을 쓰던 시절이라면 INSERT, SELECT, UPDATE, DELETE를 일일이 손으로 적었을 것이다. 그런데 Spring Data JPA의 세계에서는 그 통로를 만드는 데 코드를 거의 쓰지 않는다. 인터페이스 하나만 선언하면 된다.

    package com.example.taskboard;
    
    import org.springframework.data.jpa.repository.JpaRepository;
    
    public interface TaskRepository extends JpaRepository<Task, Long> {
    }

    이게 전부다. 정말이다. JpaRepository<Task, Long>를 상속하는 빈 인터페이스 하나를 선언했을 뿐인데, 방금 save()(저장), findById()(하나 조회), findAll()(전체 조회), deleteById()(삭제) 같은 기본 CRUD 메서드 한 묶음을 통째로 손에 넣었다. 꺾쇠 안의 Task는 “이 저장소가 다루는 엔티티”이고, Long은 “그 엔티티의 기본 키 타입”이다.

    이쯤 되면 의문이 생기는 게 정상이다. 구현 코드를 한 줄도 안 썼는데, 어떻게 save()가 실제로 동작한다는 걸까? 인터페이스는 약속일 뿐, 알맹이가 없지 않은가? 3장에서 느꼈던 그 마법 같은 찜찜함이 또 고개를 든다. 좋은 감각이다.

    예측 · 앱 보충먼저 답해 보세요

    구현 클래스 없이 interface TaskRepository extends JpaRepository<Task, Long>만 있다. taskRepository.save(task)를 실행하는 객체는 어디서 왔을까?

    프런트 다리 · 앱 보충모델만 정의하면 CRUD가 생긴다
    프런트에서Prisma Client: prisma.task.create({ data })
    Spring에서Spring Data JPA: taskRepository.save(task)
    같은 점모델(엔티티)을 정의하면 저장·조회·삭제 메서드를 직접 구현하지 않고 쓴다.
    여기서 비유가 깨진다Prisma Client는 prisma generate로 코드 파일이 생성된다. Spring Data는 생성된 파일이 없고, 애플리케이션이 시작할 때 프록시 객체를 만들어 컨테이너에 넣는다. 그래서 리포지토리 정의가 잘못되면 컴파일이 아니라 시작 단계에서 실패한다.

    메모리를 데이터베이스로 갈아 끼우기 — 점진적 부착의 핵심

    이제 가장 중요한 대목이다. 5장에서 우리는 TaskBoard의 비즈니스 로직을 서비스 계층(TaskService)으로 분리해 뒀다. 컨트롤러는 HTTP 요청을 받아 DTO로 주고받고, 할 일을 실제로 저장하고 꺼내는 일은 서비스가 맡는 구조다. 그때 서비스는 데이터를 List나 Map 같은 자바 컬렉션, 즉 메모리에 담아 두고 있었을 것이다.

    원문 보정 · 앱 보충

    서비스 계층(TaskService)을 분리한 곳은 5장이 아니라 4장이다. 5장은 그 위에 DTO와 검증, 전역 예외 처리를 더했다. 같은 절 끝의 “5장에서 흘린 땀”도 4·5장에서 나눠 둔 계층 구조를 가리킨다고 읽으면 된다.

    이제 그 메모리 저장소를 방금 만든 TaskRepository로 갈아 끼운다. 단, 컨트롤러와 DTO는 손대지 않는다. 이것이 점진적 부착의 핵심이다. 그림으로 그리면 이렇다.

    [변경 전]  컨트롤러 → 서비스 → 메모리(List/Map)
    [변경 후]  컨트롤러 → 서비스 → TaskRepository → 데이터베이스
                    ↑             ↑
                (그대로)      (여기 안쪽만 교체)

    서비스 코드는 이런 모양으로 바뀐다. 메모리에 직접 담고 꺼내던 부분을 리포지토리 호출로 옮긴다.

    package com.example.taskboard;
    
    import org.springframework.stereotype.Service;
    import java.util.List;
    
    @Service
    public class TaskService {
    
        private final TaskRepository taskRepository;
    
        // 4장에서 배운 생성자 주입 — Spring이 구현체를 건네준다
        public TaskService(TaskRepository taskRepository) {
            this.taskRepository = taskRepository;
        }
    
        public Task create(String title) {
            Task task = new Task(title);
            return taskRepository.save(task);   // 메모리에 add 하던 자리
        }
    
        public List<Task> findAll() {
            return taskRepository.findAll();    // 메모리에서 꺼내던 자리
        }
    
        public Task findById(Long id) {
            return taskRepository.findById(id)
                    .orElseThrow(() -> new TaskNotFoundException(id));
        }
    }
    원문 보정 · 앱 보충

    이 서비스를 그대로 붙이면 5장 컨트롤러가 컴파일되지 않는다. 5장 컨트롤러는 TaskResponse created = taskService.create(request);처럼 DTO를 넘기고 DTO를 돌려받기 때문이다. 컨트롤러를 정말 손대지 않으려면 서비스의 공개 메서드는 5장 모양(TaskResponse create(TaskCreateRequest request))을 유지하고, 안에서 new Task(request.title())로 엔티티를 만든 뒤 저장 결과를 TaskResponse로 바꿔 돌려주자. TaskResponse의 description을 채우려면 Task에도 description 필드가 있어야 하고, TaskNotFoundException은 아직 만든 적이 없으니 RuntimeException을 상속한 클래스로 하나 두자.

    여기서 잠깐 멈추고 음미해 보자. 컨트롤러는 여전히 taskService.create(...)를 부르고, 같은 DTO로 응답한다. 클라이언트가 보내는 요청도, 받는 JSON 응답도 한 글자 바뀌지 않았다. React 앱은 서버 안에서 메모리가 데이터베이스로 바뀌었다는 사실을 전혀 눈치채지 못한다. 그런데 이제 서버를 껐다 켜도 데이터는 멀쩡히 남는다. 바깥 계약은 그대로, 안쪽 실체만 교체. 이렇게 깔끔하게 갈아 끼울 수 있는 건 컨트롤러·서비스·저장소를 계층으로 잘 나눠 둔 덕분이다. 5장에서 흘린 땀이 여기서 빛을 발한다.

    이것이 실무에서 코드를 키워 나가는 건강한 방식이다. 한 번에 전부 갈아엎지 않고, 바깥 약속을 지키면서 안쪽을 한 겹씩 개선한다. 기억해 두자. 좋은 계층 분리는 “나중에 갈아 끼울 자유”를 미리 사 두는 일이다.

    로컬 실습 · 앱 보충ch06-1 JPA와 H2를 붙이고 메모리를 DB로 갈아 끼우기
    명령과 기대 출력 펼치기

    실행해 보기

    ./gradlew bootRun

    시작 로그에서 Hibernate가 만든 테이블을 찾는다.

    Hibernate: create table task (done boolean not null, id bigint generated by default as identity, description varchar(255), title varchar(255), primary key (id))
    

    할 일을 만들고 목록을 본다.

    curl -X POST http://localhost:8080/api/tasks \
      -H "Content-Type: application/json" \
      -d '{"title": "JPA 붙이기", "description": "6장"}'
    curl http://localhost:8080/api/tasks

    콘솔에 SQL이 찍힌다. save()가 INSERT로, findAll()이 SELECT로 바뀌었다.

    Hibernate: insert into task (description,done,title,id) values (?,?,?,default)
    Hibernate: select t1_0.id,t1_0.description,t1_0.done,t1_0.title from task t1_0
    

    이제 서버를 껐다 켜고 다시 목록을 본다.

    curl http://localhost:8080/api/tasks
    []
    

    DB를 붙였는데도 비어 있다. jdbc:h2:mem:은 메모리에 뜨는 DB라 프로세스와 함께 사라지고, create-drop은 시작할 때 테이블을 만들고 끝날 때 지운다. 지금은 "일단 동작"이 목표라 이 설정을 쓴다.

    시작 로그에 spring.jpa.open-in-view is enabled by default 경고(WARN)도 보인다. 7장에서 이 경고의 뜻을 만난다.

    로컬 실습 · 앱 보충ch06-2 하나만 조회하기와 404
    명령과 기대 출력 펼치기

    실행해 보기

    ./gradlew bootRun
    curl -X POST http://localhost:8080/api/tasks \
      -H "Content-Type: application/json" \
      -d '{"title": "JPA 붙이기"}'
    curl -i http://localhost:8080/api/tasks/1
    curl -i http://localhost:8080/api/tasks/99
    HTTP/1.1 200
    {"id":1,"title":"JPA 붙이기","description":null,"done":false}
    
    HTTP/1.1 404
    {"timestamp":"…","status":404,"message":"할 일을 찾을 수 없습니다. id=99","fieldErrors":{}}
    

    직접 깨뜨려 보기: validate로 바꾸면

    원서 5절은 "운영에서는 validate로 바꾸고 url만 갈아 끼우면 된다"고 한다. application.yml의 ddl-auto: create-drop을 validate로 바꿔 띄워 보자. 시작이 실패한다.

    Schema-validation: missing table [task]
    

    validate는 테이블을 만들지 않고 엔티티와 맞는지 검사만 한다. 빈 DB에서는 테이블이 없으니 실패한다(6장 5절의 원문 보정 상자). 운영 DB라면 테이블을 미리 만들어 두고, MySQL·PostgreSQL 드라이버 의존성도 더해야 한다. 확인했으면 git checkout -- .으로 되돌린다.

    H2 데이터베이스와 application.yml — 일단 동작부터

    그런데 한 가지 빠진 게 있다. 데이터베이스에 저장한다고 했는데, 정작 그 데이터베이스는 어디 있는가? MySQL이나 PostgreSQL 같은 진짜 데이터베이스를 깔고 설정하는 일은, 입문 단계에서 첫 좌절을 부르기 딱 좋다. 설치하고, 계정 만들고, 접속 정보 맞추다 보면 정작 배우려던 JPA는 시작도 못 한다.

    그래서 개발과 학습 단계에서는 H2라는 가벼운 데이터베이스를 쓰자. H2는 자바로 만들어진 데이터베이스라 설치가 따로 필요 없고, 의존성에 추가하기만 하면 서버가 뜰 때 메모리 안에서 함께 살아난다. “진짜 데이터베이스를 붙이기 전, 손쉽게 연습할 수 있는 모래밭” 정도로 생각하면 된다.

    먼저 build.gradle의 dependencies에 두 줄을 더한다. JPA를 쓰겠다는 선언과 H2 데이터베이스다.

    dependencies {
        // 3장부터 있던 것들
        implementation 'org.springframework.boot:spring-boot-starter-web'
    
        // 이번 장에서 추가
        implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
        runtimeOnly 'com.h2database:h2'
    }

    다음으로 설정이다. 3장에서 만든 프로젝트에는 application.properties 파일이 있었다. 같은 자리에서 같은 역할을 하되 더 읽기 좋은 형식인 application.yml로 바꿔 쓰는 경우가 많으니, 여기서는 YAML로 적어 보자(둘 중 무엇을 쓰든 효과는 같다).

    spring:
      datasource:
        url: jdbc:h2:mem:taskboard      # 메모리에 뜨는 H2
        username: sa
        password:
      jpa:
        hibernate:
          ddl-auto: create-drop          # 시작 시 테이블 생성, 종료 시 삭제
        show-sql: true                    # 실행되는 SQL을 콘솔에 찍어준다

    설정값 몇 개만 골라 짚어 보자. ddl-auto는 “애플리케이션이 뜰 때 테이블 구조(DDL)를 어떻게 다룰지”를 정한다. 여기 적은 create-drop은 “서버가 뜰 때 엔티티에 맞춰 테이블을 새로 만들고, 서버가 꺼질 때 깨끗이 지운다”는 뜻이다. 매번 백지에서 시작하니 연습에는 편하다. 다만 단어 그대로 데이터를 지우는 옵션이라, 진짜 데이터를 보존해야 하는 운영 환경에서는 절대 쓰면 안 된다. 서버를 띄울 때마다 데이터가 통째로 날아가는 사고가 난다. 운영에서는 테이블을 건드리지 않는 validate나 none을 쓴다고만 기억해 두자.

    show-sql: true는 앞서 한 약속과 곧장 맞닿는다. 이 옵션을 켜 두면, JPA가 객체를 다룰 때 실제로 어떤 SQL을 만들어 데이터베이스에 보내는지 콘솔에 그대로 찍어 준다. 우리가 taskRepository.save(task) 한 줄을 부르면, 콘솔에는 이런 SQL이 흐른다.

    insert into task (done, title, id) values (?, ?, default)

    findAll()을 부르면 또 이런 게 찍힌다.

    select t1_0.id, t1_0.done, t1_0.title from task t1_0

    여기서 작은 깨달음이 온다. 자바 메서드를 불렀을 뿐인데, 그 아래에서 ORM이 우리가 알던 SQL을 대신 써서 보내고 있었다. save()는 INSERT로, findAll()은 SELECT로. 객체 세계의 명령이 표 세계의 SQL로 번역되는 현장을 이제 두 눈으로 본다. 마법처럼 보이던 일이 실은 “내가 아는 SQL을 자동으로 써 주는 일”이었다.

    show-sql을 켜 두는 습관은 입문자에게 특히 귀하다. ORM이 무슨 SQL을 만드는지 늘 곁눈질하다 보면, 8장에서 다룰 N+1 같은 함정도 “어, 왜 SELECT가 이렇게 여러 번 나가지?” 하고 스스로 알아차릴 수 있다. 지금은 그 눈을 길들이는 첫 단계다.

    실험 · 앱 보충엔티티 ↔ DDL 미리보기

    Task 엔티티를 고치고 서버를 띄우면, show-sql이 시작 로그에 찍는 테이블 DDL을 볼 수 있습니다. 로그는 Spring Boot 3.5(Hibernate 6.6, H2 2.3)에서 실제로 띄워 얻은 것입니다.

    import
    @GeneratedValue 전략
    표식 · 필드
    spring.jpa.hibernate.ddl-auto
    실행

    Task.java고친 뒤 아직 안 띄움

    package com.example.taskboard;
    
    import jakarta.persistence.Entity;
    import jakarta.persistence.GeneratedValue;
    import jakarta.persistence.GenerationType;
    import jakarta.persistence.Id;
    
    @Entity
    public class Task {
    
        @Id
        @GeneratedValue(strategy = GenerationType.IDENTITY)
        private Long id;
    
        private String title;
    
        private boolean done;
    
        protected Task() {}
        // 생성자·getter 생략
    }
    시작 로그 (show-sql)SQL 0회
    -- 엔티티를 고친 뒤 ./gradlew bootRun을 눌러 시작 로그(show-sql)를 보세요
    • 해볼 것
    • import를 javax로 바꿔 컴파일 실패 보기
    • @Entity를 떼고 시작 실패 보기
    • camelCase 필드가 snake_case 칼럼이 되는 것 보기
    • title에 @Column 제약을 걸어 varchar(100) not null 만들기
    • ddl-auto: validate로 빈 DB에서 띄워 보기
    로컬 실습 · 앱 보충ch06-3 필드 이름과 칼럼 이름
    명령과 기대 출력 펼치기

    실행해 보기

    ./gradlew bootRun

    시작 로그의 create table에 칼럼이 하나 늘었다. 이름을 보자.

    Hibernate: create table task (done boolean not null, created_at timestamp(6), id bigint generated by default as identity, description varchar(255), title varchar(255), primary key (id))
    

    자바의 createdAt(카멜 케이스)이 DB에서는 created_at(스네이크 케이스)이 됐다. Spring Boot의 기본 이름 규칙이 바꿔 준 것이다. create-drop이라 엔티티를 고치고 재시작하면 테이블도 새 모양으로 다시 만들어진다. 운영 DB에서는 이렇게 하면 안 된다(ch06-2의 validate 실험).

    할 일을 하나 만들면 INSERT에도 created_at이 들어간다.

    curl -X POST http://localhost:8080/api/tasks \
      -H "Content-Type: application/json" \
      -d '{"title": "칼럼 이름 보기"}'
    Hibernate: insert into task (created_at,description,done,title,id) values (?,?,?,?,default)
    

    응답 JSON에는 createdAt이 없다. 응답 모양은 엔티티가 아니라 TaskResponse가 정하기 때문이다(5장의 칸막이).

    프런트 다리 · 앱 보충모델에서 테이블 만들기
    프런트에서Prisma: schema.prisma의 model Task → prisma migrate dev
    Spring에서@Entity class Task + ddl-auto: create-drop
    같은 점코드로 적은 모델에서 테이블 DDL이 만들어진다.
    여기서 비유가 깨진다Prisma migrate는 마이그레이션 파일을 남기고, 적용 시점을 내가 고른다. ddl-auto는 앱이 뜰 때마다 조용히 실행되고 이력을 남기지 않는다. 그래서 운영에서는 validate나 none으로 두고, 스키마 변경은 Flyway·Liquibase 같은 마이그레이션 도구로 따로 관리하는 경우가 많다.
    예측 · 앱 보충먼저 답해 보세요

    설정대로 jdbc:h2:mem:taskboard에 할 일을 저장했다. 서버를 껐다 켜면 그 할 일은?

    원문 보정 · 앱 보충

    “validate로 바꾸고 url만 갈아 끼우면”을 빈 데이터베이스에서 그대로 하면 시작이 실패한다. validate는 테이블을 만들지 않고 검사만 하므로 Schema-validation: missing table [task]가 난다(위 실험에서 재현할 수 있다). 테이블은 미리 만들어 둬야 하고, MySQL이라면 runtimeOnly 'com.mysql:mysql-connector-j', PostgreSQL이라면 runtimeOnly 'org.postgresql:postgresql'처럼 드라이버 의존성도 더해야 한다. 코드는 그대로지만 설정과 의존성은 바뀐다.

    AI에게 엔티티를 맡길 때 — 매핑은 의심하고, 네임스페이스는 확인하자

    AI에게 “Task 엔티티와 JPA 리포지토리를 만들어줘”라고 부탁하면 그럴듯한 코드가 순식간에 나온다. 그런데 그대로 붙이기 전에 두 가지는 꼭 직접 검수하자.

    첫째, 네임스페이스다. 3장에서 겪은 함정이 엔티티에서 가장 자주 튀어나온다. AI가 import javax.persistence.Entity를 줬다면 그건 구버전 코드다. Spring Boot 3.x에서는 전부 jakarta.persistence.*여야 한다. import 첫 줄만 봐도 1초 만에 판별할 수 있으니, 받자마자 가장 먼저 확인하자.

    둘째, 매핑이 내 의도와 맞는지다. AI는 우리 머릿속 스키마를 모른다. title을 길이 제한 없는 칼럼으로 잡았는지, 꼭 있어야 할 값을 nullable로 열어 뒀는지, 기본 키 생성 전략이 우리가 쓰는 데이터베이스와 맞는지. 이런 부분은 AI가 일반론으로 채워 넣기 때문에 우리 요구와 어긋날 수 있다. 확신이 안 서면 앞서 켜 둔 show-sql로 실제 생성되는 테이블·SQL을 눈으로 확인하면 된다. AI는 스캐폴드를 만들고, 그 스캐폴드가 우리 의도와 맞는지 판단하는 건 우리다. 이 장에서도 그 원칙은 흔들리지 않는다.

    함정 찾기 · 앱 보충AI가 준 Task 엔티티, 그대로 받아도 될까?
    내가 AI에게 한 요청
    “TaskBoard용 Task 엔티티 만들어줘. 제목은 필수고 100자 이하야.” (Spring Boot 3.5 기준)

    의심스러운 줄을 모두 눌러 표시한 뒤 “검토 끝”을 누르세요. 함정은 4개입니다.

    마무리

    이번 장에서 우리는 TaskBoard의 데이터를 휘발하는 메모리에서 데이터베이스로 옮겨 담았다. 객체와 표 사이의 임피던스 불일치를 ORM이 메워 주지만, ORM은 마법이 아니라 “SQL을 대신 써 주는 도구”일 뿐이라는 점을 확인했다. @Entity로 표식을 붙이고 JpaRepository 한 줄로 CRUD를 손에 넣었다. 무엇보다 API 계약을 그대로 둔 채 안쪽 저장소만 갈아 끼우는 점진적 부착을 직접 해 봤다. show-sql을 켜 우리가 부른 자바 메서드가 어떤 SQL로 번역되는지도 두 눈으로 확인했다.

    그런데 데이터를 저장하고 꺼내다 보면, 머지않아 고개를 갸웃하게 하는 일을 만난다. 분명 save()를 부르지 않았는데 값이 바뀌어 저장돼 있거나, 객체 하나를 꺼냈을 뿐인데 연결된 다른 객체까지 슬그머니 따라온다. JPA가 보이지 않는 곳에서 부지런히 일하고 있다는 증거인데, 그 “보이지 않는 일꾼”의 정체를 모르면 동작을 예측할 수 없어 영 찜찜하다.

    다음 장에서는 그 일꾼의 이름을 부른다. 트랜잭션이 도는 동안 우리 엔티티를 하나하나 추적하는 영속성 컨텍스트, 그리고 그것이 부리는 더티 체킹과 지연 로딩의 마법을 정면으로 들여다보자. 오늘 저장한 Task 하나가 그 해부의 첫 표본이 된다.

    확인 문제 · 앱 보충장을 덮기 전에 떠올려 보기

    1. ORM에 대한 이 장의 설명으로 맞는 것은?

    2. ddl-auto: create-drop을 운영 환경에서 쓰면 안 되는 이유는?

    3. 메모리 저장소를 TaskRepository로 바꿀 때 손대지 않아도 되는 것은?

    4. 엔티티에 protected Task() {}를 두는 이유는?

    로컬 실습 · 앱 보충TaskBoard 레포로 단계별 실습하기

    이 장의 실습은 단계마다 커밋으로 준비해 두었고, 각 단계는 본문의 해당 자리에 있습니다. 처음이라면 레포부터 받습니다.

    git clone https://github.com/younggeun0/toby-react-to-spring-taskboard.git
    cd toby-react-to-spring-taskboard
    git checkout ch06-1
    1. ch06-1JPA와 H2를 붙이고 메모리를 DB로 갈아 끼우기
    2. ch06-2하나만 조회하기와 404
    3. ch06-3필드 이름과 칼럼 이름

    이 장을 언급한 노트

    다음7장. 영속성 컨텍스트라는 출석부`save()`를 부르지 않았는데 저장되는 수수께끼, 영속성 컨텍스트라는 출석부를 펼쳐 봅니다.

    원문: 『React에서 Spring으로』 6장. 메모리에서 데이터베이스로 — JPA, Toby-AI · CC BY-NC-SA 4.0. 이 페이지는 원문에 실습 블록을 더하고, 기술 내용은 그대로 둔 채 한국어 문장을 읽기 쉽게 다듬은 2차 저작물이며 같은 라이선스를 따릅니다. ‘앱 보충’ 표시가 붙은 블록은 원문에 없는 내용입니다.

    보기 옵션