Lucian Log
Blog
Computer Science
Algorithm
DB
Network
OS
General
AI
Blockchain
Concurrency
ETC
Git
Infrastructure
AWS
Docker
Java-Ecosystem
JPA
Java
Spring
JavaScript-Ecosystem
JavaScript
Next.js
React
TypeScript
Python-Ecosystem
Django
FastAPI
Python
SQLAlchemy
Software Engineering
Architecture
Culture
Test
Home
Contact
Copyright © 2024 |
Yankos
Home
>
Java-Ecosystem
> JPA
Now Loading ...
JPA
QueryDSL Dive
QueryDSL 소개 JPQL 빌더 장점 문자인 JPQL을 코드로 작성해 컴파일 오류 발생 가능 JPQL과 달리 파라미터 바인딩을 자동 처리 라이브러리 종류 querydsl-apt: Querydsl 관련 코드 생성 기능 제공 (Q 클래스 빌드) querydsl-jpa: Querydsl 라이브러리 기본 문법 JPAQueryFactory 쿼리 작성의 기본 토대 EntityManager를 전달해 생성 JPAQueryFactory queryFactory = new JPAQueryFactory(em); 필드에 두어도 동시성 문제 걱정 없음 EntityManager가 동시성 문제 걱정이 없기 때문에 마찬가지다 Q-Type 전략 기본 인스턴스 사용 방법을 static import해 사용하자 같은 테이블을 조인해야 하는 경우에만 별칭 직접 지정 방법을 사용하자 Q 클래스 인스턴스 사용 방법 2가지 방법 1: 별칭 직접 지정 QMember qMember = new QMember("m"); 별칭 = JPQL 별칭 e.g. “select m from Member m” - m이 별칭 같은 테이블을 조인해야할 때만 사용 (다른 때는 쓸 일 없음) 방법 2: 기본 인스턴스 사용 QMember qMember = QMember.member; select와 from select, from selectFrom (축약 버전) 검색 조건 쿼리 (where) AND, OR 조건 where 조건에 ,로 파라미터를 추가하면 AND 조건 형성 -> null 값은 무시 -> 메서드 추출을 활용해 깔끔한 동적 쿼리 작성 가능 e.g. .where(member.username.eq("member1"), member.age.eq(10)) .and(), .or()로 메서드 체이닝 가능 검색 조건 예시 member.username.eq("member1") // username = 'member1' member.username.ne("member1") //username != 'member1' member.username.eq("member1").not() // username != 'member1' member.username.isNotNull() //이름이 is not null member.age.in(10, 20) // age in (10,20) member.age.notIn(10, 20) // age not in (10, 20) member.age.between(10,30) //between 10, 30 member.age.goe(30) // age >= 30 member.age.gt(30) // age > 30 member.age.loe(30) // age <= 30 member.age.lt(30) // age < 30 member.username.like("member%") //like 검색 member.username.contains("member") // like ‘%member%’ 검색 member.username.startsWith("member") //like ‘member%’ 검색 결과 조회 fetch() : 리스트 조회 데이터 없으면 : 빈 리스트 fetchOne() : 단건 조회 결과가 없으면 : null 결과가 둘 이상이면 : com.querydsl.core.NonUniqueResultException fetchFirst() : 처음 한 건 조회 (= limit(1).fetchOne()) fetchResults() -> deprecated : 페이징 정보 포함 + total count 쿼리 추가 실행 fetchCount() -> deprecated : count 쿼리로 변경해서 count 수 조회 정렬 (orderBy) desc(), asc() : 일반 정렬 nullsLast(), nullsFirst() : null 데이터 순서 부여 사용 예시 .orderBy(member.age.desc(), member.username.asc().nullsLast()) 1번 순서: 회원 나이 내림차순(desc) 2번 순서: 회원 이름 올림차순(asc) 단, 2에서 회원 이름이 없으면 마지막에 출력(nulls last) 참고: 스프링 데이터 JPA의 Sort 객체를 함께 사용할 수 있을까? 스프링 데이터 JPA는 Sort를 QueryDSL의 OrderSpecifier로 변경하는 기능 제공 다만, 정렬은 조금만 복잡해도 Sort 기능 사용이 어려우므로 파라미터로 받아 직접 처리 권장 페이징 SQL 오프셋, 리미트: offset, limit 스프링 부트 3.x(2.6 이상) 유의점 (QueryDSL 5.0) PageableExecutionUtils 패키지 변경 신규: org.springframework.data.support.PageableExecutionUtils fetchResults() , fetchCount() => Deprecated fetchCount() 대체 사용 예제 - count 쿼리 예제 (fetchOne()) Long totalCount = queryFactory //.select(Wildcard.count) //select count(*) .select(member.count()) //select count(member.id) .from(member) .fetchOne(); fetchResults() 대체 사용 예제 import org.springframework.data.support.PageableExecutionUtils; //패키지 변경 public Page<MemberTeamDto> searchPageComplex(MemberSearchCondition condition, Pageable pageable) { List<MemberTeamDto> content = queryFactory .select(new QMemberTeamDto( member.id.as("memberId"), member.username, member.age, team.id.as("teamId"), team.name.as("teamName"))) .from(member) .leftJoin(member.team, team) .where( usernameEq(condition.getUsername()), teamNameEq(condition.getTeamName()), ageGoe(condition.getAgeGoe()), ageLoe(condition.getAgeLoe()) ) .offset(pageable.getOffset()) .limit(pageable.getPageSize()) .fetch(); JPAQuery<Long> countQuery = queryFactory .select(member.count()) //count 쿼리 .from(member) .leftJoin(member.team, team) .where( usernameEq(condition.getUsername()), teamNameEq(condition.getTeamName()), ageGoe(condition.getAgeGoe()), ageLoe(condition.getAgeLoe()) // ).fetchOne(); ); // return new PageImpl<>(content, pageable, total); return PageableExecutionUtils.getPage(content, pageable, countQuery::fetchOne); //fetchOne() 사용 } 두 쿼리를 각각 메서드로 추출해도 좋음! 반환 전략 (스프링 데이터와 함께 사용하기) 기본: Page 구현체 반환 (PageImpl) e.g. return new PageImpl<>(content, pageable, total); CountQuery 최적화 (PageableExecutionUtils 사용) count 쿼리가 생략 가능한 경우 생략해서 처리 (스프링 데이터 라이브러리 제공) 페이지 시작이면서 컨텐츠 사이즈가 페이지 사이즈보다 작을 때 마지막 페이지이면서 컨텐츠 사이즈가 페이지 사이즈보다 작을 때 집합 집합 함수 List<Tuple> result = queryFactory .select(member.count(), member.age.sum(), member.age.avg(), member.age.max(), member.age.min()) .from(member) .fetch(); Tuple tuple = result.get(0); tuple.get(member.count()); //회원수 tuple.get(member.age.sum()); //나이 합 tuple.get(member.age.avg()); //평균 나이 tuple.get(member.age.max()); tuple.get(member.age.min()); groupBy(), having() .groupBy(item.price) .having(item.price.gt(1000)) 조인 기본 조인 연관관계로 조인 문법: join(조인 대상, 별칭으로 사용할 Q타입) e.g. queryFactory .selectFrom(member) .join(member.team, team) .where(team.name.eq("teamA")) .fetch(); 종류: join(), innerJoin(), leftJoin(), rightJoin() 세타 조인 연관관계가 없는 필드로 조인 e.g. queryFactory .select(member) .from(member, team) .where(member.username.eq(team.name)) .fetch(); 원리 카타시안 조인을 해버린 후 where절로 필터링 (cross join 후 where 필터링) DB가 성능 최적화함 단점: 외부조인이 불가능하므로 외부조인 필요시 on 절을 사용해야 함 on 절 활용 조인 조인 대상 필터링 외부조인에 필터링이 필요한 경우에만 사용하자 (내부 조인이면 where 절로 해결) 결과적으로 left join에만 on 절 활용이 의미있는 결과를 만듦 내부조인(inner join)을 사용하면, where 절에서 필터링하는 것과 기능이 동일 e.g. queryFactory .select(member, team) .from(member) .leftJoin(member.team, team) .on(team.name.eq("teamA")) .fetch(); 연관관계 없는 엔터티 외부 조인 - 보통 이 이유로 많이 쓰임 문법 차이: leftJoin() 부분에 일반 조인과 다르게 엔티티 하나만 들어감 일반조인: leftJoin(member.team, team) - SQL on절에 id값 매칭 O on조인: from(member).leftJoin(team).on(xxx) - SQL on절 id값 매칭 X 참고) 내부조인도 가능 e.g. queryFactory .select(member, team) .from(member) .leftJoin(team) .on(member.username.eq(team.name)) .fetch(); 페치 조인 (fetchJoin()) join(), leftJoin() 등 조인 기능 뒤에 fetchJoin() 추가 e.g. queryFactory .selectFrom(member) .join(member.team, team).fetchJoin() .where(member.username.eq("member1")) .fetchOne(); distinct select 절 뒤에 distinct() 추가 (JPQL distinct와 동일) e.g. queryFactory .select(member.username).distinct() .from(member) .fetch(); 서브 쿼리 (JPAExpressions) - static import 활용하면 코드가 더욱 깔끔해짐 서브쿼리 지원 where 절 서브 쿼리 지원 select 절 서브 쿼리 지원 (하이버네이트 사용 시 지원) from 절 서브 쿼리(인라인 뷰) 지원 X (JPA, JPQL이 지원 X) 해결책 서브 쿼리를 join으로 변경하기 (높은 확률로 가능) 애플리케이션에서 쿼리를 2번 분리해서 실행하기 nativeSQL을 사용하기 e.g. queryFactory .selectFrom(member) .where(member.age.eq( JPAExpressions .select(memberSub.age.max()) .from(memberSub) )) .fetch(); 기타 Case 문 (거의 사용 X) select 절, 조건절(where), order by에서 사용 가능 e.g. 단순한 조건 select(member.age .when(10).then("열살") .when(20).then("스무살") .otherwise("기타")) e.g. 복잡한 조건 select(new CaseBuilder() .when(member.age.between(0, 20)).then("0~20살") .when(member.age.between(21, 30)).then("21~30살") .otherwise("기타")) e.g. 임의의 순서로 출력하기 NumberExpression<Integer> rankPath = new CaseBuilder() .when(member.age.between(0, 20)).then(2) .when(member.age.between(21, 30)).then(1) .otherwise(3); List<Tuple> result = queryFactory .select(member.username, member.age, rankPath) .from(member) .orderBy(rankPath.desc()) .fetch(); 상수 (거의 사용 X) Expressions.constant(xxx) 사용 e.g. select(member.username, Expressions.constant("A")) 문자 더하기 (concat) e.g. select(member.username.concat("_").concat(member.age.stringValue())) 참고: 문자가 아닌 타입들은 stringValue() 로 문자 변환 가능 (ENUM 처리에도 자주 사용) 복잡한 쿼리에 대한 제언 SQL이 화면을 맞추기위해 너무 복잡할 필요는 없다. (from… from… from…) 따라서, DB는 데이터를 퍼올리는 용도로만 사용하자. (필터링, 그룹핑 등 데이터를 최소화해 가져오는 역할) 그리고 뷰 로직은 애플리케이션의 프레젠테이션 계층에서 처리하자. 결과적으로, 서브 쿼리와 복잡한 쿼리가 감소할 것이다. 중급 문법 프로젝션 (select 대상 지정) 프로젝션 대상이 하나 타입을 명확하게 지정 e.g. select(member.username) 프로젝션 대상이 둘 이상 튜플 조회 (Tuple) List<Tuple> result = queryFactory .select(member.username, member.age) .from(member) .fetch(); for (Tuple tuple : result) { String username = tuple.get(member.username); Integer age = tuple.get(member.age); DTO 조회 (4가지 방법) => 실용적 관점에서는 @QueryProjection이 편리하나 답은 없음 프로퍼티 접근 (Setter) 이름(별칭)을 보고 매칭 e.g. Projections.bean() select(Projections.bean(MemberDto.class, member.username, member.age) ) 필드 직접 접근 getter, setter는 무시하고 리플렉션 등의 방법으로 필드에 직접 값을 꽂음 이름(별칭)을 보고 매칭 e.g. Projections.fields() select(Projections.fields(MemberDto.class, member.username, member.age) ) 생성자 사용 타입을 보고 매칭 e.g. Projections.constructor() select(Projections.constructor(MemberDto.class, member.username, member.age) ) @QueryProjection (생성자 활용) 사용법 DTO 설정 @Data public class MemberDto { private String username; private int age; public MemberDto() {} @QueryProjection public MemberDto(String username, int age) { this.username = username; this.age = age; } } 빌드 후 DTO의 Q 클래스 생성 확인 사용 List<MemberDto> result = queryFactory .select(new QMemberDto(member.username, member.age)) .from(member) .fetch(); 장점: 컴파일러 타입 체크가 가능해 가장 안전 단점 DTO가 QueryDSL 애노테이션을 의존 DTO까지 Q 파일을 생성해야 함 유의점: 프로퍼티 or 필드 직접 접근 방식에서 이름이 다를 때 Q 클래스의 필드 이름과 DTO의 필드 이름이 다르면 별칭으로 맞춰줘야 함 별칭 적용 방법 ExpressionUtils.as(source,alias) : 필드나 서브 쿼리에 별칭 적용 username.as("memberName") : 필드에 별칭 적용 e.g. queryFactory .select(Projections.fields(UserDto.class, member.username.as("name"), ExpressionUtils.as( JPAExpressions .select(memberSub.age.max()) .from(memberSub), "age") ) ).from(member) .fetch(); 동적 쿼리 BooleanBuilder 사용 예시 private List<Member> searchMember1(String usernameCond, Integer ageCond) { BooleanBuilder builder = new BooleanBuilder(); if (usernameCond != null) { builder.and(member.username.eq(usernameCond)); } if (ageCond != null) { builder.and(member.age.eq(ageCond)); } return queryFactory .selectFrom(member) .where(builder) .fetch(); } Where 다중 파라미터 사용 (권장, 가장 깔끔) where 조건에 null 값은 무시 검색조건의 반환결과는 Predicate보다 BooleanExpression 이 좋음 (and, or 조립 가능) e.g. private BooleanExpression usernameEq(String usernameCond) 장점 메서드를 다른 쿼리에서도 재활용 가능 쿼리 자체의 가독성 상승 조합을 사용하면 반복적으로 쓰이는 코드를 묶어 더 직관적인 코드로 재사용 가능 null 체크는 조금 더 신경써야함 (e.g. null.and(null)) e.g.1 광고 상태를 나타내는 isServiceable() = isValid() + 날짜 IN e.g.2 private BooleanExpression allEq(String usernameCond, Integer ageCond) { return usernameEq(usernameCond).and(ageEq(ageCond)); } 사용 예시 private List<Member> searchMember(String usernameCond, Integer ageCond) { return queryFactory .selectFrom(member) .where(usernameEq(usernameCond), ageEq(ageCond)) .fetch(); } private BooleanExpression usernameEq(String usernameCond) { return usernameCond != null ? member.username.eq(usernameCond) : null; } private BooleanExpression ageEq(Integer ageCond) { return ageCond != null ? member.age.eq(ageCond) : null; } 수정 및 삭제 벌크 연산 (execute()) 유의점: JPQL과 마찬가지로 배치 쿼리 후에는 영속성 컨텍스트 초기화가 안전 (em.clear()) 대량 데이터 수정 기본 수정 long count = queryFactory .update(member) .set(member.username, "비회원") .where(member.age.lt(28)) .execute(); 기존 숫자에 1 더하기 (빼고 싶을 때는 -1 전달) long count = queryFactory .update(member) .set(member.age, member.age.add(1)) .execute(); 곱하기: .multiply(x) 대량 데이터 삭제 long count = queryFactory .delete(member) .where(member.age.gt(18)) .execute(); SQL function 호출하기 JPA와 같이 Dialect에 등록된 내용만 호출 가능 e.g. “member”를 “M”으로 변경하는 replace 함수 사용 String result = queryFactory .select(Expressions.stringTemplate("function('replace', {0}, {1}, {2})", member.username, "member", "M")) .from(member) .fetchFirst(); ANSI 표준 함수들은 QueryDSL이 상당 부분 내장 e.g. lower() .where(member.username.eq(member.username.lower())) 순수 JPA + QueryDSL 조합 활용 기본 사용법: 동일 리포지토리 사용 @Repository public class MemberJpaRepository { private final EntityManager em; private final JPAQueryFactory queryFactory; public MemberJpaRepository(EntityManager em) { this.em = em; this.queryFactory = new JPAQueryFactory(em); } ... } JPAQueryFactory도 스프링 빈으로 주입해 사용해도 된다 (선택 사항) @Bean JPAQueryFactory jpaQueryFactory(EntityManager em) { return new JPAQueryFactory(em); } 스프링 데이터 JPA와 QueryDSL 기본 사용법: 스프링 데이터 JPA의 사용자 정의 리포지토리 기능 활용 이 경우, MemberRepositoryImpl은 MemberRepositoryCustomImpl 도 가능 (권장) 순서 사용자 정의 인터페이스 작성 public interface MemberRepositoryCustom { List<MemberTeamDto> search(MemberSearchCondition condition); } 사용자 정의 인터페이스 구현 public class MemberRepositoryImpl implements MemberRepositoryCustom { private final JPAQueryFactory queryFactory; public MemberRepositoryImpl(EntityManager em) { this.queryFactory = new JPAQueryFactory(em); } @Override //회원명, 팀명, 나이(ageGoe, ageLoe) public List<MemberTeamDto> search(MemberSearchCondition condition) { return queryFactory .select(new QMemberTeamDto( member.id, member.username, member.age, team.id, team.name)) .from(member) .leftJoin(member.team, team) .where(usernameEq(condition.getUsername()), teamNameEq(condition.getTeamName()), ageGoe(condition.getAgeGoe()), ageLoe(condition.getAgeLoe())) .fetch(); } private BooleanExpression usernameEq(String username) { return isEmpty(username) ? null : member.username.eq(username); } private BooleanExpression teamNameEq(String teamName) { return isEmpty(teamName) ? null : team.name.eq(teamName); } private BooleanExpression ageGoe(Integer ageGoe) { return ageGoe == null ? null : member.age.goe(ageGoe); } private BooleanExpression ageLoe(Integer ageLoe) { return ageLoe == null ? null : member.age.loe(ageLoe); } } 스프링 데이터 리포지토리에 사용자 정의 인터페이스 상속 public interface MemberRepository extends JpaRepository<Member, Long>, MemberRepositoryCustom { List<Member> findByUsername(String username); } 기타 (실무 사용 어려움) 인터페이스 지원 - QuerydslPredicateExecutor QueryDSL의 Predicate을 파라미터로 넘길 수 있음 단점 클라이언트가 QueryDSL 의존 Left Join 불가 Querydsl Web 지원 컨트롤러가 QueryDSL 의존 리포지토리 지원 - QuerydslRepositorySupport getQuerydsl().applyPagination() 스프링 데이터가 제공하는 페이징을 Querydsl로 편리하게 변환 가능 단점 QueryDSL 3.x 버전 대상 QueryFactory 를 제공하지 않음 Sort 기능이 정상 동작하지 않음 Querydsl 지원 클래스 직접 만들기 Reference 실전! Querydsl
Java-Ecosystem
· 2025-01-13
Spring Data JPA Dive
스프링 데이터와 스프링 데이터 JPA 스프링 데이터 프로젝트 기본 데이터 저장소의 특수성을 유지하면서 익숙하고 일관된 Spring 기반 데이터 액세스 제공을 목표 스프링 데이터 몽고, 스프링 데이터 레디스, 스프링 데이터 JPA 등이 포함 패키지 구조 spring-data-commons 패키지: Spring-Data 프로젝트(몽고, 레디스, JPA) 모두가 공유 e.g. Repository(마커 인터페이스) CrudRepository, PagingAndSortingRepository spring-data-jpa 패키지: JPA를 위한 스프링 데이터 저장소 지원 e.g. JpaRepository 인터페이스, SimpleJpaRepository 클래스 장점: 유사한 인터페이스로 편하게 개발 가능 DB 변경은 큰 작업이라 거의 일어나지 않으므로, 구현체 교체의 편리함은 장점이 아님 공통 인터페이스 기본 사용법 임의의 설정 클래스에 @EnableJpaRepositories 적용 - 스프링 부트 사용시 생략 가능 @Configuration @EnableJpaRepositories(basePackages = "jpabook.jpashop.repository") public class AppConfig {} 만약 적용하고자 하는 패키지가 다르다면, @EnableJpaRepositories를 적용하자 JpaRepository(혹은 부모 인터페이스)를 상속한 인터페이스 만들기 @Repository도 생략 가능 - 스프링 데이터 JPA가 자동처리 (기본 구현체에 이미 적용) 기본 원리 애플리케이션 로딩 시 클래스 스캔 진행 JpaRepository~Repository를 상속한 인터페이스를 모두 찾음 org.springframework.data.repository.Repository를 상속한 인터페이스를 찾음 스프링 데이터 JPA가 구현 클래스 생성 (프록시 구현체) 이후 필요한 곳에 주입 JpaRepository 인터페이스 대부분의 공통 CRUD 제공 제네릭은 <엔티티 타입, 식별자 타입(PK)> 설정 주요 메서드 (상속한 인터페이스 포함) save(S) : 새로운 엔티티는 저장하고 이미 있는 엔티티는 병합 delete(T) : 엔티티 하나를 삭제 (내부에서 EntityManager.remove() 호출) findById(ID) : 엔티티 하나를 조회 (내부에서 EntityManager.find() 호출) getOne(ID) : 엔티티를 프록시로 조회 (내부에서 EntityManager.getReference() 호출) findAll(...) : 모든 엔티티를 조회 정렬 및 페이징 조건을 파라미터로 제공 (Sort, Pageable) existsById(ID) SimpleJpaRepository (기본 구현체) @Repository @Transactional(readOnly = true) public class SimpleJpaRepository<T, ID> ...{ @Transactional public <S extends T> S save(S entity) { if (entityInformation.isNew(entity)) { em.persist(entity); return entity; } else { return em.merge(entity); } } ... } @Repository 적용됨 컴포넌트 스캔 처리 JPA 예외를 스프링이 추상화한 예외로 변환 @Transactional 적용됨 JPA의 모든 변경은 트랜잭션 안에서 동작 트랜잭션이 이미 리포지토리 계층에 걸려있음 서비스 계층에서 트랜잭션을 시작하지 않으면 리포지토리에서 트랜잭션 시작 서비스 계층에서 트랜잭션을 시작하면 리포지토리는 해당 트랜잭션을 전파 받아 씀 => 스프링 Data JPA의 변경이 가능했던 이유 @Transactional(readOnly = true) 데이터 단순 조회 트랜잭션에서 플러시를 생략해 약간의 성능 향상 즉, 트랜잭션 종료 시 플러시 작업 제외 (변경 감지 X, DB에 SQL 전달 X) save 메서드 최적화 필요 상황 (중요) 괜찮은 상황: @GenerateValue면 save() 호출 시점에 식별자가 없어 persist() 호출 문제 상황: 식별자를 @Id만 사용해 직접 할당하는 경우 식별자 값이 있는 상태로 save()를 호출해, merge()가 호출됨 새로운 엔터티가 아닐 경우 merge()를 진행하는데, merge()는 비효율적이므로 지양해야함 merge(): DB에 이미 있는 엔터티라면, SELECT를 실행 새로운 엔터티를 판단하는 기본 전략 식별자가 객체일 때 null 로 판단 식별자가 자바 기본타입일 때 0 으로 판단 해결책: Persistable 인터페이스를 구현 public interface Persistable<ID> { ID getId(); boolean isNew(); } 등록시간(@CreatedDate)을 조합해 사용하면, 새로운 엔티티 여부 편리하게 확인 가능 @Entity @EntityListeners(AuditingEntityListener.class) @NoArgsConstructor(access = AccessLevel.PROTECTED) public class Item implements Persistable<String> { @Id private String id; @CreatedDate private LocalDateTime createdDate; public Item(String id) { this.id = id; } @Override public String getId() { return id; } @Override public boolean isNew() { return createdDate == null; } } @CreatedDate에 값이 없으면 새로운 엔티티로 판단 쿼리 메서드 전략 2개 정도 파라미터까지만 메서드 이름으로 쿼리 생성해 해결하자 더 길어지면 @Query로 JPQL 직접 정의해 풀자 스프링 데이터 JPA의 쿼리 메서드 탐색 전략 도메인 클래스 + .(점) + 메서드 이름으로 Named Query를 찾음 JpaRepository 상속 시 제네릭으로 설정한 도메인 클래스 인터페이스에 정의한 메서드 이름 없으면 메서드 이름으로 쿼리 생성 3가지 방법 메서드 이름으로 쿼리 생성 (기본) 규칙 ...은 식별하기 위한 내용(설명)이므로 무엇이 들어가도 상관 없음 e.g. findHelloBy 처럼 ...에 식별하기 위한 내용(설명)이 들어가도 됨 By 뒤에 원하는 속성과 조건을 입력하면 where 절로 간주 SQL에 들어갈 파라미터는 메서드의 파라미터로 받음 기본 제공 기능 조회: find...By, read...By, query...By, get...By COUNT: count...By 반환타입 long EXISTS: exists...By 반환타입 boolean 삭제: delete...By, remove...By 반환타입 long DISTINCT: findDistinct, findMemberDistinctBy LIMIT: findFirst3, findFirst, findTop, findTop3 장점 엔터티 필드명을 변경하면 메서드 이름도 변경해야 하는데, 컴파일 오류를 통해 인지 가능 메서드 이름으로 JPA NamedQuery 호출 (거의 사용 X) 사용법 엔터티에 정의된 JPA @NamedQuery의 name으로 메서드 이름 설정 장점: 타입 안정성이 높음 (미리 정의된 정적 쿼리를 파싱을 통해 체크) 단점: 엔터티에 쿼리가 있는 것도 좋지 않고, @Query가 훨씬 강력함 @Query 적용 (자주 사용) 인터페이스 메서드에 JPQL 쿼리 직접 정의 가능 사용법 하나의 값 조회 @Query("select m.username from Member m") List<String> findUsernameList(); DTO 직접 조회 @Query("select new study.datajpa.dto.MemberDto(m.id, m.username, t.name) " + "from Member m join m.team t") List<MemberDto> findMemberDto(); 파라미터 바인딩 @Query("select m from Member m where m.username = :name") Member findMembers(@Param("name") String username); 이름 기반 바인딩을 하자 (위치 기반 바인딩 지양) e.g. :name <-> @Param("name") 컬렉션 파라미터 바인딩 @Query("select m from Member m where m.username in :names") List<Member> findByNames(@Param("names") List<String> names); Collection 타입으로 in절 지원 장점 타입 안정성이 높음 정적 쿼리라서 틀리면 애플리케이션 시작 시점에 컴파일 에러 이름없는 Named 쿼리라 할 수 있다! 반환 타입 스프링 데이터 JPA는 반환 타입에 따라 getSingleResult() 혹은 getResultList() 등을 호출 List<Member> findByUsername(String name); //컬렉션 Member findByUsername(String name); //단건 Optional<Member> findByUsername(String name); //단건 Optional 단건 조회 결과가 있을지 없을지 모르겠다면 Optional 사용하자!! 조회 결과가 많거나 없으면? 컬렉션 결과 없음: 빈 컬렉션 반환 단건 조회 결과없음: null 반환 JPA는 NoResultException 발생, 스프링 데이터 JPA는 try~catch로 감싼 것 결과가 2건 이상: javax.persistence.NonUniqueResultException 예외 발생 결국엔 스프링 예외 IncorrectResultSizeDataAccessException로 변환됨 참고: 단건 조회 결과 Best Practice 자바 8 이전: 단건 조회의 결과가 없는 경우, 예외가 나은지 null이 나은지는 논란 => 결론: 실무에서는 null이 낫다! 자바 8 이후 => DB에서 조회했는데 데이터가 있을지 없을지 모르면 그냥 Optional을 써라!!! 페이징과 정렬 파라미터 Sort : 정렬 기능 Pageable : 페이징 기능 (내부에 Sort 포함) 반환 타입 Page : 페이징 (+ 추가 count 쿼리 결과 포함) 실무에서 최적화가 가능하다면 최대한 카운트 쿼리를 분리해 사용 (e.g. 조인 줄이기) 참고: Count 쿼리는 매우 무거움 @Query(value = "select m from Member m", countQuery = "select count(m.username) from Member m") Page<Member> findMemberAllCountBy(Pageable pageable); Slice : 페이징 - 다음 페이지만 확인 가능 (내부적으로 limit + 1 조회), 무한 스크롤 용도 List: 페이징 - 조회 데이터만 반환 예제 1 - 반환 타입 사용법 //count 쿼리 O Page<Member> findByUsername(String name, Pageable pageable); //count 쿼리 X Slice<Member> findByUsername(String name, Pageable pageable); //count 쿼리 X List<Member> findByUsername(String name, Pageable pageable); List<Member> findByUsername(String name, Sort sort); 예제 2 - Pageable, Sort 파라미터 사용법 PageRequest pageRequest = PageRequest.of(0, 3, Sort.by(Sort.Direction.DESC, "username")); Page<Member> page = memberRepository.findByAge(10, pageRequest); Pageable의 구현체 PageRequest 객체를 생성해 전달 PageRequest 생성자 파라미터 첫 번째: 현재 페이지 (0부터 시작) 두 번째: 조회할 데이터 수 추가: 정렬 정보 (Sort) 예제 3 - 페이지를 유지하면서 엔터티를 DTO로 변환하기 Page<Member> page = memberRepository.findByAge(10, pageRequest); Page<MemberDto> dtoPage = page.map(m -> new MemberDto()); 주요 메서드 Page (Slice를 상속 받았으므로, Slice의 메서드도 사용 가능) getTotalPages();: 전체 페이지 수 getTotalElements();: 전체 데이터 수 map(Function<? super T, ? extends U> converter);: 변환기 Slice getNumber(): 현재 페이지 getSize(): 페이지 크기 getNumberOfElements(): 현재 페이지에 나올 데이터 수 getContent(): 조회된 데이터 hasContent(): 조회된 데이터 존재 여부 getSort(): 정렬 정보 isFirst(): 현재 페이지가 첫 페이지 인지 여부 isLast(): 현재 페이지가 마지막 페이지 인지 여부 hasNext(): 다음 페이지 여부 hasPrevious(): 이전 페이지 여부 getPageable(): 페이지 요청 정보 nextPageable(): 다음 페이지 객체 previousPageable(): 이전 페이지 객체 map(Function<? super T, ? extends U> converter): 변환기 벌크성 수정, 삭제 쿼리 (@Modifying) JPA의 executeUpdate() 를 대신 실행 (벌크성 수정 및 삭제) @Modifying이 있으면 executeUpdate()를 실행 없으면, getSingleResult() 혹은 getResultList() 등을 실행 @Modifying(clearAutomaically = true) - 기본값은 false 벌크성 쿼리 실행 후, 영속성 컨텍스트 자동 초기화 벌크 연산 이후에는 조회 상황을 대비해, 영속성 컨텍스트 초기화 권장 예제 @Modifying @Query("update Member m set m.age = m.age + 1 where m.age >= :age") int bulkAgePlus(@Param("age") int age); 이 경우, @Modifying이 없다면 다음 예외 발생 org.hibernate.hql.internal.QueryExecutionRequestException: Not supported for DML operations 엔터티 그래프 (Entity Graph) 페치 조인의 간편 버전 예제 //공통 메서드 오버라이드 @Override @EntityGraph(attributePaths = {"team"}) List<Member> findAll(); //JPQL + 엔티티 그래프 @EntityGraph(attributePaths = {"team"}) @Query("select m from Member m") List<Member> findMemberEntityGraph(); //메서드 이름으로 쿼리에서 특히 편리하다. @EntityGraph(attributePaths = {"team"}) List<Member> findByUsername(String username) NamedEntityGraph (거의 안씀) 엔터티에 엔터티 그래프를 미리 등록해두고 불러와 쓰는 방법 @NamedEntityGraph(name = "Member.all", attributeNodes = @NamedAttributeNode("team")) @Entity public class Member {} @EntityGraph("Member.all") @Query("select m from Member m") List<Member> findMemberEntityGraph(); 전략 간단한 쿼리는 @EntityGraph로 처리 복잡한 쿼리는 JPQL로 페치조인 처리 e.g. @Query("select m from Member m left join fetch m.team") JPA Hint JPA 쿼리 힌트 (SQL 힌트가 아니라 JPA 구현체에게 제공하는 힌트) 쿼리 힌트는 readOnly 정도 말고는 잘 안씀 사실, readOnly도 잘 안씀 정말 트래픽이 많을 때 쓸 해결책이 아니다 성능 테스트를 해서 정말 중요하고 트래픽 많은 API 몇 개에만 적용 고려 예제 1 - ReadOnly @QueryHints(value = @QueryHint(name = "org.hibernate.readOnly", value = "true")) Member findReadOnlyByUsername(String username); readOnly - 하이버네이트 종속 기능 (JPA X) 변경이 없다고 생각하고 1차캐시에 스냅샷을 만들지 않도록 최적화 (-> 변경감지 없음) 조회용이라면 스냅샷이 필요없음 변경 감지는 메모리를 더 사용해 비용이 큼 (원본 객체 + 복제본 스냅샷 객체) 예제 2 - Count 쿼리 힌트 추가 @QueryHints(value = { @QueryHint(name = "org.hibernate.readOnly", value = "true")}, forCounting = true) Page<Member> findByUsername(String name, Pageable pageable); forCounting (기본값: true) Page 반환 시, 페이징을 위한 카운트 쿼리에도 동일한 쿼리 힌트를 적용할지 여부 선택 Lock 예제 - 비관적 락 (Pessimistic Lock = select ... for update) @Lock(LockModeType.PESSIMISTIC_WRITE) List<Member> findByUsername(String name); 실시간 트래픽이 많은 서비스는 가급적 락을 거는 것을 지양하자 Optimistic Lock으로 해결하거나 락을 안걸고 다른 방법으로 해결하는 쪽을 권장 Pessimistic Lock은 실시간 트래픽보다 정확도가 중요한 서비스에서 좋은 방법 e.g. 돈을 맞춰야 하는 서비스 사용자 정의 리포지토리 (매우 중요) 인터페이스에 메서드를 직접 구현하고 싶을 때 사용 인터페이스 구현체 직접 구현 시 문제 스프링 데이터 JPA는 인터페이스만 정의 후 구현체가 자동 생성 직접 구현체 생성하기에는 오버라이드해야 할 메서드가 너무 많음 사용자 정의 리포지토리 사용 이유 스프링 JDBC Template 사용, MyBatis 사용 JPA 직접 사용(EntityManager), 데이터베이스 커넥션 직접 사용 등등… Querydsl 사용 사용자 정의 리포지토리를 사용하지 않고 쿼리용 리포지토리를 따로 나누는 것도 좋은 전략! (CQRS) 사용 방법 규칙 방법 1: 리포지토리 인터페이스 명 + Impl 방법 2: 사용자 정의 인터페이스 명 + Impl (권장, 스프링 데이터 2.X~) => 스프링 데이터 JPA가 인식해서 스프링 빈으로 등록 사용자 정의 인터페이스 작성 public interface MemberRepositoryCustom { List<Member> findMemberCustom(); } 사용자 정의 인터페이스 구현 클래스 작성 방법 1: MemberRepository + Impl @RequiredArgsConstructor public class MemberRepositoryImpl implements MemberRepositoryCustom { private final EntityManager em; @Override public List<Member> findMemberCustom() { return em.createQuery("select m from Member m") .getResultList(); } } 방법 2: MemberRepositoryCustom + Impl (권장) @RequiredArgsConstructor public class MemberRepositoryCustomImpl implements MemberRepositoryCustom { private final EntityManager em; @Override public List<Member> findMemberCustom() { return em.createQuery("select m from Member m") .getResultList(); } } 사용자 정의 인터페이스 상속 public interface MemberRepository extends JpaRepository<Member, Long>, MemberRepositoryCustom { } Auditing (실무 자주 사용) 실무 케이스 실무에서 등록일, 수정일은 DB 모든 테이블에 깔고 감 관리자가 있다면 로그인한 ID를 기준으로 등록자, 수정자도 필요한 테이블에 둠 순수 JPA 구현 @MappedSuperclass @Getter public class JpaBaseEntity { @Column(updatable = false) private LocalDateTime createdDate; private LocalDateTime updatedDate; @PrePersist public void prePersist() { LocalDateTime now = LocalDateTime.now(); createdDate = now; updatedDate = now; } @PreUpdate public void preUpdate() { updatedDate = LocalDateTime.now(); } } public class Member extends JpaBaseEntity {} JPA 주요 이벤트 어노테이션 @PrePersist, @PostPersist @PreUpdate, @PostUpdate 스프링 데이터 JPA 설정 @EnableJpaAuditing -> 스프링 부트 설정 클래스에 적용해야 함 @EntityListeners(AuditingEntityListener.class) -> 엔터티에 적용 AuditorAware 스프링 빈 등록 (등록자, 수정자 처리) @EnableJpaAuditing @SpringBootApplication public class DataJpaApplication { public static void main(String[] args) { SpringApplication.run(DataJpaApplication.class, args); } @Bean public AuditorAware<String> auditorProvider() { return () -> Optional.of(UUID.randomUUID().toString()); } } 실무에서는 세션 정보나, 스프링 시큐리티 로그인 정보에서 ID를 받음 엔터티 적용 @EntityListeners(AuditingEntityListener.class) @MappedSuperclass @Getter public class BaseTimeEntity { @CreatedDate @Column(updatable = false) private LocalDateTime createdDate; @LastModifiedDate private LocalDateTime lastModifiedDate; } @EntityListeners(AuditingEntityListener.class) @MappedSuperclass @Getter public class BaseEntity extends BaseTimeEntity { @CreatedBy @Column(updatable = false) private String createdBy; @LastModifiedBy private String lastModifiedBy; } public class Member extends BaseEntity {} 적용 애노테이션 @CreatedDate, @LastModifiedDate, @CreatedBy, @LastModifiedBy Base 타입을 분리하고, 원하는 타입을 선택해서 상속 실무에서 대부분의 엔티티는 등록시간, 수정시간이 필요 등록자, 수정자는 필요한 곳도 있고 아닌 곳도 있음 저장시점에는 등록일-수정일, 등록자-수정자에 같은 데이터 저장 (유지보수 관점에서 편리) 선택사항) @EntityListeners(AuditingEntityListener.class) 생략하기 스프링 데이터 JPA가 제공하는 이벤트를 엔티티 전체에 적용 META_INF / orm.xml <?xml version="1.0" encoding="UTF-8"?> <entity-mappings xmlns="http://xmlns.jcp.org/xml/ns/persistence/orm" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/persistence/orm http://xmlns.jcp.org/xml/ns/persistence/orm_2_2.xsd" version="2.2"> <persistence-unit-metadata> <persistence-unit-defaults> <entity-listeners> <entity-listener class="org.springframework.data.jpa.domain.support.AuditingEntityListener"/> </entity-listeners> </persistence-unit-defaults> </persistence-unit-metadata> </entity-mappings> Web 확장 페이징과 정렬 @GetMapping("/members") public Page<Member> list(Pageable pageable) {...} 파라미터로 Pageable, 반환 타입으로 Page 사용 가능 파라미터로 구현체인 PageRequest가 생성되어 전달됨 요청 예시: /members?page=0&size=3&sort=id,desc&sort=username,desc page: 현재 페이지, 0부터 시작 size: 한 페이지에 노출할 데이터 건수 sort: 정렬 조건 정의 예) 정렬 속성,정렬 속성…(ASC DESC), 정렬 방향을 변경하고 싶으면 sort 파라 미터 추가 ( asc 생략 가능) 기본값 설정하기 글로벌 설정 (스프링 부트) spring.data.web.pageable.default-page-size=20 # 기본 페이지 사이즈 spring.data.web.pageable.max-page-size=2000 # 최대 페이지 사이즈 개별 설정 (@PageableDefault) public String list(@PageableDefault(size = 12, sort = "username", direction = Sort.Direction.DESC) Pageable pageable) 둘 이상의 페이징 정보는 접두사로 구분 가능 @Qualifier 에 접두사명 추가 “{접두사명}_xxx” 예제: /members?member_page=0&order_page=1 @Qualifier("member") Pageable memberPageable, @Qualifier("order") Pageable orderPageable, Page 내용을 DTO로 변환하기 (API 스펙에 엔터티 노출하지 않기) public Page<MemberDto> list(Pageable pageable) { return memberRepository.findAll(pageable).map(MemberDto::new); } Page.map() 으로 변환 가능 참고: Page는 변경 없이 0부터 시작하자 Page를 1부터 시작하는 방법 (불편) 방법 1: 직접 클래스를 만들어서 처리 Pageable, Page 파리미터 및 응답 값 사용 X 직접 PageRequest 생성해 리포지토리에 전달, 응답값도 직접 작성 방법 2: spring.data.web.pageable.one-indexed-parameters = true 한계: content만 잘나오고, 나머지는 원래 0 인덱스대로 나옴 도메인 클래스 컨버터 (실무 사용 거의 없음) @GetMapping("/members/{id}") public String findMember(@PathVariable("id") Member member) {...} HTTP 파라미터로 넘어온 엔티티의 아이디로 엔티티 객체를 찾아서 바인딩 자동으로 리포지토리 사용해 엔터티 찾음 간단한 쿼리에만 적용 가능 (트랜잭션이 없으므로 변경이 불가, 단순 조회용) 기타 기능들 - 실무 거의 사용 X Specifications (명세) -> QueryDSL 사용하자! JPA Criteria 활용해 다양한 검색 조건 조합 기능 지원 Query By Example -> **QueryDSL 사용하자! 실제 도메인 객체를 활용해 동적 쿼리 처리 (Probe, ExampleMatcher) 실무에 사용하기에는 매칭 조건이 너무 단순하고, Left Join이 안됨 Projections -> 단순할 때만 사용하고, 조금만 복잡해지면 **QueryDSL 사용하자! 프로젝션 대상이 root 엔터티면 유용 인터페이스 기반 Closed Projections 프로퍼티 형식(getter)의 인터페이스를 제공하면, 구현체는 스프링 데이터 JPA가 제공 public interface UsernameOnly { String getUsername(); } public interface MemberRepository ... { List<UsernameOnly> findProjectionsByUsername(String username); } 클래스 기반 Projections public class UsernameOnlyDto { private final String username; public UsernameOnlyDto(String username) { this.username = username; } public String getUsername() { return username; } } 인터페이스 기반 Open Projections, 동적 Projections, 중첩구조처리… 네이티브 쿼리 (99% 사용 X) 예제 @Query(value = "select * from member where username = ?", nativeQuery = true) Member findByNativeQuery(String username); 권장 해결책 복잡한 통계 쿼리도 QueryDSL로 해결 네이티브 쿼리 DTO 조회는 별도 리포지토리 파서 JDBC template or MyBatis 사용 권장 Reference 실전! 스프링 데이터 JPA
Java-Ecosystem
· 2025-01-09
JPA 활용 팁 2
요청과 응답 관련 유의 사항 요청 및 응답은 API 스펙에 맞추어 별도의 DTO로 전달하자 (엔터티 노출 X) 엔터티를 요청과 응답에 사용하면 프레젠테이션 계층과 엔터티가 결합되어 오염됨 (@NotEmpty 등…) e.g. @RequestBody CreateMemberRequest request 롬복은 DTO에 적극적으로 사용하자 (Entity에는 getter 정도 이외에는 사용 X) CQS 개발 스타일 적용하면 유지보수성이 크게 향상됨! Update 메서드는 반환없이 끝내거나 ID 값 정도만 반환 Update가 엔터티 객체를 반환하면, 업데이트하면서 조회하는 꼴 Update 후 조회가 필요하다면, PK로 하나 조회하자 특별히 트래픽 많은 API가 아니면 큰 이슈 X e.g. memberService.update(id, request.getName()); Member findMember = memberService.findOne(id); API 응답은 처음부터 Object로 반환하자 (Array X) 추후 Count를 넣어달라는 요청 등으로 언제든 요구사항이 변할 수 있음 (확장성을 위해) 지연로딩과 조회 성능 최적화 항상 지연로딩을 기본으로 하고, 성능 최적화가 필요한 경우 페치 조인 사용하자! 즉시 로딩은 연관관계가 필요 없는 경우에도 데이터를 항상 조회해 성능 문제 유발 JPQL 실행 후 응답을 받을 때 연관 관계에 즉시 로딩이 있으면, 영속성 컨텍스트에서 지연 로딩처럼 하나하나 단건 쿼리 날려 다 조회해 가져옴 (N + 1) 참고: 지연로딩은 N + 1을 만들지만, 영속성 컨텍스트에서 조회하므로 운좋게 이미 조회된 객체는 쿼리 생략 DTO 직접 조회 방식은 후순위 선택지 DTO 직접 조회는 페치 조인 없이도 한 번에 쿼리가 나감 장점 SELECT 절에 원하는 데이터 직접 선택 -> 애플리케이션 네트워크 용량 최적화 (생각보다 미비) 최근 네트워크 대역폭이 매우 좋음 대부분 성능 문제는 join에서 걸리거나 where 문이 인덱스를 잘 안탈 때 생김 SELECT 절이 문제가 될 때는 하필 필드 데이터 사이즈가 정말로 컸을 때 e.g. 필드가 10~30개 정도 되면 트래픽이 정말 많은 API는 영향 받을 수도 있음 단점 리포지토리가 API(화면)을 의존 -> API 스펙이 바뀌면 수정 -> 물리적 계층은 나뉘었지만 논리적 계층은 깨져있음 리포지토리 재사용성 감소 컬렉션 조회 최적화 컬렉션 조회시 페치 조인의 한계 컬렉션 페치 조인은 페이징 불가능 결과값은 올바르게 페이징해줄 수도 있으나, 메모리에서 진행되므로 메모리 터질 가능성 매우 큼 1 : N : M 같은 둘 이상의 연쇄적 컬렉션 패치 조인은 사용해서는 안됨 JPA 입장에서 어떤 엔터티를 기준으로 정리할지 모르게 될 수 있음 페이징 + 컬렉션 조회 전략 (default_batch_fetch_size) 전략 ToOne(OneToOne, ManyToOne) 관계를 모두 페치 조인하기 (=쿼리수 최대한 줄이기) 컬렉션은 아래 최적화 적용하고 지연 로딩으로 조회 (=N + 1 문제 완화) hibernate.default_batch_fetch_size: 글로벌 설정 (이것만으로도 충분) @BatchSize: 개별 최적화 -> 컬렉션 및 프록시 객체를 설정한 size만큼 한꺼번에 IN 쿼리로 조회 장점 페이징 가능 페치 조인 방식 보다 쿼리 호출 수는 약간 증가하지만, DB 데이터 전송량이 감소 적절한 배치 사이즈 전략 WAS, DB가 버틸 수 있으면 1000으로 설정 WAS, DB가 걱정된다면 100으로 설정하고 점점 늘리기 애매하면 500으로 설정 (100~500 두면 큰 문제 없이 사용 가능) DB 및 애플리케이션이 순간 부하를 어느정도로 견딜 수 있는지로 결정 DB에 따라 IN 절 파라미터를 1000으로 제한하는 경우도 있음 1000으로 잡으면 DB 및 WAS의 순간 부하 증가 (CPU 및 리소스) 100이면 시간은 더 걸리겠지만 순간 부하는 덜할 것 WAS 메모리 사용량은 100이든 1000이든 동일 distinct 하이버네이트 6 버전 부터는 컬렉션 조회 시 distinct 없이도 애플리케이션 단에서 자동으로 중복을 거른다. 6 이전에는 2가지 기능을 함께 수행했다. SQL에 distinct 추가 같은 엔터티가 조회되면 애플리케이션 단에서 중복 거르기 네트워크 호출 횟수와 데이터 전송량 네트워크 호출 횟수와 데이터 전송량 사이에는 성능 트레이드 오프가 존재한다. 모두 조인해서 가져오면, 한 번의 호출로 가져오지만 데이터 양이 많을 경우 성능이 저하된다. 여러 쿼리로 나눠 가져오면, 호출 수는 많아지지만 각각 최적화된 데이터 양으로 가져올 수 있어 더 나은 성능을 보일 수도 있다. 예를 들어, 한 쿼리로 1000개 데이터를 퍼올리는 상황이라면 여러 쿼리로 나누는게 나을 수 있다. 쿼리 방식 권장 선택 순서 기본: 엔터티 조회 후 DTO 변환 방식 (대부분의 성능 이슈 해결 가능) ToOne 관계 조회 페치 조인으로 쿼리 수 최적화 OneToMany 관계 조회 (컬렉션 조회) 페이징 필요 O ToOne인 부분은 최대한 페치 조인해서 가져옴 컬렉션은 hibernate.default_batch_fetch_size, @BatchSize 로 최적화 후 지연로딩 페이징 필요 X 페치 조인 최적화 e.g. 페이징이 없는 엑셀 다운로드 같은 기능은 페치 조인으로 조회 다만, 용량이 너무 많으면 앞의 default_batch_fetch_size 방법 이용 차선책: DTO 직접 조회 방법 사용 ToOne 관계 조회 단순 조인으로 한 번에 쿼리 OneToMany 관계 조회 (컬렉션 조회) 단건 One을 조회 -> One의 식별자로 Many를 조회 -> 서로 매핑 다건 분할 쿼리 (with IN 쿼리) - 권장 One을 조회 -> 해당 One의 식별자를 모아 Many를 IN 쿼리 -> Map 활용해 매핑 플랫 쿼리 한방 쿼리로 가져온 후 매핑 (페이징이 불가해 실무 비현실성, 성능 차이도 미비) 유지보수 방법 복잡한 통계 API 용으로 QueryService, QueryRepository 파서 DTO 직접 조회 사용 일반 리포지토리는 기본 엔터티 조회용으로 사용 -> 둘 구분으로 유지보수성 향상 최후의 방법: JPA 제공 네이티브 SQL 혹은 스프링 JDBC Template으로 직접 SQL 사용 이런 경우가 거의 없지만 DB 네이티브한 복잡한 기능 필요시 사용 참고 엔터티 조회 방식(페치 조인, BatchSize)으로 해결이 안되는 수준의 상황 서비스 트래픽이 정말 많은 상황이라 DTO 조회 방식으로도 해결이 안 될 가능성이 높음 캐시(레디스, 로컬 메모리 캐시) 사용이나 다른 방식으로 해결해야 함 엔터티 조회 방식은 코드 수정 거의 없이 옵션 변경만으로 성능 최적화하는 반면, DTO 직접 조회 방식은 성능 최적화 시 코드 변경이 많음 개발자는 성능 최적화와 코드 복잡도 사이에서 줄타기를 해야 한다. OSIV (Open Session In View) 전략 유래 Open Session In View: 하이버네이트 Open EntityManager In View: JPA 관례상 OSIV라고 함 하이버네이트가 JPA보다 먼저 나왔기 때문에, 이름 차이 발생 과거 하이버네이트 Session = JPA EntityManager 사용 전략 고객 서비스의 실시간 API는 OSIV 끄기 ADMIN 처럼 커넥션을 많이 사용하지 않는 곳에서는 OSIV 켜기 ADMIN은 해봤자 20~30명이 쓰는 서비스 한 프로젝트여도 멀티 모듈 사용해 분리 -> 고객 서비스와 ADMIN 서비스는 배포 군이 다름 spring.jpa.open-in-view = true (기본값) 최초 DB 커넥션 시작 시점부터 API 응답 종료까지 영속성 컨텍스트와 DB 커넥션을 유지 과정 JPA는 @Transactional 로 트랜잭션 시작시점에 커넥션을 가져옴 API 혹은 뷰 템플릿 렌더링이 끝나고 응답이 완전히 나가면 물고 있던 커넥션 반환 영속성 컨텍스트 종료 장점 API 컨트롤러 & View Template에서도 지연 로딩을 가능하게 함 영속성 컨텍스트는 기본적으로 DB 커넥션 유지 단점 (치명적) 너무 오랜시간 동안 DB 커넥션 리소스를 사용 (e.g. 컨트롤러에서 외부 API 호출) 실시간 트래픽이 중요한 애플리케이션에서는 커넥션이 모자라 장애 유발 일반적인 애플리케이션이라면 트랜잭션 종료 시 커넥션도 반환하는게 자연스러움 spring.jpa.open-in-view = false 트랜잭션을 종료할 때, 영속성 컨텍스트를 닫고 DB 커넥션 반환 장점: 커넥션 리소스 낭비 X 단점: 모든 지연 로딩은 트랜잭션안에서 처리해야 함 부분적 해결책: OSIV 켜기 / 트랜잭션 내에서 지연로딩 모두 처리 / 페치조인 영속성 컨텍스트 종료 후 바깥에서 지연 로딩 시 다음 예외 발생 LazyInitializationException: could not initialize proxy Command와 Query를 분리(CQS)하면 OSIV 끈 상태에서도 복잡성 관리가 편리 보통 성능 이슈는 조회에서 발생 핵심 비즈니스 로직과 조회 로직은 라이프사이클이 다름 뷰는 자주 변함 한 곳에 모았는데 핵심 비즈니스 로직 4~5개, 조회 로직 30개면 유지보수성 급격히 감소 패키지 구조 service.order.OrderService: 핵심 비즈니스 로직 service.order.query.OrderQueryService: 뷰 (주로 읽기 전용 트랜잭션 사용) 쿼리 서비스 용 패키지를 따로 두는게 좋음 엔터티를 뷰 용 DTO로 변환하는 작업을 QueryService에서 처리 Reference 실전! 스프링 부트와 JPA 활용2 - API 개발과 성능 최적화
Java-Ecosystem
· 2024-11-26
JPQL Dive
JPQL 개요 단순한 조회 방법 EntityManager.find() 객체 그래프 탐색 - a.getB(), b.getC() 검색조건이 포함된 SQL의 필요성 단순 조회는 문제 없지만 애플리케이션이 필요한 데이터만 DB에서 불러오려면 결국 검색 SQL이 필요 (모든 DB 데이터를 객체로 변환해 검색하는 것은 불가능) JPQL 엔터티 객체를 대상으로 검색하는 객체 지향 SQL (JPA 제공) 반면에, SQL은 데이터베이스 테이블을 대상으로 쿼리 SQL을 추상화해서 특정 DB SQL에 의존 X JPQL은 현재 설정 Dialect와 합쳐져 현재 DB에 맞는 적절한 SQL을 생성하고 전달 DB를 바꿔서 Dialect가 바뀌었더라도 JPQL 자체를 바꿀 필요는 없음 JPQL 빌더 - QueryDSL 권장 문자가 아닌 자바코드로 JPQL을 작성할 수 있음 Criteria JPA 공식 기능 너무 복잡하고 실용성이 없음 QueryDSL 컴파일 시점에 문법 오류를 찾을 수 있음 편리한 동적쿼리 작성 단순하고 쉬움 네이티브 쿼리 - 네이티브 쿼리가 필요할 때는 JdbcTemplate을 사용하는게 낫다 JPQL로 해결할 수 없는 특정 DB 의존적인 기능 해결 e.g. 오라클 CONNECT BY, … 네이티브 SQL JPA에서 SQL을 직접 사용하는 기능 em.createNativeQuery(sql, 클래스) JDBC, JdbcTemplate, MyBatis 사용 주의점: 영속성 컨텍스트를 적절한 시점에 강제로 플러시 필요 JPA로 Persist만 해둔 데이터는 JdbcTemplate으로 커넥션을 얻어 SQL 조회시 조회 X 조회 직전 flush() 호출 필요 기본 조회 select m from Member as m where m.age > 18 테이블 이름이 아닌 엔터티 이름 사용 (Member) 별칭은 필수 (m, as는 생략 가능) 엔터티와 속성은 대소문자 구분 O JPQL 키워드는 대소문자 구분 X em.createQuery 반환 타입 TypedQuery 반환 타입이 명확할 때 사용 보통 엔터티 클래스를 넘김 TypedQuery<Member> query = em.createQuery("SELECT m FROM Member m", Member.class); Query 반환 타입이 명확하지 않을 때 사용 Query query = em.createQuery("SELECT m.username, m.age from Member m"); 결과 조회 API query.getResultList() 결과가 하나 이상일 때, 리스트 반환 결과가 없으면 빈 리스트 반환 query.getSingleResult() 결과가 정확히 하나, 단일 객체 반환 이외의 결과는 예외 일으킴 결과가 없으면 javax.persistence.NoResultException 둘 이상이면 javax.persistence.NonUniqueResultException 파라미터 바인딩 이름 기준 SELECT m FROM Member m where m.username=:username query.setParameter("username", usernameParam); 위치 기준 - 버그나기 쉬우므로 사용하지 말 것! SELECT m FROM Member m where m.username=?1 query.setParameter(1, usernameParam); 프로젝션 SELECT 절에 조회할 대상을 지정하는 것 DISTINCT로 중복 제거 엔터티 프로젝션 SELECT m FROM Member m 조회된 엔터티는 영속성 컨텍스트가 관리 SELECT m.team FROM Member m 이 경우 조인 쿼리가 나가는데 예측이 어려우므로 이 형태로 사용하지 말 것 조인쿼리는 직접 조인 쿼리로 작성하자 임베디드 타입 프로젝션 SELECT m.address FROM Member m 스칼라 타입 프로젝션 SELECT m.username, m.age FROM Member m 위와 같이 여러 값을 조회할 시 3가지 방법 존재 Query 타입으로 조회 TypedQuery에서 Object[] 타입으로 조회 DTO로 바로 조회 SELECT new jpabook.jpql.UserDTO(m.username, m.age) FROM Member m TypedQuery에서 UserDTO 타입으로 조회 패키지 명을 포함한 전체 클래스 명 입력 (문자 SQL이라 적어줘야 함) 순서와 타입이 일치하는 생성자 필요 페이징 API 각각의 DB Dialect에 맞게 JPA가 추상화 setFirstResult(int startPosition): 조회 시작 위치 (0부터 시작) setMaxResults(int maxResult): 조회할 데이터 수 //페이징 쿼리 String jpql = "select m from Member m order by m.name desc"; List<Member> resultList = em.createQuery(jpql, Member.class) .setFirstResult(10) .setMaxResults(20) .getResultList(); 조인 내부 조인 SELECT m FROM Member m [INNER] JOIN m.team t 외부 조인 SELECT m FROM Member m LEFT [OUTER] JOIN m.team t 세타 조인 연관 관계가 없는 테이블끼리 조인 (카테시안 곱 발생) select count(m) from Member m, Team t where m.username = t.name ON 절 (JPA 2.1부터 지원) 조인 대상 필터링 JPQL SELECT m, t FROM Member m LEFT JOIN m.team t on t.name = 'A' SQL SELECT m.*, t.* FROM Member m LEFT JOIN Team t ON m.TEAM_ID=t.id and t.name='A' 연관관계 없는 엔터티 외부 조인 JPQL SELECT m, t FROM Member m LEFT JOIN Team t on m.username = t.name SQL SELECT m.*, t.* FROM Member m LEFT JOIN Team t ON m.username = t.name 서브 쿼리 JPA는 WHERE, HAVING 절에서만 서브 쿼리 사용 가능 하이버네이트는 SELECT, FROM 절에서 서브 쿼리 가능하도록 지원 (FROM은 6부터 지원) 서브 쿼리 지원 함수 EXISTS (subquery): 서브쿼리에 결과가 존재하면 참 ALL (subquery): 모두 만족하면 참 ANY, SOME (subquery): 하나라도 만족하면 참 IN (subquery): 하나라도 같은 것이 있으면 참 JPQL 타입 표현 문자: ‘HELLO’, ‘She’’s’ 숫자: 10L(Long), 10D(Double), 10F(Float) Boolean: TRUE, FALSE ENUM: jpabook.MemberType.Admin (패키지명 포함) 파라미터 바인딩으로 풀면 패키지명 안 쓸 수 있음 엔티티 타입: TYPE(m) = Member (상속 관계에서 사용) em.createQuery(“select i from Item i where type(i) = Book”, Item.class) where 절에 DTYPE = ‘Book’ 으로 쿼리가 나감 조건식 CASE 식 기본 CASE 식 select case when m.age <= 10 then '학생요금' when m.age >= 60 then '경로요금' else '일반요금' end from Member m 단순 CASE 식 select case t.name when '팀A' then '인센티브110%' when '팀B' then '인센티브120%' else '인센티브105%' end from Team t COALESCE 조건식을 하나씩 차례로 조회해서 null이 아닌 조건식 반환 select coalesce(m.username,'이름 없는 회원') from Member m NULLIF 두 값이 같으면 null 반환, 다르면 첫 번째 값 반환 select NULLIF(m.username, '관리자') from Member m JPQL 기본 함수 및 사용자 정의 함수 JPQL 기본 함수 CONCAT SUBSTRING TRIM LOWER, UPPER LENGTH LOCATE: 문자 위치 찾기 (locate('de', 'abcdegf')) ABS, SQRT, MOD SIZE (JPA 용도): 컬렉션의 크기를 리턴 (select size(t.members) from Team t) 사용자 정의 함수 호출 등록 방법 Hibernate 6는 FunctionContributer 구현체를 만들어야 함 Hibernate 6 이전에는 방언을 상속받고 사용자 정의 함수 등록했음 src/main/resources/META-INF/services/org.hibernate.boot.model.FunctionContributor 위 파일을 생성해 구현체 등록 (custom.CustomFunctionContributor) package custom; import org.hibernate.boot.model.FunctionContributions; import org.hibernate.boot.model.FunctionContributor; import org.hibernate.dialect.function.StandardSQLFunction; import org.hibernate.type.StandardBasicTypes; public class CustomFunctionContributor implements FunctionContributor { @Override public void contributeFunctions(FunctionContributions functionContributions) { functionContributions.getFunctionRegistry() .register("group_concat", new StandardSQLFunction("group_concat", StandardBasicTypes.STRING)); } } 호출 방법 select function('group_concat', i.name) from Item i 경로표현식 .을 찍어 객체 그래프를 탐색하는 것 경로표현식에 의한 묵시적 조인은 쓰지 말자 최대한 JPQL과 실제 SQL의 모양을 맞춰 예측가능하게 만들어야 함 조인은 쿼리 튜닝에 중요 포인트이기 때문 유의사항: 묵시적 조인은 항상 내부 조인 명시적 조인 사용하자! (별칭으로 상세 탐색 가능) select m.username from Team t join t.members m 용어 상태 필드 (state field) 단순히 값을 저장하기 위한 필드 경로 탐색의 끝 (탐색 X) m.username 연관 필드 (association field) 연관 관계를 위한 필드 단일 값 연관 필드 대상이 엔터티 (@ManyToOne, @OneToOne) 묵시적 내부 조인 발생 (탐색 O) select m.team from Member m (e.g.m.team) 컬렉션 값 연관 필드 대상이 컬렉션 (@OneToMany, @ManyToMany) 묵시적 내부 조인 발생 (탐색 X) select t.members from Team t (e.g. t.members) 페치 조인 (fetch join) - join fetch JPQL에서 성능 최적화를 위해 연관된 엔터티나 컬렉션을 SQL 한 번에 함께 조회하는 기능 페치 조인으로 가져온 연관 엔터티는 프록시가 아닌 실제 엔터티 지정한 엔터티를 즉시 로딩으로 가져오므로, N + 1 문제를 해결 사용 전략 글로벌 로딩 전략은 모두 지연로딩으로 가져가고 최적화가 필요한 곳에 페치 조인 적용 조인 사용 전략 페치 조인으로 가져오기 (객체 그래프를 유지할 때 사용하면 효과적) 페치 조인으로 가져오고 애플리케이션 단에서 알맞는 DTO로 전환해 사용 일반 조인으로 필요한 데이터들만 조회해 DTO로 프로젝션 반환 여러 테이블을 조인해 원래의 엔터티 모양과 전혀 다른 결과를 내야 한다면 일반조인 사용 페치 조인과 일반 조인과의 차이 페치 조인은 회원을 조회하면 연관된 팀도 함께 조회 (SELECT T.*, M.*) [JPQL] select t from Team t join fetch t.members [SQL] SELECT T.*, M.* FROM TEAM T INNER JOIN MEMBER M ON T.ID=M.TEAM_ID 일반 조인은 연관된 엔터티를 함께 조회 X (SELECT T.*) [JPQL] select t from Team t join t.members m [SQL] SELECT T.* FROM TEAM T INNER JOIN MEMBER M ON T.ID=M.TEAM_ID 컬렉션 페치 조인 일대다 관계에서 실행하는 페치 조인 하이버네이트 6 이후 JPQL DISTINCT 없이도 애플리케이션에서 자동으로 중복 제거 적용 하이버네이트 6 이전 조인 시 데이터 중복 발생 DB와 객체의 차이 때문에 같은 엔터티 중복 발생 실제 팀은 1개인데 멤버와의 조인으로 발생한 행 수에 의해 2개의 팀이 반환 teamname = 팀A, team = Team@0x100 -> username = 회원1, member = Member@0x200 -> username = 회원2, member = Member@0x300 teamname = 팀A, team = Team@0x100 -> username = 회원1, member = Member@0x200 -> username = 회원2, member = Member@0x300 JPQL DISTINCT 컬렉션 페치 조인 데이터 중복 방지를 위해 적용 JPQL DISTINCT는 2가지 기능을 제공 SQL에 DISTINCT 추가 실제 SQL에 적용되지만, SQL 결과에서는 중복 제거할 것이 없음 애플리케이션에서 엔터티 중복 제거 애플리케이션 단에서 같은 식별자를 가진 엔터티 제거 DISTINCT 적용시 결과 teamname = 팀A, team = Team@0x100 -> username = 회원1, member = Member@0x200 -> username = 회원2, member = Member@0x300 유의 사항 여러 엔터티 다중 페치 조인 시에만 대상에 별칭을 쓰자 하이버네이트에서는 페치 조인 대상에 별칭 사용 가능 (가급적 사용 X) 페치 조인은 연관된 엔터티를 몇 개 걸러서 가져와서는 안됨 (정합성 이슈) 페치 조인은 연관된 엔터티를 항상 모두 가져와야 함 e.g. 팀 조회 시, 팀에 연관된 멤버가 5명일 때 멤버 3명만 가져와서는 안됨 이는 누락을 동반하는 매우 위험한 조작 (Cascade etc…) JPA 설계 사상은 연관된 모든 것을 다 조회하는 것을 전제하므로 위반 조심 둘 이상의 컬렉션은 페치 조인하지 말자 페치 조인은 컬렉션을 딱 하나만 지정하자 예상치 못하게 데이터 중복이 늘어날 수 있음 e.g Team.members, Team.orders를 한꺼번에 페치 조인해서는 안됨 컬렉션을 페치 조인하면 페이징 API 사용 불가 (메모리 페이징 문제로 매우 위험) 페이징 API: setFIrstResult, setMaxResults 문제 데이터 중복 1:M 컬렉션 페치 조인은 단순히 DB 상 조인 쿼리를 생성 DB에서 조인된 테이블의 로우는 M개 (1 기준으로는 중복된 row가 많은 상황) 따라서, 생성된 DB 쿼리 상 1 기준 페이징이 불가능 데이터 누락 문제가 JPA의 객체 그래프 사상을 위반 e.g. 팀 A에 멤버가 2명 있을 때, 이를 페치 조인해 pageSize를 1로 페이징 페이징은 철저히 DB 중심 -> 팀 & 멤버 조인 테이블에서 1개 row를 가져옴 (멤버1) DB 결과에 따라 JPA는 팀 A에 멤버가 1명 있다고 생각해 문제 발생 (멤버2 누락) 데이터 중복 및 누락을 피하기 위해 메모리 페이징 발생 (장애 유발 가능성 높음) 하이버네이트는 경고 로그를 남기고 강제로 메모리에서 페이징 (매우 위험) 실제로 조인 쿼리만 날리고 DB에서 페이징하지 않고 메모리에서 페이징 e.g. 100만 건 데이터를 모두 메모리에 올리고 메모리에서 페이징… 해결 방법 일대다 쿼리를 다대일 쿼리로 바꿔 실행 (권장) 일대일, 다대일 같은 단일 값 연관 필드들은 페치 조인해도 당연히 페이징 가능 쿼리 한 번으로 해결되므로 일반적으로 성능이 좋음 팀과 멤버 (OneToMany)에서 멤버 테이블로 페이징 쿼리 후 팀 기준 group by batchSize 적용 페치 조인을 하지 않고 지연 로딩 활용 (팀에 페이징 쿼리하고 멤버를지연로딩) 배치 사이즈는 N + 1 쿼리를 막고 설정한 단위 기준으로 in-query 진행 기본적으로 글로벌 배치 사이즈 깔고 모든 작업 진행 1000 이하의 적절한 수 지정 (보통 DB의 in 절 개수의 한계가 1000) persistence.xml <property name="hibernate.default_batch_fetch_size" value="100" /> application.properties spring: jpa: properties: hibernate: default_batch_fetch_size: 100 e.g. 팀이 150개고 멤버가 레이지 로딩될 때, batchSize가 100개인 상황 원래는 150개의 N + 1 쿼리가 발생하지만 이를 예방 100개 & 50개 뭉치로 총 2번 in-query해 가져옴 DTO 쿼리 다형성 쿼리 상속 관계 매핑에서 사용 type 조회 대상을 특정 자식으로 한정 (=DTYPE where 절 자동 적용) e.g. Item 중 Book, Movie 조회하기 [JPQL] select i from Item i where type(i) IN (Book, Movie) [SQL] select i from i where i.DTYPE in (‘B’, ‘M’) treat (JPA 2.1) 부모 타입을 특정 자식 타입으로 다룸 타입 캐스팅과 유사 FROM, WHERE, SELECT(하이버네이트) 절에서 사용 가능 e.g. 부모인 Item과 자식 Book이 있을 때, 자식 속성으로 where절 걸고 싶은 경우 [JPQL] select i from Item i where treat(i as Book).author = ‘kim’ [SQL] select i.* from Item i where i.DTYPE = ‘B’ and i.author = ‘kim’ 엔터티 직접 사용 JPQL에서 엔터티를 직접 사용하면 SQL에서 해당 엔터티의 기본키 값 사용 [JPQL] select count(m.id) from Member m - 엔티티의 아이디를 사용 select count(m) from Member m - 엔티티를 직접 사용 [SQL] select count(m.id) as cnt from Member m - JPQL 둘 다 같은 SQL 실행 연관된 엔터티를 직접 사용하면 외래키 값 사용 [JPQL] select m from Member m where m.team = :team select m from Member m where m.team.id = :teamId [SQL] select m.* from Member m where m.team_id=? - JPQL 둘 다 같은 SQL 실행 Named 쿼리 미리 정의해서 이름을 부여해두고 사용하는 JPQL (=정적 쿼리) 에노테이션, XML에 정의 XML 정의가 항상 우선권을 가짐 애플리케이션 운영 환경에 따라 다른 XML 배포 가능 애플리케이션 로딩 시점에 초기화 후 재사용 - JPQL을 SQL로 미리 파싱 후 캐싱 약간의 속도 이점 애플리케이션 로딩 시점에 미리 쿼리의 예외를 검증하는 이점 에노테이션 정의 사용 예 @Entity @NamedQuery( name = "Member.findByUsername", query="select m from Member m where m.username = :username") public class Member { ... } List<Member> resultList = em.createNamedQuery("Member.findByUsername", Member.class) .setParameter("username", "회원1") .getResultList(); XML 정의 사용 예 [META-INF/persistence.xml] <persistence-unit name="jpabook" > <mapping-file>META-INF/ormMember.xml</mapping-file> [META-INF/ormMember.xml] <?xml version="1.0" encoding="UTF-8"?> <entity-mappings xmlns="http://xmlns.jcp.org/xml/ns/persistence/orm" version="2.1"> ... <named-query name="Member.findByUsername"> <query><![CDATA[ select m from Member m where m.username = :username ]]></query> </named-query> <named-query name="Member.count"> <query>select count(m) from Member m</query> </named-query> </entity-mappings> 벌크 연산 여러 개의 데이터에 대한 갱신 쿼리 벌크연산은 주로 JPQL로 진행 JPA 자체는 실시간 단건성 작업에 적합 JPA 변경 감지 기능으로 실행하려면 너무 많은 SQL 실행 e.g. 100건의 엔터티라면 100번의 UPDATE SQL 실행 executeUpdate() 영향 받은 엔터티 수 반환 쿼리 한 번으로 여러 테이블 로우 변경 UPDATE, DELETE 지원 String qlString = "update Product p " + "set p.price = p.price * 1.1 " + "where p.stockAmount < :stockAmount"; int resultCount = em.createQuery(qlString) .setParameter("stockAmount", 10) .executeUpdate(); insert into .. select 하이버네이트가 INSERT 지원 벌크 연산 사용 전략 JPQL은 영속성 컨텍스트를 무시하고 DB에 직접 쿼리하므로 벌크 연산 사용 맥락이 중요 사용 전략 1: 벌크 연산을 먼저 실행 영속성 컨텍스트에 아무 것도 하지 않고 벌크 연산만 실행 영속성 컨텍스트가 비어 있으니 벌크 연산으로 새로 조회가 발생해도 문제 없음 DB에서 최신 데이터 가져와 1차캐시에 반영할 것이므로 사용 전략 2: 벌크 연산 수행 후 영속성 컨텍스트 초기화 (em.clear()) 어떤 엔터티가 미리 조회되어 있는 상황에서 벌크 연산을 진행 JPQL 호출로 플러시 자동 발생 벌크 연산 후 영속성 컨텍스트는 DB에 비해 Old한 상태가 됨 e.g. 처음 조회한 회원 엔터티의 연봉이 5000만원 이후 수행한 벌크 연산에서 연봉이 6000만원이 되어 DB에 플러시됨 이 경우 애플리케이션에서는 여전히 회원 연봉이 5000만원임 따라서, 영속성 컨텍스트를 비워주어 깨끗한 상태에서 다시 조회될 수 있도록 해야 함 Reference 자바 ORM 표준 JPA 프로그래밍 - 기본편
Java-Ecosystem
· 2024-06-08
JPA Dive
JPA 개요 SQL 중심적인 개발의 문제점 반복적인 자바 객체 매핑 작업 (자바 객체 -> SQL, SQL -> 자바 객체) SQL 유지보수의 어려움 테이블 필드 추가 시 모든 SQL에 개발자가 직접 필드를 추가해야 함 실수 시 기능 이상 발생 정형화된 쿼리 반복 (INSERT, UPDATE, SELECT, DELETE) 패러다임의 불일치 (객체 지향 & 관계형 DB) 객체 지향 & 관계형 DB의 차이 상속 객체 상속 VS Table 슈퍼타입 서브타입 관계 (One-to-Many) 여러 테이블을 삽입하고 조회하게 되어 객체 변환 과정이 번거로움 연관관계 (e.g Team, Member) 객체는 참조(Reference) VS Table은 Foreign Key 객체를 테이블에 맞추어 모델링하게 됨 (teamId) 객체 다운 모델링을 하면 객체 변환 과정이 번거로움(Team) 객체 그래프 탐색 객체는 자유롭게 객체 그래프 탐색 VS 실행하는 SQL에 따라 탐색 범위 결정 계층형 아키텍처에서 진정한 의미의 계층 분할이 어려움 (엔터티 신뢰 문제) 즉, 물리적으로는 계층이 분할되었지만, 논리적으로는 계층이 분할되어 있지 않음 계층형 아키텍처는 다음 계층을 믿고 쓸 수 있어야 함 만약, 서비스 계층 개발 중에 다른 개발자가 만든 DAO find를 쓸 때 조회된 엔터티의 getTeam, getOrder 나아가 getDelivery가 가능한지는 DAO 내부의 SQL 쿼리를 까봐야 알 수 있음 즉, 다음 계층에 대한 신뢰가 없음 데이터 식별 방법 (==) 같은 ID를 2번의 조회로 데이터 가져온 상황에서 SQL로 조회한 2개 데이터는 서로 다르다 컬렉션에서 같은 ID로 찾은 객체는 항상 같음 객체 다운 모델링을 할수록 매핑 작업이 무수히 늘어남 객체를 자바 컬렉션에 저장하듯이 DB에 저장할 수는 없을까? JPA (Java Persistence API) 자바 진영의 ORM 기술 표준 JPA 표준 명세로 인터페이스의 모음 JPA 2.1 표준 명세를 구현한 3가지 구현체 (하이버네이트, EclipseLink, DataNucleus) 2.0에서 대부분의 ORM 기능을 포함 객체는 객체대로 RDB는 RDB대로 설계하고 ORM 프레임워크가 중간에서 매핑 JVM 내 JAVA 애플리케이션과 JDBC API 사이에서 동작 패러다임 불일치를 중간에서 해결 (SQL 생성, 객체 매핑) SQL 중심적인 개발에서 벗어나 객체 중심으로 개발해 생산성 및 유지보수 향상 필드 추가 시, JPA가 알아서 SQL을 동적 생성 자바 컬렉션에 저장하듯이 코드를 작성하여 패러다임 불일치를 해결 (객체 매핑 자동화) JPA 설정하기 JPA 설정 파일 (persistence.xml) 경로: /META-INF/persistence.xml 이름 지정: persistence-unit name 설정 값 분류 JPA 표준 속성: jakarta.persistence.~ 하이버네이트 전용 속성: hibernate.~ 스프링 부트를 쓴다면 생성할 필요 없음 대신 application.properties 사용 spring.jpa.properties 하위에 똑같은 속성 추가 Dialect (방언) SQL 표준을 지키지 않는 특정 DB만의 고유한 기능 각각 DB가 제공하는 SQL 문법 및 함수가 조금씩 다름 페이징: MySQL-LIMIT, Oracle-ROWNUM JPA는 특정 DB에 종속되지 않지만 Dialect 설정은 필요 hibernate.dialect 속성 값 지정 (하이버네이트는 40가지 이상의 Dialect 지원) H2: H2Dialect Oracle: Oracle10gDialect MySQL: MySQL5InnoDBDialect 데이터베이스 스키마 자동생성 (DDL) 애플리케이션 실행 시점에 DDL 자동 생성 설정한 Dialect에 맞춰서 적절한 DDL 생성 설정값 (hibernate.hbm2ddl.auto) create: 기존 테이블 삭제 후 다시 생성 (DROP + CREATE) create-drop create + 종료 시점에 테이블 삭제 (DROP + CREATE + DROP) 테스트 사용 시 마지막에 깔끔히 날리고 싶을 때 사용 update 변경분만 반영 (ALTER) 컬럼 추가는 가능하지만 지우기는 안됨 운영에서 사용하면 안됨 X validate: 엔터티와 테이블이 정상 매핑되었는지만 확인 none: 사용하지 않음 (주석처리하는 것과 똑같음) 유의사항 개발, 스테이지, 운영 서버는 반드시 validate 혹은 none만 사용!!!! (스크립트 권장) 개발초기 단계 혹은 로컬에서만 create 혹은 update 사용 DDL 생성 기능 JPA의 DDL 생성 기능(@Table uniqueConstraints, @Column nullable 등)은 DB에만 영향을 주고 런타임에 영향을 주지 않는다. 즉, 애플리케이션 시작 시점에 제약 추가 같은 DDL 자동 생성에만 사용하고, 실제 INSERT, SELECT 등의 JPA 실행 로직에는 큰 영향을 주지 않는다. JPA 동작 원리 주요 객체 EntityManagerFactory 하나만 생성해서 애플리케이션 전체에서 공유 EntityManager 한 요청 당 1회 사용하고 버림 (쓰레드 간 공유 X) JPA의 모든 데이터 변경은 트랜잭션 안에서 실행 EntityTransaction transaction = em.getTransaction(); transaction.begin(); ... transaction.commit(); 동작 순서 Persistence(클래스)가 persistence.xml 설정 정보 조회 Persistence가 EntityManagerFactory 생성 EntityManagerFactory가 EntityManager 생성 영속성 컨텍스트 애플리케이션과 DB(JDBC API) 사이에서 엔터티를 관리하는 논리적인 영역 엔터티를 영구 저장하는 환경 눈에 보이지 않는 논리적인 개념 엔터티 매니저와 영속성 컨텍스트는 1:1 관계 (엔터티 매니저를 통해 접근) 엔터티의 생명주기 비영속 (new/transient) 영속성 컨텍스트와 전혀 관계가 없는 새로운 상태 e.g. 새로운 객체 생성 영속 (managed) 영속성 컨텍스트에 관리되는 상태 e.g. em.persist(member); 준영속 (detached) 영속성 컨텍스트에 저장되었다가 분리된 상태 영속성 컨텍스트가 제공하는 기능을 사용하지 못함 (더티 체킹 등…) 방법 em.detach(member): 특정 엔터티만 준영속상태로 전환 em.clear(): 영속성 컨텍스트를 완전히 초기화 em.close(): 영속성 컨텍스트를 종료 삭제 (removed) 실제 DB에 삭제를 요청하는 상태 (DELETE SQL 생성) e.g. em.remove(member); 영속성 컨텍스트의 이점 - JPA 성능 최적화 기능 애플리케이션과 DB 사이에 영속성 컨텍스트라는 계층이 생기면서 Buffering, Cacheing 등의 이점 얻음 1차 캐시 ID(PK)가 Key, Entity가 value인 Map (메모리 내 영속성 컨택스트 안에 위치) 동작 엔터티가 1차 캐시에 있으면 1차 캐시에서 조회 1차 캐시에 없으면 DB에서 조회한 후 1차 캐시에 저장 (=DB 조회가 엔터티를 영속 상태로 만듦) 이점 조회 성능 향상 같은 트랜잭션 안에서는 1차 캐시를 조회해 같은 엔티티를 반환 다만, 큰 성능 향상은 없음 조회가 DB까지 가지 않아서 약간의 성능 향상 하지만, 서비스 전체적으로 봤을 때 이점을 얻는 순간이 매우 짧고 효과가 적음 한 비즈니스 로직 당 하나의 영속성 컨텍스트를 사용해서 이점 순간이 짧음 고객 10명이 와도 모두 별도의 1차 캐시를 가지므로 효과가 적음 같은 것을 여러 번 조회할 정도로 비즈니스 로직이 매우 복잡한 경우 도움이 될 때가 있을 것 동일성 보장 같은 트랜잭션 내에서 영속 엔터티는 여러 번 조회해도 동일성이 보장됨 애플리케이션 차원에서 Repeatable Read 트랜잭션 격리 수준 보장 예를 들어, 트랜잭션 격리수준이 Read Committed여도 보장 트랜잭션을 지원하는 쓰기 지연 (transactional write-behind) 쓰기 지연 트랜잭션 커밋 순간 쓰기 지연 SQL 저장소에 쌓아둔 SQL을 한 번에 DB에 전달하고 바로 커밋 INSERT SQL을 버퍼에 모아두었다 트랜잭션 커밋 시 한 번에 DB에 보냄 UPDATE, DELETE도 트랜잭션 커밋 시 한 번에 보내서 락(Lock) 시간을 최소화 JDBC BATCH SQL 이용 성능 상 이점 (일반 상황 & 배치 작업) - 큰 성능향상은 아님 변경 감지 (Dirty Checking) 엔터티의 조회 순간 1차 캐시에 엔터티와 스냅샷을 함께 보관 변경 감지 과정 transaction.commit() 호출 -> flush() 메서드 호출 현재 엔터티와 스냅샷을 비교 변경사항이 있으면 UPDATE SQL을 생성해 쓰기 지연 SQL 저장소에 적재 적재된 SQL을 한 번에 DB로 보냄 (실제 flush) 실제 DB 커밋 발생 지연 로딩 (Lazy Loading) & 즉시 로딩 (Eager Loading) 지연 로딩: 객체가 실제 사용될 때 로딩 즉시 로딩: JOIN SQL로 한번에 연관된 객체까지 미리 조회 지연 로딩으로 개발하다가 성능 최적화가 필요한 부분은 즉시 로딩을 적용해 해결 기술 사이에 계층이 생길 때 중간에 기술이 껴서 계층이 생긴다면 항상 2가지의 성능 최적화가 가능하다. 캐시 Buffer로 Write 가능 (모아서 보내기 가능) 플러시 (Flush) 영속성 컨텍스트의 변경내용을 DB에 반영하는 것 (=동기화) 쓰기 지연 SQL 저장소에 쌓아둔 쿼리를 DB에 날리는 작업 영속성 컨텍스트를 비우지는 않음 트랜잭션이 있기 때문에 플러시 개념이 존재할 수 있음 플러시는 SQL 전달 타이밍만 조절 결국 커밋 직전에만 동기화하면 됨 플러시 방법 em.flush() - 직접 호출 테스트 이외에 직접 사용할 일은 거의 없음 쿼리를 직접 확인하고 싶거나 커밋 전에 SQL을 미리 반영하고 싶을 때 트랜잭션 커밋 - 플러시 자동 호출 변경 감지가 먼저 발생 쓰기 지연 SQL 저장소의 쿼리(등록, 수정, 삭제)를 DB에 전송 JPQL 쿼리 실행 - 플러시 자동 호출 em.persist(memberA); em.persist(memberB); em.persist(memberC); //중간에 JPQL 실행 query = em.createQuery("select m from Member m", Member.class); List<Member> members= query.getResultList(); JPQL은 1차 캐시를 거치지 않고 SQL로 번역되어 바로 실행되므로 항상 플러시를 자동 호출 영속성 컨텍스트에 새로 생성된 엔터티가 아직 DB에 반영되지 않았기 떄문 em.setFlushMode로 조절할 수 있으나 굳이 이 옵션을 사용할 일은 없음 Entity 매핑 객체 & 테이블 매핑 @Entity JPA가 관리하는 객체 (=엔터티) 기본 생성자 필수 (public 또는 protected) final 클래스, final 필드, enum, interface, inner 클래스 사용 X name 속성: JPA에서 사용할 엔터티 이름 지정 (기본값: 클래스 이름, 가급적 기본값 사용) @Table 엔터티와 매핑할 테이블 지정 속성 name 매핑할 테이블 이름 지정 기본값: 엔터티 이름 지정DB 이름이 ORDERS면 name="ORDERS" 지정) uniqueConstraints(DDL): DDL 생성 시 유니크 제약 조건 생성 catalog: DB catalog 매핑 schema: DB schema 매핑 필드 & 컬럼 매핑 @Column (컬럼 매핑) name: 매핑할 컬럼 이름 nullable(DDL): null 값 허용 여부 설정 length(DDL): 문자 길이 제약조건 설정 (String 타입에만 사용, 기본값 255) precision, scale(DDL): BigDecimal 혹은 BigInteger에서 사용 insertable, updatable: DB는 못막지만 애플리케이션 단에서 등록 및 변경을 막거나 허용 unique: 유니크 제약 적용 (제약이름이 랜덤 생성되어 보통 @Table의 속성으로 유니크 적용) columnDefinition: DB 컬럼 정보 적용 (특정 DB 종속적인 옵션 적용 가능) @Enumerated (enum 타입 매핑) EnumType.String을 반드시 적용할 것! (DB에 VARCHAR(255)로 삽입) EnumType.ORDINAL는 값이 순서를 기준으로 숫자(Integer)로 DB에 삽입됨 따라서, EnumType.ORDINAL는 새로운 Enum 값 추가 시 매우 위험! @Lob (BLOB, CLOB 타입 매핑) 필드 타입에 따라 매핑이 달라짐 String, char[]: DB 타입 CLOB 매핑 byte[]: DB 타입 BLOB 매핑 @Transient 메모리 상에서만 임시로 어떤 값을 보관하고 싶을 때 사용 (메모리 임시 계산값, 캐시 데이터…) 해당 컬럼은 메모리에서만 쓰고 DB에서 쓰지 않음 @Temporal (날짜 타입 매핑) @Temporal은 생략하고 LocalDate, LocalDateTime 타입을 사용하자! JAVA 8부터 하이버네이트가 애노테이션 없이 타입만으로 컬럼 매핑 기본키 매핑 (Primary Key) 권장 식별자 전략 Long 형 + 인조키 + 키 생성전략 사용 (auto-increment 혹은 sequence 전략 사용) 때에 따라 UUID나 회사 내 룰에 따른 랜덤값 사용 @Id(직접 할당) @Id만 사용 시 PK를 사용자가 직접 할당 @GeneratedValue (자동 생성) DB가 PK 자동 생성 generator 속성 @SequeceGenerator의 name 혹은 @TableGenerator의 name을 등록 strategy 속성 IDENTITY 기본 키 생성을 데이터베이스에 위임 ID 값을 NULL로 주고 INSERT 쿼리 진행하면 DB가 자동 생성 em.persist() 시점에 즉시 INSERT SQL 실행해 DB에서 식별자 조회 DB 접근 없이는 PK 값을 알 수 없어, 영속성 컨텍스트 관리가 불가 INSERT 후 JDBC API 반환값으로 1차 캐시에 ID 및 엔터티 등록 MySQL, PostgreSQL, SQL Server, DB2 (MySQL AUTO_INCREMENT) SEQUENCE DB 시퀀스 오브젝트 사용 (유일한 값을 순서대로 생성하는 DB 오브젝트) 트랜잭션 커밋 시점에 실제 INSERT SQL 실행 em.persist() 시점에 DB에 접근해 현재 DB 시퀀스 값 조회 Hibernate: call next value for MEMBER_SEQ 메모리에 조회 시퀀스 값을 올려두고 1차 캐시에 ID 및 엔터티 등록 Oracle, PostgreSQL, DB2, H2 @SequenceGenerator: 테이블마다 시퀀스를 따로 관리하고 싶을 때 사용 name: 식별자 생성기 이름 sequenceName 매핑할 DB 시퀀스 오브젝트 이름 기본값: hibernate_sequence initialValue: 처음 시작하는 수 지정 (기본값: 1) allocationSize 시퀀스 한 번 호출에 증가하는 수 SELECT 네트워크 호출을 줄여서 성능 최적화를 시키는 방법 기본값: 50 (50~100정도가 적당) DB에 미리 50개를 올려두고 메모리에서 그 개수만큼 1씩 사용 즉, 50개마다 call next 호출 웹 서버가 여러 개여도 동시성 문제 X 시퀀스 사이에 구멍이 생길 뿐 웹서버를 껐다키면 메모리의 시퀀스 정보가 날라가므로 구멍이 문제는 없지만 낭비 최소화 위해 사이즈 너무 크게 하지 말 것 catalog, schema: DB catalog, schema 이름 TABLE 키 생성용 테이블을 사용해 마치 시퀀스처럼 동작시키는 전략 모든 DB에서 사용 가능하지만 성능이 안좋음 @TableGenerator: 키 생성기 name: 식별자 생성기 이름 table: 키 생성 테이블 명 pkColumnValue: 키로 사용할 값 이름 (기본값: 엔터티 이름) allocationSize: 시퀀스 한 번 호출에 증가하는 수 (성능 최적화) … AUTO (기본값): 방언에 따라 자동 지정 (IDENTITY, SEQUENCE, TABLE 중 하나 선택) 연관관계 매핑 객체 지향 모델링의 필요성 객체는 참조를 사용해 연관된 객체를 찾아야 함 엔터티 서로를 참조하는 단방향 연관관계 2개를 만들어야 함 (=양방향 연관관계) 테이블 중심 설계 지양 (=외래키를 그대로 엔터티에 가져오는 설계) 외래키 하나로 양방향 연관관계 맺음 (조인을 통해 서로 조회) 이는 객체 지향적 X, 객체 간 협력 관계를 만들 수 없음 연관관계 방향 단방향 연관관계 한 쪽 엔터티만 다른 쪽 엔터티를 참조 (참조가 1군데) @JoinColumn, @ManyToOne 양방향 연관관계 엔터티가 서로를 참조 (참조가 2군데) 외래키를 관리하지 않는 엔터티 쪽에도 단방향 연관관계 추가 (mappedBy) @OneToMany(mappedBy = "team") (멤버 엔터티의 팀 변수를 mappedBy에 지정) 연관관계 주인 양방향 매핑에서 외래키를 관리하는 참조 @JoinColumn 위치한 곳이 연관관계 주인 연관관계 주인을 통해서만 외래키 설정 가능 (양방향 매핑 시 주의점) 주인이 아닌 쪽은 외래키에 영향을 주지 않고 읽기만 가능 mappedBy 위치한 곳 참조 추가가 DB에 영향을 주지 않음 다중성 다대일 (N:1, @ManyToOne) 연관관계 주인이 N쪽 (외래키가 있는 쪽에 @JoinColumn) 사용 지향 (가장 많이 사용) 객체지향적으로 조금 손해 보더라도 DB에 맞춰 ORM 관리하면 운영이 편해짐 객체지향적 손해 예: Member에서 Team으로 갈 일이 없는데 참조를 만들어야 할 때 일대다 (1:N, @OneToMany) 연관관계 주인이 1쪽 (외래키가 없는 쪽에 @JoinColumn) 사용 지양 일대다 양방향은 공식적으로 존재하지 않아서 읽기 전용 필드로 우회해 구현 @JoinColumn(insertable=false, updatable=false) 양쪽 엔터티에 모두 @JoinColumn이 있고 N쪽이 읽기전용 컬럼 일대일 (1:1, @OneToOne) 주 테이블과 대상 테이블 중 외래키 위치 선택 가능 주테이블: 주로 많이 액세스하는 테이블 먼 미래 보지 않고 주 테이블 쪽에 위치시키는 것이 괜찮다 외래키가 있는 곳이 마찬가지로 연관관계의 주인 (@JoinColumn), 반대편은 mappedBy 제약 조건없이 애플리케이션 단에서 일대일이 가능하지만 세심한 관리 필요 DB 입장에서는 외래키에 UNIQUE 제약조건이 추가된게 일대일 관계 다대다 (N:M, @ManyToMany) 다대다는 연결 테이블을 추가해 일대다, 다대일 관계로 풀어내야 함 관계형 DB는 정규화된 테이블 2개로 다대다 관계를 표현할 수 없음 다만, @ManyToMany 사용은 지양 @ManyToMany는 자동으로 연결 테이블을 생성하지만 다른 데이터 필드 추가가 불가 쿼리가 생각하지 못한 방향으로 나갈 수 있음 @OneToMany, @ManyToOne 사용! 연결 테이블을 엔터티로 승격시키자 연결 테이블 PK는 Compound Key (FK + FK)보다 하나의 인조키 만드는게 낫다 운영하다보면 종속되지 않은 아이디 값이 매우 유용! 지향할 연관관계 매핑 전략 최대한 단방향 매핑으로만 설계 한 후, 애플리케이션 개발 시 고민하며 양방향 매핑 추가하자 단방향 매핑만으로도 이미 연관관계 매핑은 완료, 양방향 매핑은 조회 추가일 뿐 객체 입장에서는 양방향이 큰 메리트가 없으므로, 필요한 곳에만 추가하는 것이 더 좋음 다만 실무에서 JPQL 짜다보면 결국 양방향 매핑을 많이 쓰게되긴 함 연관관계의 주인은 DB 테이블 상 외래키가 있는 곳으로 정하자 반대로 주인을 정하면 직관적이지 않은 쿼리로 테이블이 헷갈림 Team에 멤버를 추가했는데 Member Table로 쿼리가 나가 헷갈림 성능 문제가 생김 (크진 않아도 손해는 손해) Team과 Member를 추가할 때 INSERT 2번 UPDATE 1번 실행 Team은 자신의 엔터티의 외래키가 없으므로 Member에 외래키 업데이트 실행 양방향 매핑시 연관관계 편의 메서드를 생성하자 JPA 기능적으로는 연관관계 주인에만 값을 세팅하면 동작 다만, 객체지향 관점에서 항상 양쪽 모두 값을 입력하는 것이 옳다! 주인만 값 세팅하면 커밋 전까지 1차 캐시에만 있어서 주인이 아닌 쪽 접근 시 실패 테스트 시에도 순수한 자바코드를 사용하므로 양쪽 다 입력하는 것이 문제를 예방 메서드 네이밍 시 setXxx는 지양 (e.g. changeTeam) 주인 쪽, 주인이 아닌 쪽 중 한 곳에만 연관관계 편의 메서드 작성해야 함 모두 작성하면 무한 루프 발생 확률 높음 Lombok toString 만드는 것도 왠만하면 쓰지 말 것! 컨트롤러에 엔터티 절대 반환하지 말 것! (DTO로 변환 반환, API 스펙 변경 X) 상황마다 좋은 쪽이 다름 (특정 객체를 기준으로 풀고 싶을 때 해당 객체에 위치시킴) 일대일 관계에서는 주 테이블에 외래키 위치시키자 (너무 먼 미래 고려하지 말고!) 주 테이블에 외래키 객체지향 개발자가 선호 (JPA 매핑 편리) 장점: 주 테이블만 조회해도 대상 테이블에 데이터가 있는지 확인 가능 (프록시 객체) 단점: 값이 없으면 외래키에 null 허용 대상 테이블에 외래키 (양방향만 가능) 전통적인 데이터베이스 개발자 선호 장점: 일대다 관계로 변경시 테이블 구조가 유지되어 편리 (변경 포인트가 적음) 프록시 기능의 한계로 지연 로딩으로 설정해도 항상 즉시 로딩됨 주 객체의 대상 객체 참조 여부를 판단하려면, 대상 테이블에 쿼리를 날려 외래키 존재 여부를 확인해야 하므로 즉시로딩 진행 (지연로딩 세팅이 의미가 없음) 상속 관계 매핑 DB의 슈퍼타입-서브타입 관계 논리 모델링 기법을 객체 상속을 활용해 매핑 지향 전략 기본은 조인 전략 서비스가 단순할 때는 단일 테이블 전략으로 진행 (복잡하게 에너지 쓰지 않기) 일부 컬럼을 JSON으로 저장하는 방식으로 대체하기도 함 테이블 상속 전략은 대규모 서비스에서 복잡도가 높을 수 있음 상황 맞게 선택! 주요 어노테이션 @Inheritance(strategy=InheritanceType.XXX) 슈퍼타입-서브타입 관계에 대해 물리 모델 구현 방법 지정 부모 클래스에 적용 부모 클래스는 의도상 사용하지 않으므로 abstract class 지향 부모만 단독으로 저장할 일이 있다면 일반 클래스로 사용 TABLE_PER_CLASS는 반드시 abstract class를 사용 (부모 테이블 생성 막음) 테이블 상속 전략 종류 (InheritanceType) JOINED (Identity = One to One Type = 조인 전략) 조인 전략이 정석!! 장점 테이블 정규화 객체랑 잘맞고 설계 관점에서 매우 깔끔 단점 조회시 쿼리가 복잡하고 조인을 많이 사용 (조인은 잘 맞추면 성능 매우 잘나옴) 데이터 저장 시 INSERT 쿼리 2번 호출 @DiscriminatorColumn 필요성 항상 @DiscriminatorColumn 적용하자 (운영에 유리) DTYPE이 없어도 기능상 문제는 없음 SINGLE_TABLE (Rollup = Single Type = 단일 테이블 전략) 장점 조인이 없어 조회 성능이 빠르고 쿼리가 단순함 단점 자식 엔터티 매핑 컬럼은 모두 NULL 허용 (데이터 무결성 관점에서 치명적) 단일 테이블에 모든 것 저장하므로 테이블이 커지고 상황에 따라 조회 성능 감소 @DiscriminatorColumn 필요성 @DiscriminatorColumn 생략해도 DTYPE 컬럼 생성 DTYPE이 반드시 필요하므로 TABLE_PER_CLASS (Rolldown = Plus Type = 구현 클래스마다 테이블 전략) DB 설계 관점 및 객체 ORM 관점 모두에서 지양 (사용 X) 장점 서브 타입을 명확히 구분해 처리할 때 효과적 단점 여러 자식 테이블을 함께 조회할 때 성능이 느림 (UNION SQL 필요) ID로 조회해도 3개 테이블을 다 찔러봐야 알 수 있음 변경에도 유연하지 못한 설계 @DiscriminatorColumn 필요성 @DiscriminatorColumn 필요 없음 @DiscriminatorColumn DTYPE 컬럼 생성 부모 클래스에 적용 name 속성으로 컬럼 이름 지정 (기본값: DTYPE) @DiscriminatorValue("XXX") DTYPE에 들어갈 Value 지정 자식 클래스에 적용 기본값: 자식 엔터티의 이름 공통 정보 매핑 @MappedSuperclass 공통 매핑 정보가 필요할 때 사용 부모를 상속 받는 자식 클래스에 매핑 정보만 제공 등록일, 수정일, 등록자, 수정자 등 (id, createdAt, createdBy…) 부모 클래스에 적용 (abstract class 권장) BaseEntity를 하나 만들고 다른 엔터티가 이를 상속 상속관계 매핑 X, 엔터티 X, 테이블과 매핑 X 조회, 검색 불가 (em.find(BaseEntity) 불가) JPA에서의 상속 JPA에서는 상속관계 매핑 혹은 공통 정보 매핑만 상속 가능하다. 즉, @Entity 클래스는 @Entity나 @MappedSuperclass로 지정한 클래스만 상속 가능 JPA 프록시 객체 실제 객체의 참조를 보관하는 객체 사용자 입장에서는 진짜인지 프록시인지 구분하지 않고 사용 프록시 객체를 호출하면 프록시는 실제 객체의 메서드 호출 실제 클래스를 상속 받아서 만들어짐 실제 객체에 값만 빈 껍데기 생성 target(실제 객체 주소)만 추가됨 관련 메서드 em.find(): DB에서 실제 엔터티 객체 조회 em.getReference(): DB 조회를 미루는 프록시(가짜) 엔터티 객체 조회 emf.getPersistenceUnitUtil().isLoaded(entity): 프록시 인스턴스의 초기화 여부 entity.getClass(): 프록시 클래스 확인 org.hibernate.Hibernate.initialize(entity): 프록시 강제 초기화 (JPA 표준 X) 프록시 객체의 초기화 프록시 객체에서처음 getXxx 호출 시 한 번만 초기화 진행 (=실제 객체 사용 시) ID는 클라이언트에서 이미 알고 있는 정보이므로, getId 호출 시에는 초기화 진행 X 이 때, 프록시 객체의 target이 null이므로 영속성 컨텍스트에 초기화 요청 영속성 컨텍스트는 DB에 쿼리를 날려 실제 엔터티 객체를 만들어 프록시의 target과 연결 주의사항 타입 체크 시 == 대신 instanceOf를 사용해야 한다 언제 프록시가 반환될지, 실제 엔터티가 반환될지 예측 힘듦 영속성 컨텍스트에 엔터티가 이미 있다면 getReference()가 실제 엔터티 반환 getReference()로 프록시를 먼저 조회했다면, 이후 find()는 쿼리로 실제 엔터티를 생성했음에도 프록시를 반환 이는 JPA 동일성 보장을 지키기 위함 JPA는 한 영속성 컨텍스트 내라면 PK 값이 동일한 객체에 대해 동일성이 보장됨 즉, 실제 엔터티든 프록시 객체든 pk 값이 같을 때는 == 비교 결과가 true여야함 준영속 상태일 때, 프록시를 초기화하면 예외 발생 프록시는 영속성 컨텍스트를 이용해 초기화를 시도하므로 em.detach(), em.close(), em.clear()를 호출한 준영속 상태 엔터티는 세션이 없거나 끝났다는 예외 발생 하이버네이트 예외: LazyInitializationException 실무에서는 보통 트랜잭션 끝나고 나서 프록시를 조회할 때 노세션 예외를 자주 만남 보통 트랜잭션 시작 및 끝을 영속성 컨텍스트 시작 및 끝과 맞추므로 즉시 로딩 & 지연 로딩 지연 로딩 (FetchType.LAZY) 처음 로딩 시 연관 객체는 직접 조회하지 않고 프록시로 조회 연관 객체는 실제로 사용하는 시점에 초기화 즉시 로딩 (FetchType.EAGER) 처음 로딩 시 한 번에 DB 쿼리를 날려 연관 객체의 실제 엔터티를 가져옴 조인을 사용해 가능한 SQL 한 번에 함께 조회 글로벌 패치 전략 모든 연관관계를 지연 로딩으로 사용하고 필요할 때만 패치조인으로 한 번에 가져오기 즉시 로딩은 예상치 못한 SQL 발생 즉시 로딩은 JPQL에서 N + 1 문제 일으킴 em.find()는 JPA가 최적화해 적어도 하나의 조인 쿼리로 가져오므로 위험도가 덜 함 문제는 JPQL인데, JPQL은 SQL로 바로 번역되어 쿼리를 날림 만일, 멤버 전체를 조회하는 쿼리를 날리면 전체 멤버를 가져옴 이 때, 즉시 로딩이라면 팀 값을 반드시 채워야 함 멤버 조회 후 바로 팀에 대한 쿼리를 멤버 각각에 대해 날려 N + 1 개 쿼리가 발생 기본값 설정 유의사항 @ManyToOne, @OneToOne: 기본이 즉시 로딩이므로 반드시 LAZY로 설정 @OneToMany, @ManyToMany: 기본이 지연 로딩 영속성 전이와 고아 객체 영속성 전이 (CASCADE) 엔터티를 영속화할 때, 연관된 엔터티까지 함께 영속화 (단순히 편리성 제공) 따로 Child 까지 영속화하지 않아도 Parent의 Childs 컬렉션에 등록된 모든 Child를 함께 영속화 사용 조건 단일 엔터티에 완전히 종속적일 때 하나의 부모가 자식들을 관리 자식들은 다른 엔터티와 연관이 없음 (소유자가 하나) 부모와 자식의 라이프 사이클이 동일 보통 CascadeType.ALL, CascadeType.PERSIST 정도만 사용 고아 객체 제거 (orphanRemoval = true) 부모 엔터티와 연관관계가 끊어진 자식 엔터티를 자동으로 삭제 자식 엔터티를 부모 컬렉션에서 제거하면 자동으로 DELETE 쿼리가 나감 (참조가 끊어짐) Parent parent1 = em.find(Parent.class, id); parent1.getChildren().remove(0); => DELETE FROM CHILD WHERE ID = ? 반대로 부모를 제거할 때도 자식 함께 제거 개념적으로 부모를 제거하면 자식은 고아 마치 CascadeType.ALL, CascadeType.REMOVE 처럼 동작 사용 조건 단일 엔터티에 완전히 종속적일 때 (@OneToOne, @OneToMany만 가능) CascadeType.ALL + orphanRemoval = true 부모 엔터티를 통해 자식의 생명주기를 관리할 수 있음 부모 엔터티에 적용 DDD Aggregate Root 구현에 용이 값 타입 값타입은 엔터티와 혼동하지 않고 정말 값 타입이라 판단될 때만 사용 XY 좌표 수준 말고 실무에서 거의 없음 식별자가 필요하고 지속해서 값을 추적해야한다면 엔터티 JPA 데이터 타입 분류 엔터티 타입 @Entity 정의한 객체 데이터 변경이 있어도 식별자로 지속해서 추적 가능 생명 주기 관리 공유 O 값 타입 단순히 값으로 사용하는 자바 기본 타입 혹은 객체 (int, Integer, String) 식별자 없이 값만 있으므로 변경시 추적 불가 생명주기를 엔터티에 의존 (회원을 삭제하면 이름, 나이 필드도 함께 삭제) 값타입은 공유되어서는 안됨 e.g. 회원 이름 변경시 다른 회원의 이름도 함께 변경되면 안됨 항상 값을 복사해서 사용 (Side effect를 예방) 불변 객체로 설계하는 것이 안전 타입 별 공유 예방 방법 기본 타입(primitive type)은 항상 값을 복사 (int, double) 래퍼 클래스나 특수 클래스(String)는 참조를 막을 수 없어서 값 변경 자체를 막음 임베디드 타입은 불변 객체로 설계해야함 생성자로만 값을 설정하고 수정자(Setter)를 모두 없애기 혹은 수정자를 private으로 만들면 같은 효과 값 타입은 인스턴스가 달라도 내부 값이 같으면 같은 것으로 봐야함 값 타입은 동등성 비교 필요 동등성(equivalence) 비교: 인스턴스의 값을 비교, equals() 동일성(identity) 비교: 인스턴스의 참조 값을 비교, == 사용 값 타입의 equals() 메서드를 적절하게 재정의해야 함 equals()는 기본이 == 비교이므로, 동등성 비교를 하도록 재정의 필요 IntelliJ 자동 생성 권장 (hashcode도 같이 만들것) Use getters when available 옵션 사용 getter를 사용하지 않으면 바로 필드에 접근 -> 프록시일 때 필드 접근 불가 분류 기본값 타입 e.g. 자바 기본 타입 (int, double), 래퍼 클래스 (Integer, Long), String 임베디드 타입 (복합 값 타입) 주로 기본 값 타입을 모아서 새로운 값 타입을 직접 정의 e.g. XY 좌표, Address(city, street, zipcode), Period(startDate, endDate) 주요 애노테이션 @Embeddable: 값 타입을 정의하는 곳에 표시 (기본 생성자 필수) @Embedded: 값 타입을 사용하는 곳에 표시 둘 중 하나만 사용해도 동작하지만 둘 다 명시적으로 사용하는 방향 지향 장점 재사용 가능, 높은 응집도 객체와 테이블을 세밀하게 매핑하여 설계시 개념적으로 유의미 Period.isWork()처럼 해당 값 타입만의 유의미한 메서드를 만들 수 있음 유의 사항 적용 전 후 DB 테이블이 달라지는 것은 없음 임베디드 타입 내에도 엔터티를 가질 수 있음 한 엔터티 내에서 같은 값 타입을 재사용 가능 @AttributeOverrides를 사용해 DB 컬럼 이름 매핑 임베디드 타입 값이 null이면 매핑한 컬럼 값도 모두 null 값 타입 컬렉션 값 타입을 하나 이상 저장할 때 사용 (별도의 테이블 생성) @ElementCollection, @CollectionTable 부모의 라이프 사이클의 의존 (영속성 전이 + 고아 객체 제거 기능 자동 내포) 수정이라는 개념이 없고, 컬렉션에서 값 타입 데이터를 찾아 제거하고 새로 추가 임베디드 값 타입이라면 해당 객체와 값이 똑같은 객체를 새로 생성해 remove 이 때, 해시코드가 중요 (해시코드를 정의하지 않았다면 컬렉션에서 안지워짐) 식별자 개념이 없어 변경 시 추적이 어려움 업데이트 시 테이블 데이터 전부 제거하고 다시 새로 INSERT PK는 값 타입의 모든 컬럼이 묶여 구성됨 유의 사항 값 타입 컬렉션은 정말 단순할 때만 사용 셀렉트 박스 (치킨, 피자, etc…) 수준의 단순한 비즈니스 로직 추적할 필요 없고 값이 바뀌어도 업데이트 칠 필요 없을 때 이외에는 상황에 따라 일대다 관계로 풀 것 일대다 관계를 위한 엔터티를 만들고, 그 안에서 값 타입을 사용 영속성 전이 + 고아 객체 제거를 사용해 값타입 컬렉션처럼 사용 @OneToMany(cascade = CascadeType.ALL, orphanRemoval = true) @JoinColumn(name = “MEMBER_ID”) private List<AddressEntity> addressHistory = new ArrayList<>(); INSERT시 UPDATE 쿼리 한 번 더 나가지만 쿼리 최적화 등이 편리 다대일 일대다 양방향 매핑하면 UPDATE 쿼리 제거 가능 잘 설계한 ORM 애플리케이션은 매핑한 테이블의 수보다 클래스의 수가 더 많음 Reference 자바 ORM 표준 JPA 프로그래밍 - 기본편
Java-Ecosystem
· 2024-06-02
JPA 활용 팁 1
JPA 테이블 설계 Tips 주문 테이블은 orders로 주로 사용 (예약어 order by 때문에) 테이블 이름은 소문자 + _ 스타일 사용 실무에서 @ManyToMany는 사용하지 말자 중간 테이블에 컬럼을 추가할 수 없고 세밀한 쿼리가 어려움 @ManyToOne, @OneToMany로 풀어내서 사용 연관 관계에서 외래 키가 있는 곳을 연관 관계의 주인으로 정하기 (One-to-Many에서는 Many가 주인) Getter, Setter는 모두 제공하지 않고, 꼭 필요한 별도 메서드만 제공하는게 가장 이상적이지만 실무는 다름 Getter는 모두 열어놓으면 실무상 편리 엔티티 변경은 Setter를 모두 열어두기 보다 비즈니스 메서드를 별도 제공해 변경 지점이 명확하도록 함 엔티티의 식별자는 id로 쓰더라도 PK 컬럼명은 테이블명_id로 사용하자 Foreign key와 이름을 맞출 수 있는 장점 DBA들도 선호 Cascade=ALL 엔티티를 persist하면 다른 연관관계 엔티티까지 persist를 전파 Delete할 때는 모두 같이 지워짐 값 타입(임베디드 타입)은 변경 불가능하게 설계 @Setter를 제거하고 생성자에서 초기화 강제 기본 생성자를 protected로 두어 안전 향상 JPA 스펙 상 엔티티 및 임베디드 타입은 기본 생성자를 public 혹은 protected로 두어야 함 JPA가 객체 생성시 리플랙션 같은 기술을 사용할 수 있도록 지원해야 하기 때문 실무에서는 검증 로직이 있어도 멀티 쓰레드 상황을 고려해 변경 컬럼에 유니크 제약 조건 추가하는 것이 안전 엔티티 설계 시 주의점 모든 연관관계는 지연로딩(Lazy)으로 설정 즉시로딩(Eager)은 예측이 어렵고 N + 1 문제가 자주 발생 연관 관계 엔티티 로딩 시 fetch join 혹은 엔티티 그래프 기능 사용 @XToOne 관계는 기본이 즉시로딩이므로 직접 지연로딩 설정을 해야 함 컬렉션은 필드에서 초기화 null 문제에서 안전 Hibernate은 엔티티 영속화 시 컬렉션을 감싸서 Hibernate이 제공하는 내장 컬렉션으로 변경 (PersistentBag) - 필드 초기화가 내부 매커니즘을 안전하게 지켜줌 테이블, 컬럼명 생성 전략 기본 전략 하이버네이트 기존 구현 엔티티의 필드명을 그대로 테이블 컬럼명으로 사용 SpringPhysicalNamingStrategy 스프링 부트 신규 설정 Camel case -> Snake case . -> _ 추가 전략 명시적으로 컬럼, 테이블명을 적으면 실제 테이블에 물리명 적용 (physical-strategy) 적지 않은 경우 논리명 적용 (implicit-strategy) 애노테이션 Tips @PersistenceContext 엔티티 매니저(EntityManger) 주입 Lombok 생성자 주입 사용시 애노테이션 생략 가능 @Transactional readOnly=true 플러시를 하지 않으므로 약간의 성능 향상 디폴트는 readOnly=false이므로, 큰 스코프에서 readOnly=true를 설정하고 커맨드성 작업에 @Transactional을 붙이는 방식으로 사용하면 편리 테스트에 붙으면, 테스트 종료 후 자동으로 트랜잭션 롤백 테스트를 환경을 위한 설정 파일 테스트 케이스에는 메모리 DB 사용이 효율적 데이터 소스나 JPA 관련 별도 추가 설정을 하지 않아도 됨 스프링 부트는 datasource 설정이 없으면 기본적으로 메모리 DB 사용 스프링 부트는 jpa 설정이 없으면 ddl-auto: create-drop 모드로 동작 설정 파일 읽기 전략 테스트에서 스프링을 실행하면, test/resources/application.yml을 읽음 해당 위치에 없을 경우, src/resources/application.yml 읽음 도메인 모델 패턴 VS 트랜잭션 스크립트 패턴 도메인 모델 패턴 엔티티가 비즈니스 로직을 가지고 객체 지향의 특성을 적극 활용하는 패턴 (서비스 계층은 단순히 엔티티에 필요한 요청을 위임하는 역할) 트랜잭션 스크립트 패턴 엔티티에는 비즈니스 로직이 거의 없고 서비스 계층에서 대부분의 비즈니스 로직을 처리하는 패턴 변경 감지(Dirty Checking) & 병합(merge) 준영속 엔티티 영속성 컨텍스트가 더이상 관리하지 않는 엔티티 이전에 DB에 한 번 저장되어서 식별자가 존재하나 JPA가 현재 추적하고 있지 않는 객체 준영속 엔티티를 수정하는 2가지 방법 변경 감지(Dirty Checking) - Recommendation 동작 식별자로 엔티티를 조회(find)한 후 데이터 수정 컨텍스트가 종료되면서 트랜잭션 커밋 시점에 변경 감지가 동작하고, 데이터베이스에 UPDATE SQL 실행 병합(merge) 동작 준영속 엔티티의 식별자 값으로 영속 엔티티를 조회 영속 엔티티의 값을 준영속 엔티티의 값으로 모두 교체 (병합) 트랜잭션 커밋 시점에 변경 감지 기능이 동작해서 데이터 베이스에 UPDATE SQL 실행 병합은 모든 필드를 변경해버리고 데이터가 없으면 null로 업데이트하므로 위험 Best Practice: 엔티티 변경시 항상 변경 감지 사용하기 컨트롤러에서 엔티티 생성하지 말기 서비스 계층에 식별자(id)와 변경할 데이터를 명확히 전달 (파라미터 or DTO) 서비스 계층에서 영속 상태의 엔티티를 조회하고, 엔티티의 데이터를 직접 변경 트랜잭션 커밋 시점에 변경 감지 자동 실행
Java-Ecosystem
· 2024-03-26
<
>
Touch background to close