🤔 문제 상황
DB 조회 시 성능 최적화는 개발자들이 자주 마주치는 과제이다.
특히 복잡한 엔티티 관계와 다양한 응답 값 설정이 필요한 상황에서는 더욱 그렇다. 이러한 맥락에서 findByXXX()
메소드를 사용하여 DB에서 직접 필터링하는 방식과 findAll()
로 모든 데이터를 가져온 후 Java의 stream API를 이용해 필터링하는 방식 중 전자가 더욱 성능에 좋다고는 알고 있었지만, 어떻게/ 왜 더 효율적인지에 대한 의문이 생겼다.
실무에서는 종종 findAll()
을 사용한 후 stream의 filter 등으로 데이터를 가공하는 접근법을 볼 수 있었다. 이는 주로 빠른 개발 속도를 위해 선택되며, 복잡한 연관 관계에서 SQL 쿼리 작성에 시간을 들이는 대신 컴파일 시점에 확인 가능한 stream 연산을 선호하는 경향 때문이라고 전해들었다.
일반적으로 DB에서 필요한 조건의 데이터만을 정확히 가져와 서버 단에서 처리하는 것이 효율적이라고 알려져 있다. 하지만 이 접근법의 실제 성능 이점과 그 정도를 정확히 파악하기 위해서는 실제 테스트를 통한 검증이 필요하다고 생각했다.
요약 : findAll()
로 DB에서 데이터를 모두 조회한 후stream(filter)로 조회 하는 것과 조건에 맞는 데이터를 DB에서 한번에 가져오는findByXXX()
중 어떤 것이 성능에 이점이 있을까?
🕊️ 나의 생각
따라서 다음과 같은 의문점들이 생긴다.
- 어떤 방식이 실제로 더 큰 성능상의 이점을 제공하는가?
- 데이터의 양에 따라 두 방식의 성능 차이는 어떻게 변화하는가?
- 어느 정도의 데이터 양에서 그 차이가 유의미해지는가?
📑 테스트 조건
- 데이터는 100건 / 1,000건 / 10,000건 / 100,000건 기준으로 조회해본다.
- 비교군 조회 메서드들
findAllByNameIn(
) vsfindAll()
+ stream filter (by names)findAllByEmail()
vsfindAll()
+ stream filter (by eamil)
- Name의 모든 데이터 값은 '이름 + n' 이다. (ex: 이름1,이름2,이름3...)
- Email의 데이터는 총 데이터의 10%을 조회시 필요한 조건절의 타겟 데이터를 넣는다.
Controller
@RestController
@RequestMapping("/users")
@RequiredArgsConstructor
public class UserController {
private final UserReader userReader;
@GetMapping
public String getUsers() {
long startTime = System.nanoTime();
List<Long> userIds = userReader.getUsers()
.stream()
.map(User::getId)
.collect(Collectors.toList());
double durationInMs = getDurationInMs(startTime);
String idsList = getIdsList(userIds);
return String.format("getUsers 실행 시간: %.3f ms\n사용자 ID 목록: [%s]", durationInMs, idsList);
}
@GetMapping("/db/names")
public String getUsersByNamesUseDB() {
long startTime = System.nanoTime();
List<Long> userIds = userReader.getUsersByNames(List.of("이름1", "이름2", "이름3"))
.stream()
.map(User::getId)
.toList();
double durationInMs = getDurationInMs(startTime);
String idsList = getIdsList(userIds);
return String.format("getUsers 실행 시간: %.3f ms\n사용자 ID 목록: [%s]", durationInMs, idsList);
}
@GetMapping("/db/email")
public String getUsersByEmailUseDB() {
long startTime = System.nanoTime();
List<Long> userIds = userReader.getUsersByEmail("target@email.com")
.stream()
.map(User::getId)
.collect(Collectors.toList());
double durationInMs = getDurationInMs(startTime);
String idsList = getIdsList(userIds);
return String.format("getUsers 실행 시간: %.3f ms\n사용자 ID 목록: [%s]", durationInMs, idsList);
}
@GetMapping("/stream/names")
public String getUsersByNamesUseFilter() {
long startTime = System.nanoTime();
List<Long> userIds = userReader.getUsersByNamesUseFilter(List.of("이름1", "이름2", "이름3"))
.stream()
.map(User::getId)
.collect(Collectors.toList());
double durationInMs = getDurationInMs(startTime);
String idsList = getIdsList(userIds);
return String.format("getUsers 실행 시간: %.3f ms\n사용자 ID 목록: [%s]", durationInMs, idsList);
}
@GetMapping("/stream/email")
public String getUsersByEmailUseFilter() {
long startTime = System.nanoTime();
List<Long> userIds = userReader.getUsersByEmailUseDB("target@email.com")
.stream()
.map(User::getId)
.collect(Collectors.toList());
double durationInMs = getDurationInMs(startTime);
String idsList = getIdsList(userIds);
return String.format("getUsers 실행 시간: %.3f ms\n사용자 ID 목록: [%s]", durationInMs, idsList);
}
private String getIdsList(List<Long> userIds) {
String idsList = userIds.stream()
.map(Object::toString)
.collect(Collectors.joining(", "));
return idsList;
}
private static double getDurationInMs(long startTime) {
long endTime = System.nanoTime();
long duration = endTime - startTime;
double durationInMs = duration / 1_000_000.0;
return durationInMs;
}
}
Service
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class UserReader {
private final UserRepository userRepository;
public List<User> getUsers() {
return userRepository.findAll();
}
public List<User> getUsersByNames(List<String> names) {
return userRepository.findAllByNameIn(names);
}
public List<User> getUsersByEmail(String email) {
return userRepository.findAllByEmail(email);
}
public List<User> getUsersByNamesUseFilter(List<String> names) {
return userRepository.findAll()
.stream()
.filter(user -> names.contains(user.getName()))
.toList();
}
public List<User> getUsersByEmailUseDB(String email) {
return userRepository.findAll()
.stream()
.filter(user -> user.getEmail().equals(email))
.toList();
}
}
Repository
public interface UserRepository extends JpaRepository<User, Long> {
List<User> findAllByNameIn(List<String> names);
List<User> findAllByEmail(String email);
}
위와 같은 코드로 테스트를 진행했다.
🦄 결과
조회 방식 | 100건 | 1,000건 | 10,000건 | 100,000건 |
---|---|---|---|---|
findAll() | 8ms | 9ms | 37ms | 237ms |
findAllByNameIn() | 6.4ms | 5.45ms | 17ms | 44ms |
findAll() + stream filter (by names) | 9ms | 7ms | 49ms | 256ms |
findAllByEmail() [100건만 타겟] | 8ms | 10ms | 12ms | 28ms |
findAll() + stream filter (by email) [100건만 타겟] | 9ms | 11ms | 41ms | 260ms |
위와 같은 결과가 나왔고, 대용량이라고 할 수 있는 10만건부터는 유의미한 결과가 나타났다.
하나씩 내 생각과 더불어 알아보자!
🪄 성능 차이 분석
1. 데이터 처리 위치에 따른 차이
- DB 레벨 필터링 (findByXXX)
- WHERE 절이나 IN 절을 통한 DB 단계 필터링
- DB 인덱스를 활용한 빠른 검색
- 필터링된 결과만 네트워크 전송
- 애플리케이션 레벨 필터링 (findAll + stream)
- 전체 데이터를 애플리케이션으로 로드
- 애플리케이션 메모리에서 필터링
- 불필요한 데이터까지 네트워크 전송
2.리소스 사용량 비교
- DB 레벨 필터링
- 필요한 데이터만 메모리 적재
- 최소한의 네트워크 사용
- DB 최적화 엔진 활용
- 애플리케이션 레벨 필터링
- 전체 데이터 메모리 적재
- 전체 데이터 네트워크 전송
- 애플리케이션 CPU 자원 사용
3. 연산 복잡도
- DB 레벨
findAllByNameIn()
: O(log n) * 검색 조건 수findAllByEmail()
: O(log n) (인덱스 사용 시)
- 애플리케이션 레벨
- 전체 데이터 조회: O(n)
- 스트림 필터링: O(n) * 검색 조건 수
🎯 결론 및 권장사항
데이터 규모별 특징
- 소규모 데이터 (1만건 이하)
- 두 방식의 성능 차이가 미미하다.
- 구현 편의성 기준으로 선택해도 무방할 듯 하다. (그래도 DB로 바로 필터링 처리하는게 확장성에 대비하는 방법일듯?)
- 대규모 데이터 (10만건 이상)
- DB 레벨 필터링이 압도적으로 유리하다.
- 리소스 사용량 차이가 명확하다.
권장사항
- 가능한 DB 레벨에서 필터링 (findByXXX 사용)
- DB는 이러한 쿼리에 대해 최적화가 잘 되어있다!
- 특히 name 컬럼에 인덱스가 있다면 더욱 효율적!
- 병렬 처리나 쿼리 최적화 등 DB 엔진의 장점을 활용할 수도 있다.
- 최대한 필요한 데이터만 조회 (불필요한 컬럼 제외) 한다.
- 대용량 데이터의 경우 페이징 처리 필수!!
- 복잡한 비즈니스 로직의 경우만 선별적으로 스트림 필터링 사용한다.
마치며
회사에서 업무를 진행하면서 JPA를 사용한 데이터 조회 시 단순히 "DB에서 필터링하는 것이 성능상 이점이 있다"라는 개념적인 이해에 그치지 않고, '왜 그런가?'라는 호기심이 생겼다.
이를 직접 테스트해보면서 몇 가지 중요한 인사이트를 얻을 수 있었다!
- 데이터의 규모가 커질수록 DB 레벨 필터링과 애플리케이션 레벨 필터링의 성능 차이가 극명해진다는 점
- 단순한 필터링이라도 처리 위치에 따라 네트워크 부하, 메모리 사용량, 연산량이 크게 달라진다는 점
- 성능 최적화를 위해서는 실제 데이터로 테스트하고 검증하는 과정이 중요하다는 점
이번 테스트를 통해 단순한 개념 이해를 넘어 실제 성능 차이를 수치로 확인할 수 있었고, 이는 앞으로의 개발에 있어 더 나은 의사결정을 할 수 있는 기반이 될 것 같다.
추가로 이러한 호기심과 검증 과정은 개발자로서 성장하는데 매우 중요한 요소라는 것을 다시 한 번 깨달을 수 있었다. 앞으로도 "왜?"라는 질문을 계속하며 더 나은 개발자가 되고싶다.
'트러블 슈팅' 카테고리의 다른 글
try-catch와 @Transactional을 함께 사용시 트랜잭션 롤백 여부 (0) | 2024.09.29 |
---|---|
AOP를 적용한 메서드의 파라미터 내용을 가져올 시에 파라미터 이름이 arg0 등으로 인식될 때 (0) | 2023.09.11 |