백엔드 · 4장 JPA · QueryDSL

4장 · 데이터 접근 (JPA · QueryDSL)

이제 DB를 다룹니다. ORM?이 객체와 테이블을 어떻게 잇는지, 엔티티?로 테이블을 그리고, 리포지토리?로 기본 조회를, 조건이 달라지는 복잡한 검색은 QueryDSL?로 작성하는 법을 봅니다.

ORM과 JPA란?

DB는 테이블(행·열)로 데이터를 저장하지만, Java는 객체(클래스)로 다룹니다. 이 둘을 자동으로 이어주는 기술이 ORM?(Object-Relational Mapping)이고, Java의 표준 ORM이 JPA?(보통 Hibernate가 구현)입니다. SQL을 직접 쓰지 않고 객체로 저장·조회하면, JPA가 그에 맞는 SQL을 대신 만들어 실행해요.

ORM은 "통역사". 우리는 Java 객체로 말하고, DB는 SQL·테이블로 알아듣습니다. 통역사(JPA)가 양쪽 말을 자동으로 바꿔주니, 우리는 SQL 문법보다 업무에 집중할 수 있어요.

이 프로젝트의 영속성 구조는 두 층으로 나뉩니다.

위치맡는 일
엔티티core/model/entity/테이블 ↔ 클래스 매핑 (User, Menu, UserRole)
리포지토리 (Command)core/repo/저장·삭제·기본 조회 (UserRepo, MenuRepo)
쿼리 (Query)core/query/동적 검색·페이징 (UserRepoQueryImpl)

엔티티 — 테이블을 클래스로 그리기

@Entity + @Table 로 클래스를 테이블에 매핑합니다. 이 프로젝트의 User 는 공통 베이스(BaseAuditEntity)를 상속하고, Spring Security의 UserDetails 도 함께 구현해 "로그인 사용자" 역할까지 겸합니다.

// core/model/entity/User.java
@Getter @Setter
@Entity
@Table(name = "tb_user", schema = "lds")     // lds 스키마의 tb_user 테이블
public class User extends BaseAuditEntity implements UserDetails {

    @Id
    @GeneratedValue(generator = "uuid2")     // 기본키: UUID 자동 생성
    @Column(name = "id", nullable = false)
    private UUID id;

    @Column(name = "userid", nullable = false)
    private String userid;

    @Column(name = "name", nullable = false)
    private String name;

    @Column(name = "active", nullable = false)
    private Boolean active = true;

    // 사용자-역할 1:N 관계 (tb_user_role)
    @OneToMany(mappedBy = "user",
               fetch = FetchType.EAGER,       // User를 읽을 때 역할도 함께 로딩
               cascade = CascadeType.ALL,     // User 저장/삭제가 UserRole까지 전파
               orphanRemoval = true)          // 연결 끊긴 UserRole은 자동 삭제
    private Set<UserRole> userRoles = new LinkedHashSet<>();
}

주요 애너테이션을 정리하면 이렇습니다.

애너테이션
@Entity @Table이 클래스를 어느 테이블·스키마에 매핑할지
@Id @GeneratedValue기본키와 자동 생성 전략 (여기선 UUID)
@Column필드 ↔ 열 매핑, 제약(nullable, 길이 등)
@OneToMany / @ManyToOne테이블 간 관계 (1:N, N:1)

복합키 — @EmbeddedId · @MapsId

UserRole 은 사용자와 역할을 잇는 연결(중간) 테이블입니다(사용자 ↔ 역할은 M:N). 기본키가 한 컬럼이 아니라 (user_id, role_code) 두 컬럼의 묶음이라서, 그 묶음을 별도 클래스(UserRoleId)로 만들어 @EmbeddedId 로 끼웁니다.

// core/model/entity/UserRoleId.java — 복합키 묶음
@Getter @Setter @NoArgsConstructor @AllArgsConstructor @EqualsAndHashCode
@Embeddable                                  // 다른 엔티티에 "끼워 넣을 수 있는" 값
public class UserRoleId implements Serializable {
    @Column(name = "user_id", nullable = false)
    private UUID userId;
    @Column(name = "role_code", length = 50, nullable = false)
    private String roleCode;
}
// core/model/entity/UserRole.java — 연결 테이블
@Entity
@Table(name = "tb_user_role", schema = "lds")
public class UserRole {

    @EmbeddedId
    private UserRoleId id;                    // 복합키 (user_id + role_code)

    @ManyToOne(fetch = FetchType.LAZY)
    @MapsId("userId")                         // 복합키의 userId 칸을 이 관계로 채움
    @JoinColumn(name = "user_id", nullable = false)
    private User user;

    @ManyToOne(fetch = FetchType.LAZY)
    @MapsId("roleCode")                       // 복합키의 roleCode 칸을 이 관계로 채움
    @JoinColumn(name = "role_code", nullable = false)
    private Role role;
}
@MapsId가 하는 일

@MapsId("userId") 는 "이 @ManyToOne 관계의 키를 복합키(UserRoleId)의 userId 칸에 그대로 쓰겠다"는 뜻입니다. 덕분에 외래키 컬럼(user_id)이 곧 기본키의 일부가 되어, 같은 값을 두 번 적지 않아도 됩니다.

