🪛 한계점
다양한 데이터 소스를 관리하려면 DAO 를 수행하는 객체들을 여러개 정의해야 한다.
📂 목차
- 개요
- Spring Boot RDBMS 연동하기
- Spring Data JPA 사용하기
- Spring Custom Repository
- Criteria API
- Spring Data JPA & QueryDSL
- Projection
📚 본문
데이터 소스를 접근하기 위해 자주 쓰는 보일러 플레이트 코드들을 안쓰도록 하고, 다양한 데이터 소스에 대한 접근을 일관된 코드로 가져가서 개발자에게 편의성을 제공함과 동시에 다양한 메서드를 제공하기 위한 DAO 생성 템플릿을 지원한다.
이를 위해 JDBC(Java Database Connectivity) 를 활용해 DB에 연결, 쿼리를 만들기 위한 PreparedStatement를 정의만 하면 내부적인 로직을 자동으로 짜주어서 DB 에 접근하는 세부 로직들을 다 안짜주어도 된다. 결과적으로 생산성이 비약적으로 늘어난다.
우선 개념부터 보고 가자.
개요
Java Persistence API (JPA)
JPA는 자바 객체(Entity)를 데이터베이스에 매핑하기 위한 ORM(Object-Relational Mapping) 표준 인터페이스이다.
기본 구조는 다음과 같다:
Entity (자바 객체) → Persistence Provider (예: Hibernate, EclipseLink) → Database
우리는 JPA가 정의한 인터페이스(API 명세)를 사용하고, 실제 동작은 Hibernate 등의 퍼시스턴스 제공자가 구현한다.
즉, JPA는 표준을 정의하고, 구현체는 이를 따르는 방식으로 동작한다.
Spring Template
JDBC, JMS(Java Message Service), JNDI 등 공통적으로 사용하는 저수준 API에서는 DB 연결, 예외 처리, 자원 해제 등의 보일러플레이트 코드가 반복적으로 발생한다.
Spring은 이러한 반복 작업을 줄이기 위해 Template 기반의 추상화 클래스를 제공한다.
예를 들어 JdbcTemplate
은 JDBC API를 사용할 때 필요한 연결, 쿼리 실행, 예외 처리, 자원 정리 등을 자동으로 처리해준다.
즉, 복잡한 try-catch 패턴 없이도 DB 작업을 간단하게 수행할 수 있다.
JdbcTemplate의 역할
- DB 연결 및 자원 해제 자동 처리
- SQLException → DataAccessException (스프링 공통 예외로 변환)
- SQL 실행 및 결과 매핑 지원 (
query
,update
등)
Repository
Spring에서 Repository는 전통적인 DAO(Data Access Object)의 역할을 수행하는 개념이다. @Repository
어노테이션을 사용해 데이터 접근 계층을 정의하며, 이는 Spring Data Commons에 포함된 여러 인터페이스들을 통해 기능이 확장된다.
Spring Data JPA나 JdbcTemplate 기반 Repository를 사용하면,
- CRUD 메서드가 자동 생성되며
- 쿼리 메서드(
findByName
,countByStatus
등)도 자동 구현된다 - DB 연결, 트랜잭션 처리, 예외(
DataAccessExcpetion
으로 일관된 처리) 변환 등은 모두 스프링이 자동으로 처리한다 - Bean으로 등록
즉, 인터페이스만 정의하면 구현 없이도 기본적인 데이터 접근 로직을 자동으로 생성해준다.
Spring Data Modules
- Spring Data Commons
- Spring Data JDBC
- Spring Data JPA
- Spring Data MongoDB
- Spring Data Redis
- Spring Data REST
- Spring Data Apache Casandra
- …
데이터 소스들마다 모듈들이 있어 굉장히 많은 모듈이 있다. 모듈들을 이해하기 위해 모듈을 계층적으로 나눌 수 있다.
- Spring Data Commons: Repository, CrudRepository, PagingAndSortingRepository
- Spring Data Sub-modules: JDBC, JPA, MongoDB, Casandra
- DB Layer: JDBC-MySQL, JPA-PostgreSQL, MongoDB-MongoDB, …
Spring Data Commons는 Data 서브 모듈들을 사용하기 위한 일관된 인터페이스를 개발자에게 제공하고, 서브 모듈은 각각의 다양한 DB에 연결되어 데이터 소스마다 코드 차이를 개발자가 굳이 몰라도 사용할 수 있도록 한다.
Spring Boot RDBMS 연동하기
우선 JPA를 사용하기 위해 다음 의존성을 추가한다.
implementation('org.springframework.boot:spring-boot-starter-data-jpa') {
exclude group: 'com.zaxxer', module: 'HikariCP'
}
여기서 HikariCP 말고 Connection Pool 로 다음을 설정한다.
implementation 'org.apache.tomcat:tomcat-jdbc:10.1.20' // 커넥션 풀 tomcat jdbc 사용
커넥션 풀은 서비스마다 알맞은걸 사용하면 된다.
Spring Boot Database 스키마 정의 및 초기 데이터 설정
데이터베이스 스키마를 정의하는 부분은 resources/schema.sql
, resources/data.sql
에서 할 수 있다. 그 전에 *.sql을 사용하도록 하기 위해 spring 에서 다음 프로퍼티를 설정해주어야 한다.
# application.properties
# always 내장DB, 외장DB든 상관 없이 항상 SQL 파일 실행
# embedded(기본값) H2, HSQL 등의 내장 DB 에서만 SQL 파일 실행
# never SQL 초기화 파일 실행 안함
spring.sql.init.mode=always
# schema.sql + data.sql 사용을 명시
spring.sql.init.schema-locations=classpath:schema.sql
spring.sql.init.data-locations=classpath:data.sql
# Hibernate가 테이블 만들지 않도록 설정
spring.jpa.hibernate.ddl-auto=none
# JPA SQL 쿼리를 콘솔에 출력하도록 함
spring.jpa.show-sql=true
스키마를 설정한다.
// schema.sql
CREATE TABLE IF NOT EXISTS USERS (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100),
email VARCHAR(100)
);
기본적으로 핸들링 할 데이터를 추가해준다.
// data.sql
INSERT INTO USERS(id, name, email)
SELECT 1, 'Alice', 'alice@example.com'
WHERE NOT EXISTS (SELECT 1 FROM USERS WHERE id = 1);
데이터는 그냥 GPT 가 주는 예시로 했다.
Spring Boot MySQL Database
우선 mysql을 연결하기 위해 다음 의존성을 추가해줘야 한다.
runtimeOnly 'mysql:mysql-connector-java:8.0.33'
dev 환경에서 MySql server를 내부적으로 사용하기 위해 다음을 입력한다. 포트는 3306이다.
docker run --name mysql-dev \
-e MYSQL_ROOT_PASSWORD={password} \
-e MYSQL_DATABASE={DB 이름} \
-p 3306:3306 \
-d mysql:8
properties 설정하기
자주 사용되는 url과 password 등을 properties에 저장한다.
# mysql.properties
spring.datasource.url=jdbc:mysql://localhost:3306/{DB 이름}
spring.datasource.username=root
spring.datasource.password=1234
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
필자는 mysql.properties
를 만들어, application.properties
에
spring.config.import=classpath:mysql.properties
를 추가해줬다.
다양한 방법으로 import 해준다. Spring Data 에서 자체적으로 DataSource 라는 싱글톤 객체에 우리가 정의한 spring.datasource 프로퍼티들을 토대로 자동으로 connection 을 생성해준다. 이를 log 찍어보자.
Testing
@Test
public void givenDatasourceAvailableWhenAccessDetailsThenExpectDetails()
throws SQLException {
assertThat(dataSource.getClass().getName())
.isEqualTo("org.apache.tomcat.jdbc.pool.DataSource");
assertThat(dataSource.getConnection().getMetaData().getDatabaseProductName())
.isEqualTo("MySQL");
}
기본적으로 예외 처리 또한 일관성 있게 지원해주기 때문에 메서드에 throws SQLException 만 넣어주면 exception 을 받을 수 있다. 이제 데이터 또한 들어갔는지를 살펴보자.
@Test
public void givenUserWhenGetUserNameByIdThenGetUser() throws Exception {
try(
Connection cn = dataSource.getConnection();
PreparedStatement ps = cn.prepareStatement("SELECT name FROM USERS WHERE id=1");
ResultSet rs = ps.executeQuery();
) {
if (rs.next())
assertThat("Alice").isEqualTo(rs.getString("name"));
else
fail("No user found with id=1");
}
}
현재 JPA Repository를 따로 정의하지 않았기 때문에, Spring Data JPA의 기능은 사용하지 않고 순수 JDBC 방식으로 테스트를 진행했다.
Spring Boot PostgreSQL Database 로 변경해보기
Postgre 전용 db 를 사용하기 위한 임시 서버를 연다. 포트는 5432 이다.
docker run --name postgres-dev \
-e POSTGRES_USER={username} \
-e POSTGRES_PASSWORD={password} \
-e POSTGRES_DB={DB 이름} \
-p 5432:5432 \
-d postgres:15
의존성을 추가해준다.
runtimeOnly 'org.postgresql:postgresql'
properties 분리하기
postgre 를 사용할 때는 세팅 값이 바뀌어야 하므로 postgres, mysql 별로 properties 를 따로 만들어준다.
# application-postgres.properties
# DB Initialize
spring.sql.init.schema-locations=classpath:db/postgres/schema.sql
spring.sql.init.data-locations=classpath:db/postgres/data.sql
# DataSource
spring.datasource.url=jdbc:postgresql://localhost:5432/{DB 이름}
spring.datasource.username={username}
spring.datasource.password={password}
spring.datasource.driver-class-name=org.postgresql.Driver
위를 만들어준다고 해서 자동으로 참조하지는 않는다. application.properties에서 다음을 넣어주면 된다.
# application.properties
spring.profiles.active=postgres
이렇게 되면 db server 가 바뀔 때마다 이 property 만 바꿔주면 될 터이다.
Postgre SQL 작성
-- shema.sql
CREATE TABLE IF NOT EXISTS USERS (
id INT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name VARCHAR(100),
email VARCHAR(100)
);
-- data.sql
INSERT INTO USERS(id, name, email)
OVERRIDING SYSTEM VALUE
VALUES (1, 'Alice', 'alice@example.com')
ON CONFLICT (id) DO NOTHING;
Testing
@Test
public void givenDatasourceAvailableWhenAccessDetailsThenExpectDetails()
throws SQLException {
assertThat(dataSource.getClass().getName())
.isEqualTo("org.apache.tomcat.jdbc.pool.DataSource");
// assertThat(dataSource.getConnection().getMetaData().getDatabaseProductName())
// .isEqualTo("MySQL");
assertThat(dataSource.getConnection().getMetaData().getDatabaseProductName())
.isEqualTo("PostgreSQL");
}
@Test
public void givenUserWhenGetUserNameByIdThenGetUser() throws Exception {
try(
Connection cn = dataSource.getConnection();
PreparedStatement ps = cn.prepareStatement("SELECT name FROM USERS WHERE id=1");
ResultSet rs = ps.executeQuery();
) {
if (rs.next())
assertThat("Alice").isEqualTo(rs.getString("name"));
else
fail("No user found with id=1");
}
}
테스트에서 수정할 부분은 한가지 뿐이다(찾아보아라).
Spring Data JPA 사용하기
코드에서도 데이터베이스에 대한 명령을 수행할 수 있고, 이를 JPA를 통해 한다고 사전에 보았을 것이다. 여기서는 이 JPA의 가장 기본적인 Persistence Provider 의 Repository 를 본다.
@Indexed
public interface Repository<T, ID> { }
Repository
인터페이스를 파보면 제너릭 타입 T, ID 가 있음을 볼 수 있다. ID 는 행을 구분하는 즉, 레코드를 구분하는 컬럼 명 혹은 필드 명이다. T는 이 ID 의 타입을 선언한다.
하지만 내부는 비어있는걸 볼 수 있는데 이를 Marker Interface 라고 한다.
CrudRepository
Repository
를 상속 받는 CrudRepository
를 보자. 자주 사용하는 놈이다.
@NoRepositoryBean
public interface CrudRepository<T, ID> extends Repository<T, ID> {
<S extends T> S save(S entity);
<S extends T> Iterable<S> saveAll(Iterable<S> entities);
Optional<T> findById(ID id);
boolean existsById(ID id);
Iterable<T> findAll();
Iterable<T> findAllById(Iterable<ID> ids);
long count();
void deleteById(ID id);
void delete(T entity);
void deleteAllById(Iterable<? extends ID> ids);
void deleteAll(Iterable<? extends T> entities);
void deleteAll();
}
메서드 명은 설명 안해도 될 정도로 직관적이고 명확하다. 위 메서드를 다 지원하며, 굳이 이를 상속하는 구현체에 위 메서드를 다 구현해야 하는 수고를 덜 수 있다.
이제 이를 사용해야 하는데, JVM은 DB 쪽 서버에서 정의된 스키마를 모르기에 Java 내에서 해당 레코드와 맞먹는 클래스를 구현해야 한다. 다음을 정의하자.
@Entity 와 @Table
엔티티는 DB의 한 테이블에 매핑되는 도메인 객체(비즈니스 모델)이며, 스키마와 1:1로 대응될 수 있도록 정의된다. USERS
테이블에 대응되는 비즈니스 엔티티를 만들어보자.
import jakarta.persistence.*;
import lombok.*;
@Entity
@Table(name = "USERS")
@Data
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;
@Column(length = 100)
private String name;
@Column(length = 100, nullable = false)
private String email;
public User() {}
public User(@NonNull String name, @NonNull String email) {
this.name = name;
this.email = email;
}
}
NonNull 과 Data 어노테이션은 필자의 Lombok 글을 보자.
@NonNull
과@Data
는 Lombok 애노테이션이며,@NonNull
은 생성자 인자에 null이 들어오지 않도록 런타임 검사를 수행한다.
@Data
를 사용하더라도 Spring Data JPA는 생성자 기반 객체 생성을 하지 않을 수도 있기 때문에, 명시적 생성자를 선언해주는 것이 안정적이다.
MySQL 기준으로 Java 의 타입과 DB 스키마의 필드 타입 매핑은 다음과 같다.
- INT - Integer, int
- BIGINT - Long
- VARCHAR(n) - String
- CHAR(n) - String
- TEXT, LONGTEXT - String
- BOOLEAN, TINYINT(1) - Boolean / boolean
- DATE - java.time.LocalDate
- DATETIME, TIMESTAMP - javatime.LocalDateTime
- DECIMAL, NUMERIC, FLOAT, DOUBLE - java.math.BigDecimal
- 이진 관련 데이터 - byte[]
주의할 점
int 는 null 을 허용하지 않기 때문에 자동으로 생성되는 ID의 값과 충돌 가능성이 있다
Integer이나 Long을 사용하여 null을 허용하고 DB 자체에 접근이 될때 미지정 상태로 넣어주면 DB에서 알아서 정의해줄 것이다.
따라서, Long은 BIGINT AUTO_INCREMENT 와도 같고, Integer은 INT AUTO_INCREMENT와 같다. int 의 사용은 되도록 일반적인 필드에서만 사용하도록 하자. 그리고 이를 java 에서 사용하고 싶다면 @GeneratedValue(strategy = GenerationType.IDENTITY)
를 추가하여 해당 필드가 AUTO_INCREMENT 임을 타 프로그래머와 제 3자에게 알려주자.
또한, PK, FK, 누적값에 대한 필드에 대해서는 Long을 쓰는 것이 더 바람직하다.
TEXT 계열은 인덱싱 제한이 있기 때문에 검색 필드로는 사용 주의가 필요하다.
GenerationValue
- GeneratedType.Table: DB의 AUTO_INCREMENT를 사용하지 않고 JPA 구현체가 ID를 직접 증가시키기 위해 직접 키 생성 전용 테이블을 만들어서 사용, schema 에서 AUTO_INCREMENT를 빼야 함.
- GeneratedType.Identity: DB에서 생성된 식별자 컬럼에서 생성된 값을 기본키로 사용
- GeneratedType.Sequence: 이름 그대로 JPA 구현체가 데이터베이스의 시퀀스를 사용하여 키를 생성하고 이를 기본키로 사용
- GeneratedType.Auto: JPA 구현체가 기본 키 생ㅅ어 방식을 스스로 결정
TIP
어노테이션
@Column
에 자체적으로length
라는 인자를 받을 수 있는데 이를 설정해주는 걸로 VARCHAR 의 길이와 매핑이 된다.
Java에는 String
이 null
값을 가질 수도 있는데, 이를 제어하기 위해 @Column(nullable = false)
를 해주면 무조건 입력하도록 하게 할 수 있다(unique
인자도 있음).
이제 정의된 Entity 를 Table Schema와 연결시키자.
@Data
@Table(name = "USERS")
public class User {
이제 JPA 를 사용하기 위한 준비가 끝났다(Repository
사용 준비 끝).
CrudRepository를 활용한 User Table Schema 구현
import com.example.study.dao.entity.*;
import org.springframework.stereotype.Repository;
import org.springframework.data.repository.CrudRepository;
@Repository
public interface UserRepository extends CrudRepository<User, Long> { }
이제 JPA 에서 제공하는 CRUD, 쿼리 메서드들을 사용할 수 있게 된다. 나머지 필요한 메서드는 서비스가 요구하는 상황에 맞춰 그때그때 추가해주는게 바람직하다.
JPA 와 sql 사이의 DB 초기화 고찰
이제 프로퍼티에 관해서 보자. 앞서 봤듯이 우리는 sql 문을 통해 초기 데이터들을 정의하고 테스트를 수행했는데 그렇다면, 단순 SQL 파일이 아닌 JPA 자체를 통해서도 초기 데이터를 설정할 수 있지 않을까 하는 의문이 생긴다.
sql로 수행할 때, 다음 properties 들을 썼다:
spring.jpa.hibernate.ddl-auto
: JPA가 ddl을 어떻게 처리할 지에 대해 명시- create-drop 은 엔티티를 기반으로 테이블 생성
- create 는 엔터티를 기반으로 새 테이블 생성(기존 테이블 모두 삭제)
- update 는 엔터티를 기반으로 스키마를 업데이트하며 기존 테이블이 있다면 수정하고 없으면 생성
- validate 는 프로덕션 환경에서 스키마가 올바른지 확인
- none 은 Hibernate 가 전혀 스키마를 관리하지 않는다.
spring.sql.init.mode
:schema.sql
/data.sql
실행 여부 제어
이제 이를 조금 바꿔가면서 케이스마다 어떤 프로퍼티 값을 가지게 해야할지 정리해보자.
Case 1: JPA가 테이블을 만들고, SQL은 초기 데이터만 삽입
spring.jpa.hibernate.ddl-auto=create
spring.sql.init.mode=always
create는 애플리케이션 시작 시 테이블을 모두 드롭 후 재생성하는 설정이다.
즉, 기존 데이터는 매번 삭제되며, 테스트나 개발 초기에만 사용하기 적합하다.
이 설정에서는 JPA가 직접 스키마를 생성하므로, schema.sql
은 무시되고 실행되지 않는다.
다만 data.sql
은 JPA가 테이블 생성을 완료한 후에 실행되기 때문에 초기 데이터 삽입 용도로는 유효하다.
Case 2: SQL로 테이블을 정의하고, JPA는 건들지 않음
spring.jpa.hibernate.ddl-auto=none
spring.sql.init.mode=always
SQL 파일에 모든 테이블과 초기 데이터를 명시적으로 관리한다. 보통 실무에서 많이 사용한다.
Case 3: 둘 다 사용했지만 충돌 발생 가능
update는 JPA 기준으로 테이블을 수정하려고 시도하며, 동시에 schema.sql
이 적용되면
서로의 구조가 충돌할 수 있다. 특히 컬럼 중복, 타입 불일치 시 오류 발생 가능하다.
spring.jpa.hibernate.ddl-auto=update
spring.sql.init.mode=always
유지보수가 어렵고 예측이 불가능하다. 비추천
Case 4: 테스트 환경에서만 초기화
내장 DB 전용이다.
spring.jpa.hibernate.ddl-auto=create
spring.sql.init.mode=embedded
내장 DB일 때만, schema.sql
, data.sql
이 실행되고,
실제 MySQL이나 PostgreSQL에서는 schema.sql
, data.sql
이 무시된다.
서버를 올리고 나서는 JPA를 기준으로 돌아가게 된다.
Testing
테스트를 하기 전에 위의 경우들에 맞춰 프로퍼티를 설정해주길 바란다.
import com.example.study.dao.entity.*;
import org.junit.jupiter.api.*;
import org.springframework.beans.factory.annotation.*;
import org.springframework.boot.test.context.*;
import static org.junit.jupiter.api.Assertions.*;
@SpringBootTest
public class UserRepositoryTest {
@Autowired
UserRepository userRepository;
@Test
public void givenCreateUserWhenLoadTheUserThenExpectExistedUser() {
User user = new User("IU", "1234");
userRepository.save(user);
assertEquals("IU", userRepository.findById(1L).orElseThrow(
() -> new AssertionError("User not found")
).getName());
}
@Test
public void givenCreateUserWhenLoadTheUserThenExpectSameUser() {
User user = new User("Alice", "alice@alice.co.kr"); // 여기선 user id 가 null 이지만
User savedUser = userRepository.save(user); // 여기서는 user id가 자동 설정됨
assertEquals(user, savedUser);
}
@Test
public void givenUpdateUserWhenLoadTheUserThenExpectUpdatedUser() {
User user = userRepository.findById(1L)
.orElseThrow(() -> new AssertionError("User not found"));
user.setName("Bob");
userRepository.save(user);
User foundUser = userRepository.findById(1L).get();
assertNotEquals("IU", foundUser.getName());
assertEquals("Bob", foundUser.getName());
}
@Test
public void givenDeleteUserWhenLoadTheUserThenExpectNoUser() {
long total = userRepository.count();
User user = userRepository.findById(1L)
.orElseThrow(() -> new AssertionError("User not found"));
userRepository.delete(user);
assertEquals(total-1,userRepository.count());
userRepository.deleteAll();
assertEquals(0L, userRepository.count());
}
}
전부 통과해야 한다.
PagingAndSortingRepository 를 활용한 페이징
어떤 쇼핑몰이든 게시물들이던 1000만 개의 포스트나 글들을 불러오는 것은 굉장히 무거운 작업이다. 따라서 많은 양의 데이터를 여러 페이지로 잘게 나눠서 조회한다면 page 만큼의 양만 조회하기 때문에 효율적이게 된다. 이런 기술을 Paging 이라고 한다.
스프링 데이터에서는 PagingAndSoringRepository
인터페이스를 지원하며, Repository
를 상속 받는 클래스이다.
CrudRepository 와 PagingAndSortingRepository 를 통한 포스트 조회
하기 전에 새로운 엔티티를 다루자. Paging 기능을 극한으로 활용할 수 있는 곳은 게시글, 상품 글들 보기 등등 일 것이다. 필자는 게시글로 Post 엔티티를 정의한다. 정의하기 전에 low-level의 DB 단에서 schema 를 정의한다.
DROP TABLE IF EXISTS USERS;
DROP TABLE IF EXISTS POSTS;
CREATE TABLE USERS (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100),
email VARCHAR(100)
);
CREATE TABLE POSTS (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
created_at DATETIME NOT NULL,
title VARCHAR(50),
content TEXT
);
초기 데이터도 삽입해준다(물론 필자는 init.mode
가 never
이지만 넣어줬다).
INSERT INTO USERS (name, email)
SELECT 'Alice', 'alice@example.com';
INSERT INTO POSTS (time, title, content)
VALUES (NOW(), 'Spring Boot Intro', 'This is the content of the post.');
포스트를 선언하자.
package com.example.study.dao.entity;
import jakarta.persistence.*;
import lombok.*;
import java.time.*;
@Entity
@Table(name = "POSTS")
@Data
public class Post {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Lob
@Column(nullable = false)
private LocalDateTime createdAt;
@Column(length = 50, nullable = false)
private String title;
@Column(nullable = false)
private String content;
public Post() {}
public Post(@NonNull LocalDateTime createdAt, @NonNull String title, @NonNull String content) {
this.createdAt = createdAt;
this.title = title;
this.content = content;
}
}
이제 Repository를 선언해준다.
import com.example.study.dao.entity.*;
import org.springframework.data.repository.*;
import org.springframework.stereotype.Repository;
@Repository
import com.example.study.dao.entity.*;
import org.springframework.data.repository.*;
import org.springframework.stereotype.Repository;
@Repository
public interface PostRepository extends PagingAndSortingRepository<Post, Long>, CrudRepository<Post, Long> { }
테스트를 작성하자.
import com.example.study.dao.entity.*;
import org.junit.jupiter.api.*;
import org.springframework.beans.factory.annotation.*;
import org.springframework.boot.test.context.*;
import org.springframework.data.domain.*;
import java.time.*;
import java.util.*;
import static org.junit.jupiter.api.Assertions.*;
@SpringBootTest
class PostRepositoryTest {
@Autowired
PostRepository postRepository;
@Test
void readPostWhenLoadThePostThenExpectExistedPost() {
// given
Post post1 = new Post(
LocalDateTime.of(2025, 7, 3, 10, 0),
"Spring Boot Intro", "Introduction to Spring Boot"
);
Post post2 = new Post(
LocalDateTime.of(2025, 7, 3, 11, 0),
"JPA Basics", "Learn JPA with Spring"
);
Post post3 = new Post(
LocalDateTime.of(2025, 7, 3, 12, 0),
"Testing with JUnit", "Unit testing guide"
);
Post post4 = new Post(
LocalDateTime.of(2025, 7, 3, 13, 0),
"REST API", "Building REST APIs"
);
Post post5 = new Post(
LocalDateTime.of(2025, 7, 3, 14, 0),
"Spring Security", "Securing applications"
);
Post post6 = new Post(
LocalDateTime.of(2025, 7, 3, 15, 0),
"Advanced JPA", "Advanced JPA techniques"
);
postRepository.saveAll(List.of(post1, post2, post3, post4, post5, post6));
Pageable pageable = PageRequest.of(0, 5);
// when
Page<Post> page = postRepository.findAll(pageable);
assertEquals(0, page.getNumber());
assertEquals(5, page.getSize());
assertEquals(5, page.getNumberOfElements());
assertEquals(6, page.getTotalElements());
// then
List<Post> posts = page.getContent();
assertTrue(posts.stream().anyMatch(post ->
post.getId().equals(1L) && post.getTitle().equals("Spring Boot Intro")),
"ID가 1이고 제목이 'Spring Boot Intro'인 Post가 존재해야 한다.");
}
}
@DataJpaTest와 @AutoConfigureTestDatabase 를 활용한 테스팅
우리는 테스트를 할 때 항상 IoC
에 Bean
을 전부 등록하고, 다 등록된 후에야 테스트를 수행하게 된다. 단위 테스트에 대해서는 테스트 할 컴포넌트와 의존적인 컴포넌트만 올리면ㄷ 되지만, 다른 컴포넌트까지 올려버리기 때문에 굉장히 비효율적이다. 따라서 이를 방지하기 위해 Spring 에서는 Data 에 대한 컴포넌트 끼리의 테스트 컨텍스트를 구분시켜주는 어노테이션을 지원해준다.
DataJpaTest
를 사용하려면 다음과 같이 어노테이션을 테스트 클래스 레벨에 붙여준다.
@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
여기서 AutoConfigureTestDatabase
의 역할은 기본적으로 테스트 수준에서의 DB는 In-memory 를 사용하여 데이터를 관리하기 때문에 이걸 참조하는 프로퍼티를 비활성화 해주어야 한다. 이는 AutoConfigureTestDataBase
를 통해 해당 인메모리 DB 로의 대체를 비활성화해주는 프로퍼티를 넣어주면 application.properties
의 DB 구성 설정을 따르게 된다.
@DataJpaTest 와 @ActiveProfiles 를 활용한 테스팅
스프링 데이터에서 자동으로 설정해주는 테스트 환경으로 하기가 싫을 때, properties 가 이미 test 환경에 대한 구성 설정 properties 파일이 있을 때는 다음 어노테이션 @ActiveProfiles 을 사용하여 우리가 db properties 를 설정만으로 바꾸 듯이 여기서도 사용할 수 있게 된다.
import ...
@DataJpaTest
@ActiveProfiles("test") // application-test.properties 로드
public class PostRepositoryTest {
...
}
이제 application-test.properties
를 설정해주도록 하자.
Spring Custom Repository
실무에서는 CRUD
기능들 중에 front에게 굳이 노출하지 않아도 되는 API는 숨겨야 한다. 스프링 데이터의 레포지토리는 인터페이스를 사용하여 어플리케이션 도메인 객체를 관리하지만, 이 인터페이스들을 요구사항에 맞게 구현시키도록 한다.
이는 근본적인 Repository
를 먼저 상속하는 인터페이스를 구현하여, 이를 해결할 수 있다. 예를들어 create, read 만 구현하고 싶다면 다음과 같이 근본 repository
를 정의할 수 있다.
import org.springframework.data.repository.*;
public interface BaseRepository<T, ID> extends Repository<T, ID> {
<S extends T> S save(S entity);
Iterable<T> findAll();
}
@NoRepositoryBean 애너테이션
여기서 위처럼 interface를 선언하고 서비스를 구동하면 BaseRepository
라는 빈이 생성될 것이다. 즉 구현체가 만들어진다. 이를 방지하기 위해 @NoRepositoryBean
애너테이션을 사용하여서 이를 내려주자.
package com.example.study.dao;
import org.springframework.data.repository.*;
public interface BaseRepository<T, ID> extends Repository<T, ID> {
<S extends T> S save(S entity);
Iterable<T> findAll();
}
Spring Data Query
스프링 데이터에서는 Repository
인터페이스를 통해 다양한 기본 CRUD
쿼리를 제공해주지만, 이보다 더 많고 다양한 쿼리들을 정의하고 싶을 수 있다. 엔티티의 프로퍼티에 조건을 걸어서 조회하거나 하나 이상의 프로퍼티를 기준으로 정렬 조회 같은 것도 하고 싶을 수 있다. 스프링 데이터는 다음 두 가지 방법을 지원한다.
- Query Method: 인터페이스에 메서드의 이름을 패턴에 맞게 작성하면 스프링 데이터가 알아서 파싱하여 맞는 쿼리 만들어냄
- 선언적 Query: 필요한 쿼리문을 직접 작성해서 전달해주면 스프링 데이터가 이 쿼리를 실행
Query Methods
Query Methods 는 스프링 데이터에서 주는 패턴만 잘 따르도록 메서드를 정의하면 알아서 쿼리를 실행한다.
[쿼리 패턴][속성 이름][조회 키워드][연결자][조건]
다음 규칙을 가지며, 보통 맨 앞의 쿼리 패턴에는 다음이 올 수 있다.
Query Pattern(Prefix)
쿼리 패턴(Prefix) | 설명 |
---|---|
findBy |
조건에 맞는 데이터를 조회함 (가장 일반적) |
getBy |
findBy 와 유사하지만, 반드시 결과가 있어야 함을 암시 |
readBy |
findBy 와 유사, 읽기 동작 강조 |
queryBy |
쿼리 수행의 의도를 강조하는 표현 |
countBy |
조건에 맞는 데이터의 개수를 반환 |
existsBy |
조건에 맞는 데이터의 존재 여부를 반환 (boolean) |
deleteBy |
조건에 맞는 데이터를 삭제 |
removeBy |
deleteBy 와 동일하게 작동, 표현의 차이만 있음 |
반환 타입에 따라 여러 개 인지 하나인지 알 수 있지만, 보통은 findAll 처럼 여러개가 반환되면 시그니처에
All
을 붙여 제 3자의 프로그래머에게 알려주는 것이 좋다.
또한 Optional을 반환으로 썼다는 것은 해당 타입이 0개 혹은 1개 인지를 말해준다. 존재하지 않다면.empty()
메서드가 true 일터이다.
Attribute(Entity Field)
- CamelCase 로 작성
- 대소문자 구분 없음
조건 키워드
조건 키워드 | 설명 |
---|---|
Is , Equals |
값이 일치함 (=) |
Between |
두 값 사이의 범위 |
LessThan , LessThanEqual |
미만, 이하 조건 (<, <=) |
GreaterThan , GreaterThanEqual |
초과, 이상 조건 (>, >=) |
IsNull , IsNotNull |
null 여부 판단 |
Like , NotLike |
SQL LIKE 문 (부분 일치) |
StartingWith , EndingWith , Containing |
문자열 시작, 끝, 포함 여부 (LIKE 기반) |
In , NotIn |
포함/제외된 값들 집합 (IN 조건) |
True , False |
boolean 값 조건 |
연결자
설명 생략
- And
- Or
정렬 조건
설명 생략
- Asc
- Desc
쿼리메서드는 반환되는 게 여러 개라면 Iterable, Stream 으로 반환되지만, Stream 을 쓸 때는 Transactional 사용을 유의해야 한다. 따로 다룰 수도 있다.
Stream은 map-reduce-filter 를 사용하여 대용량 데이터를 다룰 수 있다.
Query 애너테이션을 활용한 커스텀 쿼리 선언
두 번째 방법이며, 다양한 쿼리문 자체를 넣어 다음 장점을 가져갈 수 있다.
- 특정 DB에 특화된 기능을 사용하여 최적화된 쿼리를 활용하기 위함
- 두 개 이상의 테이블을 조인하기 위함
NamedQuery, Query, QueryDSL 을 사용해 쿼리문을 직접 지정할 수 있다.
@NamedQuery 애너테이션
자카르타 퍼시스턴스 쿼리 언어(Jakarta Persistence Query Lang., JPQL) 을 사용하여 쿼리를 정의한다. NamedQuery는 엔티티 클래스, 엔티티 클래스의 super 클래스에 정의하기 때문에 실제 repository 클래스가 행해야 하는 일을 이 애너테이션을 통해 엔티티에 클래스에 붙여버리면 책임분리가 안되며 프로그래머가 찾지도 못할 수 있다. 하지만 다양한 방법 중에 하나이므로 이것도 살펴본다.
NamedQuery 는 인자로 다음을 가지고 있다:
- name: 보통 엔티티명.쿼리명 형식을 따르도록 넣는다.
- query: JPQL 로 작성된 문자열이며, SQL 과 유사하지만, 테이블과 컬럼 ㅐ신 엔티티 클래스와 그 속성을 참조한다.
?1
,?2
와 같은 문자열은:paramName
같은 이름 기반의 파라미터를 사용한다. 예를들어?email
을 사용한다면 repository 에서 API 선언으로 @Param(“email”) 로 매핑할 수 있다. - lockMode(선택)
- hints(선택)
NamedQuery를 여러개 쓰고 싶다면 @NamedQueries
를 사용하면 된다.
@Query 애너테이션
위 NamedQuery를 사용하면 비즈니스 도메인 클래스가 데이터 저장/조회와 strictly coupling 이 발생한다. 따라서 해당 애너테이션의 위치를 옮겨야 한다. 이때 query를 통해 작성한다.
@Repository
public interface PostRepository extends PagingAndSortingRepository<Post, Long>, CrudRepository<Post, Long> {
@Query("SELECT p FROM
p WHERE :keyword IN p.content")
Iterable<Post> search(@Param("keyword") String keyword);
}
위와 같이 작성한다면 비즈니스 엔티티는 정의에 대한 책임, 레포지토리는 엔티티 관리와 생성, 삭제에 대한 책임으로 분리가 되며, 가독성 측면에서도 엔티티와 레포지토리 둘 다 안보아도 된다. 만약 native query를 사용하고 싶다면 다음과 같이 입력하면 된다.
@Repository
public interface PostRepository extends PagingAndSortingRepository<Post, Long>, CrudRepository<Post, Long> {
@Query("SELECT p FROM POSTS p WHERE p.content LIKE %:keyword%")
Iterable<Post> search(@Param("keyword") String keyword);
}
⭐️수정과 삭제에 대한 데이터 일관성 가져오기
@Modifying
@Transactional
@Query("UPDATE POSTS p set p.content=:content WHERE p.id=:id")
int updateCourseRatingByName(@Param("id") Long id, @Param("content") String content);
-
@Modifying 애너테이션: @Query 애너테이션과 같이 쓰며, 정의된 쿼리가 조회가 아닌 수정 작업을 한다는 것을 알려준다. Modifying 애너테이션을 붙이지 않은 데이터의 변경이 수반되는 쿼리를 사용하면
InvalidDataAccessApiUsageException
의 예외가 발생한다. -
@Transactional 애너테이션: 데이터 변경(예: INSERT, UPDATE, DELETE)과 같은 작업을 트랜잭션 단위로 관리하는 데 사용되며, 원자성을 보존하게 되어 작업이 모두 완료되거나(COMMIT) 취소(ROLLBACK) 단위로 사용하게 된다.
중요하다. 다음 글을 읽기 전에는 Java 의 persistence 패키지를 보고 오자(Spring 섹션에서 안다룸).
Criteria API
위에서 사용된 애너테이션 내에서 사용되는 쿼리를 JPQL 쿼리라고 한다. JPQL을 컴파일 타입에서 검증할 수 없기 때문에 잘못 작성한 쿼리에 대한 문제는 런타임 에러로만 발견할 수 있다.
이런 문제점을 방지하기 위해서 Criteria API를 사용하여 쿼리문을 문자열이 아닌 프로그램 소스처럼 작성하도록 하여 타입 안전성을 확보할 수 있다.
EntityManager
JPA 를 관리하는 객체인 EntityManager는 엔티티의 생명주기를 관리하는 애다. javax.persistence.EntityManager
의 인터페이스를 따라야 하며, Spring은 이를 참고하여 편리하게 사용할 수 있도록 할 뿐이다. Spring의 EntityManager
의 관리 범위는 다음과 같다.
- Managing Persistence Context: 영속성 컨텍스트라는 개념이 나오는데 DB에 실제로 저장하기 전에 1차적으로 저장하는 캐시와 유사하다. 여기서 미리 처리를 다 하고 이를 올리게 된다. git 에서 local 의 작업이 끝나고 최종 결과를 root 에 올리는 것과 유사하다.
- Persisting Entity: 영속성 컨텍스트(Persist Context)가 있는데 여기에 저장되는 엔티티들은 보통 DB에 저장되기 직전 준비를 하고 있는 데이터들이다.
- Transaction Context:
EntityManager
는EntityTransaction
인터페이스를 통해 데이터베이스 트랜잭션을 시작하고 커밋하며 롤백하는 기능을 제공한다. 하지만 스프링 같은 프레임워크에서는 보통 선언적 트랜잭션(@Transactional
) 관리를 통해 자동으로 처리되게 된다. - Query Creation and Excution:
createQuery()
,createNamedQuery()
,createNativeQuery()
,getCriteriaBuilder()
등을 통해 다양한 쿼리를 생성하고 실행하여 데이터를 조회하거나 조작할 수 있다.
밑은 기본적으로 있어야 할 기능이기에 따로 뺐다.
- Finding Entity:
find()
메서드를 통해 PK 로 조회를 할 수 있다. 보통 find 보다는createQuery()
,createNamedQuery()
를 사용하여 조회를 하는 경우가 대부분이다. - Updating Entity: Persistence Context 의 entity 상태가 변경되면 트랜잭션이 commit 될 때 자동으로 변경 사항을 저장하게 된다.
- Removing Entity: remove() 메서드는 영속성 컨텍스트에서 엔티티를 제거하며, 트랜잭션 commit 시 데이터베이스에서도 해당 엔티티가 삭제되도록 한다.
이제 위를 참고하여 Criteria Query 를 작성해보자.
EntityManager 참조 및 CriteriaBuilder 선언
쿼리를 만들기 위해서, 엔티티를 관리하기 위해서 EntityManager
를 들고 와야 한다. Spring 의 Bean 에 자동으로 등록되는 EntityManager
를 테스트던 Repository
클래스 컨텍스트 쪽에서 자동주입으로 들고오자.
@Autowired
private EntityManager em;
엔티티를 관리하기 위한, JPA를 사용하기 위한 준비가 끝났다. 이제 쿼리를 검증하기 위한 클래스를 가져오자. CriteriaBuilder
는 검증하기 위한 쿼리를 생성하기에 앞서 부분 부분 각 쿼리의 절(SELECT 절, WHERE 절 등등) 의 절과 Query의 뼈대를 만들기 위한 빌더 패턴이다.
당연히 EntityManager 의 getCriteriaBuilder()
메서드를 통해 들고 올 수 있다(EntityManager
가 보통 다 가지고 있다).
CriteriaBuilder cb = em.getCriteriaBuilder();
쿼리의 뼈대를 생성하고 살을 채워넣자.
// 쿼리 뼈대 생성
CriteriaQuery<Post> criteriaQuery = cb.createQuery(Post.class);
이제 살을 채워주자. 채워주기 전에 참조를 쉽게쉽게 코딩하기 위해 참조를 저장하는 것을 생성한다. 여기서 참조의 명세는 Root로 되어있다.
// SELECT * FROM Post 와 같은 격
Root<Post> root = criteriaQuery.from(Post.class);
쿼리 작성
// SELECT * FROM Post as root WHERE root.title = "HA HA HA Example"
criteriaQuery.select(root)
// root.get("컬럼명") 으로 database 구성요소를 들고 올 수 있다.
.where(cb.equal(root.get("title"), "HA HA HA Example"));
여기서 에러 처리를 위해 더 구분하고 싶다면 where 절의 Predicate
인스턴스를 넣는 곳을 분리시켜주면 된다. 위를 지우고 다음처럼 써보자.
Predicate condition = cb.equal(root.get("title"), "HA HA HA Example");
만든걸 종합하면 다음과 같다.
@Test
void givenSelectWhenLoadedTheDataThenExpectedResult() {
// Take CriteriaBuilder for handling Entity
CriteriaBuilder cb = entityManager.getCriteriaBuilder();
// Define Blueprint for Post CriteriaQuery
CriteriaQuery<Post> cq = cb.createQuery(Post.class);
// Write Statement
Root<Post> p = cq.from(Post.class);
Predicate condition = cb.equal(p.get("title"), "Hello World");
// Merge
cq.select(p)
.where(condition);
}
여기서는 타입 검사가 자동으로 일어나고 JPQL 쿼리를 직접 날 것으로 작성하지 않아도 쿼리를 날릴 수 있는 것을 볼 수 있다.
이제 검증 쿼리가 아닌 실제 쿼리로 바꾸어서 결과를 fetch 해오자.
@Test
void givenSelectWhenLoadedTheDataThenExpectedResult() {
// Take CriteriaBuilder for handling Entity
CriteriaBuilder cb = entityManager.getCriteriaBuilder();
// Define Blueprint for Post CriteriaQuery
CriteriaQuery<Post> cq = cb.createQuery(Post.class);
// Write Statement
Root<Post> p = cq.from(Post.class);
Predicate condition = cb.equal(p.get("title"), "Hello World");
// Merge
cq.select(p)
.where(condition);
// Actual Query Created
TypedQuery<Post> query = entityManager.createQuery(cq);
// Fetch
assertEquals(query.getResultList().size(), 0);
}
위와 살짝 다르긴 한데, 그래도 흐름은 동일하다.
Spring Data JPA & QueryDSL
위에서는 Criteria API 와 스프링 데이터 JPA를 사용하여 데이터를 활용하는 방법을 봤는데, 위를 짜면서 느낀 것은 코드가 굉장히 긴 것을 볼 수 있다. 단순한 조회 쿼리를 작성하기 위해 저렇게나 많은 양의 코드가 필요하다. 여기서 대체재로 QueryDSL
을 사용하는 것을 검토할 수 있다. 코드 작성량을 줄임과 동시에 검증 또한 컴파일 타임중에 할 수 있다.
서드 파티 라이브러리인 QueryDSL
은 다음 검증을 지원한다:
- 엔티티 타입이 실제로 존재하고 해당 엔티티를 DB에 저장 가능한가?
- 모든 프로퍼티가 엔티티에 실제로 존재하고 해당 프로퍼티를 DB에 저장 가능한가?
- 모든 SQL 연산자에는 적합한 타입이 사용되었나?
- 최종 쿼리가 문법적으로 올바른가?
Spring Data는 QueryDSL을 사용할 수 있도록 QuerydslPredicateExecutor
인터페이스를 제공한다. 이를 보자. 하기 전에 위에 썼던 Criteria API 사용한 코드들은 다 지워버리자 :)
QueryDSL 의존성 추가
우선 의존성 추가를 해주자.
plugins {
...
// 이 플러그인이 Q-클래스 생성 작업을 간편하게 해줌
id "com.ewerk.gradle.plugins.querydsl" version "1.0.10"
}
dependencies {
...
// querydsl Spring Boot 3.x 이상과 호환하려면 :jakarta classifier 를 사용
implementation "com.querydsl:querydsl-jpa:5.0.0:jakarta"
// querydsl Q-class 생성을 위한 어노테이션 프로세서
annotationProcessor 'com.querydsl:querydsl-apt:5.0.0:jakarta'
}
// Q 클래스들이 생성될 디렉토리 지정
def querydslDir = "$buildDir/generated/querydsl"
// QueryDSL Q-클래스 생성 설정
// 이 플러그인은 Q-클래스가 생성될 경로를 지정하고 클린 작업을 자동으로 설정
querydsl {
jpa = true // JPA를 사용할 경우 true로 설정
querydslSourcesDir = querydslDir // Q-클래스 생성 경로
}
- com.querydsl:querydsl-apt: 엔티티 클래스 바탕으로
Q-타입 클래스
를 생성하기 위한 애너테이션 처리 도구, 만약 Course 클래스에 애너테이션을 넣어주면 QCourse 가 생성 - com.querydsl:querydsl-jpa: JPA를 사용하는 어플리케이션에서 QueryDSL 을 사용 가능. 만약 MongoDB 를 사용하면
querydsl-mongodb
로 바꿔주면 된다. - com.ewerk.gradle.plugins.querydsl: querydsl 블럭을 사용하여 querydsl의 q-class 생성 위치나 설정을 간편하게 해준다.
Q-class 라는 것은 QueryDSL을 사용할 때 데이터베이스 쿼리를 타입-세이프(Type-Safe)하게 작성할 수 있도록 도와주는 특별한 static 클래스
여기서 생성된 Q-class는 어플리케이션 소스코드로도 사용되기 때문에 outputDirectory 프로퍼티로 지정된 디렉터리는 프로젝트의 소스 디렉터리로도 지정되어야 한다.
QuerydslPredicateExecutor 인터페이스
보통 Repository 에 상속하여 QueryDSL 기능을 사용할 수 있게 한다.
@Repository
public interface UserRepository extends CrudRepository<User, Long>, QuerydslPredicateExecutor<User> { }
이제 Q-class 를 생성하기 위해 터미널에서 다음을 입력한다.
./gradlew clean build
build 후에 querydslDir 의 저장소에 Q-class 가 생성된 것을 볼 수 있다.
여기서는 우리가 정의했던 Entity 들의 Q-class 들이 있다.
위처럼 쓰며, 다음 메서드를 기본적으로 지원해준다.
- findOne
- findAll
- count
- exists
- findBy
CrudRepository 도 findAll이 있지만, 이를 오버로딩한 메서드를 제공한다. 들어가는 인자가 Q-class 관련 변수들이며, 이는 동적이고 타입-세이프한 쿼리 기능을 제공하게 된다(QPE 사용 이유).
@Test
public void givenQueryDSLWhenLoadedTheDataThenExpectedResults() {
QUser user = QUser.user;
JPAQuery query1 = new JPAQuery(em);
query1.from(user).where(user.name.eq("IU"));
assertEquals(query1.fetch().size(), 0);
JPAQuery query2 = new JPAQuery(em);
query2.from(user).where(user.email.eq("ssss@gmail.com").and(user.id.gt(3)));
assertEquals(query2.fetch().size(), 0);
assertFalse(userRepository.exists(user.name.eq("IU")));
OrderSpecifier<Integer> descOrderSpecifier = user.id.desc();
assertEquals((new ArrayList<> (
(Collection<User>) userRepository.findAll(descOrderSpecifier)
)).size(), 0);
}
Projection
엔티티를 조회할 때마다 테이블의 모든 컬럼은 조회할 필요가 없다. 이때, DB에는 프로젝션이라는 기능을 사용하는데 여기서도 사용할 수 있다.
기본적으로 인터페이스 기반 프로젝션, 클래스 기반 프로젝션이 있다.
인터페이스 기반 프로젝션
인터페이스 기반 프로젝션은 Repository 의 반환값을 임의로 설정한 인터페이스로 두고, 안에 관심있는 Column 들만 Getter 로 선언하여 Projection을 하도록 할 수 있다.
public interface PostInfo {
// 엔티티의 'id' 필드에 직접 매핑
Long getId();
// 엔티티의 'title' 필드에 직접 매핑
String getTitle();
// 엔티티의 'createdAt' 필드에 직접 매핑
LocalDateTime getCreatedAt();
}
위는 인터페이스 기반 프로젝션에서 closed projection 이다. open projection은 SpEL을 사용하여 하는데, 이는 나중에 공부해도 무방하다.
레포지토리에 이를 써보자.
@Repository
public interface UserRepository extends CrudRepository<User, Long>, QuerydslPredicateExecutor<User> {
// 필요한 column, 관심있는 column 만 반환
Iterable<UserInfo> findByName(String name);
}
테스트
@Test
public void givenFindUserDTOWhenLoadTheUserThenExpectedResult() {
User user = new User("seonghun", "seonghun@gmail.com");
userRepository.save(user);
Optional<UserInfo> userInfo = userRepository.findByName("seonghun");
userInfo.ifPresent(info -> assertThrows(
NoSuchMethodException.class,
() -> {
Class<?> clazz = info.getClass();
clazz.getMethod("getId");
},
"UserDTO는 id를 인자로 가지지 않습니다."
));
}
java.lang.reflect
를 써서 테스트 가능하다.
클래스 기반 프로젝션
인터페이스 대신 데이터 전송 객체라고 불리는 DTO 개념이 도입되고, 이는 자바 POJO 기반의 구현체로 DAO 계층과 서비스 계층 사이에서 데이터를 담당하게 된다(Bridge
패턴이다).
DTO를 사용하려면 보통 @Query 가 동반된다. 메서드 마다 쿼리를 사용하여 새로 정의해줘야 하게 되고, 또한 DTO 클래스에 멤버 변수가 많아지면 많아진 만큼 생성자가 거대해진다. 이 예시는 바로 밑에서 다룬다.
도메인 객체 관계 관리
하나의 테이블에서 데이터를 조회하는 것은 상대적으로 쉽지만 회사의 크기가 커질수록 하나의 테이블에서만 데이터를 조회하진 않는다.
엔티티 간에는 1:1 관계, 1:n 관계, n:1 관계, n:m 관계 등이 있다. 이를 DTO를 사용하여 구현해보자.
다대다 관계 관리
Post
와 Comment
의 관계는 다대다 관계로 볼 수 있다. 새로운 entity 인 Comment
를 정의하자
@Data
@Entity
@Table(name="COMMENTS")
public class Comment {
@Id
@GeneratedValue(strategy=GenerationType.IDENTITY)
private long id;
@Column(nullable=false)
private String name;
@Column(nullable=false)
private String description;
}
Post
와 Comment
는 서로 다대다 이기 때문에 이를 구현해주려면 두 도메인을 식별할 수 있는 데이터를 담은 조인 테이블이 따로 필요하다. 그러기 위해 POSTS_COMMENTS
라는 조인테이블을 만들어서 두 값의 pk 들을 모으면 된다.
erDiagram
POSTS ||--o{ POST_COMMENT : ""
COMMENTS ||--o{ POST_COMMENT : ""
POST_COMMENT {
long post_id FK "Post's ID"
long comment_id FK "Comment's ID"
}
POSTS {
Long id PK
String title
String content
DATETIME createdAt
}
COMMENTS {
long id PK
String name
String description
}
스키마를 위처럼 수정해주고 더 기준이 되는 주체의 엔티티 정의를 수정해주자.
@ManyToMany
JPA 에서 지원해주는 애너테이션이고 두 테이블 간의 연관성을 표시해줄 수 있다. DB 자체에서는 다대다 관계를 지정해줄 수 없고, 이를 JPA가 자동으로 관리하도록 하게 해준다.
public class Post {
...
@ManyToMany
private Set<Comment> comments = new HashSet<>();
}
멤버 변수로 위와 같이 지정해주면 된다. 매핑이 되는 엔티티에서는 다음과 같이 정의해준다.
public class Comment {
...
@ManyToMany(mappedBy="comments") // 소유자의 멤버 변수명을 넣어줘야 함
private Set<Post> posts = new HashSet<>();
}
다대다의 관계에서는 항상 소유자와 비소유자가 존재하여서 소유자는 관계를 소유하는 입장이고, 비소유자는 참조되는 입장이다. 반면에 일대다에서는 다 쪽이 소유자이어야 하고, 일 쪽이 소유자가 된다면 다 쪽을 가르키는 참조 여러 개를 관리해야 하기 때문에 복잡해진다.
다대다에서는 엔티티의 의미를 파악하여서 어떤 쪽을 소유자로 할지를 정해주어야 한다.
@JoinTable
관계의 소유자 쪽에 매핑 테이블 정보를 지정해서 조인 테이블을 정의할 수 있다. 만약 JoinTable이 지정되어지지 않으면 기본적으로 소유자 쪽 테이블 이름과 비소유자 쪽 테이블 이름을 _로 연결한 테이블이 자동적으로 생성된다.
public class Post {
...
@ManyToMany
@JoinTable(
name="POSTS_COMMENTS",
joinColumns = {@JoinColumn(name="post_id", referencedColumnName="id", nullable = false, updatable = false)},
inverseJoinColumns = {@JoinColumn(name="comment_id", referencedColumnName="id", nullable = false, updatable = false)}
)
private Set<Comment> comments = new HashSet<>();
joinColumns
속성과 inverseJoinColumns
속성을 통해 조인 테이블의 소유자 쪽인 POSTS
테이블의 식별자 컬럼을 가리키는 외래 키를 지정할 수 있고, inverseJoinColumns
는 조인 테이블의 비소유자 쪽의 COMMENTS
테이블의 외래 키를 참조하게 만들 수 있다. 여기서 updatable
이나 nullable
을 통해 어플리케이션이 해당 값을 변경하거나 null 값을 만들지 않도록 할 수 있다.
이제 DTO 를 정의하여 해당 JoinTable
에 대한 필요한 필드만 조회하도록 해보자.
DTO 클래스를 사용한 Projection
우선 주고 받게 될 불변 클래스 DTO 를 선언해주자. 불변이기 때문에 최신 문법인 record
를 써서 선언해주자.
public record PostInfo(
long id,
String postTitle,
String postContent,
String commentName,
String commentDescription
) {
}
이제 이를 활용하여 데이터를 가져오기 위해 repository 에 다음을 추가해준다.
@Query("SELECT new com.example.study.dao.dto.PostInfo(p.id, p.title, p.content, c.name, c.description) " +
"FROM Post p JOIN p.comments c")
Iterable<PostInfo> getPostInfo();
p에서 썼던 comments 를 통해 HQL 를 Join 과 함께 쓸 수 있고, Join이 들어감과 동시에 정의했던 JoinTable을 참고하여 맞는 record에 대해서 전부 값들을 들고오게 된다.
테스트
@BeforeEach
void setup() {
Post post1 = new Post(LocalDateTime.of(2000, 12, 1, 12, 59), "title", "content");
Post post2 = new Post(LocalDateTime.now(), "NOW", "THIS IS NOW");
Comment comment1 = new Comment("HI", "WORLD");
Comment comment2 = new Comment("Hello", "World!");
post1.getComments().add(comment1);
post1.getComments().add(comment2);
postRepository.saveAll(List.of(post1, post2));
commentRepository.saveAll(List.of(comment1, comment2));
}
@Test
void test() {
Iterable<PostInfo> list = postRepository.getPostInfo();
System.out.println("\n\n\n\n");
list.forEach(postInfo -> System.out.println(postInfo.toString()));
System.out.println("\n\n\n\n");
}
✒️ 용어
ORM
객체와 DB 테이블을 매핑하여 객체 지향적으로 데이터베이스를 다룰 수 있도록 해주는 기술이다. SQL을 직접 작성하지 않아도 객체를 통해 데이터 조작이 가능하다.
예) 자바 클래스의 Member 정의와 DB의 member 테이블이 자동으로 매핑됨
Member member = entityManager.find(Member.class, 1L);
Java Database Connectivity (JDBC)
관계형 데이터베이스(RDBMS)에 접근할 수 있도록 해주는 자바 표준 API이다.
자바 코드에서 SQL을 실행하고 결과를 가져오는 기능을 제공하는 저수준의 DB 연결 도구이다.
String sql = "SELECT * FROM member WHERE id = ?";
PreparedStatement pstmt = conn.prepareStatement(sql);
pstmt.setLong(1, 1L);
ResultSet rs = pstmt.executeQuery();
JPA(ORM)를 사용한 코드와 비교해 보면, JDBC는 SQL과 연결 코드(보일러플레이트)를 직접 명시해야 하는 반면, JPA는 미리 정의된 메서드(find
, persist
, remove
등)를 통해 간결하고 추상화된 코드로 DB를 다룰 수 있다.
PreparedStatement
JDBC에서 SQL 구문을 미리 컴파일하여 실행하는 객체이다.
SQL Injection 방지, 성능 향상 등의 장점이 있으며,
동적 파라미터를 안전하게 바인딩할 수 있다.
Connection Pool
DB를 연결할 때는 네트워크 연결, 사용자 인증, 세션 설정 등 많은 작업이 필요하다. 매 요청마다 이를 새로하게 되면 속도 저하, 리소스 낭비가 된다.
한 번만 만들어놓고 재사용하는 방식으로 커넥션 풀을 생성하면, 스레드를 여러 개 사용하여 사용자들이 여러명 있어도 새로 생성하지 않고, 스레드를 하나 꺼내서 사용하고 다 사용하면 풀에 반납하는 방식으로 작동한다.
이렇게 하면 DB 연결 속도가 향상되며, 리소스 절약이 된다. Spring Boot에서는 HikariCP 라는 커넥션 풀을 기본적으로 사용하는데, tomcat-jdbc의 커넥션 풀을 사용해도 된다. 취향 차이이다.
예: maximum-pool-size 프로퍼티가 10, 유저가 동시 접근이 11명, 1명은 스레드(자원)이 반환될 때까지 기다려야 함.
Marker Interface
말 그대로 ‘표식’ 인터페이스이다. 아무 메서드도 수행하지 않고 이런 인터페이스이다 라는 메타 데이터만 주기 위해서 사용한다. 보통 java 를 뜯어내다 보면 다음을 볼 수 있는데 다음도 마커 인터페이스이다.
- Serializable
- Cloneable
- Remote
해당 interface를 통해 JVM 혹은 프레임워크 단에서 조건 분기 처리가 가능해지므로 가독성과 동작 제어가 뛰어나다.