일단 이유는 JPA의 엔티티 객체 그래프는 DB와 데이터 일관성을 유지해야 하기 때문입니다.
뭔 소리냐고요?
(저도 처음 듣고 엥스바리 했습니다.)
예시를 들어 보겠습니다.
(저는 이번에 공부하면서 영속성 컨텍스트라는 개념이 얼마나 중요한지 ㅋ
싸악 알게 되었습니다)
먼저 예시 데이터를 보겠습니다.
Team1 - 유저1, 유저2
Team2 - 유저3
인 상황입니다.
JPQL
// 1. 팀을 페치조회하는데 유저이름으로 필터링
public List<Team> findByMemberName(String memberNm){
return em.createQuery("select t from Team t join fetch t.members m"+
" where m.name = memberNm", Team.class)
.setParameter("memberNm",memberNm)
.getResultList();
}
// 2. teamId로 team을 가져옴
public List<Team> findByTeamId(Long teamId){
return em.createQuery("select t from Team t where t.id =teamId", Team.class)
.setParameter("teamId", teamId)
.getResultList();
}
첫 번째 JPQL을 보면 Team을 조회하면서 fetch조인으로 연관된 Member 엔티티를 모두 가져오는데 별칭을 사용해 memberNm으로 필터링해서 가져옵니다. (Team1 {members=['유저1']} )
두 번째 JPQL은 단순히 teamId로 Team을 가져옵니다.
그럼 이 테스트 코드를 보겠습니다.
테스트 코드
@Test
public void 페치조인_데이터_일관성_확인() throws Exception{
List<Team> teamsWithUser1 = memberRepository.findByMemberName("유저1");
List<Team> fullTeamById = memberRepository.findByTeamId(1L);
for (Team team:teamsWithUser1) {
for (Member member:team.getMembers()) {
System.out.println("teamsWithUser1.member = " + member.getName());
}
}
for (Team team2:fullTeamById) {
for (Member member:team2.getMembers()) {
System.out.println("fullTeamById.member = " + member.getName());
}
}
}
단순히 위에 JPQL을 호출해서 해당 팀에 멤버를 조회하는 테스트 코드입니다.
결과가 예상 가능하신가요?
첫 번째 teamWithUser1에서는 유저1 / 두 번째 fullTeamById 에서는 teamId 1인 팀에 멤버를 가져오니까 유저1, 유저2 이렇게 조회될까요?
테스트 결과
select
team0_.team_id as team_id1_7_0_,
members1_.member_id as member_i1_4_1_,
team0_.name as name2_7_0_,
members1_.city as city2_4_1_,
members1_.street as street3_4_1_,
members1_.zipcode as zipcode4_4_1_,
members1_.name as name5_4_1_,
members1_.team_team_id as team_tea6_4_1_,
members1_.team_team_id as team_tea6_4_0__,
members1_.member_id as member_i1_4_0__
from
team team0_
inner join
member members1_
on team0_.team_id=members1_.team_team_id
where
members1_.name='유저1'
2025-05-13 15:30:15.556 DEBUG 34196 --- [ Test worker] org.hibernate.SQL :
select
team0_.team_id as team_id1_7_,
team0_.name as name2_7_
from
team team0_
where
team0_.team_id='1'
team.member = 유저1
team2.member = 유저1
놀랍게도 teamId 1인 Team을 조회한 Team1의 멤버는 유저1만 나옵니다.
왜일까요????
영속성 컨텍스트를 기억하시나요?
조회된 엔티티는 영속성 컨텍스트 1차 캐시에 들어가게 되고 식별자는 id값입니다.
그래서 처음 조회한 필터링된 Team1이 1차 캐시에
id = 1 , Entity = Team {Memebers=['유저1']} 이러한 형태로 들어가게 됩니다.
그다음 teamId 값으로 Team을 조회했을 때 db에 접근하기 전에 1차 캐시를 먼저 들려 id를 확인해서 있으니
이건 다음에 다른 글로 포스팅하고 먼저 이해해야 하는 일반 조인과 fatch 조인의 차이를 알아보겠다!
Fetch Join
일단 fetch 조인이란 sql과 비슷해 보이지만 jpql에서만 제공하는 기능으로 엔티티를 조회해 올 때 연관된 엔티티를
한 번에 같이 조회 할수 있는 기능이다.
연관된 엔티티를 한번에 같이 조회할 수 있는 게 어떤 의미인가 하면! 함께 조회한 엔티티가 영속성 컨텍스트에 올라가게
되는데 밑에 예제를 보면서 자세히 보겠습니다 ㅋ
모든 멤버와 해당 멤버의 팀까지 조회해보겠습니다.
현재 데이터는 아래와 같습니다. 유저 1,2가 있고 모두 팀이 1인 상황입니다.
멤버, 팀 데이터
JPQL
// 1.일반조인
public List<Member> findAll(){
return em.createQuery("select m from Member m join m.team t",Member.class)
.getResultList();
}
// 2.fetch조인
public List<Member> findAllWithFetchJoin(){
return em.createQuery("select m from Member m join fetch m.team ",Member.class)
.getResultList();
}
일반 조인 테스트 코드
@Test
public void 일반조인() throws Exception{
List<Member> members = memberRepository.findAll(); // 1
for (Member member:members) {
System.out.println("member.class = " + member.getTeam().getClass());
System.out.println("member.class init= " + Hibernate.isInitialized(member.getTeam()));
System.out.println("member name = " + member.getTeam().getName());
member.getTeam().getName();
}
}
혹시 쿼리가 몇 번 나올 것인지 예상되시나요?
처음 모든 Member를 가져오는 쿼리 한번 , 해당 멤버의 팀을 가져오는 쿼리 1번(멤버의 팀이 달랐다면 2번 쿼리가 나갔을 텐데 첫 번째 유저의 팀을 조회했을 때 영속성 컨텍스트에 저장되어 있기 때문에 1차 캐시에서 가져오고 DB조회 안 함)
도합 2번 나갑니다.
조회된 일반 조인 SQL
select
member0_.member_id as member_i1_4_,
member0_.city as city2_4_,
member0_.street as street3_4_,
member0_.zipcode as zipcode4_4_,
member0_.name as name5_4_,
member0_.team_team_id as team_tea6_4_
from
member member0_
inner join
team team1_
on member0_.team_team_id=team1_.team_id
member.class = class jpabook.jpashop.domain.Team$HibernateProxy$cRvKZ9yd -- 프록시객체
member.class init= false
2025-05-12 15:02:34.096 DEBUG 2092 --- [ Test worker] org.hibernate.SQL :
select
team0_.team_id as team_id1_7_0_,
team0_.name as name2_7_0_
from
team team0_
where
team0_.team_id=?
member name = Team1
member.class = class jpabook.jpashop.domain.Team$HibernateProxy$cRvKZ9yd-- 프록시객체
member.class init= true
member name = Team1
로그를 보면 member에서 team 객체에서 프록시 객체를 넣어 지연로딩 합니다.
그리고 나는 프록시 객체에서 초기화하면 실제 객체로 바뀌지 않을까? 했는데 그냥 초기화 해주고 로그를 보면 프록시 객체 그대로 쓰는 것 같다.
fetch조인은 연관된 엔티티도 다 가져와 영속성 컨텍스트에 저장하기 때문에 연관객체를 프록시 객체로 가져오지 않고 실제 객체로 가져옵니다.
그래서 밑에서 team을 조회해도 다시 db에서 조회하지 않고 영속성 컨텍스트에 있는 값을 가져옵니다.
조회된 Fetch 조인 SQL
select
member0_.member_id as member_i1_4_0_,
team1_.team_id as team_id1_7_1_,
member0_.city as city2_4_0_,
member0_.street as street3_4_0_,
member0_.zipcode as zipcode4_4_0_,
member0_.name as name5_4_0_,
member0_.team_team_id as team_tea6_4_0_,
team1_.name as name2_7_1_
from
member member0_
inner join
team team1_
on member0_.team_team_id=team1_.team_id
member.class = class jpabook.jpashop.domain.Team
membersFetch = Team1
member.class = class jpabook.jpashop.domain.Team
membersFetch = Team1
내부구조 : ArrayList는 내부적으로 배열을 사용해서 데이터를 저장한다. 초기 크기를 설정할수 있지만 데이터가 추가되면 크키가 자동으로 증가된다.
특징
https://poiemaweb.com/js-array-is-not-arrray
- 인덱스를 통한 접근 속도 : 내부적으로 배열으로 되어 있기 때문에 인덱스를 통해 데이터를 접근하기 때문에 굉장히 빠르다.O(1)
- 삽입/삭제 속도 : 배열의 특정 위치에 삽입하거나 삭제하려면 해당 위치 요소를 다 이동해야 하므로 성능이 떨어진다. O(n)
- 메모리 사용효율 : 배열은 메모리가 할당될때 연속적으로 할당되므로 LinkedList보다 메모리 사용이 효율적이다.
LinkedList
노드를 사용해서 데이터를 저장하는 리스트 자료구조
public class Node {
Object item;
Node next;
public Node(Object item){
this.item = item;
}
}
public class MyLinkedListV3<E> {
private Node<E> first;
private int size = 0;
...
}
내부구조 : LinkedList 는 내부적으로 노드들로 이어져 있다. 노드는 데이터와 다음, 이전 노드를 가리키는 참조로 되어 있다.
특징
- 삽입/삭제 속도 : 리스트의 처음이나 중간에 데이터를 삽입하거나 삭제하는 경우, 노드의 이전 이후 참조만 변경하면 되므로 빠르다 O(1)
- 인덱스를 통한 접근 속도 : 인덱스를 통해 접근하려면 첫 노드부터 순차적으로 탐색 해야하므로 속도가 느리다. O(n)
- 메모리 사용 효율 : 각 노드가 데이터와 두개의 참조(다음,이전)을 가지고 있기 때문에, ArrayList 보다 메모리를 더 많이 사용한다.
ArrayList vs LinkedList 성능 비교
package collection.list;
public class MyListPerformanceTest {
public static void main(String[] args) {
int size = 50_000;
System.out.println("== MyArrayList 추가 =="); // 크기만큼 데이터 추가
addFirst(new MyArrayList<>(),size);
addMid(new MyArrayList<>(),size);
MyArrayList<Integer> arrayList = new MyArrayList<>();
addLast(arrayList,size);
System.out.println("== MyLinkedList 추가 ==");
addFirst(new MyLinkedList<>(),size);
addMid(new MyLinkedList<>(),size);
MyLinkedList<Integer> linkedList = new MyLinkedList<>();
addLast(linkedList,size);
int loop = 10000;
System.out.println("==MyArrayList 조회=="); // 루프만큼 인덱스로 조회
getIndex(arrayList, loop, 0);
getIndex(arrayList, loop, size / 2);
getIndex(arrayList, loop, size - 1);
System.out.println("==MyLinkedList 조회==");
getIndex(linkedList, loop, 0);
getIndex(linkedList, loop, size / 2);
getIndex(linkedList, loop, size - 1);
System.out.println("==MyArrayList 검색==");
search(arrayList, loop, 0);
search(arrayList, loop, size / 2);
search(arrayList, loop, size - 1);
System.out.println("==MyLinkedList 검색==");
search(linkedList, loop, 0);
search(linkedList, loop, size / 2);
search(linkedList, loop, size - 1);
}
private static void addFirst(MyList<Integer> list, int size){
long startTime = System.currentTimeMillis();
for (int i = 0; i < size; i++) {
list.add(0,i);
}
long endTime = System.currentTimeMillis();
System.out.println("앞에 추가 - 크기: " + size + ", 계산 시간: " + (endTime - startTime) + "ms");
}
private static void addMid(MyList<Integer>list, int size){
long startTime = System.currentTimeMillis();
for (int i = 0; i <size ; i++) {
list.add(i/2,i);
}
long endTime = System.currentTimeMillis();
System.out.println("평균 추가 - 크기: " + size + ", 계산 시간: " + (endTime - startTime) + "ms");
}
private static void addLast(MyList<Integer>list, int size){
long startTime = System.currentTimeMillis();
for (int i = 0; i < size ; i++) {
list.add(i);
}
long endTime = System.currentTimeMillis();
System.out.println("뒤에 추가 - 크기: " + size + ", 계산 시간: " + (endTime - startTime) + "ms");
}
private static void getIndex(MyList<Integer> list, int loop, int index){
long startTime = System.currentTimeMillis();
for (int i = 0; i < loop; i++) {
list.get(index);
}
long endTime = System.currentTimeMillis();
System.out.println("index: " + index + ", 반복: " + loop + ", 계산 시간: " + (endTime - startTime) + "ms");
}
private static void search(MyList<Integer> list, int loop ,int findValue){
long startTime = System.currentTimeMillis();
for (int i = 0; i < loop; i++) {
list.indexOf(findValue);
}
long endTime = System.currentTimeMillis();
System.out.println("findValue: " + findValue + ", 반복: " + loop + ", 계산 시간: " + (endTime - startTime) + "ms");
}
}
== MyArrayList 추가 ==
앞에 추가 - 크기: 50000, 계산 시간: 2051ms
평균 추가 - 크기: 50000, 계산 시간: 1018ms
뒤에 추가 - 크기: 50000, 계산 시간: 3ms
== MyLinkedList 추가 ==
앞에 추가 - 크기: 50000, 계산 시간: 4ms
평균 추가 - 크기: 50000, 계산 시간: 1316ms
뒤에 추가 - 크기: 50000, 계산 시간: 1626ms
==MyArrayList 조회==
index: 0, 반복: 10000, 계산 시간: 0ms
index: 25000, 반복: 10000, 계산 시간: 1ms
index: 49999, 반복: 10000, 계산 시간: 0ms
==MyLinkedList 조회==
index: 0, 반복: 10000, 계산 시간: 0ms
index: 25000, 반복: 10000, 계산 시간: 313ms
index: 49999, 반복: 10000, 계산 시간: 624ms
==MyArrayList 검색==
findValue: 0, 반복: 10000, 계산 시간: 1ms
findValue: 25000, 반복: 10000, 계산 시간: 123ms
findValue: 49999, 반복: 10000, 계산 시간: 231ms
==MyLinkedList 검색==
findValue: 0, 반복: 10000, 계산 시간: 1ms
findValue: 25000, 반복: 10000, 계산 시간: 447ms
findValue: 49999, 반복: 10000, 계산 시간: 904ms
위에서 구현한 배열리스트와 연결리스트 성능
기능
배열 리스트
연결 리스트
앞에 데이터 추가(삭제)
O(n) - 2051ms (크기만큼 뒤로 밈)
O(1) - 4ms (first정보가 있음)
평균 데이터 추가(삭제)
O(n) - 1018ms (인덱스로 한번에 찾지만 데이터를 뒤로 밈)
O(n) - 1316ms(루프만큼 돌아서 인덱스를 찾음)
뒤에 데이터 추가(삭제)
O(1) - 3ms (인덱스로 한번에 찾고 뒤로 밀 데이터 없음)
O(n) - 1626ms (루프만큼 돌아서 인덱스를 찾음)
인덱스 조회
O(1) - 1ms (내부가 배열로 되어 있음 한버넹 찾음)
O(n) - (평균) 313ms
검색
O(n) - (평균) 123ms
O(n) - (평균) 447ms
자바에서 제공하는 배열리스트와 연결리스트 성능 비교
(※자바에서 제공하는 연결리스트는 이중연결리스트이다.)
(왼) 자바 제공 (오) 구현한 연결리스트
기능
배열 리스트
연결 리스트
앞에 데이터 추가(삭제)
O(n) - 106ms
O(1) - 2ms
평균 데이터 추가(삭제)
O(n) - 49ms
O(n) - 1116ms
뒤에 데이터 추가(삭제)
O(1) - 1ms
O(n) - 2ms
인덱스 조회
O(1) - 1ms
O(n) - (평균) 312ms
검색
O(n) - (평균) 104ms
O(n) - (평균) 427ms
실제 성능
- 이론적으로 LinkedList의 중간 삽입 연산은 ArrayList보다 빠를 수 있다.(왜냐면 순차로 찾아서 중간에 싸악 삽입 또는 삭제해주면 되니깐 ArraylList는 뒤로 데이터를 다 밀어줘야해서 더 늦다.) 그러나 실제적 접근 속도, 메모리 할당 및 해체 비용, cpu 캐시 활용도 등 다양한 요소에 의해 영향 받는다.
추가로 'ArrayList' 는 데이터를 한 칸씩 직접 이동하지 않고, 대신에 메모리 고속 복사(자바에서 System.arrayCopy)를 사용한다.
'ArrayList' 는 요소들이 메모리 상에서 연속적으로 위치하여 CPU 캐시 효율이 좋고, 메모리 접근 속도가 빠르다.
반면, 'LinkedList' 는 각 요소가 별도의 객체로 존재하고 다음 요소의 참조를 저장하기 때문에 CPU 캐시 효율 이 떨어지고, 메모리 접근 속도가 상대적으로 느려질 수 있다
정리하면 이론적으로 'LinkedList' 가 중간 삽입에 있어 더 효율적일 수 있지만, 현대 컴퓨터 시스템의 메모리 접근 패 턴, CPU 캐시 최적화, 메모리 고속 복사 등을 고려할 때 'ArrayList'가 실제 사용 환경에서 더 나은 성능을 보여주는 경우가 많다.
배열 리스트 vs 연결 리스트
대부분의 경우 배열 리스트가 성능상 유리하다. 이런 이유로 실무에서는 주로 배열 리스트를 기본으로 사용한다. 만약 데이터를 앞쪽에서 자주 추가하거나 삭제할 일이 있다면 연결 리스트를 고려하자.
allMatch는 스트림의 모든 요소가 주어진 조건을 만족하는지 검사하는 최종 연산입니다.
i -> previousData.get(conditionColumns.get(k)).equals(currentData.get(conditionColumns.get(k)) 는 람다 표현식으로, 인덱스 i에 대해 previousData 과 currentData 의 해당 인덱스에 있는 값이 동일한지 비교합니다.
모든 요소가 이 조건을 만족하면 true, 하나라도 만족하지 않으면 false를 반환합니다
그러면 람다식은 어떻게 동작할까?
스트림 생성:
IntStream.range(0, conditionColumns.size())는 지정된 범위의 정수 스트림을 생성합니다.
예를 들어, conditionColumns.size()가 5라면 0, 1, 2, 3, 4의 값을 가지는 스트림을 생성합니다.
스트림 처리:
allMatch 메서드는 스트림의 각 요소를 순차적으로 처리합니다.
스트림의 각 요소(여기서는 인덱스 i)에 대해 주어진 조건을 검사합니다.
i가 0에서 시작하여 conditionColumns.size() - 1까지 반복됩니다.
조건 검사:
각 인덱스 i에 대해 previousData.get(conditionColumns.get(k)).equals(currentData.get(conditionColumns.get(k)) 조건을 검사합니다.
이 비교는 previousData 과 currentData 의 해당 인덱스에 있는 값이 같은지 확인합니다.
결과 반환:
모든 인덱스 i에 대해 조건이 참이면 allMatch는 true를 반환합니다.
하나라도 조건이 거짓이면 allMatch는 false를 반환합니다.
이 과정은 내부적으로 모든 요소를 검사한 후 결과를 반환합니다.
for문과 람다식 성능 비교해 보자.
순차 처리:
기본적으로 IntStream.range와 allMatch는 순차적으로 데이터를 처리합니다. 각 요소에 대해 주어진 조건을 하나씩 검사합니다.
병렬 처리:
필요에 따라 스트림을 병렬 스트림으로 변환할 수 있습니다. parallel() 메서드를 사용하면 병렬 처리를 통해 성능을 향상할 수 있습니다. 병렬 스트림은 여러 CPU 코어를 활용하여 데이터를 병렬로 처리합니다.
오버헤드:
스트림 API의 사용에는 약간의 오버헤드가 있을 수 있습니다. 이는 스트림의 생성, 처리, 그리고 결과를 집계하는 과정에서 발생합니다. 그러나 이 오버헤드는 코드의 간결함과 가독성, 그리고 병렬 처리의 이점을 고려하면 종종 수용할 만한 수준입니다.
결론
여기서 위에 엑셀 병합의 경우에는 순차처리를 사용하면 for문과 다를 게 없고 스트림의 생성, 처리, 결과 집계 하는 과정이 추가되어서 약간에 오버해드가 있을 수 있는데 큰 경우는 아니어서 for문을 쓰는 것보다 소스 가독성이 훨씬 나은 것 같다. 그리고 순서가 상관없기 때문에 parallel()를 사용해서 병렬처리해도 될 것 같다라고 생각했지만 병렬 처리(병렬 처리는 여러 개의 작업을 동시에 실행하여 처리 속도를 향상하는 기법 )는 작은 데이터셋에는 병렬 처리로 인한 추가적인 스레드 관리 오버헤드가 발생하기 때문에 대량 데이터를 처리할 때 사용하기로~~