JSON 컬럼 — @JdbcTypeCode(SqlTypes.JSON)

Menu 엔티티는 자유 형식 설정값을 Map 으로 들고 있다가 PostgreSQL의 jsonb 컬럼에 저장합니다. @JdbcTypeCode(SqlTypes.JSON) 가 Java Map ↔ DB jsonb 변환을 맡아요.

// core/model/entity/Menu.java
@JdbcTypeCode(SqlTypes.JSON)                  // Map <-> PostgreSQL jsonb 자동 변환
@Column(name = "properties", columnDefinition = "jsonb")
private Map<String, Object> properties;

리포지토리 — 기본 조회

리포지토리?는 DB 접근을 맡는 인터페이스입니다. 구현 코드는 우리가 안 짜도 Spring Data JPA가 자동으로 만들어줘요. 조회를 만드는 방법은 세 가지가 있습니다.

① 메서드 이름 쿼리

메서드 이름만 규칙대로 지으면 JPA가 SQL을 만들어 줍니다.

// core/repo/MenuRepo.java
public interface MenuRepo extends BaseRepo<Menu, String> {
    // active = ? 조건으로 찾고, sortOrder 순으로 정렬
    List<Menu> findAllByActiveOrderBySortOrder(Boolean active);
}

findAllBy + Active(조건) + OrderBySortOrder(정렬) 처럼, 이름을 읽으면 곧 쿼리 뜻이 됩니다.

② @Query로 직접 작성

이름 규칙으로 표현하기 어려운 쿼리는 @Query 에 직접 적습니다. nativeQuery=true 면 진짜 SQL을 그대로 씁니다.

// core/repo/UserRepo.java
@Repository
public interface UserRepo extends BaseRepo<User, UUID>, UserRepoQuery {

    // 메서드 이름 쿼리
    Optional<User> findByUserid(String userid);

    // 직접 작성한 네이티브 SQL
    @Query(value = "select * from tb_user u where u.active = true "
                 + "and concat('C1', upper(u.userid)) = :username",
           nativeQuery = true)
    Optional<User> findBySsoId(String username);
}

③ 공통 베이스 BaseRepo — 읽기/쓰기 분리 (CQRS 흔적)

이 프로젝트의 모든 리포지토리는 BaseRepo 를 상속합니다. JpaRepository 대신 CrudRepository 만 확장해 기본 CRUD(저장·삭제·단건 조회)만 제공하고, 복잡한 조회 기능은 일부러 뺐어요. 복잡한 조회는 core/query 로 따로 분리한다는 설계 의도입니다.

// shared/base/BaseRepo.java
/**
 * Command 역할의 repository.
 * Command와 Query를 구분하기 위해 crud 기능만 제공하고 query 기능은 뺀다.
 */
@NoRepositoryBean    // 이 인터페이스 자체는 빈으로 만들지 않음(상속용 베이스)
@EnableJpaRepositories(queryLookupStrategy = QueryLookupStrategy.Key.USE_DECLARED_QUERY)
public interface BaseRepo<T, ID> extends CrudRepository<T, ID> { }

QueryDSL — 동적 검색과 페이징

검색어·필터·페이징처럼 조건이 실행 때마다 달라지는 조회는 메서드 이름이나 @Query 로는 표현이 어렵습니다. 이럴 때 QueryDSL?Java 코드로 쿼리를 조립합니다. 문자열 SQL이 아니라 코드라서, 오타가 있으면 컴파일 단계에서 잡혀요.

QueryDSL은 "조건을 끼웠다 뺐다 하는 레고". 검색어가 있으면 검색 블록을 끼우고, 없으면 빼고, 페이지 정보를 마지막에 붙여 완성된 쿼리를 만듭니다.

listByPage — if로 조건을 붙이는 스타일

// core/query/querydsl/UserRepoQueryImpl.java
@RequiredArgsConstructor
public class UserRepoQueryImpl implements UserRepoQuery {

    private final JPAQueryFactory queryFactory;   // QuerydslConfig가 주입

    @Override
    public Page<User> listByPage(UserFilter filter, Pageable pageable) {
        var query = queryFactory.selectFrom(user);   // user = QUser (자동 생성)

        // 검색어가 있을 때만 where 추가
        if (filter.keyword() != null) {
            query.where(user.name.containsIgnoreCase(filter.keyword())
                    .or(user.userid.containsIgnoreCase(filter.keyword()))
                    .or(user.email.containsIgnoreCase(filter.keyword())));
        }
        if (filter.active() != null) {               // active 필터(null이면 전체)
            query.where(user.active.eq(filter.active()));
        }

        // 페이지 한 장(content) + 전체 개수(total)
        var content = query.clone()
                .orderBy(user.createdAt.desc(), user.name.asc())
                .offset(pageable.getOffset())
                .limit(pageable.getPageSize())
                .fetch();
        var total = Optional.ofNullable(query.clone()
                        .select(user.count()).fetchOne())
                .orElse(0L);

        return new PageImpl<>(content, pageable, total);   // 목록 + 페이지 정보
    }
}
Q타입(QUser)은 어디서 오나요?

