Repository

이동영·2025년 10월 28일

웹개발

목록 보기
20/36

Repository란?

Repository는 Spring Data JPA에서 데이터베이스에 접근하는 객체(DAO)를 대신 만들어주는 인터페이스(interface)이다.
과거에는 개발자가 try-catch로 DB 연결을 열고, SQL 쿼리를 직접 작성하고(INSERT, SELECT, ...), ResultSet을 받아 객체로 변환하는 복잡한 작업을 모두 직접 했다. 필자의 경우에도 학원에서 배울 때, 가르쳐준 방식이 mybatis를 사용하면서 DAO, DTO를 만드는 방식이었다.

1. JDBC(Java Database Connectivity)

가장 간단하게 비유하자면, JDBC는 Java와 모든 DB를 연결해주는 표준 규격이다.

  • 표준 규격 (API) : 자바는 DB에 접속할 때 Connection객체, SQL을 실행할 때 Statement객체, 결과를 받을 때 ResultSet객체를 쓰라는 "표준 규칙"을 정했다.
  • 드라이버 (Driver) : PostgreSQL, Oracle, MySQL 같은 DB 회사들을 이 "표준 규칙"을 따르는 "전용 플러그(드라이버.jar)"를 만든다.
  • 동작 : 개발자는 PostgreSQL 드라이버를 프로젝트에 끼우기만 하면, 표준 JDBC 코드(Connection, Statement...)를 사용해서 PostgreSQL과 통신할 수 있다.

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();
}

이 방식의 단점은 다음과 같다.

  • 반복코드 : 1~8번까지 코드가 모든 DAO 메소드마다 거의 똑같이 반복된다.
  • 자원 관리의 위험 : finally 블록에서 Connection, Statement 등을 하나라도 빼먹고 닫지 않으면, DB 커넥션이 고갈되어 서버가 멈춘다. (자원 누수)
  • 수동 매핑 : rs.getString("name") 처럼, DB 칼럼을 DTO 필드에 하나하나 수동으로 매핑해야 해서 지루하고 실수하기 쉽다.

그래서 이를 해결하기 위해 나온 것이 MyBatis 그리고 JPA이다.

2. DAO + DTO + MyBatis

이 조합의 핵심 키워드는 "개발자의 SQL 직접 제어"이다.

  • MyBatis (SQL Mapper) : MyBatis는 "SQL 매퍼"이다. 개발자가 userMapper.xml 같은 파일에 SQL 쿼리를 직접 작성하면 (SELECT * FROM user WHERE id = ?), MyBatis가 이 SQL을 실행하고 결과를 DTO나 Map에 매핑(mapping)해주는 도구이다. JDBC의 try-catch-finally 같은 자원 반납도 MyBatis가 자동으로 해준다.
  • DAO (Data Access Object) : UserDAO는 이 MyBatis를 실행하는 클래스이다.'public UserDTO findById(String id)'라는 메소드 안에는 'sqlSession.selectOne("userMapper.findById", id)'처럼 MyBatis를 호출하는 Java 코드가 들어간다. 개발자가 DAO 클래스와 그 안의 메소드를 직접 구현해야 한다.
  • DTO (Data Transfer Object) : UserDTO는 데이터를 담는 상자이다. 'userMapper.xml'의 resultType="UserDTO"처럼, MyBatis가 DB에서 가져온 데이터를 담아서 Service나 Controller로 전달해 주는 순수한 Java 객체(POJO)이다.
  • 이 방식의 흐름 : Controller -> Service -> UserDAO -> MyBatis -> DB

3. Repository + Entity + JPA

