Repository는 Spring Data JPA에서 데이터베이스에 접근하는 객체(DAO)를 대신 만들어주는 인터페이스(interface)이다.
과거에는 개발자가 try-catch로 DB 연결을 열고, SQL 쿼리를 직접 작성하고(INSERT, SELECT, ...), ResultSet을 받아 객체로 변환하는 복잡한 작업을 모두 직접 했다. 필자의 경우에도 학원에서 배울 때, 가르쳐준 방식이 mybatis를 사용하면서 DAO, DTO를 만드는 방식이었다.
가장 간단하게 비유하자면, JDBC는 Java와 모든 DB를 연결해주는 표준 규격이다.
JDBC는 표준이지만, 날것 그대로 사용하면 개발자가 너무 힘들고 반복적인 작업을 해야한다.
Raw JDBC 코드로 유저 1명을 조회하는 과정을 예로 들어보겠다.
// 1. 드라이버 로드
Class.forName("org.postgresql.Driver");
Connection conn = null;
PreparedStatement pstmt = null;
ResultSet rs = null;
try {
// 2. 연결 (Connection)
String url = "jdbc:postgresql://...";
conn = DriverManager.getConnection(url, "user", "pass");
// 3. SQL 준비 (Statement)
String sql = "SELECT * FROM \"User\" WHERE user_id = ?";
pstmt = conn.prepareStatement(sql);
pstmt.setLong(1, 1L); // 4. 파라미터 바인딩
// 5. SQL 실행 및 결과 수신 (ResultSet)
rs = pstmt.executeQuery();
// 6. DTO/Entity로 '수동' 변환 (Object Mapping)
UserDTO user = null;
if (rs.next()) {
user = new UserDTO();
user.setUserId(rs.getLong("user_id"));
user.setName(rs.getString("name"));
// ... 모든 컬럼을 수동으로 ...
}
return user;
} catch (SQLException e) {
// 7. 예외 처리
e.printStackTrace();
} finally {
// 8. 자원 '수동' 반납
if (rs != null) rs.close();
if (pstmt != null) pstmt.close();
if (conn != null) conn.close();
}
이 방식의 단점은 다음과 같다.
그래서 이를 해결하기 위해 나온 것이 MyBatis 그리고 JPA이다.
이 조합의 핵심 키워드는 "개발자의 SQL 직접 제어"이다.
이 조합의 핵심 키워드는 "JPA가 SQL을 자동 생성"이다. 이는 JDBC를 완전히 숨긴 도구라고 보면 된다.
"그럼 DTO는 이제 안 쓰나요?" -> NO! DTO는 이 방식에서도 여전히, 매우 중요하게 사용된다. 단지 역할이 조금 바뀔 뿐이다. JPA 방식에서 DTO는 Service 계층이 Controller(API) 계층으로 데이터를 반환할 때 사용된다.
이유는 예를들어 'User'엔티티에는 비밀번호나 @OneToMany 관계처럼 API에 노출되면 안 되거나 불필요한 정보가 많다. 그러므로 Service는 Repository에서 'User'엔티티를 조회한 뒤, 'UserResponseDTO'같은 DTO 데이터를 깨끗하게 가공/변환해서 Controller에 전달해야 한다.
JPA가 자동으로 해주기 때문에 더 좋아보일 수는 있으나, 단점도 존재한다.
높은 학습곡선
JPA 관련 다양한 스펙과 작성법(@Entitity, @Table, @Column, @Id, @OneToMany, @ManyToOn)을 학습해야 하고, 또 JPA 적용으로 생기는 다양한 이슈, 즉시 로딩(EAGER LOADING), 지연 로딩(LASY LOADING), 영속성 전이(CascadeType), 복합키 매핑(@EmbededId, @IdClass) 등에 대한 해결 방법을 익혀야 한다. MyBatis에 비해 배우기가 어렵다.
복잡한 SQL 생성의 어려움
일반적으로 시스템을 개발하면 단순한 CRUD와 같은 기능도 많이 있지만, 통계 또는 분석과 같은 화면과 기능도 개발이 필요하다. 이때는 복잡한 쿼리를 만들어야 하는데 여러 테이블을 Join 해서 데이터 결과를 가져와야 하는 경우에, JPA로는 복잡한 쿼리를 만드는 데 용이하지 않다.
직접적인 SQL 작성을 통해서 복잡한 쿼리를 만들어야 하는 경우, JPA와 같이 자동으로 만들어지는 SQL로는 원하는 결과를 정확히 얻기가 힘든 경우가 많다.
성능에 대한 고려 필요
JPA에 의해 자동으로 SQL이 만들어지다 보니, DB의 특성(index, join 등)을 이해하고 DB에 맞는 SQL을 직접 튜닝해서 만들면 성능이 훨씬 뛰어날 수 있으나, 자동화된 SQL 문으로 인해서 데이터 조회 성능이 떨어질 가능성이 있다.
일반적인 간단한 CRUD에는 큰 문제가 없으나, 데이터가 많아지고, 테이블 간 Join이 많아지는 경우, SQL 문을 어떻게 튜닝하는 가로 인한 성능이 크게 차이가 날 여지가 있으므로, JPA 사용 시 이러한 부분을 주의해서 고려할 필요가 있다.
이번에 21개의 Repository를 만들면서 사용한 핵심 기능은 4가지가 있다.
가장 기본적인 기능이다. 'UserRepository'를 작성할 때 아래와 같이 사용했다.
public interface UserRepository extends JpaRepository<User, Long> {
...
}
여기서 'UserRepository'는 그저 'JpaRepository'를 상속받았을 뿐인데, 우리는 아무 코드도 짜지 않았음에도 아래 기능들을 그냥 쓸 수 있게 된다.
JpaRepository가 기본으로 주지 않는 조회 기능, 예를 들어 '이메일로 유저 찾기'같은 기능은 정해진 규칙(명명 규칙)에 따라 메소드 이름만 지어주면, 그 이름을 분석해서 SQL 쿼리를 자동으로 생성한다.
예시 1) 기본조회
Optional<User> findByEmail(String email);
예시 2) 중첩 프로퍼티 조회 : 필자가 겪은 오류였다. JoinApplication 엔티티는 'private User user;' 필드를 가졌다. 처음에 그냥 findByUserId()로 했지만 오류가 나서 아래와 같이 바꾸니 해결이 되었다.
Optional<JoinApplication> findByUser_UserId(Long userId);
쿼리 메소드 이름이 너무 길어지거나, JOIN FETCH 처럼 복잡한 최적화가 필요할 때, 직접 쿼리를 작성할 수 있다. 이것이 @Query 어노테이션이다.
이 때 작성하는 쿼리는 SQL이 아닌 JPQL(Java Persistence Query Language)이다.
@Query("SELECT p FROM Post p JOIN FETCH p.user WHERE p.deletedAt IS NULL ORDER BY p.createdAt DESC")
Page<Post> findAllWithUser(Pageable pageable);
'게시판 3페이지 보여주기'같은 페이징 처리를 하려면 매우 할 것이 많다. (LIMIT, OFFSET, COUNT 쿼리 등..) 하지만 JPA는 이 페이징 처리도 자동화해준다.
이렇게 하면 JPA는 동작을 이렇게 한다.
1. JPA가 Pageable 객체에 "3페이지, 한 페이지에 10개" 정보가 담겨있는 것을 확인한다.
2. 쿼리 1 (데이터 조회) : LIMIT 10 OFFSET 20 같은 페이징 SQL을 자동으로 생성해서 3페이지 데이터를 가져온다.
3. 쿼리 2 (카운트 조회) : SELECT COUNT(*) 쿼리를 자동으로 생성해서 3페이지 데이터를 가져온다.
4. 이 모든 정보를 Page< Post > 객체에 담아서 반환한다. (Page 객체는 '3페이지 데이터 리스트', '총 페이지 수, '총 아이템 개수' 등을 모두 포함)
findByEmail을 호출했는데, 해당 이메일이 DB에 없으면, null을 반환하여 NullPointerException 에러가 발생하기 쉽다. 그럴 때 사용하는 게 Optional< T >이다.