C
Java/컬렉션_함수형/Lesson 05

컬렉션 + 함수형 — List·Set·Map·Lambda·Stream

60분·theory

컬렉션 + 함수형 — List·Set·Map·Lambda·Stream

🎯 이 lesson 을 읽고 나면

이 lesson 을 다 읽고 나면 아래 3가지를 자신 있게 할 수 있습니다.

  • ✅ List · Set · Map 각각의 사용 시점
  • ✅ Stream API map/filter/reduce 체인으로 for 루프 리팩토링
  • ✅ Iterator vs for-each + ConcurrentModificationException 함정

학습 목표를 체크리스트로 두고 다 답할 수 있게 되면 lesson 을 닫으세요.

Java 컬렉션이 뭐냐 — 자료를 담는 *그릇*

핵심 한 줄

Java Collections Framework = 데이터 여러 개를 담는 표준 그릇. 1998 년 Java 2 에서 도입돼 지금까지 모든 Java 개발자가 매일 쓰는 가장 기본적인 도구.

왜 4개의 인터페이스로 나뉘는가

데이터를 담는 방식은 목적에 따라 다릅니다.

  • List순서가 있고 중복도 허용. "장바구니에 담긴 상품들" 같은 것. 첫 번째·두 번째·세 번째 같은 인덱스 가 의미 있을 때 씁니다.
  • Set중복 X, 순서 보통 X. "오늘 방문한 사용자 ID 목록" 같은 것. 같은 사람이 100번 와도 1로 세고 싶을 때.
  • Map키 → 값 매핑. "사용자 ID → 사용자 정보" 처럼 으로 다루고 싶을 때. 가장 자주 쓰는 구조.
  • Queue순서대로 들어가고 순서대로 나오는. 작업 대기열·이벤트 처리. FIFO 가 자연스러운 경우.

이 4가지 추상화 덕분에 어떤 구현을 골라도 같은 방식으로 다룹니다. List 가 ArrayList 든 LinkedList 든, 코드는 똑같이 list.add()·list.get().

가장 자주 만나는 ArrayList vs LinkedList

이름은 비슷하지만 완전히 다른 동작입니다.

ArrayList 는 내부적으로 배열 을 씁니다. 그래서 인덱스로 바로 점프 할 수 있어요 — n번째 원소를 1번에 가져옵니다 (O(1)). 대신 중간에 삽입 하면 뒤에 있는 모든 원소를 한 칸씩 밀어야 해서 느립니다. 그래도 메모리가 연속적으로 붙어있어 CPU 캐시 친화적이라 실무에선 대부분의 경우 더 빠릅니다.

LinkedList 는 노드들이 서로를 가리키는 연결 리스트. 중간 삽입은 링크만 바꿔 O(1) 인 듯 보이지만, 그 위치를 찾아가는데 O(n) 걸립니다. 게다가 노드마다 별도 메모리 라 캐시 효율이 나쁩니다.

결론: 인덱스 접근이 잦으면 ArrayList. 중간 삽입이 정말 많은 경우만 LinkedList 를 고려하지만, 실제론 거의 안 씁니다. 이름과 달리 LinkedList 는 느린 경우가 더 많습니다.

HashMap — Java 에서 가장 많이 쓰는 자료구조

HashMap<String, User> 같은 코드는 매일 봅니다. 어떻게 동작하는지 한 번은 짚고 가야 합니다.

내부는 배열 + 연결 리스트 의 조합입니다. 키의 hashCode() 를 계산해 배열 인덱스를 찾고, 같은 인덱스에 여러 키가 충돌하면 그 자리에서 연결 리스트로 이어붙입니다. Java 8 부터는 충돌이 8개 이상 쌓이면 그 부분만 Red-Black Tree 로 자동 변환돼 최악의 경우도 O(log n) 으로 보장됩니다.

가장 흔한 실수: 키로 쓰는 객체의 hashCode()equals() 를 둘 다 올바르게 구현해야 합니다. 둘 중 하나만 오버라이드하면 조회가 안 됩니다. Lombok 의 @EqualsAndHashCode 또는 record (Java 14+) 가 자동 처리해 줍니다.

