← Java 강의 목록으로
🌱
Spring Boot
Spring Boot · 선수: 이전 단원

26. 데이터 저장

지금까지는 인메모리 `Map` 으로만 데이터를 다뤘습니다. 이제 **Spring Data JPA** 와 **H2 인메모리 DB** 를 사용해 **재시작해도 같은 형태로 동작하는 영속화 계층** 을 만듭니다. JPA 는 Java 객체와 SQL 사이의 매핑을 자동화하는 ORM 표준입니다.

Spring BootJavaREST데이터 저장
소요 시간
약 1.5~2시간
난이도
📊 중급-고급
선수 조건
🎯 이전 단원 또는 동등 지식
결과물
지금까지는 인메모리 `Map` 으로만 데이터를 다뤘습니다. 이제 **Spring Data JPA** 와 **H2 인메모리 DB** 를 사용해 **재시작해도 같은 형태로 동작하는 영속화 계층** 을 만듭니다. JPA 는 Java 객체와 SQL 사이의 매핑을 자동화하는 ORM 표준입니다.

이 강의에서 배우는 것

  • 1`@Entity` 로 클래스를 테이블에 매핑한다
  • 2`JpaRepository` 인터페이스만 정의하고 구현 없이 사용한다
  • 3H2 콘솔에서 실제 SQL 을 본다
  • 4CRUD 가 동작하는 작은 메모장 API 를 완성한다

소개

지금까지는 인메모리 `Map` 으로만 데이터를 다뤘습니다. 이제 **Spring Data JPA** 와 **H2 인메모리 DB** 를 사용해 **재시작해도 같은 형태로 동작하는 영속화 계층** 을 만듭니다. JPA 는 Java 객체와 SQL 사이의 매핑을 자동화하는 ORM 표준입니다.

핵심 개념

1) `@Entity`

java
@Entity
@Table(name = "memos")
public class Memo {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String title;

    @Column(columnDefinition = "TEXT")
    private String body;

    protected Memo() {}    // JPA 가 사용
    public Memo(String title, String body) { this.title = title; this.body = body; }
    // getters/setters ...
}
  • 기본 생성자(protected/public) 필수
  • `@Id` 는 PK
  • `@GeneratedValue(IDENTITY)` 는 DB 가 PK 를 자동 생성

2) `JpaRepository`

java
public interface MemoRepository extends JpaRepository<Memo, Long> {
    List<Memo> findByTitleContaining(String keyword);
}

`save`, `findAll`, `findById`, `delete`, `count` 등은 **자동 구현** 됩니다. 이름 규칙(`findBy필드명`)으로 간단한 조회 메서드는 시그니처만 선언하면 됩니다.

3) H2 인메모리 DB

properties
spring.datasource.url=jdbc:h2:mem:lecture26
spring.datasource.driver-class-name=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=
spring.jpa.hibernate.ddl-auto=update
spring.h2.console.enabled=true
spring.h2.console.path=/h2-console
spring.jpa.show-sql=true

브라우저 `http://localhost:8087/h2-console` 로 콘솔 접속, JDBC URL 에 위 url 을 그대로 입력하면 테이블 조회 가능합니다.

4) 트랜잭션

java
@Service
public class MemoService {
    @Transactional
    public Memo create(...) { ... }
}

`@Transactional` 메서드 안에서 일어난 DB 작업은 모두 성공해야 커밋, 하나라도 실패하면 자동 롤백됩니다.

핵심 예제

예제 1 — `Memo.java` : 엔티티

java
@Entity
@Table(name = "memos")
public class Memo {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String title;
    @Column(columnDefinition = "TEXT")
    private String body;
    // ...
}

예제 2 — `MemoRepository.java` : Spring Data JPA

java
public interface MemoRepository extends JpaRepository<Memo, Long> {
    List<Memo> findByTitleContaining(String keyword);
}

이 인터페이스를 정의만 하면 Spring 이 런타임에 구현체를 생성·주입합니다.

예제 3 — `MemoService.java` : 비즈니스 로직

java
@Service
@Transactional
public class MemoService {
    private final MemoRepository repository;
    public MemoService(MemoRepository repository) { this.repository = repository; }

    public Memo create(String title, String body) { ... }
    public Memo update(Long id, String title, String body) { ... }
    public void delete(Long id) { ... }
    public Memo get(Long id) { ... }
    public List<Memo> search(String q) { ... }
}

예제 4 — `MemoController.java` : REST API

java
@RestController
@RequestMapping("/api/memos")
public class MemoController {
    private final MemoService service;
    // ...
    @GetMapping public List<MemoResponse> list(...) { ... }
    @GetMapping("/{id}") public MemoResponse one(@PathVariable Long id) { ... }
    @PostMapping public MemoResponse create(@RequestBody MemoRequest req) { ... }
    @PutMapping("/{id}") public MemoResponse update(...) { ... }
    @DeleteMapping("/{id}") public ResponseEntity<Void> delete(...) { ... }
}

