[Java] 람다, 스트림

thezz9·2025년 2월 27일

람다를 이해하려면 익명 클래스를 먼저 알아둘 필요가 있다.

1. 익명 클래스

1️⃣ 별도의 클래스 파일을 만들지 않고 코드 내에서 일회성으로 정의해 사용한다.
2️⃣ 인터페이스, 클래스(일반, 추상)의 구현과 상속을 활용해 구현할 수 있다.
3️⃣ 람다에서는 인터페이스를 사용한 익명 클래스가 활용된다.
public interface Calculator {
    int sum(int a, int b);
}
public class Main {
    public static void main(String[] args) {
    
		// 익명 클래스 활용
        Calculator calculator1 = new Calculator() {
            @Override
            public int sum(int a, int b) {
                return a + b;
            }
        };
        
        int ret1 = calculator1.sum(1, 1);
        System.out.println("ret1 = " + ret1); // 출력: ret1 = 2
    }
}

2. 람다 (Lambda)

람다는 익명 클래스를 더 간결하게 표현하는 문법이라고 생각하면 된다.
함수형 인터페이스를 통해서 구현하는 걸 권장한다.

(⭐중요) 왜 함수형 인터페이스를 통해 구현하는 걸 권장할까?

함수형 인터페이스는 단 하나의 추상 메서드만 가지도록 강제하는 어노테이션이다.
람다식을 활용하면 컴파일 시점에 컴파일러가 람다 표현식을 보고 메서드를 추론하는 과정이 일어난다.
만약 아래와 같은 코드가 있다고 가정해보자.
public interface Calculator {
    int sum(int a, int b); 
    int sum(int a, int b, int c); // 오버로딩으로 선언 가능, 모호성 발생
}

사용자가 어떤 메서드를 사용하려고 하는지 컴파일러가 판단하기 어려워진다.
따라서 모호성이 발생하지 않게, 하나의 추상 메서드만 가지도록 아래와 같이 구현하는 게 권장된다.

@FunctionalInterface // 함수형 인터페이스 선언
public interface Calculator {
    int sum(int a, int b); 
    int sum(int a, int b, int c); // 어노테이션으로 인해 선언 불가
}

하나의 추상 메서드만 작성한다면, 어노테이션이 없는 일반 인터페이스를 통해서도 사용할 수 있다.

이번엔 람다식을 매개변수로 전달하는 방법을 코드를 통해 알아보자.

1) 익명 클래스를 변수에 담아 전달

public class Main {
    public static int calculate(int a, int b, Calculator calculator) {
        return calculator.sum(a, b);
    }

    public static void main(String[] args) {

        Calculator cal1 = new Calculator() {
            @Override
            public int sum(int a, int b) {
                return a + b;
            }
        };

        // 익명 클래스를 변수에 담아 전달
        int ret2 = calculate(3, 3, cal1);
        System.out.println("ret2 = " + ret2); // 출력: ret2 = 6
    }
}

2) 람다식을 변수에 담아 전달

public class Main {
    public static int calculate(int a, int b, Calculator calculator) {
        return calculator.sum(a, b);
    }

    public static void main(String[] args) {
        Calculator cal2 = (a, b) -> a + b;
        
        // 람다식을 변수에 담아 전달
        int ret3 = calculate(4, 4, cal2);
        System.out.println("ret3 = " + ret3); // 출력: ret3 = 8
    }
}

3) 람다식을 직접 전달

public class Main {
    public static int calculate(int a, int b, Calculator calculator) {
        return calculator.sum(a, b);
    }

    public static void main(String[] args) {
        // 람다식을 직접 매개변수로 전달
        int ret4 = calculate(5, 5, (a, b) -> a + b);
        System.out.println("ret4 = " + ret4); // 출력: ret4 = 10
    }
}

이 과정을 통해 람다식을 사용하면 컴파일 과정에서 1번 과정의 코드처럼 변환된다는 것을 추론할 수 있다.
훨씬 간단해 보이기만 하는 람다식의 장단점을 알아보자.

장점
효율적으로 람다식을 활용하면 코드가 간결해지고, 재사용성이 극대화된다는 장점이 있다.

