26. 데이터 저장
지금까지는 인메모리 `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`
@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`
public interface MemoRepository extends JpaRepository<Memo, Long> {
List<Memo> findByTitleContaining(String keyword);
}`save`, `findAll`, `findById`, `delete`, `count` 등은 **자동 구현** 됩니다. 이름 규칙(`findBy필드명`)으로 간단한 조회 메서드는 시그니처만 선언하면 됩니다.
3) H2 인메모리 DB
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) 트랜잭션
@Service
public class MemoService {
@Transactional
public Memo create(...) { ... }
}`@Transactional` 메서드 안에서 일어난 DB 작업은 모두 성공해야 커밋, 하나라도 실패하면 자동 롤백됩니다.
핵심 예제
예제 1 — `Memo.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
public interface MemoRepository extends JpaRepository<Memo, Long> {
List<Memo> findByTitleContaining(String keyword);
}이 인터페이스를 정의만 하면 Spring 이 런타임에 구현체를 생성·주입합니다.
예제 3 — `MemoService.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
@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 시나리오
cd 06_Spring_Boot/26_데이터_저장
mvn spring-boot:run# 생성
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
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
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
package com.codingnow.lecture.spring26.dto;
public record MemoRequest(String title, String body) {}
src/main/java/com/codingnow/lecture/spring26/dto/MemoResponse.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
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
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
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
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
자주 하는 실수
- 엔티티에 기본 생성자가 없어서 시작 실패
- `@Transactional` 누락 → lazy loading 에서 예외
- `save` 가 update 인지 insert 인지 헷갈림 (id 가 있으면 update)
- DDL 자동 (`ddl-auto=update`) 을 운영 환경에 그대로 적용
- `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`
예상 동작
$ 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 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
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
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
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
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
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
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
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
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
직접 해 보기
cd 06_Spring_Boot/26_데이터_저장
mvn spring-boot:run
# 다른 터미널에서 위 curl 시나리오 실행다음 단원
수고하셨습니다! 26편 강의를 완주하셨습니다. 다음 단계로는
- 인증/인가 (Spring Security)
- 실제 DB (PostgreSQL/MySQL) 연동
- 외부 API 호출 (RestTemplate / WebClient)
- 테스트 (Mockito + MockMvc)
- 배포 (Docker / Cloud)
같은 주제로 이어 가 보세요. 루트 [README](../../) 의 미니 프로젝트 워크북도 참고가 됩니다.