포스트

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

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 쿼리가 나갑니다. 여기에는 영속성 컨텍스트와 변경 감지(더티 체킹) 라는 개념이 필요한데, 이 시리즈의 범위를 넘어서 여기서는 다루지 않습니다.

지금은 저장은 save(), 조회는 findById() 로 충분합니다.

정리

  • 메모리 저장소는 프로세스가 죽으면 데이터도 죽는다. 데이터를 프로세스 밖에 남기려면 DB가 필요하다
  • @Entity가 붙은 클래스를 보고 JPA가 테이블을 만든다. create table을 직접 쓰지 않는다
  • JPA는 조회 결과로 객체를 만들 때 빈 객체를 먼저 만들고 값을 꽂으므로 인자 없는 생성자가 필요하다. protected로 열어두면 우리 코드에서는 못 쓴다
  • JpaRepository를 상속한 인터페이스만 만들면 스프링이 구현체를 만들어 빈으로 등록한다
  • 쿼리 메서드는 이름이 곧 쿼리다. 오타나 필드명 변경이 시작 시점에 잡힌다
  • 인터페이스에 의존하게 짜뒀기 때문에 저장 방식을 바꿔도 서비스 코드는 그대로다

다음 6편에서는 지금 만든 엔티티를 컨트롤러에서 그대로 반환했을 때 무슨 일이 벌어지는지 확인합니다.

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