간만에 자바..
1. DB 1회 조회 + 자바단 그루핑 vs 반복문 내부 다중 DB 조회
🅰️ 패턴 A: 반복문 안에서 DB 여러 번 조회 (N+1 문제)
의식의 흐름 버전 (많이들 처음에 이렇게 생각함):
List<Student> studentList = repository.selectStudentList(); // 학생 전체 조회
for (Student s : studentList) {
List<Subject> subjects =
repository.selectSubjectListByStudentId(s.getStudentId()); // 학생별 과목
s.setSubjectList(subjects);
}
selectStudentList()→ 1번- 학생이 100명 →
selectSubjectListByStudentId(...)100번 - 합계: DB 호출 101번 (N+1 패턴)
단점
DB round-trip이 너무 많다.
- DB는 네트워크 + 커넥션 풀 + 쿼리 파싱 등 오버헤드가 큼.
- 1번 vs 100번은 차이가 어마어마함.
성능이 데이터 개수에 비례해 폭증
- 학생 수 n → SQL 호출 횟수 = n+1
- n이 커질수록 “선형이 아니라 체감상 폭발” 😅
트랜잭션/락 유지 시간이 길어질 수 있음
- 한 서비스 메서드 안에서 트랜잭션 걸고 이걸로 반복문 돌리면
- 그 동안 DB 락이나 row-level lock이 길어질 수 있음.
코드도 보기 애매
- “학생 조회” + “과목 조회” 책임이 섞여 있음.
- 서비스 단에서 구조 설계가 지저분해짐.
🅱️ 패턴 B: DB에서 한 번에 전부 가져와서 자바단에서 그루핑
List<Student> studentList = repository.selectStudentList(); // 학생 전체 1쿼리
List<Subject> subjectList = repository.selectSubjectList(); // 과목 전체 1쿼리
Map<Integer, List<Subject>> subjectGrouped =
subjectList.stream()
.collect(groupingBy(Subject::getStudentId));
for (Student s : studentList) {
int id = s.getStudentId();
s.setSubjectList(subjectGrouped.getOrDefault(id, new ArrayList<>()));
}
- DB 호출: 딱 2번
- 학생 전체 1번
- 과목 전체 1번
장점
DB 호출 수 고정
- 학생이 10명이어도, 1000명이어도 → 쿼리 2개로 끝.
- 스케일 올라가도 DB에 부담 적음.
네트워크 왕복 감소
- 쿼리 1번 vs 100번은 네트워크 레벨에서 이미 승부 끝.
서비스 계층 책임이 명확
- “데이터 다 가져와서 자바에서 조립해!” → 역할이 단순/명확.
정책 바꿀 때 자바 코드로 유연하게 수정 가능
- “학생 1명당 과목 수 제한”, “정렬 순서”, “필터 로직” 등을
- SQL이 아니라 자바에서 바꿀 수 있음.
2. groupingBy 내부 동작 요약 (조금 더 깊게)
사용 코드 (학생/과목 예제)
Map<Integer, List<Subject>> subjectGrouped =
subjectList.stream()
.collect(groupingBy(Subject::getStudentId));
groupingBy의 시그니처 (간단)
public static <T, K> Collector<T, ?, Map<K, List<T>>>
groupingBy(Function<? super T, ? extends K> classifier)
T: 스트림의 각 요소 타입 → 여기서는SubjectK: 그룹핑에 사용할 key 타입 → 여기서는Integer(studentId)classifier:T → K함수, 여기서는Subject::getStudentId
내부적으로 하는 일 (개념)
subjectList = [s1, s2, s3, ...] 를 순회하면서:
key = classifier.apply(subject)→subject.getStudentId()map에서key에 해당하는 리스트를 찾음- 없으면
new ArrayList<Subject>()만들어서 map에 넣음
- 없으면
그 리스트에 현재 subject를
add해서 집어넣음
예제: subjectList가 이렇게 생겼다 치자
Subject(studentId=1, name="수학")
Subject(studentId=1, name="영어")
Subject(studentId=2, name="국어")
groupingBy 후 map:
{
1 -> [ (1,"수학"), (1,"영어") ],
2 -> [ (2,"국어") ]
}
flow를 진짜 한 줄씩 따라가면:
첫 번째 subject (1, "수학")
- key = 1
- map 에 1 없음 → new ArrayList 생성해서 put
- 그 리스트에 “수학” 추가
두 번째 subject (1, "영어")
- key = 1
- map 에 이미 key 1 있음 → 기존 리스트에 “영어” 추가
세 번째 subject (2, "국어")
- key = 2
- 없으니 새 리스트 만들고 “국어” 추가
3. 왜 타입이 Map<Integer, List<...>> 인가?
직관 체크
"그룹핑"이란 “key 하나에 요소 여러 개 붙이는 것”이다.
학생 1명은 과목 여러 개 가질 수 있음 → 1:N
그래서
Map<Integer, Subject>❌ (1:1)Map<Integer, List<Subject>>✅ (1:N)
groupingBy는 원래부터 “key별 List”를 만드는 Collector 라서
리턴 타입이 항상 Map<K, List<T>> 모양으로 나온다.
4. getOrDefault 전/후 & 객체 상태 차이
코드
for (Student s : studentList) {
int id = s.getStudentId();
List<Subject> subjects = subjectGrouped.getOrDefault(id, new ArrayList<>());
s.setSubjectList(subjects);
}
Map.get() 만 썼을 때 문제
List<Subject> subjects = subjectGrouped.get(id); // 없으면 null
for (Subject sub : subjects) { ... } // NPE 가능성
- 특정 학생이 과목이 하나도 없는 경우, map에는 key가 아예 없을 수 있음 →
null - 이후
for (Subject sub : subjects)에서 바로 NullPointerException
getOrDefault 의 역할
V getOrDefault(Object key, V defaultValue)
- key에 값이 있으면 → 그 값을 리턴
- 없으면 → 두 번째 파라미터인
defaultValue리턴
그래서:
subjectGrouped.getOrDefault(id, new ArrayList<>());
은 의미상:
List<Subject> tmp = subjectGrouped.get(id);
if (tmp == null) {
tmp = new ArrayList<>();
}
return tmp;
→ 항상 “null이 아닌 List” 를 받게 됨.
전/후 비교 (학생 하나 기준)
🔴 before 매핑 / getOrDefault 사용 전
StudentstudentId = 10\subjectList = null(초기 상태)
map에는:
- key 10이 없을 수도 있음
🟢 after grouping + getOrDefault + setSubjectList
StudentstudentId = 10subjectList = [...]또는[](empty list)
즉,
- “null” 대신 “빈 리스트”를 쓰므로
- 이후 로직에서 항상
for,stream()등의 연산을 NPE 걱정 없이 호출 가능
5. 최초 객체 vs 최종 객체 구조 비교
5-1. 최초 상태 (DB에서 바로 가져왔을 때)
List<Student> studentList = repository.selectStudentList(); // 학생 전체
List<Subject> subjectList = repository.selectSubjectList(); // 과목 전체
studentList- Student 1 (subjectList = null)
- Student 2 (subjectList = null)
- ...
subjectList- (1, "수학")
- (1, "영어")
- (2, "국어")
- ...
이 시점엔 학생과 과목이 별개 컬렉션이고, 연결 정보는 오직 “키값(studentId)” 로만 존재.
5-2. groupingBy 실행 후
Map<Integer, List<Subject>> subjectGrouped =
subjectList.stream()
.collect(groupingBy(Subject::getStudentId));
새로 생긴 객체
subjectGrouped(맵)- key: studentId (Integer)
- value: 그 studentId를 가진 Subject 리스트
studentList자체는 아직 변화 없음 (subjectList = null 그대로)
5-3. 학생 객체에 다시 주입 후 (최종 상태)
for (Student s : studentList) {
int id = s.getStudentId();
List<Subject> subjects = subjectGrouped.getOrDefault(id, new ArrayList<>());
s.setSubjectList(subjects);
}
이제:
studentListStudent 1
- subjectList = [ (1,"수학"), (1,"영어") ]
Student 2
- subjectList = [ (2,"국어") ]
Student 3
- subjectList = [] (빈 리스트)
subjectGrouped맵은 “중간 조립용”으로 사용했지만,- 필요하다면 이후 로직에서도 그대로 재활용 가능(예: 빠른 look-up 이 필요할 때)
🎯 한 줄 요약
- 처음엔 “평평한(flat) 리스트 2개(학생, 과목) + key 기반 연관만 존재”
- 마지막엔 “학생 객체 안에 이미 과목 리스트까지 붙어있는 계층 구조 로 변환된 상태”
7. 한 방에 다시 정리하면
DB 한 번 + 자바 grouping
- N+1 피하고, DB 부하 줄이고, 스케일 좋아짐.
groupingBy
List<T>→Map<K, List<T>>로 재구성하는 도구.- key는 classifier 함수의 반환값, value는 그 key에 해당하는 요소들의 리스트.
getOrDefault
map.get(key)가 null일지 신경 안 쓰고- “항상 null이 아닌 컬렉션”을 받을 수 있게 해줌 → NPE 방지.
전후 객체 상태 차이
- Before: 평평한 리스트 + key 숫자만 존재
- After: 상위 객체(Entrant/Student) 안에 하위 리스트(HostBizar/Subject)가 붙은 트리 구조
✅ 1. groupingBy 의 성능
결론: O(n) → 매우 빠르고 안정적
groupingBy는 내부적으로:
- 스트림의 각 요소를 한 번씩만 순회
- key 계산
- HashMap.put() or computeIfAbsent()
- List.add()
이 4단계를 수행한다.
시간 복잡도
- 스트림 순회: n번
- HashMap 접근: 평균 O(1)
- ArrayList add: 평균 O(1)
➡ 전체 시간: O(n)
➡ n = 1000건이면 1000번 연산하고 끝
메모리 사용량
- Map에 key 개수만큼 엔트리가 생김
- 각 value는
List<T> - 결국 subjectList(전체 row)와 메모리 크기가 동일한 정도
→ 당연한 수준, 과도한 메모리 사용 없음.
Java가 groupingBy 에서 실제 수행하는 일
아주 단순한 해시 분류 작업이라:
for (T element : list) {
K key = classifier.apply(element);
map.computeIfAbsent(key, k -> new ArrayList<>()).add(element);
}
→ 우리가 손으로 Map에 목록 채우는 작업과 정확히 동일
→ “DSL처럼 보일 뿐, 실제로는 저수준 단순 반복문”
🚫 2. 반복문 + 다중 DB 조회 방식의 성능 (N+1 문제)
이건 groupingBy 랑 비교하면 성능 차이가 수백~수천배 이상 날 수도 있다.
예시:
for each entrant E:
select * from ZONE_AUTH_HOST_BIZAR where entrant_seq = E.seq
출입자 50명, 엔트리 300개면:
- DB round-trip 50번
- DB I/O 50번
- DB 쿼리 파싱/플랜 50번
- 네트워크 왕복 50번
- 트랜잭션 홀딩 시간 증가
➡ 성능 관점에서는 groupingBy 와 비교 대상 자체가 아님.
⚙ getOrDefault 의 성능
결론: 무시해도 될 정도로 가벼움 √
내부 구현은:
V v = map.get(key);
return (v != null || map.containsKey(key)) ? v : defaultValue;
map.get(key)→ HashMap 접근: O(1)containsKey(key)→ HashMap 다시 접근: O(1)- 조건문 → O(1)
➡ 즉 O(1) 상수 시간.
1000번 호출해도 몇 마이크로초 수준.
⭐ 성능 비교 한눈에표
| 방식 | 시간 복잡도 | DB I/O | 특징 |
|---|---|---|---|
| groupingBy | O(n) | 0 | CPU 기반 작업, 매우 빠름 |
| getOrDefault | O(1) | 0 | null-safe lookup |
| 반복문 + DB조회(N+1) | O(n) + DB n회 | n | 네트워크·DB 비용 극심 |
➡ 실제 속도 비교 감각
| 방법 | 엔트리 500개 처리 시간 |
|---|---|
| groupingBy | 0.1 ~ 0.5 ms |
| getOrDefault | < 0.01 ms |
| N+1 DB 쿼리 | 100~500 ms (심하면 수초) |
즉 groupingBy 의 오버헤드는 bytes 단위,
N+1 의 오버헤드는 milliseconds ~ seconds 단위.
🔥 최종 결론
groupingBy / getOrDefault 성능 걱정은 100% 불필요
(자바 컬렉션 수준의 연산은 CPU가 밥 먹듯이 처리함)
오히려 성능의 핵심은 “DB 호출 횟수”
👉 이걸 최소화하는 것이 전체 성능을 결정함
👉 그래서 groupingBy / toMap / reduce 등 자바 컬렉션 기반 재조립이 정답임
만약 학생이 100만명쯤 된다면?
// 1) DB 전체 조회 (단 1회씩)
List<Student> studentList = repository.selectStudentList();
List<Subject> allSubjects = repository.selectAllSubjectList();
// subjectList: size = 전체 과목 수 (학생ID 포함됨)
// 2) 학생ID 별로 과목들을 병렬 grouping
Map<Long, List<Subject>> subjectGrouped =
allSubjects.parallelStream()
.collect(Collectors.groupingByConcurrent(Subject::getStudentId));
// 3) 매핑
studentList.parallelStream().forEach(student -> {
List<Subject> subjects =
subjectGrouped.getOrDefault(student.getStudentId(), new ArrayList<>());
student.setSubjectList(subjects);
});
으로 groupingBy 병렬 스트림 사용을 고려해볼 수 있다.
✅ 1. 병렬 스트림(parallelStream) → 언제 쓰면 되는가?
✔ CPU 연산이 많고, 데이터가 크고, 각 요소가 독립적인 경우
예:
- 100만 학생 목록을 그룹핑/집계
- 100만 로그 라인을 파싱·정규화
- AI inference 결과 대량 transform
- 정렬, 해싱, 매핑 등 CPU heavy 작업
즉, CPU 바운드 + 대량 데이터 처리 + 요소 간 독립적이면 OK
✔ 멀티스레드로 동시에 처리해도 안전한 경우
- 공유된 mutable 객체 없음
- 부작용(side effect) 없음
- 공용 자료구조는 Concurrent 계열 사용
예:
Map<Long, List<Subject>> grouped =
subjects.parallelStream()
.collect(Collectors.groupingByConcurrent(Subject::getStudentId));
✔ 성능 이득이 충분할 때
parallelStream 은 기본적으로 CPU 코어 수 만큼 쓰레드 생성
(8코어면 8개 스레드)
입력 데이터가 적으면 overhead(스레드 분배 비용)가 더 커질 수도 있음.
보통 1만~5만 건 이상일 때 이득이 발생
100만 건이면 병렬이 엄청 도움됨.
⛔ 2. 병렬 스트림 절대 지양해야 하는 경우
이건 매우 중요함.
❌ I/O 바운드(DB, 네트워크, 파일) 작업이 있는 경우
병렬 스트림은 CPU 멀티스레드 기반이며,
- DB 연결은 1개 커넥션 → 다중 스레드가 동시에 못씀
- JDBC 는 스레드 언세이프
- 네트워크 작업은 오히려 느려짐
즉:
병렬 스트림 안에서 DB 조회하면 100% 잘못된 구조
예 (절대 금지):
studentList.parallelStream().forEach(student -> {
List<Subject> subjects = repository.selectByStudent(student.getId()); // ❌
student.setSubjects(subjects);
});
❌ 상태를 공유하는 mutable 객체 수정이 있는 경우
아래처럼 ArrayList, HashMap 수정은 병렬에서는 터짐.
List<String> output = new ArrayList<>();
items.parallelStream().forEach(i -> output.add(i)); // ❌ concurrency 문제
❌ 순서가 중요한 로직
parallelStream 은 처리 순서가 보장되지 않음. (처리 결과 순서 x)
예:
studentList.parallelStream().forEach(System.out::println); // 처리 순서 뒤죽박죽
stream()은 입력 자료구조가 순서를 가지고 있다면(stream() 원소 순서 = 원본 순서)
✔ groupingBy()
- 일반 stream: 순서 그대로 accumulation
- parallelStream + groupingBy(): 내부 순서 보장 안됨 (map 도 경쟁하며 쓰니까)
✔ groupingByConcurrent()
- 병렬 grouping 전용
- map 자체가 순서 없는 ConcurrentHashMap 기반
→ 순서는 처음부터 기대하는 것이 아님
→ 키의 순서도 보장 안됨
→ 키별 value 리스트의 순서도 보장 안됨
❌ 키가 적고 그룹이 매우 적은 경우
subjectGrouped 의 key 가 몇 개 안 되면
concurrent map 락이 성능을 오히려 깎아먹음.
예:
100만개가 "A", "B", "C" 로만 그룹핑된다면?
병렬로 해봤자 3개 키에서 더럽게 경쟁함 → 오히려 느려짐.
❌ 서버 환경이 제한된 경우 (CPU가 적음)
AWS t2.micro 같은 1 vCPU 서버에서는 parallelStream 의미 없음.
⭐ 병렬 스트림이 “필수”가 되는 경우 예시
예시: 학생 100만 명 + 과목 300만 개 grouping
Map<Long, List<Subject>> subjectGrouped =
allSubjects.parallelStream()
.collect(Collectors.groupingByConcurrent(Subject::getStudentId));
이 경우 병렬 효과가 폭발적으로 나타남.
🔥 최종 결론
✔ 병렬 스트림을 써도 되는 경우
- 데이터가 1~10만 이상
- CPU 연산 중심
- 요소 간 완전 독립
- 공유 상태 없음
- groupingByConcurrent 같은 thread-safe collector 사용
❌ 병렬 스트림 쓰면 안 되는 경우
- 데이터 적음 (수백~수천)
- DB / 네트워크 호출 포함
- 순서 중요함
- 동일 키 경쟁 심함
- 서버 CPU 낮음
'GPT Archiving' 카테고리의 다른 글
| FLUSH (3) | 2025.07.18 |
|---|---|
| CGLIB 프록시 (0) | 2025.04.28 |
| 펌젠과 메타스페이스 (0) | 2025.04.15 |
| 싱글턴 패턴 - 4가지 구현 방법 (0) | 2025.03.08 |
| 싱글톤 패턴 - Double-Checked Locking (volatile) (0) | 2025.03.08 |