단점
람다식을 사용할 때, 연산 방식에 따라 모든 원소를 전부 순회하는 경우가 생길 수 있다.
이런 경우엔 일반 반복문보다 성능이 느릴 수도 있다.
또한 람다식을 남용하면 오히려 코드를 이해하기 어려울 수 있다.
그래서 람다식을 사용할 땐 적절한 주석을 추가하는 게 좋다.
주석 없이 무분별하게 사용하면 흔히 말하는 스파게티 코드가 될 가능성이 높다.


3. 스트림 (Stream)

1️⃣ 스트림은 데이터를 효율적으로 처리할 수 있는 흐름이다.
2️⃣ 선언형 스타일로 가독성이 굉장히 뛰어나다.
3️⃣ 컬렉션 (List, Set 등)과 함께 자주 활용된다.

각 요소를 10배로 변환 후 출력하는 예시로 for문과 스트림을 비교해보자.

for문 (명령형 스타일)

public class Main {
    public static void main(String[] args) {

        List<Integer> arrayList = new ArrayList<>(List.of(1, 2, 3, 4, 5));

        // for 명령형 스타일
        List<Integer> ret1 = new ArrayList<>();
        for (Integer num : arrayList) {
            int multipliedNum = num * 10; // 각 요소 * 10
            ret1.add(multipliedNum);
        }
        System.out.println("ret1 = " + ret1); 
    }
}

스트림 (선언형 스타일)

public class Main {
    public static void main(String[] args) {
    
        List<Integer> arrayList = new ArrayList<>(List.of(1, 2, 3, 4, 5));

        // 스트림 선언형 스타일
        List<Integer> ret2 = arrayList.stream() // 1. 데이터 준비
                          .map(num -> num * 10) // 2. 중간 연산 등록 (람다식 적용)
                 .collect(Collectors.toList()); // 3. 최종 연산
        System.out.println("ret2 = " + ret2);
    }
}

스트림의 구문을 살펴보면 총 3단계(준비 → 중간 연산 → 최종 연산)로 구성되며,
실행 과정에서는 다음과 같은 방식으로 동작하게 된다.

// 1. 데이터 준비: 스트림 생성
Stream<Integer> stream = arrayList.stream();

// 2. 중간 연산 등록: 각 요소를 10배로 변환 로직 등록
Stream<Integer> mappedStream = stream.map(num -> num * 10);

// 3. 최종 연산: 최종 결과 리스트로 변환
List<Integer> ret2 = mappedStream.collect(Collectors.toList());

(💡+추가) 짝수만 출력하고 싶을 땐 아래와 같이 filter()를 추가해주면 된다.

List<Integer> arrayList = new ArrayList<>(List.of(1, 2, 3, 4, 5));

// filter() + map()
List<Integer> ret3 = arrayList.stream() // 1. 데이터 준비: 스트림 생성
        .filter(num -> num % 2 == 0)    // 2. 중간 연산: 짝수만 필터링
        .map(num -> num * 10)           // 3. 중간 연산: 10배로 변환
        .collect(Collectors.toList());  // 4. 최종 연산: 리스트로 변환

System.out.println(ret3); // 출력: [20, 40]

스트림의 장단점 역시 알아보자.

장점
제일 큰 장점은 람다와 동일하게 가독성을 크게 향상 시킬 수 있다.

단점
단점으론 중간 연산이 체이닝으로 연결되기 때문에, 디버깅 시 어느 단계에서 문제가 발생하는지 파악이 어렵고, 소량의 데이터에서는 Stream API를 사용하는 것보다 일반 루프를 사용하는 것이 성능 측면에서 더 효율적일 가능성이 크다.


그리고 오늘 느낀 점이 하나 있다.

람다와 스트림을 1년 전에 학원에서 배웠을 땐, 처음보는 구문이라 그런지 거부감이 들었다.
그땐 왜 이걸 써야 하는지 굳이 필요성을 느끼지 못해서 복습도 하지 않았었다.
근데 곰곰히 생각해보니까, 람다와 스트림을 활용해서 프로젝트를 맡거나 유지보수를 해야 하는 상황이 올 수도 있다.
그래서 이번에 블로그에 정리하면서 제대로 공부해봤다.
아직 손에 익진 않았지만, 배우고 나니까 구현도 쉽고 가독성도 훨씬 좋다는 걸 느꼈다.
개발자는 열린 마음으로 여러 기술들을 받아들여야 한다는 걸 다시 한번 깨달은 하루다.

profile
개발 취준생

0개의 댓글