실행 + curl 시나리오

bash
cd 06_Spring_Boot/26_데이터_저장
mvn spring-boot:run
bash
# 생성
curl -X POST localhost:8087/api/memos -H "Content-Type: application/json" \
     -d '{"title":"Spring","body":"좋다"}'
# {"id":1,"title":"Spring","body":"좋다"}

# 조회
curl localhost:8087/api/memos
# [{"id":1, ...}]

curl localhost:8087/api/memos/1

# 검색
curl "localhost:8087/api/memos?q=Spr"

# 수정
curl -X PUT localhost:8087/api/memos/1 \
     -H "Content-Type: application/json" \
     -d '{"title":"Spring Boot","body":"매우 좋다"}'

# 삭제
curl -X DELETE localhost:8087/api/memos/1

전체 예제 코드 (src/)

src/main/java/com/codingnow/lecture/spring26/Application.java

java
package com.codingnow.lecture.spring26;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class Application {
    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}

src/main/java/com/codingnow/lecture/spring26/controller/MemoController.java

java
package com.codingnow.lecture.spring26.controller;

import java.util.List;

import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.ExceptionHandler;
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.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import com.codingnow.lecture.spring26.dto.MemoRequest;
import com.codingnow.lecture.spring26.dto.MemoResponse;
import com.codingnow.lecture.spring26.service.MemoService;

@RestController
@RequestMapping("/api/memos")
public class MemoController {
    private final MemoService service;

    public MemoController(MemoService service) {
        this.service = service;
    }

    @GetMapping
    public List<MemoResponse> list(@RequestParam(required = false) String q) {
        return service.search(q).stream().map(MemoResponse::from).toList();
    }

    @GetMapping("/{id}")
    public MemoResponse one(@PathVariable Long id) {
        return MemoResponse.from(service.get(id));
    }

    @PostMapping
    public ResponseEntity<MemoResponse> create(@RequestBody MemoRequest req) {
        MemoResponse body = MemoResponse.from(service.create(req.title(), req.body()));
        return ResponseEntity.status(HttpStatus.CREATED).body(body);
    }

    @PutMapping("/{id}")
    public MemoResponse update(@PathVariable Long id, @RequestBody MemoRequest req) {
        return MemoResponse.from(service.update(id, req.title(), req.body()));
    }

    @DeleteMapping("/{id}")
    public ResponseEntity<Void> delete(@PathVariable Long id) {
        service.delete(id);
        return ResponseEntity.noContent().build();
    }

    @ExceptionHandler(IllegalStateException.class)
    public ResponseEntity<String> handleMissing(IllegalStateException e) {
        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(e.getMessage());
    }

    @ExceptionHandler(IllegalArgumentException.class)
    public ResponseEntity<String> handleBadRequest(IllegalArgumentException e) {
        return ResponseEntity.badRequest().body(e.getMessage());
    }
}

src/main/java/com/codingnow/lecture/spring26/dto/MemoRequest.java

java
package com.codingnow.lecture.spring26.dto;

public record MemoRequest(String title, String body) {}

src/main/java/com/codingnow/lecture/spring26/dto/MemoResponse.java

java
package com.codingnow.lecture.spring26.dto;

import com.codingnow.lecture.spring26.entity.Memo;

public record MemoResponse(Long id, String title, String body) {
    public static MemoResponse from(Memo m) {
        return new MemoResponse(m.getId(), m.getTitle(), m.getBody());
    }
}

src/main/java/com/codingnow/lecture/spring26/entity/Memo.java

java
package com.codingnow.lecture.spring26.entity;

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

@Entity
@Table(name = "memos")
public class Memo {

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

    private String title;

    @Column(columnDefinition = "TEXT")
    private String body;

    protected Memo() {}

    public Memo(String title, String body) {
        this.title = title;
        this.body = body;
    }

    public Long getId() { return id; }
    public String getTitle() { return title; }
    public String getBody() { return body; }

    public void update(String title, String body) {
        this.title = title;
        this.body = body;
    }
}

src/main/java/com/codingnow/lecture/spring26/repository/MemoRepository.java

java
package com.codingnow.lecture.spring26.repository;

import java.util.List;

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

import com.codingnow.lecture.spring26.entity.Memo;

public interface MemoRepository extends JpaRepository<Memo, Long> {
    List<Memo> findByTitleContaining(String keyword);
}

src/main/java/com/codingnow/lecture/spring26/service/MemoService.java

java
package com.codingnow.lecture.spring26.service;

import java.util.List;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import com.codingnow.lecture.spring26.entity.Memo;
import com.codingnow.lecture.spring26.repository.MemoRepository;