멀티스레드 환경 — ConcurrentHashMap

여러 스레드가 동시에 같은 HashMap 을 수정하면 무한 루프에 빠지거나 데이터가 깨질 수 있습니다. 흔히 서버에서 캐시 용으로 HashMap 을 공유하다 사고가 납니다.

해결책은 ConcurrentHashMap 입니다. 내부적으로 버킷 단위로 락 을 잡아 동시성을 허용합니다. Collections.synchronizedMap() 이라는 전체 락 방식도 있지만, 동시성 ↑ 가 필요하면 ConcurrentHashMap 이 사실상 표준입니다.

비슷한 이유로 CopyOnWriteArrayList 도 있습니다 — 읽기는 많고 쓰기는 드문 경우 (예: 이벤트 리스너 목록) 에 쓰입니다.

한 번 정리

인터페이스가장 흔한 구현언제
ListArrayList인덱스·순서·중복 다 필요
SetHashSet / LinkedHashSet중복 제거 / 순서까지
MapHashMap / ConcurrentHashMapkey→value, 동시성이면 후자
QueueArrayDequeFIFO 작업 큐
SortedTreeMap / TreeSet키 정렬이 필요할 때

> 💡 현장 기준: 90% 의 경우 ArrayList + HashMap 으로 시작합니다. 멀티스레드면 ConcurrentHashMap. 정렬이 필요하면 그때 TreeMap 으로 바꿉니다.

람다와 함수형 — Java 가 *다시 태어난* 순간

왜 Java 8 이 분기점인가

2014 년 Java 8 이 람다 표현식Stream API 를 도입했습니다. 이전까지 Java 는 객체지향만의 언어 였습니다. 함수 하나 전달하려면 new Runnable() { public void run() { ... } } 같은 익명 클래스 5줄 코드를 써야 했죠.

람다는 이걸 한 줄로 줄여줍니다.

java
// 옛날
new Thread(new Runnable() {
    public void run() { System.out.println("hi"); }
}).start();

// 람다 — Java 8+
new Thread(() -> System.out.println("hi")).start();

코드 길이가 줄어든 게 핵심이 아닙니다. 진짜 변화는 함수를 데이터처럼 다룰 수 있게 됐다는 점입니다. 변수에 저장하고, 다른 함수에 인자로 넘기고, 리턴할 수 있습니다.

Functional Interface 의 정체

람다가 가능한 이유는 추상 메서드가 딱 1개인 인터페이스 — Functional Interface — 덕분입니다. 컴파일러가 람다를 보면 어떤 함수형 인터페이스인지 추론해서 알아서 구현 클래스를 만들어 줍니다.

자주 쓰는 4가지:

  • Function<T, R> — T 받아 R 리턴. 변환 할 때. 예: 문자열 → 길이.
  • Predicate<T> — T 받아 boolean. 필터 할 때. 예: "성인인가?"
  • Consumer<T> — T 받아 아무것도 안 돌려줌. 소비 만. 예: 로그 출력.
  • Supplier<T>아무것도 안 받고 T 리턴. 공급 만. 예: 현재 시각 가져오기.

이름이 직관적입니다. 받는다·돌려준다·둘 다·둘 다 X 의 4가지 조합.

Stream API — 컬렉션을 흐름 으로

Stream 은 컬렉션 데이터를 파이프라인 으로 처리하는 도구입니다. 옛날 코드는 이랬습니다:

java
List<String> result = new ArrayList<>();
for (User u : users) {
    if (u.getAge() >= 30) {
        result.add(u.getName().toUpperCase());
    }
}
Collections.sort(result);

Stream 으로 쓰면 의도가 그대로 코드에 드러납니다:

java
List<String> result = users.stream()
    .filter(u -> u.getAge() >= 30)
    .map(u -> u.getName().toUpperCase())
    .sorted()
    .toList();

.filter 는 조건에 맞는 것만 통과. .map 은 각 원소를 변환. .sorted 는 정렬. .toList수집 해서 List 로. 위에서 아래로 읽으면 데이터가 흘러가는 모습 이 그려집니다.

