Java Stream, Spark, Reactive가 공유하는 하나의 아이디어.
map을 직접 구현하면서 람다, 제네릭, 선언형 프로그래밍의 본질을 이해한다.
개발을 하다보면 어떤 API는 너무 자연스럽게 사용하게 된다.
그 중 하나가 바로 Java의 map() 이다.
그런데 왜 존재하는지 깊게 생각해본 적 있는가?
단순히 "있으니까 쓰는" API가 아니다. map 의 본질은 데이터 변환이다.
이 글에서 얻어갈 것들
- map의 본질 을 개념이 아닌 코드 수준에서 직접 이해할 수 있다,
- 람다와 Function 인터페이스 가 왜 등장했는지 그 필연성을 이해하게 된다,
- 제네릭 을 활용해 어떤 타입에도 동작하는 범용 map을 직접 만들어 볼 수 있다.
- 명령형 vs 선언형 프로그래밍 의 차이를 코드로 직접 체감한다,
- Java Stream API 가 어떤 사고방식에서 탄생했는지, 왜 그런 구조인지 이해한다.
- filter + map 조합이 데이터 파이프라인 이라는 더 큰 개념의 시작임을 알게 된다.
map 이란 무엇인가?
map 은 mapping(매핑)의 줄임말이다. 매핑이란 어떤 값을 다른 값으로 변환하는 과정을 의미한다.
- 문자열 "1" 을 숫자 1로 바꾸는 것, "apple" 을 "APPLE"로 바꾸는 것, 학생 객체에서 이름만 꺼내는 것.
이 모든 것이 매핑이다. 하나의 데이터를 다른 형태로 변환하는 작업이다.
그렇다면 왜 map 은 어디에나 존재할까?
이유는 단순하다. 데이터 처리의 본질이 결국 "어떤 형태의 데이터를 다른 형태로 바꾸는 것" 이기 때문이다. 이름은 달라도 내부에는 동일한 철학이 흐른다. map을 이해하면 이 모든 기술이 낯설지 않게 된다.
즉 데이터 처리의 핵심은 데이터 변환이다.
이 때문에 Java Stream뿐 아니라 Spark, Reactive, Kafka Streams 등 모든 데이터 처리 프레임워크에 map 이 존재한다.
처음엔 항상 중복이 보인다
가장 단순한 방식으로 시작해보자. 문자열 리스트를 두 가지 방식으로 변환하는 코드다.
private static List<Integer> mapStringToInteger(List<String> list) {
List<Integer> numbers = new ArrayList<>();
for (String s : list) {
numbers.add(Integer.valueOf(s)); // ← 이 한 줄만 다름
}
return numbers;
}
private static List<Integer> mapStringToLength(List<String> list) {
List<Integer> numbers = new ArrayList<>();
for (String s : list) {
numbers.add(s.length()); // ← 이 한 줄만 다름
}
return numbers;
}
두 메서드를 나란히 놓고 보면 딱 한줄만 다르다.
나머지는 리스트를 순회하고, 값을 담아 반환하는 완전히 동일한 구조다. 경험 있는 개발자라면 이 순간 본능적인 불쾌감을 느낀다.
이 불쾌감을 중요하다. 좋은 코드를 향한 감각은 이 찝찝함에서 시작된다.
좋은 코드의 목표는 단순히 중복을 제거하는 것이 아니다. 변하는 것과 변하지 않는 것을 분리하는 것이다.
여기서 변하지 않는 것은 "리스트를 순회하면서 변환한 값을 모은다" 는 구조, 변하는 것은 "어떻게 변환하느냐" 다.
이런 감각이 쌓이면 개발자는 점점 더 작은 중복도 참지 못하게 된다. 그리고 그 순간이 바로 추상화가 태어나는 순간이다.
람다로 변환 전략을 외부에서 주입하기
변하는 부분, 즉 "변환 방법" 을 외부에서 주입하면 된다.
public static void main(String[] args) {
List<String> list = List.of("1", "12", "123", "1234");
// 변환 전략을 람다로 전달
Function<String, Integer> toNumber = s -> Integer.valueOf(s);
List<Integer> numbers = map(list, toNumber);
Function<String, Integer> toLength = s -> s.length();
List<Integer> lengths = map(list, toLength);
}
private static List<Integer> map(List<String> list,
Function<String, Integer> mapper) {
List<Integer> numbers = new ArrayList<>();
for (String s : list) {
Integer value = mapper.apply(s); // 전달받은 람다를 실행
numbers.add(value);
}
return numbers;
}
map 은 이제 변환 방법을 모른다.
단지 "리스트를 순회하며 mapper를 적용하고 결과를 모은다" 는 역할만 한다. 변환 전략은 완전히 외부에 위임됐다.
이 구조는 객체지향의 전략 패턴(Strategy Pattern)과 본질적으로 동일하다. 전략 패턴에서는 인터페이스를 구현한 클래스를 주입했다면, 람다에서는 클래스 없이 코드 조각 자체를 직접 주입한다.
제네릭으로 범용성 획득하기
현재 map은 String -> Integer 변환만 가능하다. 제네릭을 도입하면 어떤 타입 조합도 처리할 수 있는 범용 map이 된다.
public class GenericMapper {
public static <T, R> List<R> map(List<T> list,
Function<T, R> mapper) {
List<R> results = new ArrayList<>();
for (T item : list) {
R value = mapper.apply(item);
results.add(value);
}
return results;
}
}
T는 입력 타입, R은 출력 타입이다. String이었던 자리에 T가, Integer였던 자리에 R이 들어갔을 뿐이다.
List<String> fruits = List.of("apple", "banana", "orange");
// String → String
List<String> upper = map(fruits, s -> s.toUpperCase());
// String → Integer
List<Integer> lengths = map(fruits, s -> s.length());
// Integer → String
List<Integer> nums = List.of(1, 2, 3);
List<String> stars = map(nums, n -> "*".repeat(n));
upper = [APPLE, BANANA, ORANGE]
lengths = [5, 6, 6]
stars = [*, **, ***]
여기서 중요한 건 단순히 코드가 유연해졌다는게 아니다.
타입에 대한 추상화 수준이 올라갔다는 것이다.
처음 구현에서 코드는 이렇게 생각했다. "String 을 Integer 로 바꾼다"
제네릭을 도입 시 "어떤 타입이든 다른 타입으로 변환할 수 있다."
문제를 바라보는 관점이 구체적인 타입 → 변환이라는 행위 자체로 올라간다. 이것이 고차 함수(Higher-Order Function)의 특징이다.
즉 행위 자체(변환)에 집중하게 된다 → 고차 함수(Higher-Order Function) 특징
filter + map: 데이터 파이프라인의 탄생
filter 로 걸러내고 map으로 변환하는 조합은 강력하다. 이 두 가지를 함께 사용하는 순간 단순한 유틸리티가 아니라 데이터 파이프라인이라는 더 큰 개념이 모습을 드러낸다.
학생 목록에서 80점 이상인 학생의 이름만 추출해보자.
IN
List<Student>
Apple/100, Banana/80, Berry/50, Tomato/40
▼
filter( score >= 80 )
Apple/100, Banana/80 → 통과
▼
map( s → s.getName() )
Student → String 변환
OUT
List<String>
["Apple", "Banana"]
명령형 스타일 - 어떻게 (HOW)
static List<String> direct(List<Student> students) {
List<String> result = new ArrayList<>();
for (Student s : students) {
if (s.getScore() >= 80) {
result.add(s.getName());
}
}
return result;
}
- 집중 포인트: 어떻게 순회하고, 조건 검사하고, 결과를 수집할지
- 문제점: 실행 흐름을 머릿속에서 시뮬레이션해야 로직을 이해
- 중복/변화: 조건과 수집 로직이 코드 곳곳에 섞여 있음
선언형 스타일 - 무엇을(WHAT)
- 코드 자체가 요구사항을 표현
static List<String> lambda(List<Student> students) {
List<Student> filtered =
GenericFilter.filter(students,
s -> s.getScore() >= 80); // 80점 이상만
List<String> names =
GenericMapper.map(filtered,
s -> s.getName()); // 이름만 추출
return names;
}
- 집중 포인트: "80점 이상 학생의 이름"이라는 의도 자체를 선언
- 장점: 코드만 봐도 요구사항과 데이터 흐름이 직관적으로 이해됨
- 추상화: 변하는 부분(람다)과 변하지 않는 부분(순회/수집) 완전히 분리
- 재사용성: 제네릭과 고차 함수를 활용하면 다른 타입/조건에도 그대로 재사용 가능
direct() 는 for 와 if 를 직접 제어한다. 어떻게 할지를 설명한다.
lambda() 는 filter와 map 으로 선언한다. 무엇을 원하는지 말한다.
같은 결과를 내지만 코드를 읽는 방식이 완전히 다르다. direct() 는 실행 흐름을 따라가야 의도가 보이고, lambad() 는 코드 자체가 요구사항이다.
이게 filter + map 조합이 단순한 유틸리티를 넘어서는 이유다. 데이터가 어떤 조건으로 걸러지고, 어떤 형태로 변환되는지를 파이프라인처럼 선언적으로 표현할 수 있게 된다.
핵심 차이
| 구분 | 명령형(HOW) | 선언형(WHAT) |
| 사고 초점 | 어떻게 구현할까 | 무엇을 원하는가 |
| 로직 이해 | 실행 흐름 시뮬레이션 필요 | 코드만 읽어도 요구사항 직관적 |
| 코드 구조 | 조건/순회/수집 혼합 | 변하는 부분과 변하지 않는 부분 분리 |
| 재사용성 | 타입마다 코드 반복 필요 | 제네릭+람다로 범용적 |
| 가독성 | 복잡해질수록 난해 | 요구사항이 코드 그대로 드러남 |
결론: map + 람다 + 제네릭 + 선언형 패턴은, 단순히 문법이 아니라 데이터 변환 사고방식을 '무엇을 원하는가' 중심으로 바꾸는 도구다.
명령형 vs 선언형 : 사고 방식의 전환
두 스타일의 차이는 단순히 코드 길이가 아니다. 문제를 바라보는 방식 자체가 다르다.
명령형 프로그래밍
- 어떻게(HOW) 할 것인가에 집중
- 각 단계를 명확하게 지시
- 상태 변화를 직접 관리
- 로직이 복잡해질수록 코드도 복잡해짐
- 세밀한 제어가 필요할 때 유리
선언형 프로그래밍
- 무엇을(WAHT) 할 것인가에 집중
- 원하는 결과를 선언
- 내부 구현은 추상화
- 요구사항이 코드에 그대로 드러남
- 가독성과 유지보수에 유리
| 스타일 | 초점 | 특징 |
| 명령형 | 어떻게(HOW) | for, if, 상태 직접 제어, 로직 따라가며 이해 필요 |
| 선언형 | 무엇(WHAT) | 요구사항을 직접 표현, 내부 구현 추상화, 가독성·유지보수 유리 |
선언형 코드는 코드가 곧 문서가 된다.
filter(score >= 80)를 읽으면 "80점 이상 필터: 라는 비즈니스 요구사항이 한눈에 보인다.
반면 명령형 코드는 for, if, add가 뒤섞여 의도를 파악하려면 머릿속에서 시뮬레이션을 돌려야한다. 비즈니스 로직이 복잡해질수록 이 차이는 더 극명하게 드러난다.
좋은 코드는 대부분 "무엇을 하고 싶은지" 가 더 잘 드러나는 코드다.
결국 중요한 건 사고 방식이다.
글 앞에서 이 글에서 얻어갈 것들을 나열했다. 하나씩 되짚어보자.
map 의 본질은 변환이다. 어떤 값을 다른 값으로 바꾸는 행위. 단순한 메서드가 아니라 데이터 처리의 본질을 표현하는 추상화다.
- 람다 → 변환 전략을 외부에서 주입
- 제네릭 → 타입 추상화
- 선언형 → 무엇을 원하는지 명시
결국 데이터를 어떻게 변환할 것인가라는 사고 방식 자체를 바꿔주는 도구가 바로 map 이다.
map이 바꾸는 사고방식: 전후 비교
| 전(명령형) | 후(선언형) |
| 어떻게(HOW) 할지를 고민 | 무엇(WHAT)을 원하는지 표현 |
| for/if로 직접 순회, 조건 체크, 결과 담기 | filter/map으로 의도 그대로 선언 |
| 실행 흐름을 머릿속으로 시뮬레이션해야 로직 이해 | 코드만 읽어도 요구사항과 데이터 흐름 직관적 이해 |
| 중복과 변하는 부분이 코드 곳곳에 흩어짐 | 변하는 부분(람다)과 변하지 않는 부분(순회/수집) 분리 |
| 타입마다 별도 코드 필요 | 제네릭으로 모든 타입에서 재사용 가능 |
핵심 차이:
map + 람다 + 제네릭 + 선언형 스타일을 쓰면, “어떻게 구현할지”가 아니라 “무엇을 원하는지”에 집중할 수 있고, 데이터 변환 로직을 추상화하여 재사용과 가독성이 극대화된다.
람다는 "변하는 것" 을 꺼내기 위해 등장했다. 두 메서드가 한 줄만 다를 때, 그 한 줄을 밖으로 꺼내는 도구가 람다다. Function<T ,R> 은 그 계약이다.
제네릭은 타입이라는 구체성을 지웠다. String -> Integer 가 아니라, T ->R. "변환한다" 는 행위만 남긴 것이다.
명령형은 어떻게를, 선언형은 무엇을 말한다. for/if 로 흐름을 제어하는 코드와, filter / map 으로 의도를 선언하는 코드는 읽히는 방식 자체가 다르다.
Java Stream API 는 이 모든 것의 완성형이다. 우리가 GenericFilter, GenericMapper 를 직접 만든 것처럼, Java 가 범용적으로 만들어둔 것이 Stream 이다. filter + map 의 체이닝, 그게 Stream 의 본질이다.
filter + map 은 데이터 파이프 라인의 시작이다. Spark도, Reactive도, Kafka Streams도 결국 같은 질문에서 출발한다. "데이터를 어떻게 걸러내고, 어떤 형태로 바꿀 것인가"
이걸 직접 만들면 어떤 구조가 나올까?
새로운 API 를 배울 때마다 이 질문을 한 번씩 던져보는 것을 권한다. 직접 구현해 보면 단순한 문법 뒤에 숨어 있던 설계 의도가 보이기 시작한다.
stream().map() 한 줄 안에는 세 가지가 들어 있다. 변하는 것과 변하지 않은 것의 분리, 타입을 넘어선 추상화, 의도를 드러내는 코드. 그게 람다와 map 이 단순한 문법 설탕이 아닌 이유다.
'JAVA' 카테고리의 다른 글
| 테스트가 어렵다면 코드 탓이 아니라 설계 탓이다 - 숨겨진 의존성의 진실 (1) | 2026.03.08 |
|---|---|
| 스프링 데이터 JPA는 인터페이스만 정의했는데 어떻게 동작할까? (0) | 2025.10.07 |
| 외부 연동 방식 변경과 어댑터 패턴 적용 (3) | 2025.08.03 |
| 설계원칙 1- 단일 추상화 수준 원칙과 조합메서드 (0) | 2025.06.27 |
| 5-1 단일 책임 원칙 (1) | 2025.05.29 |