GPT Archiving

groupingBy, getOrDefalut

99duuk 2025. 12. 3. 11:03

간만에 자바..

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 패턴)

단점

  1. DB round-trip이 너무 많다.

    • DB는 네트워크 + 커넥션 풀 + 쿼리 파싱 등 오버헤드가 큼.
    • 1번 vs 100번은 차이가 어마어마함.
  2. 성능이 데이터 개수에 비례해 폭증

    • 학생 수 n → SQL 호출 횟수 = n+1
    • n이 커질수록 “선형이 아니라 체감상 폭발” 😅
  3. 트랜잭션/락 유지 시간이 길어질 수 있음

    • 한 서비스 메서드 안에서 트랜잭션 걸고 이걸로 반복문 돌리면
    • 그 동안 DB 락이나 row-level lock이 길어질 수 있음.
  4. 코드도 보기 애매

    • “학생 조회” + “과목 조회” 책임이 섞여 있음.
    • 서비스 단에서 구조 설계가 지저분해짐.

🅱️ 패턴 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번

장점

  1. DB 호출 수 고정

    • 학생이 10명이어도, 1000명이어도 → 쿼리 2개로 끝.
    • 스케일 올라가도 DB에 부담 적음.
  2. 네트워크 왕복 감소

    • 쿼리 1번 vs 100번은 네트워크 레벨에서 이미 승부 끝.
  3. 서비스 계층 책임이 명확

    • “데이터 다 가져와서 자바에서 조립해!” → 역할이 단순/명확.
  4. 정책 바꿀 때 자바 코드로 유연하게 수정 가능

    • “학생 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 : 스트림의 각 요소 타입 → 여기서는 Subject
  • K : 그룹핑에 사용할 key 타입 → 여기서는 Integer (studentId)
  • classifier : T → K 함수, 여기서는 Subject::getStudentId

내부적으로 하는 일 (개념)

subjectList = [s1, s2, s3, ...] 를 순회하면서:

  1. key = classifier.apply(subject)subject.getStudentId()

  2. map에서 key에 해당하는 리스트를 찾음

    • 없으면 new ArrayList<Subject>() 만들어서 map에 넣음
  3. 그 리스트에 현재 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 사용 전

  • Student

    • studentId = 10\
    • subjectList = null (초기 상태)
  • map에는:

    • key 10이 없을 수도 있음

🟢 after grouping + getOrDefault + setSubjectList

  • Student
    • studentId = 10
    • subjectList = [...] 또는 [](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);
}

이제:

  • studentList

    • Student 1

      • subjectList = [ (1,"수학"), (1,"영어") ]
    • Student 2

      • subjectList = [ (2,"국어") ]
    • Student 3

      • subjectList = [] (빈 리스트)
  • subjectGrouped 맵은 “중간 조립용”으로 사용했지만,

    • 필요하다면 이후 로직에서도 그대로 재활용 가능(예: 빠른 look-up 이 필요할 때)

🎯 한 줄 요약

  • 처음엔 “평평한(flat) 리스트 2개(학생, 과목) + key 기반 연관만 존재”
  • 마지막엔 “학생 객체 안에 이미 과목 리스트까지 붙어있는 계층 구조 로 변환된 상태”

7. 한 방에 다시 정리하면

  1. DB 한 번 + 자바 grouping

    • N+1 피하고, DB 부하 줄이고, 스케일 좋아짐.
  2. groupingBy

    • List<T>Map<K, List<T>> 로 재구성하는 도구.
    • key는 classifier 함수의 반환값, value는 그 key에 해당하는 요소들의 리스트.
  3. getOrDefault

    • map.get(key) 가 null일지 신경 안 쓰고
    • “항상 null이 아닌 컬렉션”을 받을 수 있게 해줌 → NPE 방지.
  4. 전후 객체 상태 차이

    • Before: 평평한 리스트 + key 숫자만 존재
    • After: 상위 객체(Entrant/Student) 안에 하위 리스트(HostBizar/Subject)가 붙은 트리 구조

✅ 1. groupingBy 의 성능

결론: O(n) → 매우 빠르고 안정적

groupingBy는 내부적으로:

  1. 스트림의 각 요소를 한 번씩만 순회
  2. key 계산
  3. HashMap.put() or computeIfAbsent()
  4. 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