groupingBy — 자주 쓰는 마법

Stream 의 진가는 집계 에서 드러납니다. 부서별로 직원을 묶고 싶을 때:

java
Map<String, List<Employee>> byDept = employees.stream()
    .collect(Collectors.groupingBy(Employee::getDept));

한 줄로 끝납니다. SQL 의 GROUP BY 가 Java 에 들어왔다고 생각하면 됩니다. 부서별 평균 연봉 도 비슷하게:

java
Map<String, Double> avgSalaryByDept = employees.stream()
    .collect(Collectors.groupingBy(
        Employee::getDept,
        Collectors.averagingDouble(Employee::getSalary)));

흔한 함정 — 알아두면 좋은 것

Stream 안에서 외부 변수를 수정하면 안 됩니다. 병렬 처리 시 race condition 이 생기고, 함수형 사고 자체를 깨뜨립니다. 대신 collect·reduce불변 결과 를 만들어 받아야 합니다.

parallelStream() 은 만능이 아닙니다. 작은 데이터·I/O 작업엔 오히려 느립니다. 컨텍스트 스위치 비용이 더 큽니다. CPU 가 많이 도는 큰 데이터 에서만 효과가 있습니다.

Optional 도 같은 시기에 나왔습니다. null 대신 비어있을 수 있음을 명시 하는 박스입니다. Optional<User> 를 받으면 null 일 수도 있구나 바로 알 수 있죠.

java
Optional<User> u = userRepo.findById(id);
String name = u.map(User::getName).orElse("(이름 없음)");

NullPointerException 의 80% 가 이 패턴으로 사라집니다.

멀티스레딩 — *동시에* 일을 시키는 법

왜 스레드가 필요한가

요즘 CPU 는 코어가 여러 개 입니다 (보통 8~16개). 하지만 평범한 Java 프로그램은 코어 하나만 씁니다. 나머지는 놀고 있죠. 스레드를 잘 쓰면 놀고 있는 코어 를 활용해 처리 속도가 N배 빨라집니다.

또 다른 이유는 기다림 입니다. DB 응답을 기다리는 1초 동안 CPU 는 아무것도 안 합니다. 그 시간에 다른 일을 시키면 처리량이 폭증 합니다. 이게 웹 서버가 동시에 1000명의 요청을 처리하는 비결입니다.

스레드를 만드는 3가지 방법

1. 직접 만들기: new Thread(() -> { ... }).start(). 가장 단순하지만 수동 관리 가 번거롭습니다. 1만 개 만들면 OS 가 비명 지릅니다.

2. ExecutorService: 스레드 을 만들고 작업을 던집니다. 실무 표준입니다.

java
ExecutorService pool = Executors.newFixedThreadPool(10);
for (int i = 0; i < 1000; i++) {
    pool.submit(() -> doWork());
}
pool.shutdown();

스레드 10개로 1000개 작업을 순서대로 처리합니다. 새 스레드를 매번 만드는 비용이 사라집니다.

3. CompletableFuture비동기 조합 의 끝판왕입니다. "A 와 B 를 동시에 가져와서 둘 다 끝나면 합쳐서 C 호출" 같은 흐름을 깔끔하게 표현할 수 있습니다.

java
CompletableFuture<User> userFut    = CompletableFuture.supplyAsync(() -> fetchUser());
CompletableFuture<Order> orderFut  = CompletableFuture.supplyAsync(() -> fetchOrders());
CompletableFuture<UserDto> dto = userFut.thenCombine(orderFut, UserDto::new);

Java 21 의 진짜 혁명 — Virtual Thread

오랫동안 Java 에서 스레드를 수천 개 만드는 건 위험했습니다. 각 스레드가 OS 자원을 잡아먹기 때문이죠. 보통 수백 개 가 한계였습니다.

Java 21Virtual Thread 는 이 한계를 깼습니다. JVM 이 가상으로 스레드를 관리해서 1만 개·10만 개 도 가볍게 만들 수 있습니다. I/O 대기 시간엔 자동으로 yield 해서 다른 작업이 끼어듭니다.

