스프링 초급 (5) - Database: JPA로 저장하고 조회하기
H2와 Spring Data JPA를 붙여 엔티티를 저장하고 조회하는 과정을 다룹니다.
스프링 초급 시리즈의 5편입니다. 전체 목차는 0편에 있습니다.
지금까지 만든 저장소의 문제
4편에서 만든 저장소는 HashMap이었습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
@Repository
public class MemoryMemberRepository implements MemberRepository {
private final Map<Long, Member> store = new HashMap<>();
private long sequence = 0L;
@Override
public Member save(Member member) {
member.setId(++sequence);
store.put(member.getId(), member);
return member;
}
}
동작은 잘 합니다. 회원을 저장하고 조회하면 그대로 나옵니다.
1
2
3
4
5
curl -X POST localhost:8080/members \
-H "Content-Type: application/json" \
-d '{"name":"민아","email":"abcd1234@gmail.com"}'
curl localhost:8080/members
1
[{"id":1,"name":"민아","email":"abcd1234@gmail.com"}]
그런데 서버를 껐다 켜고 다시 조회해 봅니다.
1
curl localhost:8080/members
1
[]
사라졌습니다. HashMap은 애플리케이션 메모리 안에 있으니 프로세스가 죽으면 같이 죽습니다. 데이터를 프로세스 밖에 남기려면 데이터베이스가 필요합니다.
이번 편에서는 이 HashMap을 진짜 DB로 바꿉니다.
무엇을 붙이는가
두 가지를 추가합니다.
- H2 — 데이터베이스 그 자체입니다. 인메모리 모드로 띄우면 별도 설치 없이 애플리케이션과 함께 실행됩니다
- Spring Data JPA — 자바 객체와 DB 테이블을 이어주는 도구입니다. 우리가 SQL을 직접 안 써도 되게 해줍니다
build.gradle에 두 줄을 넣습니다.
1
2
3
4
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
runtimeOnly 'com.h2database:h2'
}
h2가 runtimeOnly인 이유는 우리 코드가 H2 클래스를 직접 부르지 않기 때문입니다. 실행할 때 드라이버로만 쓰입니다.
H2를 인메모리로 쓰면 서버를 끌 때 데이터가 사라지는 건
HashMap과 똑같습니다. 이번 편의 목표는 데이터를 영원히 남기는 게 아니라 DB를 거쳐 저장하는 구조로 바꾸는 것입니다. 나중에 MySQL로 바꿔도 아래 코드는 그대로 둡니다.
설정 파일 작성
src/main/resources/application.yml을 만듭니다. 프로젝트 생성 시 application.properties가 있다면 지우고 .yml로 바꿔도 됩니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
spring:
datasource:
url: jdbc:h2:mem:testdb
driver-class-name: org.h2.Driver
username: sa
password:
jpa:
hibernate:
ddl-auto: create
show-sql: true
properties:
hibernate:
format_sql: true
h2:
console:
enabled: true
path: /h2-console
한 줄씩 무슨 뜻인지 보겠습니다.
| 설정 | 의미 |
|---|---|
url: jdbc:h2:mem:testdb |
testdb라는 이름의 인메모리 DB에 접속한다 |
ddl-auto: create |
애플리케이션이 뜰 때 테이블을 새로 만든다 (기존 테이블은 지운다) |
show-sql: true |
실행되는 SQL을 콘솔에 출력한다 |
format_sql: true |
그 SQL을 줄바꿈해서 읽기 좋게 출력한다 |
h2.console.enabled |
브라우저에서 DB를 들여다보는 웹 콘솔을 켠다 |
ddl-auto: create는 뜰 때마다 테이블을 지우고 다시 만듭니다. 학습용 인메모리 DB니까 괜찮지만, 실제 데이터가 든 DB에 이 설정을 켜면 전부 날아갑니다. 운영 환경에서는validate나none을 씁니다.
이 상태로 실행하면 시작 로그에 이런 줄이 보입니다.
1
2
o.s.b.a.h2.H2ConsoleAutoConfiguration : H2 console available at '/h2-console'.
Database available at 'jdbc:h2:mem:testdb'
엔티티 만들기
기존 Member 클래스에 애노테이션 세 개를 붙입니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
package com.example.demo.member;
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;
private String name;
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 String getEmail() { return email; }
}
애노테이션은 jakarta.persistence 패키지에서 가져옵니다. 인터넷 예제에 javax.persistence가 많은데, 그건 Spring Boot 2.x까지 쓰던 이름입니다. Spring Boot 3부터 jakarta로 바뀌었고, 우리가 쓰는 4.x도 그대로입니다.
각각의 의미입니다.
@Entity— “이 클래스는 DB 테이블 하나에 대응한다”는 표시입니다. 클래스 이름Member가 테이블 이름member가 됩니다@Id— 이 필드가 기본 키(PK)라는 표시입니다. 엔티티에 반드시 하나 있어야 합니다@GeneratedValue(strategy = IDENTITY)— 키 값을 우리가 정하지 않고 DB가 알아서 채우게 맡깁니다. 4편에서 직접 세던sequence변수가 필요 없어집니다
기본 생성자가 왜 필요한가
위 코드에서 눈에 걸리는 건 이 부분일 겁니다.
1
2
protected Member() {
}
이름도 이메일도 안 받는, 아무것도 안 하는 생성자입니다. 지우면 어떻게 될까요. 애플리케이션이 아예 안 뜹니다.
1
2
Caused by: org.hibernate.InstantiationException:
No default constructor for entity 'com.example.demo.member.Member'
이유는 이렇습니다. DB에서 조회한 결과로 Member 객체를 만드는 건 우리가 아니라 JPA입니다. JPA는 우리 생성자에 어떤 인자를 넣어야 할지 모르므로, 일단 빈 객체를 만든 다음 필드에 값을 하나씩 꽂아 넣습니다. 그러려면 인자 없는 생성자가 있어야 합니다.
public이 아니라 protected인 이유는, 우리 코드에서는 new Member()를 못 쓰게 막으면서 JPA에게는 열어주기 위해서입니다. 이름과 이메일이 빈 회원이 만들어질 일이 없어집니다.
확인 1: 실제로 나가는 SQL 보기
show-sql: true를 켰으니 애플리케이션을 띄우기만 해도 테이블 생성 SQL이 보입니다.
1
2
3
4
5
6
7
8
9
Hibernate:
drop table if exists member cascade
Hibernate:
create table member (
id bigint generated by default as identity,
email varchar(255),
name varchar(255),
primary key (id)
)
우리는 create table을 한 줄도 안 썼습니다. @Entity가 붙은 클래스를 보고 JPA가 만들어낸 것입니다. Long id가 bigint, String name이 varchar(255)로 매핑된 것도 보입니다.
이제 회원을 하나 저장해 봅니다.
1
2
3
curl -X POST localhost:8080/members \
-H "Content-Type: application/json" \
-d '{"name":"민아","email":"abcd1234@gmail.com"}'
1
2
3
4
5
6
Hibernate:
insert
into
member (email, name)
values
(?, ?)
조회도 해봅니다.
1
curl localhost:8080/members/1
1
2
3
4
5
6
7
8
9
Hibernate:
select
m1_0.id,
m1_0.email,
m1_0.name
from
member m1_0
where
m1_0.id=?
insert 문에 id가 없다는 점을 보세요. IDENTITY 전략이라 값을 DB가 채우기 때문입니다.
리포지토리: 인터페이스만 만들기
이제 MemoryMemberRepository를 대체합니다. 놀라운 부분은 여기입니다.
1
2
3
4
5
6
package com.example.demo.member;
import org.springframework.data.jpa.repository.JpaRepository;
public interface MemberRepository extends JpaRepository<Member, Long> {
}
끝입니다. 구현 클래스를 안 만듭니다. implements도 없고 @Override도 없습니다.
JpaRepository<Member, Long>의 두 타입은 각각 “어떤 엔티티를 다루는지”와 “그 엔티티의 @Id 타입”입니다.
이 인터페이스를 상속하는 것만으로 아래 메서드들을 쓸 수 있습니다.
1
2
3
4
5
memberRepository.save(member); // 저장
memberRepository.findById(1L); // PK로 하나 조회 → Optional<Member>
memberRepository.findAll(); // 전부 조회 → List<Member>
memberRepository.count(); // 개수
memberRepository.delete(member); // 삭제
구현체가 없는데 어떻게 동작할까요. 애플리케이션이 뜰 때 스프링이 이 인터페이스를 스캔해서 구현체를 만들고 빈으로 등록합니다. 우리가 @Repository를 안 붙여도 됩니다.
4편에서 본 의존성 주입이 그대로 통합니다. 서비스는 인터페이스만 알면 됩니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Service
public class MemberService {
private final MemberRepository memberRepository;
public MemberService(MemberRepository memberRepository) {
this.memberRepository = memberRepository;
}
public Member join(String name, String email) {
return memberRepository.save(new Member(name, email));
}
public Member find(Long id) {
return memberRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("회원 없음: " + id));
}
}
서비스 코드는 HashMap을 쓰던 때와 거의 그대로입니다. 저장 방식이 바뀌었는데 서비스가 안 바뀐 것은, 4편에서 인터페이스에 의존하게 만들어 뒀기 때문입니다.
쿼리 메서드: 이름이 곧 SQL
기본 메서드 말고 우리만의 조회가 필요할 때가 있습니다. “이메일이 이미 있는지 확인”, “이름으로 찾기” 같은 것들입니다.
인터페이스에 메서드 선언만 추가합니다.
1
2
3
4
5
6
public interface MemberRepository extends JpaRepository<Member, Long> {
boolean existsByEmail(String email);
List<Member> findByName(String name);
}
여전히 구현하지 않습니다. 스프링 데이터 JPA가 메서드 이름을 읽어서 쿼리를 만듭니다.
existsBy+Email→ “email이 ?인 행이 존재하는가”findBy+Name→ “name이 ?인 행들을 찾아라”
Email, Name은 Member 클래스의 필드 이름입니다. 컬럼 이름이 아니라 자바 필드 이름을 기준으로 해석합니다.
확인 2: 메서드 이름만 바꿔보기
정말 이름만으로 쿼리가 달라지는지 봅니다. 세 개를 선언해 두고 각각 호출했습니다.
1
2
3
List<Member> findByName(String name);
List<Member> findByNameAndEmail(String name, String email);
List<Member> findByEmailContaining(String keyword);
findByName
1
2
3
4
5
6
7
Hibernate:
select
m1_0.id, m1_0.email, m1_0.name
from
member m1_0
where
m1_0.name=?
findByNameAndEmail
1
2
3
4
5
6
7
8
Hibernate:
select
m1_0.id, m1_0.email, m1_0.name
from
member m1_0
where
m1_0.name=?
and m1_0.email=?
findByEmailContaining
1
2
3
4
5
6
7
Hibernate:
select
m1_0.id, m1_0.email, m1_0.name
from
member m1_0
where
m1_0.email like ? escape ''
And를 넣으니 and가 붙고, Containing을 넣으니 like가 됐습니다. 자바 코드는 한 줄도 안 썼습니다.
여기서 한 걸음 더 가봅니다. 일부러 오타를 냅니다.
1
List<Member> findByNamee(String name); // e가 하나 더
애플리케이션이 시작조차 못 합니다.
1
2
3
4
5
Caused by: org.springframework.data.repository.query.QueryCreationException:
Could not create query for public abstract java.util.List
com.example.demo.member.MemberRepository.findByNamee(java.lang.String);
Reason: Failed to create query for method ...;
No property 'namee' found for type 'Member'
No property 'namee' found for type 'Member' — Member에 그런 필드가 없다는 뜻입니다.
이게 중요한 이유는, 메서드 이름이 단순한 관례가 아니라 시작 시점에 검증되는 계약이기 때문입니다. 나중에 name 필드 이름을 바꾸면 이 쿼리 메서드도 같이 터집니다. 조용히 넘어가서 운영 중에 발견되는 게 아니라, 뜨기 전에 잡힙니다.
DB를 직접 열어보기
여기까지는 전부 로그로만 확인했습니다. SQL이 나갔다는 건 알겠는데 정말 저장됐는지는 아직 눈으로 못 봤습니다. application.yml에서 켜둔 h2-console로 DB를 직접 엽니다.
확인 3: h2-console에서 테이블 직접 보기
애플리케이션을 켜둔 채 브라우저에서 http://localhost:8080/h2-console로 갑니다. 접속 화면에서 JDBC URL을 application.yml에 적은 값과 똑같이 넣어야 합니다.
| 항목 | 값 |
|---|---|
| JDBC URL | jdbc:h2:mem:testdb |
| User Name | sa |
| Password | (비움) |
이 화면의 JDBC URL 기본값은
jdbc:h2:~/test입니다. 그대로 두고 Connect하면 우리 애플리케이션이 쓰는 DB가 아닌 다른 DB에 붙습니다. 테이블이 없다고 나오면 대부분 이것 때문입니다.
접속한 뒤 회원 두 명을 저장하고 조회해 봅니다.
1
SELECT * FROM MEMBER;
1
2
3
ID NAME EMAIL
1 민아 abcd1234@gmail.com
2 지훈 jihoon@example.com
create table도 insert도 직접 쓴 적이 없는데 테이블이 있고 데이터가 들어 있습니다. 자바 객체를 저장했더니 행이 생겼습니다. 이게 JPA가 하는 일입니다.
여기서 서버를 껐다 켜고 다시 조회하면 테이블이 비어 있습니다. mem 모드라 그렇고, ddl-auto: create가 테이블을 다시 만들기도 합니다. 이 동작이 불편해지는 시점이 오면 그때 실제 DB로 바꾸면 됩니다.
여기서는 다루지 않는 것
JPA를 쓰다 보면 이런 코드를 보게 됩니다.
1
2
3
4
5
6
@Transactional
public void changeName(Long id, String newName) {
Member member = memberRepository.findById(id).orElseThrow();
member.changeName(newName);
// save()를 안 부르는데 DB가 바뀐다
}
save()가 없는데 update 쿼리가 나갑니다. 여기에는 영속성 컨텍스트와 변경 감지(더티 체킹) 라는 개념이 필요한데, 이 시리즈의 범위를 넘습니다. JPA 기초 시리즈에서 따로 다룹니다.
지금은 저장은 save(), 조회는 findById() 로 충분합니다.
정리
- 메모리 저장소는 프로세스가 죽으면 데이터도 죽는다. 데이터를 프로세스 밖에 남기려면 DB가 필요하다
@Entity가 붙은 클래스를 보고 JPA가 테이블을 만든다.create table을 직접 쓰지 않는다- JPA는 조회 결과로 객체를 만들 때 빈 객체를 먼저 만들고 값을 꽂으므로 인자 없는 생성자가 필요하다.
protected로 열어두면 우리 코드에서는 못 쓴다 JpaRepository를 상속한 인터페이스만 만들면 스프링이 구현체를 만들어 빈으로 등록한다- 쿼리 메서드는 이름이 곧 쿼리다. 오타나 필드명 변경이 시작 시점에 잡힌다
- 인터페이스에 의존하게 짜뒀기 때문에 저장 방식을 바꿔도 서비스 코드는 그대로다
다음 6편에서는 지금 만든 엔티티를 컨트롤러에서 그대로 반환했을 때 무슨 일이 벌어지는지 확인합니다.