JAVA

왜 모든 데이터 처리에는 map 이 존재할까?

경딩 2026. 3. 12. 01:26

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 이 단순한 문법 설탕이 아닌 이유다.