컬렉션 + 함수형 — List·Set·Map·Lambda·Stream
컬렉션 + 함수형 — 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 도 있습니다 — 읽기는 많고 쓰기는 드문 경우 (예: 이벤트 리스너 목록) 에 쓰입니다.
한 번 정리
> 💡 현장 기준: 90% 의 경우 ArrayList + HashMap 으로 시작합니다. 멀티스레드면 ConcurrentHashMap. 정렬이 필요하면 그때 TreeMap 으로 바꿉니다.
람다와 함수형 — Java 가 *다시 태어난* 순간
왜 Java 8 이 분기점인가
2014 년 Java 8 이 람다 표현식 과 Stream API 를 도입했습니다. 이전까지 Java 는 객체지향만의 언어 였습니다. 함수 하나 전달하려면 new Runnable() { public void run() { ... } } 같은 익명 클래스 5줄 코드를 써야 했죠.
람다는 이걸 한 줄로 줄여줍니다.
코드 길이가 줄어든 게 핵심이 아닙니다. 진짜 변화는 함수를 데이터처럼 다룰 수 있게 됐다는 점입니다. 변수에 저장하고, 다른 함수에 인자로 넘기고, 리턴할 수 있습니다.
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 은 컬렉션 데이터를 파이프라인 으로 처리하는 도구입니다. 옛날 코드는 이랬습니다:
Stream 으로 쓰면 의도가 그대로 코드에 드러납니다:
.filter 는 조건에 맞는 것만 통과. .map 은 각 원소를 변환. .sorted 는 정렬. .toList 는 수집 해서 List 로. 위에서 아래로 읽으면 데이터가 흘러가는 모습 이 그려집니다.
groupingBy — 자주 쓰는 마법
Stream 의 진가는 집계 에서 드러납니다. 부서별로 직원을 묶고 싶을 때:
한 줄로 끝납니다. SQL 의 GROUP BY 가 Java 에 들어왔다고 생각하면 됩니다. 부서별 평균 연봉 도 비슷하게:
흔한 함정 — 알아두면 좋은 것
Stream 안에서 외부 변수를 수정하면 안 됩니다. 병렬 처리 시 race condition 이 생기고, 함수형 사고 자체를 깨뜨립니다. 대신 collect·reduce 로 불변 결과 를 만들어 받아야 합니다.
parallelStream() 은 만능이 아닙니다. 작은 데이터·I/O 작업엔 오히려 느립니다. 컨텍스트 스위치 비용이 더 큽니다. CPU 가 많이 도는 큰 데이터 에서만 효과가 있습니다.
Optional 도 같은 시기에 나왔습니다. null 대신 비어있을 수 있음을 명시 하는 박스입니다. Optional<User> 를 받으면 null 일 수도 있구나 바로 알 수 있죠.
NullPointerException 의 80% 가 이 패턴으로 사라집니다.
멀티스레딩 — *동시에* 일을 시키는 법
왜 스레드가 필요한가
요즘 CPU 는 코어가 여러 개 입니다 (보통 8~16개). 하지만 평범한 Java 프로그램은 코어 하나만 씁니다. 나머지는 놀고 있죠. 스레드를 잘 쓰면 놀고 있는 코어 를 활용해 처리 속도가 N배 빨라집니다.
또 다른 이유는 기다림 입니다. DB 응답을 기다리는 1초 동안 CPU 는 아무것도 안 합니다. 그 시간에 다른 일을 시키면 처리량이 폭증 합니다. 이게 웹 서버가 동시에 1000명의 요청을 처리하는 비결입니다.
스레드를 만드는 3가지 방법
1. 직접 만들기: new Thread(() -> { ... }).start(). 가장 단순하지만 수동 관리 가 번거롭습니다. 1만 개 만들면 OS 가 비명 지릅니다.
2. ExecutorService: 스레드 풀 을 만들고 작업을 던집니다. 실무 표준입니다.
스레드 10개로 1000개 작업을 순서대로 처리합니다. 새 스레드를 매번 만드는 비용이 사라집니다.
3. CompletableFuture — 비동기 조합 의 끝판왕입니다. "A 와 B 를 동시에 가져와서 둘 다 끝나면 합쳐서 C 호출" 같은 흐름을 깔끔하게 표현할 수 있습니다.
Java 21 의 진짜 혁명 — Virtual Thread
오랫동안 Java 에서 스레드를 수천 개 만드는 건 위험했습니다. 각 스레드가 OS 자원을 잡아먹기 때문이죠. 보통 수백 개 가 한계였습니다.
Java 21 의 Virtual Thread 는 이 한계를 깼습니다. JVM 이 가상으로 스레드를 관리해서 1만 개·10만 개 도 가볍게 만들 수 있습니다. I/O 대기 시간엔 자동으로 yield 해서 다른 작업이 끼어듭니다.
이 한 줄이 Go 의 goroutine·Kotlin coroutine 과 같은 수준의 동시성을 Java 에 가져왔습니다. 단, CPU 바운드 작업엔 효과가 없습니다 — 실제 CPU 코어 수는 그대로니까요.
가장 중요한 함정 — Race Condition
두 스레드가 같은 변수를 동시에 수정하면 결과가 예측 불가 입니다.
counter + 1 은 3개 명령 (읽기·더하기·쓰기) 으로 나뉘는데, 그 사이에 다른 스레드가 끼어들 수 있기 때문입니다.
해결책은 락 (synchronized·Lock) 또는 원자 연산 (AtomicInteger):
Deadlock — 영원한 대기
두 스레드가 서로의 락을 기다리면 영원히 멈춥니다.
A 가 락 X 를 잡고 Y 를 기다리고, B 가 락 Y 를 잡고 X 를 기다리면 — 둘 다 영원히 진행 안 됩니다. 데드락입니다.
가장 흔한 원인은 락을 잡는 순서가 다른 경우입니다. 모든 스레드가 항상 같은 순서로 락을 잡도록 강제하면 데드락은 발생할 수 없습니다.
실무에서 데드락이 의심되면 jstack <PID> 로 모든 스레드의 stack trace 를 뽑습니다. Java 가 데드락을 자동으로 감지 해서 "Found one Java-level deadlock" 메시지를 출력해 줍니다.
한 번 정리
스레드 = CPU 를 더 잘 쓰는 도구. 핵심은 공유 데이터 를 어떻게 안전하게 다룰지입니다. 락·원자 연산·메시지 패싱 중 상황에 맞는 것 을 골라 쓰는 게 실무의 핵심 역량입니다.
Java 21 부터는 Virtual Thread 로 동시성의 진입장벽이 크게 낮아졌습니다. 새 프로젝트라면 적극 고려 해도 좋은 선택지입니다.
Iterator 패턴 — for-each 가 어떻게 돌아가나
for-each 는 Iterator 의 문법 설탕
위 코드는 컴파일 시 실제로 이렇게 변환됩니다:
Iterable 인터페이스를 구현한 컬렉션은 모두 for-each 가능. ArrayList·HashSet·LinkedList 다 됩니다 (HashMap 은 entrySet/keySet/values 통해서).
Iterator 의 3가지 메서드
반복 중 수정의 함정 — ConcurrentModificationException
반복 도중 컬렉션을 수정 하면 터집니다. 해결책 2가지:
직접 Iterable 만들기 — 커스텀 컬렉션
Iterable 만 구현하면 for-each 가능 — 이게 Java 컬렉션의 핵심 디자인입니다.
☕ 직접 실행 — List · Map · Stream
🤖 AI 에게 이렇게 요청해보세요
이 lesson 의 개념을 알면 AI 에게 구체적으로 지시할 수 있습니다. 막연한 "고쳐줘" 가 아니라 어휘를 가진 요청 — 그게 토큰 절약의 출발점입니다.
- ▸"이 for 루프를 Stream API 의 map/filter/reduce 체인으로 바꿔줘"
- ▸"이 ArrayList 작업이 Iterator 안에서 안전하게 remove 하도록 고쳐줘"
- ▸"이 코드의 List 를 불변 List.of() 로 리팩토링해줘"
왜 이게 토큰을 줄이나
개념을 모를 땐 AI 답변을 받고도 "그게 뭐예요?" 를 다시 물어야 합니다. 그 "다시 물음" 이 토큰을 잡아먹습니다. 개념 한 번 익혀두면 대화가 한 번에 끝납니다.