스프링 핵심 원리 - 기본편 - 인프런
스프링 입문자가 예제를 만들어가면서 스프링의 핵심 원리를 이해하고, 스프링 기본기를 확실히 다질 수 있습니다. 초급 프레임워크 및 라이브러리 웹 개발 서버 개발 Back-End Spring 객체지향 온
www.inflearn.com
강의를 들으며 생각 정리 + "자바 ORM 표준 JPA 프로그래밍" 책 참고
경로 표현식
경로 표현식이란 .(점)을 찍어 객체 그래프를 탐색하는 것이다. 다음 JPQL을 보자.
select m.username -> 상태 필드
from Member m
join m.team t -> 단일 값 연관 필드
join m.orders o -> 컬렉션 값 연관 필드
where t.name = '팀A'
용어 정리
경로 표현식을 이해하려면 우선 다음 용어들을 알아야 한다.
- 상태 필드 : 단순히 값을 저장하기 위한 필드
- 연관 필드 : 연관관계를 위한 필드, 임베디드 타입 포함
- 단일 값 연관 필드 : @ManytoOne, @OnetoOne, 대상이 엔티티
- 컬렉션 값 연관 필드 : @OnetoMany, @ManytoMany, 대상이 컬렉션
특징
JPQL에서 경로 표현식을 사용해서 경로 탐색을 하려면 다음 3가지 경로에 따라 어떤 특징이 있는지 이해해야 한다.
- 상태 필드 경로 : 경로 탐색의 끝이다. 더는 탐색할 수 없다.
- 단일 값 연관 경로 : 묵시적으로 내부 조인이 일어난다. 단일 값 연관 경로는 계속 탐색할 수 있다.
- 컬렉션 값 연관 경로 : 묵시적으로 내부 조인이 일어난다. 더는 탐색할 수 없다. 단 FROM 절에서 조인을 통해 별칭을 얻으면 별칭으로 탐색할 수 있다.
예제를 통해 경로 탐색을 하나씩 알아보자.
상태 필드 경로 탐색
select m.username, m.age from Member m
상태 필드 경로 탐색은 단순히 값을 저장하기 위한 필드로 m.username.xxx와 같이 추가 탐색이 불가능하다.
단일 값 연관 경로 탐색
select o.member from Order o
단일 값 연관 필드로 경로 탐색을 하면 SQL에서 내부 조인이 일어나는데 이것을 묵시적 조인이라 한다. 참고로 묵시적 조인은 모두 내부 조인이다.
SQL에서 다음과 같이 내부 조인이 발생한다.
select m.*
from Orders o
inner join Member m on o.member_id=m.id
+) 참고로 임베디드 타입에 접근하는 것도 단일 값 연관 경로 탐색이지만 엔티티에 이미 포함되어 있으므로 조인이 발생하지 않는다.
컬렉션 값 연관 경로 탐색
select t.members from Team t //성공
select t.members.username from Team t //실패
역시 묵시적 조인이 발생한다.
JPQL을 다루면서 많이 하는 실수 중 하나는 컬렉션 값에서 경로 탐색을 시도하는 것이다. .t.members 컬렉션까지는 경로 탐색이 가능하다. 하지만 t.members.username처럼 컬렉션에서 경로 탐색을 시작하는 것은 허락하지 않는다. 만약 컬렉션에서 경로 탐색을 하고 싶으면 다음 코드처럼 조인을 사용해서 새로운 별칭을 획득해야 한다.
select m.username from Team t join t.members m
이처럼 경로 탐색을 사용하면 묵시적 조인이 발생해서 SQL에서 내부 조인이 일어날 수 있다. 조인이 성능상 차지하는 부분은 아주 크다. 묵시적 조인은 조인이 일어나는 상황을 한눈에 파악하기 어렵다는 단점이 있다. 따라서 단순하고 성능에 이슈가 없으면 크게 문제가 안 되지만 성능이 중요하다면 분석하기 쉽도록 묵시적 조인보다는 명시적 조인을 사용하자.
페치 조인 1 - 기본
페치 조인은 SQL에서 이야기하는 조인의 종류는 아니고 JPQL에서 성능 최적화를 위해 제공하는 기능이다. 이것은 연관된 엔티티나 컬렉션을 한 번에 같이 조인하는 기능인데 실무에서 매우 중요하다.
엔티티 페치 조인
페치 조인을 사용해서 회원 엔티티를 조회하면서 연관된 팀 엔티티도 함께 조회하는 JPQL을 보자.
select m from Member m join fetch m.team
join fetch를 사용하면 연관된 엔티티나 컬렉션을 함께 조회하는데 여기서는 회원과 팀을 함께 조회한다. 참고로 페치 조인은 별칭을 사용할 수 없다. (참고로 하이버네이트는 페치 조인에도 별칭을 허용한다.)
실행된 SQL은 다음과 같다.
SELECT M.*, T.* FROM MEMBER M
INNER JOIN TEAM T ON M.TEAM_ID=T.ID
select m으로 회원 엔티티만 선택했는데 실행된 SQL을 보면 회원과 연관된 팀도 함께 조회된 것을 확인할 수 있다.
다음은 SQL에서 조인의 결과다.
이제 출력을 해보자.
String query = "select m from Member m join fetch m.team";
List<Member> resultList = em.createQuery(query, Member.class).getResultList();
for (Member member1 : resultList) {
System.out.println("member1.getUsername() = " + member1.getTeam().getName());
}
member1.getTeam().getName()으로 팀 엔티티를 사용한다.
만약 회원과 팀을 지연 로딩으로 설정하고 페치 조인을 사용하지 않았다고 가정하자. 그럼 m.team은 프록시로 가져오기 때문에 getTeam().getName() 메서드를 사용할 때마다 쿼리가 한번씩 더 나간다.
//회원1, 팀A(SQL)
//회원2, 팀A(1차캐시)
//회원3, 팀B(SQL)
...
//회원 100명 -> N + 1
그러나 페치 조인을 사용하면 지연 로딩이더라도 팀 엔티티는 프록시가 아닌 실제 엔티티를 조회한다. 즉, 회원과 팀을 한 번에 조회하기 때문에 getTeam().getName()에서 추가 쿼리가 없다.
컬렉션 페치 조인
이번에는 일대다 관계인 컬렉션을 페치 조인해보자.
select t from Team t join fetch t.members where t.name = 'teamA'
엔티티 페치 조인과 마찬가지로 팀을 조회하면서 연관된 회원 컬렉션도 함께 조회한다.
그러나 그림을 보면 이상한 점이 있다. 팀A 하나에 회원이 두 명일 때 조인한 결과 2건이 조회되는 것을 볼 수 있다. '팀A'는 하나지만 MEMBER 테이블가 조인하면서 결과가 증가해서 위와 같이 '팀A'가 2건 조회되는 것이다. 이처럼 다대일과 다르게 일대다 조인은 결과가 증가할 수 있다.
출력을 해보자.
String query = "select t from Team t join fetch t.members where t.name = 'teamA'";
List<Team> resultList = em.createQuery(query, Team.class).getResultList();
for (Team team : resultList) {
System.out.println("teamname = " + team.getName() + ", team = " + team);
for (Member member1 : team.getMembers()) {
System.out.println("->username = " + member1.getUsername() + ", member = " + member);
}
}
출력 결과
teamname = 팀A, team = jpql.Team@1f3b992
->username = 회원1, member = Member{id=3, username='회원1', age=10}
->username = 회원2, member = Member{id=3, username='회원1', age=10}
teamname = 팀A, team = jpql.Team@1f3b992
->username = 회원1, member = Member{id=3, username='회원1', age=10}
->username = 회원2, member = Member{id=3, username='회원1', age=10}
이처럼 중복이 발생한다.
페치 조인과 DISTINCT
앞서 일대다 연관관계의 경우 중복된 결과가 발생한다. SQL의 DISTINCT는 이런 중복된 결과를 제거하는 명령어다. JPQL의 DISTINCT 명령어는 SQL에 DISTINCT를 추가하는 것은 물론이고 애플리케이션에서 한 번 더 중복을 제거한다. 이전 컬렉션 페치 조인 예시에서 DISTINCT를 추가해보자.
select distinct t from Team t join fetch t.members
먼저 DISTINCT를 사용하면 SQL에 DISTINCT가 추가 된다. 하지만 다음과 같이 각 로우의 데이터가 완전히 같지 않기 때문에 SQL의 DISTINCT는 효과가 없다.
그래서 다음 과정으로 애플리케이션에서 distinct 명령어를 보고 중복된 데이터를 걸러낸다. 같은 식별자를 가진 엔티티를 중복으로 보고 제거한다. 이렇게 중복 제거한 결과를 출력하면 다음과 같이 하나만 출력된다.
teamname = 팀A, team = jpql.Team@1f3b992
->username = 회원1, member = Member{id=3, username='회원1', age=10}
->username = 회원2, member = Member{id=3, username='회원1', age=10}
페치 조인과 일반 조인의 차이
페치 조인을 사용하지 않고 조인만 사용하면 어떻게 될까?
select t from Team t join t.members m
JPQL은 결과를 반환할 때 연관관계까지 고려하지 않는다. 단지 SELECT 절에 지정한 엔티티만 조회할 뿐이다. 따라서 팀 엔티티만 조회하고 연관된 회원 컬력센은 조회하지 않는다. 만약 회원 컬렉션을 지연 로디으로 설정하면 프록시를 반환하고 즉시 로딩으로 설정하면 회원 컬렉션을 위해 쿼리를 한 번 더 실행한다.
반면에 페치 조인을 사용하면 연관된 엔티티도 함께 조회한다. 페치 조인은 객체 그래프를 SQL 한 번에 조회하는 개념이다.
페치 조인 2 - 한계
별칭
페치 조인 대상에는 별칭을 줄 수 없다. 페치 조인은 연관된 것을 모두 끌고온다는 개념이기 때문에 where m.username ="member1' 같이 조건을 주면 안 된다. JPA 표준에서는 지원하지 않지만 하이버네이트에서는 별칭을 지원한다. 그러나 데이터 무결성이 깨지는 등 복잡해지기 때문에 가능하면 페치 조인에 별칭을 사용하지 않는다.
둘 이상의 컬렉션 페치 조인
둘 이상의 컬렉션을 페치할 수 없다. 일대다 연관관계의 경우 한 번의 페치 조인으로도 추가 로우가 생기는 것을 위에서 확인할 수 있었다. 만약 일대다 -> 다대다로 컬렉션 페치 조인을 두 번한다면 추가 로우는 굉장히 많아지고 복잡해질 것이다. 그래서 JPQL에서 하나의 컬렉션만 페치 조인할 수 있도록 한다.
페이징
컬렉션이 아닌 단일 값 연관 필드(일대일, 다대일)들은 페치 조인을 사용해도 페이징 API를 사용할 수 있다. 그러나 컬렉션을 페치 조인하면 앞서 말했듯이 데이터가 추가되기 때문에 페이징을 사용하면 경고 로그를 남기면서 메모리에서 페이징 처리를 한다. 데이터가 적으면 상관없겠지만 데이터가 많으면 성능 이슈와 메모리 초과 예외가 발생할 수 있어서 위험하다.
-> 페이징을 사용하고 싶다면 팀 엔티티만 조회하는 방법이 있다.
-> 그러나, 지연 로딩으로 인해 N+1문제가 발생한다.
-> @Batchsize를 사용하면 한번 엔티티를 사용할 때(ex. getMembers().add()) 옵션으로 설정한 size만큼 한 번에 조회하기 때문에 성능 향상을 기대할 수 있다.
페치 조인은 SQL 한 번으로 연관된 여러 엔티티를 조회할 수 있어서 성능 최적화에 상당히 유용하다. 하지만 모든 것을 페치 조인으로 해결할 수는 없다. 페치 조인은 객체 그래프를 유지할 때 사용하면 효과적이다. 여러 테이블을 조회해서 엔티티가 가진 모양이 아닌 전혀 다른 결과를 내야 한다면 억지로 페치 조인을 사용하기보다는 여러 테이블에서 필요한 필드들만 조회해서 DTO로 반환하는 것이 더 효과적일 수 있다.
-> 실무에서 글로벌 로딩 전략은 모두 지연 로딩으로 하고 최적화가 필요한 곳은 페치 조인을 적용한다.
다형성 쿼리
TYPE
TYPE은 엔티티의 상속 구조에서 조회 대상을 특정 자식 타입으로 한정할 때 주로 사용한다. Item <- Book, Movie 상속구조가 있을 때 다음 예시를 보자.
select i from Item i
where type(i) in (Book, Movie)
Item 중에 Book, Movie를 조회한다.
TREAT
자바의 타입 캐스팅과 비슷하다. 상속 구조에서 부모 타입을 특정 자식 타입으로 다룰 때 사용한다. JPA 표준은 FROM, WHERE 절에서 사용할 수 있고, 하이버네이트는 SELECT 절에서도 사용할 수 있다.
select i from Item i where treat(i as Book).author = 'kim'
JPQL을 보면 treat를 사용해서 부모 타입인 Item을 자식 타입인 Book으로 다룬다. 따라서 author 필드에 접근할 수 있다.
엔티티 직접 사용
기본 키 값
JPQL에서 엔티티를 직접 사용하면 SQL에서 해당 엔티티의 기 본 키 값을 사용한다. 예시를 보자.
select count(m.id) from Member m
select count(m) from Member m
count(m)을 보면 엔티티의 별칭을 직접 넘겨줬다. 이렇게 엔티티를 직접 사용하면 JPQL이 SQL로 변환될 때 해당 엔티티의 기본 키를 사용한다. 따라서 count(m.id) 코드와 동일한 결과가 조회한다.
외래 키 값
select m from Member m where m.team = :team
m.team은 현재 team_id라는 외래 키와 매핑되어 있다. 그래서 JPQL이 SQL로 변활될 때 외래 키 값을 사용하게 된다.
Named 쿼리
정적 쿼리는 미리 정의한 쿼리에 이름을 부여해서 필요할 때 사용할 수 있는데 이것을 Named 쿼리라고 한다. Named 쿼리는 한 번 정의하면 변경할 수 없는 정적인 쿼리다.
Named 쿼리는 애플리케이션 로딩 시점에 JPQL 문법을 체크하고 미리 파싱해둔다. 따라서 오류를 빨리 확인할 수 있고, 사용하는 시점에는 파싱된 결과를 재사용하므로 성능상 이점도 있다.
어노테이션
먼저 @NamedQuery 어노테이션을 사용해 정적 쿼리를 사용해 보자.
@Entity
@NamedQuery(
name = "Member.findByUsername",
query="select m from Member m where m.username = :username")
public class Member {
...
}
name 옵션에 쿼리 이름을 부여하고 query 옵션에 사용할 쿼리를 입력한다.
List<Member> resultList = em.createNamedQuery("Member.findByUsername", Member.class)
.setParameter("username", "회원1")
.getResultList();
Named 쿼리를 사용할 때는 위 예시와 같이 em.createNamedQuery() 메소드에 Named 쿼리 이름을 입력하면 된다.
+) 일반적으로 충돌을 방지하기 위해 Named 쿼리는 name 앞에 [클래스명]. 을 붙여준다.
벌크 연산
엔티티를 수정하려면 영속성 컨텍스트의 변경 감지 기능이나 병합을 사용한다. 하지만 이 방법으로 수백개 이상의 엔티티를 하나씩 처리하기에는 시간이 너무 오래 걸린다. 이럴 때 여러 건을 한 번에 수정하는 벌크 연산을 사용하면 된다.
다음 예시는 모든 회원의 나이를 20살로 바꾸는 벌크 연산이다.
int resultCount = em.createQuery("update Member m set m.age = 20")
.executeUpdate();
System.out.println("resultCount = " + resultCount);
executeUpdate() 메소드는 벌크 연산으로 영향을 받은 엔티티 건수를 반환한다.
여기서 주의해야 할게 있다. 벌크 연산은 영속성 컨텍스트를 무시하고 데이터베이스에 직접 쿼리한다. 그러므로 벌크 연산 수행 후 영속성 컨텍스트와 데이터베이스 간에 차이가 생긴다.
문제를 해결하는 다양한 방법이 있다.
벌크 연산 먼저 실행
영속성 컨텍스트가 비어있을 때 벌크 연산을 가장 먼저 실행하는 방법이다. 벌크 연산을 먼저 실행하고 나서 엔티티를 조회하면 벌크 연산으로 이미 변경된 엔티티를 조회하게 된다.
벌크 연산 수행 후 영속성 컨텍스트 초기화
벌크 연산을 수행한 직후에 바로 영속성 컨텍스트를 초기화해서 영속성 컨텍스트에 남아 있는 엔티티를 제거하는 것도 좋은 방법이다. 그렇지 않으면 엔티티를 조회할 때 영속성 컨텍스트에 남아 있는 엔티티를 조회할 수 있는데 이 엔티티에는 벌크 연산이 적용되어 있지 않다. 영속성 컨텍스트를 초기화하면 이후 엔티티를 조회할 때 벌크 연산이 적용된 데이터베이스에서 엔티티를 조회한다.
다음 예시를 보자.
int resultCount = em.createQuery("update Member m set m.age = 20")
.executeUpdate();
Member findMember = em.find(Member.class, member.getId());
System.out.println("findMember.getAge() = " + findMember.getAge());
다음과 같이 벌크 연산을 수행하면 데이터베이스에만 반영된다. 그러나 em.find()는 영속성 컨텍스트를 우선으로 조회하기 때문에 원하는 getAge가 안나올 수 있다.
따라서 다음과 같이 영속성 컨텍스트를 초기화 해서 데이터베이스에서 바로 조회할 수 있도록 한다.
int resultCount = em.createQuery("update Member m set m.age = 20")
.executeUpdate();
em.clear();
Member findMember = em.find(Member.class, member.getId());
System.out.println("findMember.getAge() = " + findMember.getAge());
'java > jpa' 카테고리의 다른 글
[JPA] 도메인 분석 설계 (0) | 2021.02.11 |
---|---|
[JPA] 프로젝트 환경설정 (0) | 2021.02.10 |
[JPA] 객체지향 쿼리 언어1 - 기본 문법 (0) | 2021.02.03 |
[JPA] 값 타입 (0) | 2021.02.02 |
[JPA] 프록시와 연관관계 관리 (0) | 2021.01.29 |