@Service
@Transactional
public class MemoService {
    private final MemoRepository repository;

    public MemoService(MemoRepository repository) {
        this.repository = repository;
    }

    public Memo create(String title, String body) {
        if (title == null || title.isBlank()) {
            throw new IllegalArgumentException("title required");
        }
        return repository.save(new Memo(title, body));
    }

    @Transactional(readOnly = true)
    public List<Memo> findAll() {
        return repository.findAll();
    }

    @Transactional(readOnly = true)
    public List<Memo> search(String keyword) {
        if (keyword == null || keyword.isBlank()) return repository.findAll();
        return repository.findByTitleContaining(keyword);
    }

    @Transactional(readOnly = true)
    public Memo get(Long id) {
        return repository.findById(id)
            .orElseThrow(() -> new IllegalStateException("memo not found: " + id));
    }

    public Memo update(Long id, String title, String body) {
        Memo memo = get(id);
        memo.update(title, body);
        return memo;
    }

    public void delete(Long id) {
        if (!repository.existsById(id)) {
            throw new IllegalStateException("memo not found: " + id);
        }
        repository.deleteById(id);
    }
}

src/main/resources/application.properties

properties
server.port=8087
spring.application.name=lecture-spring26

# H2
spring.datasource.url=jdbc:h2:mem:lecture26;DB_CLOSE_DELAY=-1
spring.datasource.driver-class-name=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=

# JPA
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true

# H2 콘솔
spring.h2.console.enabled=true
spring.h2.console.path=/h2-console

자주 하는 실수

  1. 엔티티에 기본 생성자가 없어서 시작 실패
  2. `@Transactional` 누락 → lazy loading 에서 예외
  3. `save` 가 update 인지 insert 인지 헷갈림 (id 가 있으면 update)
  4. DDL 자동 (`ddl-auto=update`) 을 운영 환경에 그대로 적용
  5. `findById(id).get()` 사용 → 없을 때 즉시 예외, `orElseThrow` 가 안전

정리

  • `@Entity` + `JpaRepository` 만으로 CRUD 의 80% 가 끝남
  • 비즈니스 로직은 서비스 계층 + `@Transactional`
  • H2 콘솔로 실제 SQL · 데이터 확인 가능
  • 외부 DB 로 교체할 때는 `application.properties` 만 바꾸면 됨

과제

# 과제 - 26. 데이터 저장

## 문제 — `Book` CRUD with JPA

  • 위치: `answer/`
  • 핵심 개념: `@Entity`, `JpaRepository`, `@Transactional`, H2

요구사항

  • 엔티티 `Book(id, title, author, pages)`
  • `BookRepository extends JpaRepository<Book, Long>`
  • `BookService` 의 CRUD: create, list, get, update, delete (없으면 404)
  • 컨트롤러 `/api/books` 가 5 가지 메서드 모두 제공
  • DB 는 H2 인메모리 + `ddl-auto=update`

예상 동작

bash
$ curl -X POST localhost:8088/api/books -H "Content-Type: application/json" \
       -d '{"title":"Effective Java","author":"Joshua Bloch","pages":384}'
$ curl localhost:8088/api/books
$ curl localhost:8088/api/books/1
$ curl -X PUT localhost:8088/api/books/1 -H "Content-Type: application/json" \
       -d '{"title":"...","author":"...","pages":400}'
$ curl -X DELETE localhost:8088/api/books/1

## 정답 확인 [`answer/`](./answer/) 폴더의 Maven 프로젝트를 보세요.

정답 코드 (homework/answer/)

answer/pom.xml

xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0">
    <modelVersion>4.0.0</modelVersion>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.3.6</version>
        <relativePath/>
    </parent>

    <groupId>com.codingnow</groupId>
    <artifactId>lecture-spring26-hw</artifactId>
    <version>1.0.0</version>

    <properties>
        <java.version>21</java.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-jpa</artifactId>
        </dependency>
        <dependency>
            <groupId>com.h2database</groupId>
            <artifactId>h2</artifactId>
            <scope>runtime</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>

answer/src/main/java/com/codingnow/lecture/spring26hw/Application.java

java
package com.codingnow.lecture.spring26hw;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class Application {
    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}

answer/src/main/java/com/codingnow/lecture/spring26hw/controller/BookController.java

java
package com.codingnow.lecture.spring26hw.controller;

import java.util.List;

import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.ExceptionHandler;
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.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import com.codingnow.lecture.spring26hw.dto.BookRequest;
import com.codingnow.lecture.spring26hw.dto.BookResponse;
import com.codingnow.lecture.spring26hw.service.BookService;

@RestController
@RequestMapping("/api/books")
public class BookController {
    private final BookService service;

    public BookController(BookService service) {
        this.service = service;
    }

    @GetMapping
    public List<BookResponse> list() {
        return service.findAll().stream().map(BookResponse::from).toList();
    }