java
Thread.startVirtualThread(() -> doWork());

이 한 줄이 Go 의 goroutine·Kotlin coroutine 과 같은 수준의 동시성을 Java 에 가져왔습니다. 단, CPU 바운드 작업엔 효과가 없습니다 — 실제 CPU 코어 수는 그대로니까요.

가장 중요한 함정 — Race Condition

두 스레드가 같은 변수를 동시에 수정하면 결과가 예측 불가 입니다.

java
int counter = 0;
// Thread A: counter = counter + 1;  → 0 읽음 → 1 저장
// Thread B: counter = counter + 1;  → 0 읽음 → 1 저장
// 결과: counter = 1 (기대 2)

counter + 13개 명령 (읽기·더하기·쓰기) 으로 나뉘는데, 그 사이에 다른 스레드가 끼어들 수 있기 때문입니다.

해결책은 (synchronized·Lock) 또는 원자 연산 (AtomicInteger):

java
private final AtomicInteger counter = new AtomicInteger(0);
counter.incrementAndGet();   // 원자적 +1

Deadlock — 영원한 대기

두 스레드가 서로의 락을 기다리면 영원히 멈춥니다.

A 가 락 X 를 잡고 Y 를 기다리고, B 가 락 Y 를 잡고 X 를 기다리면 — 둘 다 영원히 진행 안 됩니다. 데드락입니다.

가장 흔한 원인은 락을 잡는 순서가 다른 경우입니다. 모든 스레드가 항상 같은 순서로 락을 잡도록 강제하면 데드락은 발생할 수 없습니다.

실무에서 데드락이 의심되면 jstack <PID> 로 모든 스레드의 stack trace 를 뽑습니다. Java 가 데드락을 자동으로 감지 해서 "Found one Java-level deadlock" 메시지를 출력해 줍니다.

한 번 정리

스레드 = CPU 를 더 잘 쓰는 도구. 핵심은 공유 데이터 를 어떻게 안전하게 다룰지입니다. 락·원자 연산·메시지 패싱 중 상황에 맞는 것 을 골라 쓰는 게 실무의 핵심 역량입니다.

Java 21 부터는 Virtual Thread 로 동시성의 진입장벽이 크게 낮아졌습니다. 새 프로젝트라면 적극 고려 해도 좋은 선택지입니다.

💻 📌 자주 쓰는 코드 (외울 필요 X, 참고용)
// ========================================
// 1. Stream — 필터·변환·수집 가장 자주 쓰는 패턴
// ========================================
List<Order> orders = orderRepo.findAll();

// 결제완료 주문의 사용자 이메일 (중복 제거)
List<String> emails = orders.stream()
    .filter(o -> o.getStatus() == OrderStatus.PAID)
    .map(o -> o.getUser().getEmail())
    .distinct()
    .toList();

// ========================================
// 2. 집계 — 그룹화·평균·합
// ========================================
// 부서별 직원 그룹화
Map<String, List<Employee>> byDept = employees.stream()
    .collect(Collectors.groupingBy(Employee::getDept));

// 부서별 평균 연봉
Map<String, Double> avgSalary = employees.stream()
    .collect(Collectors.groupingBy(
        Employee::getDept,
        Collectors.averagingDouble(Employee::getSalary)));

// 전체 매출 합계
BigDecimal total = orders.stream()
    .map(Order::getAmount)
    .reduce(BigDecimal.ZERO, BigDecimal::add);

// ========================================
// 3. Optional — null 안전 처리
// ========================================
Optional<User> u = userRepo.findById(id);
String name = u.map(User::getName).orElse("(이름 없음)");

u.ifPresent(user -> sendEmail(user.getEmail()));

// ========================================
// 4. CompletableFuture — 비동기 조합
// ========================================
CompletableFuture<User> userFut    = CompletableFuture.supplyAsync(() -> fetchUser(id));
CompletableFuture<List<Order>> ordersFut = CompletableFuture.supplyAsync(() -> fetchOrders(id));

CompletableFuture<UserDto> dto = userFut.thenCombine(ordersFut,
    (user, orders) -> new UserDto(user, orders));

