개념적 차이
List와 Set은 모두 JCF이다. 당연히 차이도 존재한다.
- List에는 중복 데이터가 포함될 수 있지만, Set에는 중복 데이터가 포함될 수 없다.
- List는 삽입 순서를 유지하지만 Set은 유지할 수 있고, 유지하지 않을 수도 있다.
- Set은 삽입 순서가 유지되지 않을 수 있으므로 인덱스 기반 액세스를 허용하지 않는다.
- LinkedHashSet과 같이 순서를 유지하는 Set도 있다.
List와 Set 성능 비교
JMH(Java Microbench Harness)를 사용해서 List, Set 데이터 구조의 성능을 비교해 보자. 먼저 ListAndSetAddBenchmark 및 ListAndSetContainBenchmark라는 두 개의 클래스를 만든다. 그다음 List 및 Set 데이터 구조에 대한 add() 및 contain() 메서드의 실행 시간을 측정한다.
데이터 추가 성능
다음 파라미터를 사용하여 벤치마크 테스트를 진행해 봤다. 참고로 이번 글에서는 벤치마크 테스트에 대해서는 자세하게 다루지 않는다.
@BenchmarkMode(Mode.SingleShotTime)
@Warmup(iterations = 3, time = 10, timeUnit = TimeUnit.MILLISECONDS)
@Measurement(iterations = 3, time = 10, timeUnit = TimeUnit.MILLISECONDS)
public class ListAndSetAddBenchmark {
@State(Scope.Benchmark)
public static class Params {
public int addNumber = 10000000;
public List<Integer> arrayList = new ArrayList<>();
public List<Set> hashSet = new HashSet<>();
}
@Benchmark
public void addElementsToArrayList(Params param, Blackhole blackhole) {
param.arrayList.clear();
for (int i = 0; i < param.addNumber; i++) {
blackhole.consume(arrayList.add(i));
}
}
@Benchmark
public void addElementToHashSet(Params param, Blackhole blackhole) {
param.hashSet.clear();
for (int i = 0; i < param.addNumber; i++) {
blackhole.consume(hashSet.add(i));
}
}
}
위 클래스에서는 벤치마크 모드를 지정한다.
- @BenchmarkMode(Mode.SingleShotTime) : 벤치마크가 실행될 모드를 설정한다.
- SingleShotTime : 벤치마크가 한 번 실행되는 데 걸리는 시간을 측정한다.
- @Warmup : 반복 횟수와 준비 단계 동안 각 반복을 실행하는 시간을 지정한다.
- 위 코드의 경우 세 번의 반복으로 구성되며, 각 반복 주기는 10밀리 초다.
- @Measurement : 반복 횟수와 측정 단계 중 각 반복을 실행하는 시간이다.
- 위 코드의 경우 측정 단계가 세 번의 반복으로 구성되고 각 반복이 10밀리 초 동안 실행된다.
- @Benchmark : 성능을 측정할 메서드를 지정한다.
마지막으로 테스트 결과를 비교해 보자.
- Benchmark : 벤치마크 테스트의 이름을 나타낸다.
- Mode : 벤치마크 모드를 나타낸다.
- 여기서는 "thrpt"로 설정되어 있다. 이는 처리량(throughput) 모드로, 초당 작업 횟수(ops/s)를 측정하는 모드다.
- Cnt : 벤치마크 테스트가 실행된 횟수를 나타낸다.
- 여기서는 3번 실행되었다.
- Score : 벤치마크 테스트의 결과를 나타내는 값이다.
- 이 값은 높을수록 좋다.(단, Memory Score는 낮을수록 좋다.) 각 벤치마크에 대한 점수가 표시되어 있다.
- Error : 결과의 오차를 나타낸다.
- 작은 오차는 더 안정적인 결과를 나타낸다.
- Units : 결과 값의 단위를 나타낸다.
- 여기서는 "ops/s"로 설정되어 있으므로, 초당 작업 횟수를 나타낸다.
결과는 ArrayList에 데이터를 추가하는 것이 HashSet에 데이터를 추가하는 것보다 빠르다는 것을 보여준다. 또한 오차범위 또한 크게 차이 나기 때문에 가능한 한 빨리 컬렉션에 요소를 추가해야 하는 상황에서는 ArrayList가 더 효율적이라는 근거를 얻을 수 있다.
데이터 조회 성능
컬렉션에 특정 데이터가 존재하는지 여부를 판단하는 contains() 메서드의 성능을 비교해 보자.
@BenchmarkMode(Mode.SingleShotTime)
@Warmup(iterations = 3, time = 10, timeUnit = TimeUnit.MILLISECONDS)
@Measurement(iterations = 3, time = 10, timeUnit = TimeUnit.MILLISECONDS)
public class ListAndSetAddBenchmark {
@State(Scope.Benchmark)
public static class Params {
@Param({"5000000"})
public int searchElement;
@Param({"10000000"})
public int collectionSize;
public List<Integer> arrayList;
public Set<Integer> hashSet;
@Setup(Level.Iteration)
public void setup() {
arrayList = new ArrayList<>();
hashSet = new HashSet<>();
for (int i = 0; i < collectionSize; i++) {
arrayList.add(i);
hashSet.add(i);
}
}
@Benchmark
public void searchElementInArrayList(Params param, Blackhole blackhole) {
for (int i = 0; i < param.containNumber; i++) {
blackhole.consume(arrayList.contains(searchElement));
}
}
@Benchmark
public void searchElementInHashSet(Params param, Blackhole blackhole) {
for (int i = 0; i < param.containNumber; i++) {
blackhole.consume(hashSet.contains(searchElement));
}
}
}
}
성능 비교
결과는 HashSet에서 데이터를 검색하는 것이 ArrayList에서 데이터를 검색하는 것보다 빠르다는 것을 보여준다. 이는 컬렉션의 요소를 빠르고 효율적인 방법으로 검색하려는 시나리오에서 HashSet이 더 효율적이라는 것을 확인할 수 있다.
메모리 비교
벤치마크를 실행하는 동안 GC를 지정하여 벤치마크 방법에 대한 메모리 할당을 측정해 보겠다. main() 메서드를 수정하고 두 벤치마크 클래스에 대한 JMH 실행 옵션을 구성해 보자.
public static void main(String[] args) throws RunnerException {
Options opt = new OptionsBuilder()
.include(ListAndSetContainBenchmark.class.getSimpleName())
.forks(1)
.addProfiler("gc")
.build();
new Runner(opt).run();
}
위의 방법에서는 JMH를 구성하기 위해 새 옵션 개체를 만든다. 먼저, 실행되어야 할 벤치마크를 지정하기 위해 include() 메서드를 사용한다. 다음으로, 벤치마크가 fork() 메서드로 실행되어야 하는 횟수를 지정한다.
결과는 addElementToArrayList 메서드의 경우 약 172MB, addElementToHashSet 메서드의 경우 약 504MB으로 데이터 추가 작업은 HashSet이 ArrayList에 비해 실행 중 더 많은 메모리를 할당한다는 것을 의미한다. 또한 데이터 검색 작업도 HashSet이 ArrayList에 비해 검색 작업에 약간 더 많은 메모리를 할당한다는 것을 보여준다.
오류 값은 결과에 약간의 변동이 있음을 나타내며, 이는 JVM 워밍업 및 코드 최적화와 같은 요인에 따라 달라질 수 있다.
성능 비교 결론
List와 Set의 성능을 간단하게 비교해 보았다. 그래서 우리는 아래와 같은 결론을 얻을 수 있다.
- 데이터 추가 성능 : Set < List
- 데이터 조회 성능 : List < Set
- 데이터 조회 메모리 성능 : Set < List
- 데이터 검색 메모리 성능 : Set < List
JPA에서 컬렉션 인터페이스를 사용할 때 고려할 것들
Hibernate 5.0.8 버전 이하에서 발생하는 추가 insert 쿼리 문제
해당 버전 이전의 hibernate라면 연관관계 객체 생성에 유의할 필요가 있다. cascade를 merge, persist, all 중 하나를 선택하여 사용할 때 연관관계의 주인이 아닌 쪽의 객체를 생성하면, Insert 쿼리가 2번 발생해 중복 쿼리가 발생했었다.
하이버네이트의 아틀라시안 이슈에 따르면 다음과 같은 동작 방식 때문에 해당 문제가 발생했다고 한다.
- 영속성에서 A 인스턴스를 조회한다.
- A a = em.find(A.class, id);
- A에 새로운 B 인스턴스를 추가한다.
- a.getBs.add(nes B());
- A 인스턴스를 다시 persistence로 병합한다.
- em.merge(a);
이 과정에서 트랜잭션 커밋 시 하이버네이트로 전환하면 추가 insert 쿼리가 발생하고 데이터 원본에 b의 새로운 인스턴스가 두 번 삽입된다.
이 문제를 해결하려면 cascade를 수정하거나 Set으로 처리해야 한다. 다만, cascade 옵션을 변경하는 것은 많은 부담이 있기에, 보통은 Set으로 변경하여 사용하는 걸 권장했다. 그래서 오래된 프로젝트들을 보면 Set을 사용한 코드가 종종 보일 것이다.
5.0.8 이후의 hibernate라면 해당 이슈가 해결되었기 때문에 List를 사용해도 중복 쿼리 문제가 발생하지 않는다.
프록시 강제 초기화 문제
Set은 자료구조 특성상 초기화되지 않은 상태에서 컬렉션에 값을 넣게 되면 중복 데이터가 있는지 비교한다. 그러려면 컬렉션의 모든 데이터를 조회해야 한다. 이때 지연로딩을 해 놓은 객체(em.getReference)가 있다면 프록시가 강제 초기화되는 문제가 발생한다.
프록시 객체 초기화
프록시 객체가 실제 사용될 때 데이터베이스를 조회해서 실제 엔티티 객체를 생성하는 것
프록시 강제 초기화 문제 검증
아래는 Team(1) : (N) Members의 연관관계이다. 특이사항은 team -> members가 set 자료구조로 선언되어 있다.
Team
@Entity
@Getter
@Setter
@Builder
@AllArgsConstructor
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Team {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@Builder.Default
@OneToMany(mappedBy = "team", cascade = CascadeType.ALL)
private Set<Members> members = new HashSet<>();
public void addMembers(Members members) {
this.members.add(members);
}
}
Members
@Entity
@Getter
@Setter
@Builder
@AllArgsConstructor
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Members {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "team_id")
private Team team;
}
테스트 코드
@DataJpaTest
class TeamRepositoryTest {
@PersistenceContext
private EntityManager em;
@Autowired
private TeamRepository teamRepository;
public Team 팀_생성(String name) {
return Team.builder()
.name(name)
.build();
}
public Members 회원_생성(String name) {
return Members.builder()
.name(name)
.build();
}
@Test
public void test() {
Team teamA = 팀_생성("Team A");
Members membersA = 회원_생성("Member A");
membersA.setTeam(teamA);
teamA.setMembers(new HashSet<>(List.of(membersA)));
teamRepository.save(teamA);
em.clear();
System.out.println("======Team 조회 Query======");
Team team = teamRepository.getById(1L);
System.out.println("======프록시 초기화 쿼리 발생======");
Members membersB = 회원_생성("Member B");
team.addMembers(membersB);
}
}
결과
이렇게 지연로딩 해놓았던 객체의 프록시를 강제로 초기화해 버리는 쿼리가 발생하는 것을 확인했다. 별개로 조금 특수한 상황도 있다. ManyToMany 연관관계에서는 Set이 List보다 쿼리가 적게 발생한다. 자세한 내용은 이 글을 읽어보자.
결론
자바에서 제공하는 List와 Set의 성능, 메모리 비교는 다음과 같다고 했다.
- 데이터 추가 성능 : Set < List
- 데이터 조회 성능 : List < Set
- 데이터 조회 메모리 성능 : Set < List
- 데이터 검색 메모리 성능 : Set < List
그렇다면 여기에 JPA를 얹으면 어떻게 될까? Hibernate v5.0.8 이상을 사용한다고 가정할 때 조회 성능에서 조차 List가 압도적인 속도를 보여줄 것이다. 그렇다면 최종 결과는 아래와 같이 낼 수 있다.
- 데이터 추가 성능 : Set < List
- 데이터 조회 성능 : Set < List
- 데이터 조회 메모리 성능 : Set < List
- 데이터 검색 메모리 성능 : Set < List
그렇다면 Set을 사용할 일은 없을까? 아직까지 Set을 사용할 이유를 찾지는 못했다. List를 사용하는 것이 성능상으로도 좋고 Hibernate 상에서도 좋다.
'Spring Data > JPA' 카테고리의 다른 글
[JPA] 어드민 페이지 동시성 이슈 해결기 (1) | 2023.10.26 |
---|---|
[Hibernate] 영속성 컨텍스트는 항상 thread-safe하지 않다.(Feat. Hibernate가 Session Race Condition을 대처하는 방법) (0) | 2023.10.25 |
[JPA] JPA에서 Fetch Join에 대한 On절을 지원하지 않는 이유 (7) | 2023.07.29 |