    @GetMapping("/{id}")
    public BookResponse one(@PathVariable Long id) {
        return BookResponse.from(service.get(id));
    }

    @PostMapping
    public ResponseEntity<BookResponse> create(@RequestBody BookRequest req) {
        BookResponse body = BookResponse.from(service.create(req.title(), req.author(), req.pages()));
        return ResponseEntity.status(HttpStatus.CREATED).body(body);
    }

    @PutMapping("/{id}")
    public BookResponse update(@PathVariable Long id, @RequestBody BookRequest req) {
        return BookResponse.from(service.update(id, req.title(), req.author(), req.pages()));
    }

    @DeleteMapping("/{id}")
    public ResponseEntity<Void> delete(@PathVariable Long id) {
        service.delete(id);
        return ResponseEntity.noContent().build();
    }

    @ExceptionHandler(IllegalStateException.class)
    public ResponseEntity<String> handleMissing(IllegalStateException e) {
        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(e.getMessage());
    }
}

answer/src/main/java/com/codingnow/lecture/spring26hw/dto/BookRequest.java

java
package com.codingnow.lecture.spring26hw.dto;

public record BookRequest(String title, String author, int pages) {}

answer/src/main/java/com/codingnow/lecture/spring26hw/dto/BookResponse.java

java
package com.codingnow.lecture.spring26hw.dto;

import com.codingnow.lecture.spring26hw.entity.Book;

public record BookResponse(Long id, String title, String author, int pages) {
    public static BookResponse from(Book b) {
        return new BookResponse(b.getId(), b.getTitle(), b.getAuthor(), b.getPages());
    }
}

answer/src/main/java/com/codingnow/lecture/spring26hw/entity/Book.java

java
package com.codingnow.lecture.spring26hw.entity;

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

@Entity
@Table(name = "books")
public class Book {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String title;
    private String author;
    private int pages;

    protected Book() {}

    public Book(String title, String author, int pages) {
        this.title = title;
        this.author = author;
        this.pages = pages;
    }

    public Long getId() { return id; }
    public String getTitle() { return title; }
    public String getAuthor() { return author; }
    public int getPages() { return pages; }

    public void update(String title, String author, int pages) {
        this.title = title;
        this.author = author;
        this.pages = pages;
    }
}

answer/src/main/java/com/codingnow/lecture/spring26hw/repository/BookRepository.java

java
package com.codingnow.lecture.spring26hw.repository;

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

import com.codingnow.lecture.spring26hw.entity.Book;

public interface BookRepository extends JpaRepository<Book, Long> {
}

answer/src/main/java/com/codingnow/lecture/spring26hw/service/BookService.java

java
package com.codingnow.lecture.spring26hw.service;

import java.util.List;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import com.codingnow.lecture.spring26hw.entity.Book;
import com.codingnow.lecture.spring26hw.repository.BookRepository;

@Service
@Transactional
public class BookService {
    private final BookRepository repository;

    public BookService(BookRepository repository) {
        this.repository = repository;
    }

    public Book create(String title, String author, int pages) {
        return repository.save(new Book(title, author, pages));
    }

    @Transactional(readOnly = true)
    public List<Book> findAll() {
        return repository.findAll();
    }

    @Transactional(readOnly = true)
    public Book get(Long id) {
        return repository.findById(id)
            .orElseThrow(() -> new IllegalStateException("book not found: " + id));
    }

    public Book update(Long id, String title, String author, int pages) {
        Book b = get(id);
        b.update(title, author, pages);
        return b;
    }

    public void delete(Long id) {
        if (!repository.existsById(id)) {
            throw new IllegalStateException("book not found: " + id);
        }
        repository.deleteById(id);
    }
}

answer/src/main/resources/application.properties

properties
server.port=8088
spring.application.name=lecture-spring26-hw

spring.datasource.url=jdbc:h2:mem:books;DB_CLOSE_DELAY=-1
spring.datasource.driver-class-name=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=

spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true

직접 해 보기

bash
cd 06_Spring_Boot/26_데이터_저장
mvn spring-boot:run
# 다른 터미널에서 위 curl 시나리오 실행

다음 단원

수고하셨습니다! 26편 강의를 완주하셨습니다. 다음 단계로는

  • 인증/인가 (Spring Security)
  • 실제 DB (PostgreSQL/MySQL) 연동
  • 외부 API 호출 (RestTemplate / WebClient)
  • 테스트 (Mockito + MockMvc)
  • 배포 (Docker / Cloud)

같은 주제로 이어 가 보세요. 루트 [README](../../) 의 미니 프로젝트 워크북도 참고가 됩니다.

예제 코드 / 강의 자료

전체 강의 자료와 예제 코드는 GitHub에서 자유롭게 받아볼 수 있습니다.

GitHub에서 보기 ↗