이 조합의 핵심 키워드는 "JPA가 SQL을 자동 생성"이다. 이는 JDBC를 완전히 숨긴 도구라고 보면 된다.

  • JPA(Java Persistence API) : JPA는 "객체 관계 매핑(ORM)" 기술의 표준 명세(규칙)이다. JPA는 'User' 엔티티 객체를 DB 테이블과 동일시하며, 객체를 save하면 알아서 INSERT SQL을 만들어주고, 객체를 find하면 알아서 SELECT SQL을 만들어준다.
  • Entity : Entity는 데이터베이스 테이블을 그대로 옮겨놓은 Java 클래스이다. 그러나 엔티티는 단순한 DTO와는 다르다. @Id, @OneToMany 같은 어노테이션을 통해 "나는 DB 테이블과 이렇게 연결되어 있다"라는 메타데이터를 스스로 인지한다.
  • Repository : DAO와 달리 '인터페이스'이다. 한마디로 말하자면 메소드를 따로 구현하지 않는다는 것이다. JpaRepository를 상속받고 findByEmail 처럼 메소드 이름만 정의하면 Spring Data JPA가 런타임에 이 인터페이스의 구현 클래스를 자동으로 생성하고, 이름을 분석해 SQL까지 대신 만들어 준다.
  • 이 방식의 흐름 : Controller -> Service -> UserRepository -> JPA/Hibernate -> DB

"그럼 DTO는 이제 안 쓰나요?" -> NO! DTO는 이 방식에서도 여전히, 매우 중요하게 사용된다. 단지 역할이 조금 바뀔 뿐이다. JPA 방식에서 DTO는 Service 계층이 Controller(API) 계층으로 데이터를 반환할 때 사용된다.
이유는 예를들어 'User'엔티티에는 비밀번호나 @OneToMany 관계처럼 API에 노출되면 안 되거나 불필요한 정보가 많다. 그러므로 Service는 Repository에서 'User'엔티티를 조회한 뒤, 'UserResponseDTO'같은 DTO 데이터를 깨끗하게 가공/변환해서 Controller에 전달해야 한다.

JPA 단점

JPA가 자동으로 해주기 때문에 더 좋아보일 수는 있으나, 단점도 존재한다.

  1. 높은 학습곡선
    JPA 관련 다양한 스펙과 작성법(@Entitity, @Table, @Column, @Id, @OneToMany, @ManyToOn)을 학습해야 하고, 또 JPA 적용으로 생기는 다양한 이슈, 즉시 로딩(EAGER LOADING), 지연 로딩(LASY LOADING), 영속성 전이(CascadeType), 복합키 매핑(@EmbededId, @IdClass) 등에 대한 해결 방법을 익혀야 한다. MyBatis에 비해 배우기가 어렵다.

  2. 복잡한 SQL 생성의 어려움
    일반적으로 시스템을 개발하면 단순한 CRUD와 같은 기능도 많이 있지만, 통계 또는 분석과 같은 화면과 기능도 개발이 필요하다. 이때는 복잡한 쿼리를 만들어야 하는데 여러 테이블을 Join 해서 데이터 결과를 가져와야 하는 경우에, JPA로는 복잡한 쿼리를 만드는 데 용이하지 않다.
    직접적인 SQL 작성을 통해서 복잡한 쿼리를 만들어야 하는 경우, JPA와 같이 자동으로 만들어지는 SQL로는 원하는 결과를 정확히 얻기가 힘든 경우가 많다.

  3. 성능에 대한 고려 필요
    JPA에 의해 자동으로 SQL이 만들어지다 보니, DB의 특성(index, join 등)을 이해하고 DB에 맞는 SQL을 직접 튜닝해서 만들면 성능이 훨씬 뛰어날 수 있으나, 자동화된 SQL 문으로 인해서 데이터 조회 성능이 떨어질 가능성이 있다.
    일반적인 간단한 CRUD에는 큰 문제가 없으나, 데이터가 많아지고, 테이블 간 Join이 많아지는 경우, SQL 문을 어떻게 튜닝하는 가로 인한 성능이 크게 차이가 날 여지가 있으므로, JPA 사용 시 이러한 부분을 주의해서 고려할 필요가 있다.


Repository 작성하며 사용한 핵심 기능

이번에 21개의 Repository를 만들면서 사용한 핵심 기능은 4가지가 있다.

1. JpaRepository<T, ID> : 상속받기만 해도 CRUD가 완성됨

가장 기본적인 기능이다. 'UserRepository'를 작성할 때 아래와 같이 사용했다.

public interface UserRepository extends JpaRepository<User, Long> {
    ...
}