dto.thenAccept(d -> System.out.println(d))
   .exceptionally(ex -> { log.error("실패", ex); return null; });

// ========================================
// 5. Virtual Thread (Java 21+) — 1만 동시 요청
// ========================================
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    List<Future<Response>> results = userIds.stream()
        .map(id -> executor.submit(() -> httpClient.get("/api/users/" + id)))
        .toList();
    // 사용자가 1만 명이어도 OS 스레드 1만 개 없이 처리
}

Iterator 패턴 — for-each 가 어떻게 돌아가나

for-each 는 Iterator 의 문법 설탕

java
List<String> list = List.of("a", "b", "c");
for (String s : list) {
    System.out.println(s);
}

위 코드는 컴파일 시 실제로 이렇게 변환됩니다:

java
Iterator<String> it = list.iterator();
while (it.hasNext()) {
    String s = it.next();
    System.out.println(s);
}

Iterable 인터페이스를 구현한 컬렉션은 모두 for-each 가능. ArrayList·HashSet·LinkedList 다 됩니다 (HashMap 은 entrySet/keySet/values 통해서).

Iterator 의 3가지 메서드

java
public interface Iterator<E> {
    boolean hasNext();   // 다음 요소가 있는가
    E next();            // 다음 요소를 꺼내고 커서 이동
    default void remove();  // 현재 요소 삭제 (옵션)
}

반복 중 수정의 함정 — ConcurrentModificationException

java
List<String> list = new ArrayList<>(List.of("a", "b", "c"));
for (String s : list) {
    if (s.equals("b")) list.remove(s);   // ❌ 예외 발생
}

반복 도중 컬렉션을 수정 하면 터집니다. 해결책 2가지:

java
// ✅ 1. Iterator 직접 사용 + Iterator.remove()
Iterator<String> it = list.iterator();
while (it.hasNext()) {
    if (it.next().equals("b")) it.remove();
}

// ✅ 2. removeIf (Java 8+)
list.removeIf(s -> s.equals("b"));

직접 Iterable 만들기 — 커스텀 컬렉션

java
class Range implements Iterable<Integer> {
    private final int start, end;
    Range(int s, int e) { this.start = s; this.end = e; }

    @Override
    public Iterator<Integer> iterator() {
        return new Iterator<>() {
            int cur = start;
            public boolean hasNext() { return cur < end; }
            public Integer next()    { return cur++; }
        };
    }
}

for (int i : new Range(1, 5)) System.out.println(i);  // 1,2,3,4

Iterable 만 구현하면 for-each 가능 — 이게 Java 컬렉션의 핵심 디자인입니다.

☕ 직접 실행 — List · Map · Stream

컬렉션 + Stream API 핵심. 함수형 변환·필터·집계.
☕ Java
✏️ 코드 편집기
📟 출력 결과
▶ 실행하기 버튼을 눌러보세요
💡 코드를 직접 수정하고 실행해보세요. 변수값을 바꾸거나 println을 추가해 결과를 확인하세요!
☁️ Judge0 API로 서버에서 실행 — Java / Python / JS / C++ 지원

🤖 AI 에게 이렇게 요청해보세요

이 lesson 의 개념을 알면 AI 에게 구체적으로 지시할 수 있습니다. 막연한 "고쳐줘" 가 아니라 어휘를 가진 요청 — 그게 토큰 절약의 출발점입니다.

  • "이 for 루프를 Stream API 의 map/filter/reduce 체인으로 바꿔줘"
  • "이 ArrayList 작업이 Iterator 안에서 안전하게 remove 하도록 고쳐줘"
  • "이 코드의 List 를 불변 List.of() 로 리팩토링해줘"

왜 이게 토큰을 줄이나

개념을 모를 땐 AI 답변을 받고도 "그게 뭐예요?" 를 다시 물어야 합니다. 그 "다시 물음" 이 토큰을 잡아먹습니다. 개념 한 번 익혀두면 대화가 한 번에 끝납니다.

컬렉션 + 함수형 — List·Set·Map·Lambda·Stream - Java