4장 · 데이터 접근 (JPA · QueryDSL)
이제 DB를 다룹니다. ORM?이 객체와 테이블을 어떻게 잇는지, 엔티티?로 테이블을 그리고, 리포지토리?로 기본 조회를, 조건이 달라지는 복잡한 검색은 QueryDSL?로 작성하는 법을 봅니다.
ORM과 JPA란?
DB는 테이블(행·열)로 데이터를 저장하지만, Java는 객체(클래스)로 다룹니다. 이 둘을 자동으로 이어주는 기술이 ORM?(Object-Relational Mapping)이고, Java의 표준 ORM이 JPA?(보통 Hibernate가 구현)입니다. 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("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이 아니라 코드라서, 오타가 있으면 컴파일 단계에서 잡혀요.
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); // 목록 + 페이지 정보
}
}user 는 QUser.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 은 "조건 없음"으로 통일해 코드를 평평하게 만드는 패턴입니다. 두 스타일 다 같은 목적(동적 조건)을 이룹니다.
자동 쿼리와 직접 구현을 한 리포지토리로 합치기
UserRepo 는 BaseRepo(자동 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 — 코드 생성까지만 되어 있습니다(생성 패키지
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... |
| 이름으로 어려운 SQL | JPA @Query | findBySsoId(nativeQuery) |
| 조건이 바뀌는 검색·페이징 | QueryDSL | listByPage, findAuthLogs |
조건이 고정이면 JPA, 실행 때마다 달라지면 QueryDSL. 그리고 그 둘은 한 리포지토리(UserRepo)에서 합쳐져 함께 쓰입니다.