여기서 'UserRepository'는 그저 'JpaRepository'를 상속받았을 뿐인데, 우리는 아무 코드도 짜지 않았음에도 아래 기능들을 그냥 쓸 수 있게 된다.

  • save(User) : 유저 저장 (INSERT/UPDATE)
  • findById(Long id) : ID로 유저 1명 조회 (SELECT)
  • findAll() : 모든 유저 조회 (SELECT)
  • delete(User) : 유저 삭제 (DELETE)
  • count() : 총 유저 수 조회 (COUNT)
  • 이 외 수많은 기본 기능들

2. 쿼리 메소드 (Query Method) : 이름이 곧 SQL이 됨

JpaRepository가 기본으로 주지 않는 조회 기능, 예를 들어 '이메일로 유저 찾기'같은 기능은 정해진 규칙(명명 규칙)에 따라 메소드 이름만 지어주면, 그 이름을 분석해서 SQL 쿼리를 자동으로 생성한다.

예시 1) 기본조회

Optional<User> findByEmail(String email);
  • JPA의 해석 : 'User'엔티티에서 'email'필드가 일치하는 것 찾기.
  • 자동 생성된 SQL : SELECT * FROM "User" WHERE email = ?

예시 2) 중첩 프로퍼티 조회 : 필자가 겪은 오류였다. JoinApplication 엔티티는 'private User user;' 필드를 가졌다. 처음에 그냥 findByUserId()로 했지만 오류가 나서 아래와 같이 바꾸니 해결이 되었다.

Optional<JoinApplication> findByUser_UserId(Long userId);
  • JPA의 해석 : 'JoinApplication'의 'User'필드 안에 있는 'UserId'필드 검색
  • 자동 생성된 SQL : SELECT * FROM "JoinApplication" WHERE user_id = ?

3. @Query : 쿼리 메소드로 안될 땐, JPQL

쿼리 메소드 이름이 너무 길어지거나, JOIN FETCH 처럼 복잡한 최적화가 필요할 때, 직접 쿼리를 작성할 수 있다. 이것이 @Query 어노테이션이다.
이 때 작성하는 쿼리는 SQL이 아닌 JPQL(Java Persistence Query Language)이다.

  • SQL : DB 테이블(Post, User)을 기준으로 쿼리
  • JPQL : Java 엔티티(Post.java, User.java 클래스)를 기준으로 쿼리
@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);
  • SELECT p FROM Post p : "DB의 Post 테이블이 아니라, Post 엔티티(p)를 조회"
  • JOIN FETCH p.user : 게시글(p)을 조회하면서, 연관된 작성자(p.user) 정보까지 한 번의 쿼리로 다 가져오라는 뜻이다. (N+1 문제 해결)

4. Pageable과 Page< T > : 페이징 처리

'게시판 3페이지 보여주기'같은 페이징 처리를 하려면 매우 할 것이 많다. (LIMIT, OFFSET, COUNT 쿼리 등..) 하지만 JPA는 이 페이징 처리도 자동화해준다.

  • Pageable(파라미터) : findAllWithUser(Pageable pageable)처럼 메소드 파라미터에 Pageable을 넣는다.
  • Page< T >(리턴타입) : 리턴 타입을 Page< Post >로 받는다.

이렇게 하면 JPA는 동작을 이렇게 한다.
1. JPA가 Pageable 객체에 "3페이지, 한 페이지에 10개" 정보가 담겨있는 것을 확인한다.
2. 쿼리 1 (데이터 조회) : LIMIT 10 OFFSET 20 같은 페이징 SQL을 자동으로 생성해서 3페이지 데이터를 가져온다.
3. 쿼리 2 (카운트 조회) : SELECT COUNT(*) 쿼리를 자동으로 생성해서 3페이지 데이터를 가져온다.
4. 이 모든 정보를 Page< Post > 객체에 담아서 반환한다. (Page 객체는 '3페이지 데이터 리스트', '총 페이지 수, '총 아이템 개수' 등을 모두 포함)

3. Optional< T > : "없을 수도 있다."

findByEmail을 호출했는데, 해당 이메일이 DB에 없으면, null을 반환하여 NullPointerException 에러가 발생하기 쉽다. 그럴 때 사용하는 게 Optional< T >이다.

  • Optional< User > findByEmail(String email);
  • 의미 : "이메일로 유저를 찾아보고, 있으면 User 객체를 Optional에 담아서 주고, 없으면 'Empty'를 Optional에 담아서 주겠다."

0개의 댓글