userQUser.user 입니다. querydsl-apt 가 컴파일할 때 각 엔티티마다 QUser·QUserRole 같은 Q타입을 자동 생성해요. 그래서 user.name, user.active 처럼 필드를 타입 안전하게 참조할 수 있습니다. JPAQueryFactory 빈은 config/QuerydslConfig.java 에서 EntityManager 로 만들어 주입합니다.

allOf — null 조건을 자동으로 건너뛰는 스타일

또 다른 깔끔한 방법은, 조건을 만드는 작은 헬퍼가 조건이 없으면 null 을 반환하게 하고 allOf(...) 로 묶는 것입니다. allOf 는 섞여 있는 null알아서 제외해요.

// core/query/querydsl/AuthLogRepoQueryImpl.java
var conditions = allOf(
    useridEq(filter.userid()),       // 값 없으면 null → 자동 제외
    statusEq(filter.status()),
    filter.startDate() != null ? authLog.attemptTime.goe(filter.startDate().atStartOfDay()) : null,
    filter.endDate()   != null ? authLog.attemptTime.lt(filter.endDate().plusDays(1).atStartOfDay()) : null
);
// ... .where(conditions) 로 한 번에 적용

private BooleanExpression useridEq(String userid) {
    return StringUtils.hasText(userid) ? authLog.userid.eq(userid) : null;
}

if 를 여러 번 쓰는 대신, null 은 "조건 없음"으로 통일해 코드를 평평하게 만드는 패턴입니다. 두 스타일 다 같은 목적(동적 조건)을 이룹니다.

자동 쿼리와 직접 구현을 한 리포지토리로 합치기

UserRepoBaseRepo(자동 CRUD)와 UserRepoQuery(QueryDSL 프래그먼트)를 둘 다 상속합니다. 스프링이 이 둘을 하나의 리포지토리로 합쳐주기 때문에, 호출하는 쪽은 userRepo.findByUserid(...)(자동)와 userRepo.listByPage(...)(직접 구현)를 같은 객체에서 씁니다.

UserRepo  extends  BaseRepo<User, UUID>     // findByUserid 등 자동 생성
                   UserRepoQuery            // listByPage (인터페이스)
                        ↑ 구현
              UserRepoQueryImpl             // 직접 작성한 QueryDSL 코드

스프링 규칙: 프래그먼트 인터페이스 이름 + "Impl" = 구현 클래스
  UserRepoQuery  →  UserRepoQueryImpl  (자동 연결)
왜 이렇게 나누나요?

단순 저장·단건 조회는 손댈 게 없어 자동 생성으로 충분하고, 검색·필터·페이징 같은 복잡한 조회만 직접 작성합니다. "쉬운 건 자동, 어려운 건 직접" — 이름 규칙(*Impl)만 지키면 둘이 하나처럼 동작합니다.

jOOQ · MyBatis는 "자리만" 마련됨

build.gradle 에는 영속성 기술이 네 개(JPA·QueryDSL·jOOQ·MyBatis) 들어 있지만, 실제로 쿼리하는 코드는 JPA + QueryDSL 둘뿐입니다. 나머지 둘은 회사 스택을 따라 자리만 잡아둔 상태예요.

jOOQ · MyBatis의 정확한 현황
  • jOOQ코드 생성까지만 되어 있습니다(생성 패키지 com.example.study.core.jooq). 그 생성된 클래스를 호출하는 코드는 아직 없습니다.
  • MyBatis의존성·설정만 있고, mapper XML/인터페이스가 없습니다. 즉 동작하는 매퍼가 없어요.

그래서 데이터 접근을 이해할 때는 JPA(엔티티·리포지토리) + QueryDSL(동적 조회) 두 가지만 따라가면 충분합니다.

이 구조가 실제로 어디에
  • 새 테이블 추가 — 엔티티(core/model/entity) → 리포지토리(core/repo) 순으로 만든다
  • 단건 조회/저장/삭제BaseRepo 자동 CRUD 또는 findByXxx 메서드 이름 쿼리
  • 검색·필터·페이징 화면core/query 에 QueryDSL *QueryImpl 로 동적 쿼리 작성
  • 데이터가 안 맞을 때 — 엔티티 매핑(컬럼·관계)부터, 그다음 쿼리(조건·조인)를 본다

정리 — JPA vs QueryDSL, 언제?

상황쓰는 것예시
저장·삭제·단건 조회JPA (BaseRepo)save, findById
고정 조건의 간단 조회JPA 메서드 이름 쿼리findByUserid, findAllByActive...
이름으로 어려운 SQLJPA @QueryfindBySsoId(nativeQuery)
조건이 바뀌는 검색·페이징QueryDSLlistByPage, findAuthLogs
한 줄 기준

조건이 고정이면 JPA, 실행 때마다 달라지면 QueryDSL. 그리고 그 둘은 한 리포지토리(UserRepo)에서 합쳐져 함께 쓰입니다.