포스트

스프링 초급 (5) - Database: JPA로 저장하고 조회하기

H2와 Spring Data JPA를 붙여 엔티티를 저장하고 조회하는 과정을 다룹니다.

스프링 초급 (5) - Database: 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'
}

h2runtimeOnly인 이유는 우리 코드가 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에 이 설정을 켜면 전부 날아갑니다. 운영 환경에서는 validatenone을 씁니다.

이 상태로 실행하면 시작 로그에 이런 줄이 보입니다.

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 idbigint, String namevarchar(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, NameMember 클래스의 필드 이름입니다. 컬럼 이름이 아니라 자바 필드 이름을 기준으로 해석합니다.

확인 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 tableinsert도 직접 쓴 적이 없는데 테이블이 있고 데이터가 들어 있습니다. 자바 객체를 저장했더니 행이 생겼습니다. 이게 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편에서는 지금 만든 엔티티를 컨트롤러에서 그대로 반환했을 때 무슨 일이 벌어지는지 확인합니다.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.