728x90
반응형
SMALL

참고자료

 

김영한의 실전 자바 - 고급 3편, 람다, 스트림, 함수형 프로그래밍 강의 | 김영한 - 인프런

김영한 | , [사진]국내 개발 분야 누적 수강생 1위,제대로 만든 김영한의 실전 자바[사진][임베딩 영상]단순히 자바 문법을 안다? 이걸로는 안됩니다!전 우아한형제들 기술이사, 누적 수강생 40만

www.inflearn.com

 

단일 스트림

자바 병렬 스트림을 제대로 이해하려면, 스트림은 물론이고, 멀티스레드, Fork/Join 프레임워크에 대한 기본 지식이 필요하다. 여기서는 단일 스트림부터 시작해서 멀티스레드, 스레드 풀, Fork/Join 프레임워크, 병렬 스트림으로 이어지는 전체 과정을 예제를 통해 점진적으로 알아가보자.

 

병렬 스트림 준비 예제

package util;

import java.time.LocalTime;
import java.time.format.DateTimeFormatter;

public class MyLogger {
    private static final DateTimeFormatter formatter = DateTimeFormatter.ofPattern("HH:mm:ss.SSS");

    public static void log(Object object) {
        String time = LocalTime.now().format(formatter);
        System.out.printf("%s [%9s] %s\n", time, Thread.currentThread().getName(), object);
    }
}
  • MyLogger 클래스는 현재 시간, 스레드 이름, 그리고 전달받은 객체를 로그로 출력한다. 이 클래스를 사용하면 어떤 스레드에서 어떤 작업이 실행되는지 시간과 함께 확인할 수 있다.
package parallel;

import util.MyLogger;

public class HeavyJob {
    public static int heavyTask(int i) {
        MyLogger.log("calculate " + i + " -> " + i * 10);
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
        return i * 10;
    }

    public static int heavyTask(int i, String name) {
        MyLogger.log("[" + name + "] " + i + " -> " + i * 10);
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
        return i * 10;
    }
}
  • HeavyJob 클래스는 오래 걸리는 작업을 시뮬레이션하는데, 각 작업은 1초 정도 소요된다고 가정해보자. 입력값에 10을 곱한 결과를 반환하며, 작업이 실행될때마다 로그를 출력한다.
  • 추가로 로그를 찍어서 어느 스레드가 이 작업을 처리 중인지 확인할 수 있다.

 

예제1 - 단일 스트림

먼저, 단일 스트림으로 IntStream.rangeClosed(1, 8)에서 나온 1부터 8까지의 숫자 각각에 대해 heavyTask()를 순서대로 수정해보자.

package parallel;

import java.util.stream.IntStream;

import static util.MyLogger.log;

public class ParallelMain1 {
    public static void main(String[] args) {
        long startTime = System.currentTimeMillis();

        int sum = IntStream.rangeClosed(1, 8)
                .map(HeavyJob::heavyTask)
                .reduce(0, Integer::sum);

        long endTime = System.currentTimeMillis();

        log("time : " + (endTime - startTime) + "ms, sum : " + sum);
    }
}
  • map(HeavyJob::heavyTask)로 1초씩 걸리는 작업을 8번 순차로 호출하므로, 약 8초가 소요된다.
  • 마지막에 reduce(0, (a, b) -> a + b) 또는 sum()으로 최종 결과를 합산한다.
  • 결과적으로 단일 스레드에서 작업을 순차적으로 수행하기 때문에 로그에도 [main] 스레드만 표시된다.

실행 결과

10:03:12.849 [     main] calculate 1 -> 10
10:03:13.857 [     main] calculate 2 -> 20
10:03:14.859 [     main] calculate 3 -> 30
10:03:15.865 [     main] calculate 4 -> 40
10:03:16.869 [     main] calculate 5 -> 50
10:03:17.873 [     main] calculate 6 -> 60
10:03:18.882 [     main] calculate 7 -> 70
10:03:19.885 [     main] calculate 8 -> 80
10:03:20.900 [     main] time : 8062ms, sum : 360
  • 실제 출력 로그를 보면, calculate 1 -> 10, calculate 2 -> 20, ... , calculate 8 -> 80 등이 순서대로 찍힌다.
  • 전체 시간이 8초 정도 걸리는 것을 확인할 수 있다.

8초는 너무 오래 걸린다. 스레드를 사용해서 실행 시간을 단축해보자.

 

스레드 직접 사용

앞서, 하나의 메인 스레드로 1 ~ 8의 범위를 모두 계산했다. 이제 여러 스레드를 동시에 사용해서 작업을 더 빨리 처리해보자.

각 스레드는 한 번에 하나의 작업만 처리할 수 있다. 따라서, 1 ~ 8을 처리하는 큰 단위의 작업을 더 작은 단위의 작업으로 분할해야 한다. 여기서는 1 ~ 8의 큰 작업을 1 ~ 4, 5 ~ 8과 같이 절반으로 분할해서 두 개의 스레드로 처리해보자.

 

예제2 - 스레드 직접 사용

package parallel;

import static util.MyLogger.log;

public class ParallelMain2 {
    public static void main(String[] args) throws InterruptedException {
        long startTime = System.currentTimeMillis();

        SumTask task1 = new SumTask(1, 4);
        SumTask task2 = new SumTask(5, 8);

        Thread thread1 = new Thread(task1, "thread-1");
        Thread thread2 = new Thread(task2, "thread-2");

        thread1.start();
        thread2.start();

        thread1.join();
        thread2.join();
        log("main 스레드 대기 완료");

        int sum = task1.result + task2.result;

        long endTime = System.currentTimeMillis();

        log("time : " + (endTime - startTime) + "ms, sum : " + sum);
    }

    static class SumTask implements Runnable {

        int startValue;
        int endValue;
        int result = 0;

        public SumTask(int startValue, int endValue) {
            this.startValue = startValue;
            this.endValue = endValue;
        }

        @Override
        public void run() {
            log("작업 시작");
            int sum = 0;
            for (int i = startValue; i <= endValue; i++) {
                int calculated = HeavyJob.heavyTask(i);
                sum += calculated;
            }
            result = sum;
            log("작업 완료. result = " + result);
        }
    }
}
  • SumTaskRunnable을 구현했고, 내부에서 1초씩 걸리는 heavyTask()를 루프를 돌면서 합산한다.
  • new SumTask(1, 4), new SumTask(5, 8)을 통해 작업을 두 개로 분할한다.
  • thread1.start(), thread2.start()로 각 스레드가 동시에 작업을 시작하고, thread1.join(), thread2.join()으로 두 스레드가 끝날 때까지 main 스레드가 대기한다.
  • 작업 완료 후 task1, task2의 결과를 더해서 최종 합계를 구한다.

실행 결과

10:09:11.720 [ thread-1] 작업 시작
10:09:11.720 [ thread-2] 작업 시작
10:09:11.727 [ thread-1] calculate 1 -> 10
10:09:11.727 [ thread-2] calculate 5 -> 50
10:09:12.729 [ thread-2] calculate 6 -> 60
10:09:12.729 [ thread-1] calculate 2 -> 20
10:09:13.730 [ thread-2] calculate 7 -> 70
10:09:13.730 [ thread-1] calculate 3 -> 30
10:09:14.732 [ thread-2] calculate 8 -> 80
10:09:14.732 [ thread-1] calculate 4 -> 40
10:09:15.742 [ thread-2] 작업 완료. result = 260
10:09:15.742 [ thread-1] 작업 완료. result = 100
10:09:15.743 [     main] main 스레드 대기 완료
10:09:15.750 [     main] time : 4041ms, sum : 360
  • thread-1, thread-2가 작업을 분할해서 처리했기 때문에 8초의 작업을 4초로 줄일 수 있었다.
  • 하지만, 이렇게 스레드를 직접 사용하면 스레드 수가 늘어나면 코드가 복잡해지고, 예외 처리, 스레드 풀 관리 등 추가 관리 포인트가 생기는 문제가 있다.

 

스레드 풀 사용

이번엔 자바가 제공하는 ExecutorService를 사용해서 더 편리하게 병렬 처리를 해보자.

예제3 - 스레드 풀

package parallel;

import java.util.concurrent.*;

import static util.MyLogger.log;

public class ParallelMain3 {
    public static void main(String[] args) throws InterruptedException, ExecutionException {

        ExecutorService es = Executors.newFixedThreadPool(2);

        long startTime = System.currentTimeMillis();

        SumTask task1 = new SumTask(1, 4);
        SumTask task2 = new SumTask(5, 8);

        Future<Integer> future1 = es.submit(task1);
        Future<Integer> future2 = es.submit(task2);

        Integer i1 = future1.get();
        Integer i2 = future2.get();
        log("main 스레드 대기 완료");

        int sum = i1 + i2;

        long endTime = System.currentTimeMillis();

        log("time : " + (endTime - startTime) + "ms, sum : " + sum);
    }

    static class SumTask implements Callable<Integer> {

        int startValue;
        int endValue;

        public SumTask(int startValue, int endValue) {
            this.startValue = startValue;
            this.endValue = endValue;
        }

        @Override
        public Integer call() {
            log("작업 시작");
            int sum = 0;
            for (int i = startValue; i <= endValue; i++) {
                int calculated = HeavyJob.heavyTask(i);
                sum += calculated;
            }
            log("작업 완료. result = " + sum);
            return sum;
        }
    }
}
  • Executors.newFixedThreadPool(2)로 스레드 풀을 만든다. 이 스레드 풀은 최대 2개의 스레드를 제공한다.
  • new SumTask(1, 4), new SumTask(5, 8)을 통해 작업을 두 개로 분할한다.
  • submit(Callable)로 스레드 풀에 작업을 맡기면 Future 객체를 반환 받는다.
  • 메인 스레드는 future.get()을 통해 실제 계산 결과가 반환될 때까지 대기(join과 유사)한다.

이 예제는 스레드 풀과 Future를 사용해서 결과값을 반환받는 방식으로 구현되었다. 작업이 완료되면 Futureget() 메서드를 통해 결과를 얻는다. 참고로, get() 메서드는 블로킹 메서드이다. 이전 예제와 마찬가지로 2개의 스레드가 병렬로 계산을 처리하므로 약 4초가 소요된다.

 

실행 결과

10:14:49.798 [pool-1-thread-1] 작업 시작
10:14:49.798 [pool-1-thread-2] 작업 시작
10:14:49.806 [pool-1-thread-1] calculate 1 -> 10
10:14:49.806 [pool-1-thread-2] calculate 5 -> 50
10:14:50.807 [pool-1-thread-2] calculate 6 -> 60
10:14:50.807 [pool-1-thread-1] calculate 2 -> 20
10:14:51.809 [pool-1-thread-2] calculate 7 -> 70
10:14:51.810 [pool-1-thread-1] calculate 3 -> 30
10:14:52.811 [pool-1-thread-2] calculate 8 -> 80
10:14:52.811 [pool-1-thread-1] calculate 4 -> 40
10:14:53.821 [pool-1-thread-1] 작업 완료. result = 100
10:14:53.821 [pool-1-thread-2] 작업 완료. result = 260
10:14:53.826 [     main] main 스레드 대기 완료
10:14:53.833 [     main] time : 4046ms, sum : 360
  • 이전 예제처럼 스레드가 2개이므로, 각각 4개씩 나눠 처리한다.
  • Future로 반환값을 쉽게 받아올 수 있기 때문에, 결과값을 합산하는 과정이 더 편리해졌다. 하지만 여전히 코드 레벨에서 분할/병합 로직을 직접 짜야하고, 스레드 풀 생성과 관리도 개발자가 직접해야 한다. 

 

Fork/Join 패턴

분할(Fork), 처리(Execute), 모음(Join)

스레드는 한번에 하나의 작업을 처리할 수 있다. 따라서 하나의 큰 작업을 여러 스레드가 처리할 수 있는 작은 단위의 작업으로 분할(Fork)해야 한다. 그리고 이렇게 분할한 작업을 각각의 스레드가 처리(Execute)하는 것이다. 각 스레드의 분할된 작업 처리가 끝나면 분할된 결과를 하나로 모아야(Join) 한다. 

 

이렇게 분할(Fork) → 처리(Execute) → 모음(Join)의 단계로 이루어진 멀티스레드 패턴을 Fork/Join 패턴이라고 부른다. 이 패턴은 병렬 프로그래밍에서 매우 효율적인 방식으로, 복잡한 작업을 병렬적으로 처리할 수 있게 해준다. 지금까지 우리는 이 과정을 다음과 같이 직접 처리했다. 우리가 진행했던 예제를 그림과 함께 다시 정리해보자. 

 

 

1. 작업 분할(Fork)

  • 1 ~ 8 분할
  • 1 ~ 4: thread-1 처리
  • 5 ~ 8: thread-2 처리

1 ~ 8의 작업을 절반으로 분할하자. 그래서 1 ~ 4의 작업은 thread-1이 처리하고, 5 ~ 8의 작업은 thread-2가 처리하는 것이다. 이렇게 하면 작업의 수를 늘려서 여러 스레드가 동시에 많은 작업을 처리할 수 있다. 예제에서는 하나의 스레드가 처리하던 작업을 두 개의 스레드가 처리하므로 처리 속도를 최대 2배로 늘릴 수 있다. 

 

이렇게 큰 작업을 여러 작은 작업으로 쪼개어(Fork) 각각의 스레드나 작업 단위로 할당하는 것을 포크(Fork)라 한다. 참고로, Fork라는 이름은 식당에서 사용하는 포크가 여러 갈래로 나뉘어 있는 모양을 떠올려보면 된다. 이처럼 하나의 큰 작업을 여러 작은 작업으로 분할하는 것을 포크라고 한다.

 

2. 처리(Execute)

  • 1 ~ 4 처리(thread-1)
  • 5 ~ 8 처리(thread-2)

thread-1, thread-2는 분할된 각각의 작업을 처리한다. 

 

3. Join 모음, 결과 합치기

분할된 작업들이 모두 끝나면, 각 스레드 혹은 작업 단위별 결과를 하나로 합쳐야 한다. 예제에서는 thread1.join(), thread2.join()을 통해 모든 스레드가 종료되길 기다린 뒤, task1.result + task2.result로 최종 결과를 계산한다. Join은 이렇게 갈라진 작업들이 모두 끝난 뒤, 다시 합류하여 하나로 결과를 모으는 모습을 의미한다.

 

정리

지금까지 작업을 직접 분할하고, 처리하고, 처리된 결과를 합쳤다. 이러한 분할 → 처리(작업 병렬 실행) → 모음의 과정을 더 편리하게 구현할 수 있는 방법은 없을까? 자바는 Fork/Join 프레임워크를 제공해서 개발자가 이러한 패턴을 더 쉽게 구현할 수 있도록 지원한다.

 

Fork/Join 프레임워크 - 1

자바의 Fork/Join 프레임워크는 자바 7부터 도입된 java.util.concurrent 패키지의 일부로, 멀티코어 프로세서를 효율적으로 활용하기 위한 병렬 처리 프레임워크이다. 주요 개념은 다음과 같다.

 

분할 정복(Divide and Conquer) 전략

  • 큰 작업(task)을 작은 단위로 재귀적으로 분할(fork)
  • 각 작은 작업의 결과를 합쳐(join) 최종 결과를 생성
  • 멀티코어 환경에서 작업을 효율적으로 분산 처리

작업 훔치기(Work Stealing) 전략

  • 각 스레드는 자신의 작업 큐를 가짐
  • 작업이 없는 스레드는 다른 바쁜 스레드의 큐에서 작업을 "훔쳐와서" 대신 처리
  • 부하 균형을 자동으로 조절하여 효율성 향상

주요 클래스

Fork/Join 프레임워크를 이루는 주요 클래스는 다음과 같다.

  • ForkJoinPool
  • ForkJoinTask
    • RecursiveTask
    • RecursiveAction

 

ForkJoinPool

  • Fork/Join 작업을 실행하는 특수한 ExecutorService 스레드 풀
  • 작업 스케쥴링 및 스레드 관리를 담당
  • 기본적으로 사용 가능한 프로세서 수만큼 스레드 생성 (예: CPU 코어가 10 코어면 10개의 스레드 생성)
  • 쉽게 이야기해서, 분할 정복과 작업 훔치기에 특화된 스레드 풀이다.
// 기본 풀 생성 (프로세서 수에 맞춰 스레드 생성)
ForkJoinPool pool = new ForkJoinPool();

// 특정 병렬 수준으로 풀 생성
ForkJoinPool customPool = new ForkJoinPool(4);

 

ForkJoinTask

  • ForkJoinTaskFork/Join 작업의 기본 추상 클래스다.
  • Future를 구현했다.
  • 개발자는 주로 다음 두 하위 클래스를 구현해서 사용한다.
    • RecursiveTask<V>: 결과를 반환하는 작업
    • RecursiveAction: 결과를 반환하지 않는 작업 (void)

 

RecursiveTask / RecursiveAction의 구현 방법

  • compute() 메서드를 재정의해서 필요한 작업 로직을 작성한다.
  • 일반적으로, 일정 기준(임계값)을 두고 작업 범위가 작으면 직접 처리하고, 크면 작업을 둘로 분할하여 각각 병렬 처리하도록 구현한다.

 

fork() / join() 메서드

  • fork(): 현재 스레드에서 다른 스레드로 작업을 분할하여 보내는 동작(비동기 실행)
  • join(): 분할된 작업이 끝날 때까지 기다린 후 결과를 가져오는 동작
참고로, Fork/Join 프레임워크를 실무에서 직접적으로 다룰 일은 드물다. 따라서 이런게 있다 정도만 알아두고 넘어가자. 개념만 알아두면 충분하다.

 

Fork/Join 프레임워크 활용

실제 Fork/Join 프레임워크를 사용해서 우리가 앞서 처리한 예시를 개발해보자. 기본적인 처리 방식은 다음과 같다.

핵심은 작업의 크기가 임계값보다 크면 분할하고, 임계값보다 같거나 작으면 직접 처리하는 것이다. 예를 들어, 작업의 크기가 8이고, 임계값이 4라고 가정해보자.

  1. Fork: 작업의 크기가 8이면 임계값을 넘었다. 따라서, 작업을 절반으로 분할한다.
  2. Execute: 다음으로 작업의 크기가 4라면 임계값 범위 안에 들어오므로 작업을 분할하지 않고, 처리한다.
  3. Join: 최종 결과를 합친다.

Fork/Join 프레임워크를 사용하려면, RecursiveTask.compute() 메서드를 재정의해야 한다. 다음에 작성한 SumTaskRecursiveTask<Integer>를 상속받아 리스트의 합을 계산하는 작업을 병렬로 처리하는 클래스이다. 이 클래스는 Fork/Join 프레임워크의 분할 정복 전략을 구현한다.

 

package parallel.forkjoin;

import parallel.HeavyJob;

import java.util.List;
import java.util.concurrent.RecursiveTask;

import static util.MyLogger.log;

public class SumTask extends RecursiveTask<Integer> {

    private static final int THRESHOLD = 4;

    private final List<Integer> list;

    public SumTask(List<Integer> list) {
        this.list = list;
    }

    @Override
    protected Integer compute() {
        if (list.size() <= THRESHOLD) {
            log("[처리 시작] " + list);
            int sum = list.stream()
                    .mapToInt(HeavyJob::heavyTask)
                    .sum();
            log("[처리 완료] " + list + " -> sum: " + sum);
            return sum;
        } else {
            int mid = list.size() / 2;
            List<Integer> leftList = list.subList(0, mid);
            List<Integer> rightList = list.subList(mid, list.size());
            log("[분할] " + list + " -> LEFT: " + leftList + ", RIGHT: " + rightList);

            SumTask leftTask = new SumTask(leftList);
            SumTask rightTask = new SumTask(rightList);

            // 왼쪽 작업은 다른 스레드에서 처리
            leftTask.fork();
            // 오른쪽 작업은 현재 스레드에서 처리
            Integer rightResult = rightTask.compute();

            // 왼쪽 작업 결과를 기다림
            Integer leftResult = leftTask.join();
            int joinSum = leftResult + rightResult;
            log("LEFT[" + leftResult + "]" + "RIGHT[" + rightResult + "] -> sum: " + joinSum);
            return joinSum;
        }
    }
}
  • THRESHOLD (임계값): 작업을 더 이상 분할하지 않고 직접 처리할 리스트의 크기를 정의한다. 여기서는 4로 설정되어, 리스트 크기가 4 이하일 때 직접 계산한다. 4보다 크면 작업을 분할한다.
  • 작업 분할: 리스트의 크기가 임계값보다 크면, 리스트를 반으로 나누어 leftListrightList로 분할한다.
  • fork(), compute():
    • fork()는 왼쪽 작업을 다른 스레드에 위임하여 병렬로 처리한다.
    • compute()는 오른쪽 작업을 현재 스레드에서 직접 수행한다(재귀 호출)
  • join(): 분할된 왼쪽 작업이 완료될 때까지 기다린 후 결과를 가져온다.
  • 결과 합산: 왼쪽과 오른쪽 결과를 합쳐 최종 결과를 반환한다.
package parallel.forkjoin;

import java.util.List;
import java.util.concurrent.ForkJoinPool;
import java.util.stream.IntStream;

import static util.MyLogger.log;

public class ForkJoinMain1 {
    public static void main(String[] args) {
        List<Integer> data = IntStream.rangeClosed(1, 8)
                .boxed()
                .toList();

        log("[생성] " + data);

        long startTime = System.currentTimeMillis();

        ForkJoinPool pool = new ForkJoinPool(10);
        SumTask sumTask = new SumTask(data);

        Integer result = pool.invoke(sumTask);
        pool.close();

        long endTime = System.currentTimeMillis();

        log("time : " + (endTime - startTime) + "ms, sum : " + result);
        log("pool: " + pool);
    }
}
  • 데이터 생성: IntStream.rangeClosed(1, 8)를 사용해 1부터 8까지의 숫자 리스트를 생성한다.
  • ForkJoinPool 생성:
    • new ForkJoinPool(10)으로 최대 10개의 스레드를 사용할 수 있는 풀을 생성한다.
    • 참고로, 기본 생성자(new ForkJoinPool())를 사용하면 시스템의 프로세서 수에 맞춰 스레드가 생성된다.
  • invoke(): 메인 스레드가 pool.invoke(sumTask)를 호출하면, SumTask를 스레드 풀에 전달한다. SumTaskForkJoinPool에 있는 별도의 스레드에서 실행된다. 메인 스레드는 작업이 완료될 때까지 기다린 후 결과를 받는다.
  • pool.close(): 더 이상 작업이 없으므로 풀을 종료한다.
  • 결과 출력:계산된 리스트의 합과 실행 시간을 출력한다.

실행 결과

11:13:31.391 [     main] [생성] [1, 2, 3, 4, 5, 6, 7, 8]
11:13:31.400 [ForkJoinPool-1-worker-1] [분할] [1, 2, 3, 4, 5, 6, 7, 8] -> LEFT: [1, 2, 3, 4], RIGHT: [5, 6, 7, 8]
11:13:31.401 [ForkJoinPool-1-worker-1] [처리 시작] [5, 6, 7, 8]
11:13:31.401 [ForkJoinPool-1-worker-2] [처리 시작] [1, 2, 3, 4]
11:13:31.405 [ForkJoinPool-1-worker-2] calculate 1 -> 10
11:13:31.404 [ForkJoinPool-1-worker-1] calculate 5 -> 50
11:13:32.406 [ForkJoinPool-1-worker-1] calculate 6 -> 60
11:13:32.406 [ForkJoinPool-1-worker-2] calculate 2 -> 20
11:13:33.408 [ForkJoinPool-1-worker-1] calculate 7 -> 70
11:13:33.409 [ForkJoinPool-1-worker-2] calculate 3 -> 30
11:13:34.409 [ForkJoinPool-1-worker-1] calculate 8 -> 80
11:13:34.410 [ForkJoinPool-1-worker-2] calculate 4 -> 40
11:13:35.418 [ForkJoinPool-1-worker-1] [처리 완료] [5, 6, 7, 8] -> sum: 260
11:13:35.419 [ForkJoinPool-1-worker-2] [처리 완료] [1, 2, 3, 4] -> sum: 100
11:13:35.424 [ForkJoinPool-1-worker-1] LEFT[100]RIGHT[260] -> sum: 360
11:13:35.430 [     main] time : 4032ms, sum : 360
11:13:35.431 [     main] pool: java.util.concurrent.ForkJoinPool@5def115d[Terminated, parallelism = 10, size = 0, active = 0, running = 0, steals = 2, tasks = 0, submissions = 0]
  • 작업이 2개로 분할되어서 총 4초의 시간이 걸린 것을 확인할 수 있다.

 

작업 시작

  1. main 스레드가 invoke(sumTask)를 호출해서
  2. ForkJoinPool에 작업을 요청
  3. 스레드 풀은 스레드를 꺼내서 작업을 실행. 여기서는 ForkJoinPool-1-worker-1 스레드가 실행됨. 줄여서 w1 이라고 표현.
  4. w1 스레드는 task(SumTask)compute()를 호출

 

작업 분할

[w1] [분할] [1, 2, 3, 4, 5, 6, 7, 8] -> LEFT[1, 2, 3, 4], RIGHT[5, 6, 7, 8]
  1. 리스트 크기가 THRESHOLD(4)보다 크므로 분할됨
  2. [1,2,3,4,5,6,7,8]LEFT[1,2,3,4]RIGHT[5,6,7,8]로 나뉨
  3. w1은 분할한 왼쪽 리스트인 LEFT[1,2,3,4]fork(leftTask)를 호출해서 다른 스레드가 작업을 처리하도록 요청함
  4. w1은 분할한 오른쪽 리스트인 RIGHT[5,6,7,8]는 자기 자신의 메서드인 compute(rightTask)를 호출해서 자기 자신이 스스로 처리함 (재귀 호출)

 

병렬 처리

  • 각 스레드가 동시에 HeavyJob.heavyTask()를 실행하며 병렬로 계산
  • w1 스레드가 [5,6,7,8]을 순서대로 처리
  • w2 스레드가 [1,2,3,4]를 순서대로 처리
  • [1,2,3,4] 작업의 합은 100, [5,6,7,8]의 작업의 합은 260

 

작업 완료

  • 최종 결과의 합을 구하기 위해 w1 스레드는 w2 스레드의 작업에 join() 메서드를 호출해서 w2의 결과를 기다림
  • 두 결과가 합쳐져 최종 합계 360이 계산됨

 

정리

  • Fork/Join 프레임워크를 사용하면 RecursiveTask를 통해 작업을 재귀적으로 분할하는 것을 확인할 수 있다. 여기서는 작업을 단순히 2개로만 분할해서 스레드도 동시에 2개만 사용할 수 있었다.
  • THRESHOLD(임계값)을 더 줄여서 작업을 더 잘게 분할하면 더 많은 스레드를 활용할 수 있다. 물론, 이 경우 풀의 스레드 수도 2개보다 더 많아야 효과가 있다.

참고 - 작업 훔치기

  • 이번 그림은 단순하게 설명하기 위해 작업 훔치기는 생략했다.
  • 실제로는 Fork/Join 풀의 스레드는 각자 자신의 작업 큐를 가진다. 그리고 자신의 작업이 없는 경우, 다른 스레드에 대기 중인 작업을 훔쳐서 대신 처리할 수 있다. 이 내용을 이제 알아보자.

 

Fork/Join 프레임워크 - 2 - 작업 훔치기

더 분할하기

이번에는 임계값을 줄여서 작업을 더 잘게 분할해보자. 다음 코드를 참고해서 THRESHOLD 값 4를 2로 변경하자. 그러면 8개의 작업이 4개의 작업으로 분할될 것이다.

package parallel.forkjoin;

import parallel.HeavyJob;

import java.util.List;
import java.util.concurrent.RecursiveTask;

import static util.MyLogger.log;

public class SumTask extends RecursiveTask<Integer> {

    private static final int THRESHOLD = 2;
    ....
}

 

이 상태에서 ForkJoinMain1을 실행해보자.

13:19:59.782 [     main] [생성] [1, 2, 3, 4, 5, 6, 7, 8]
13:19:59.792 [ForkJoinPool-1-worker-1] [분할] [1, 2, 3, 4, 5, 6, 7, 8] -> LEFT: [1, 2, 3, 4], RIGHT: [5, 6, 7, 8]
13:19:59.792 [ForkJoinPool-1-worker-1] [분할] [5, 6, 7, 8] -> LEFT: [5, 6], RIGHT: [7, 8]
13:19:59.792 [ForkJoinPool-1-worker-2] [분할] [1, 2, 3, 4] -> LEFT: [1, 2], RIGHT: [3, 4]
13:19:59.792 [ForkJoinPool-1-worker-1] [처리 시작] [7, 8]
13:19:59.792 [ForkJoinPool-1-worker-3] [처리 시작] [5, 6]
13:19:59.792 [ForkJoinPool-1-worker-2] [처리 시작] [3, 4]
13:19:59.792 [ForkJoinPool-1-worker-4] [처리 시작] [1, 2]
13:19:59.797 [ForkJoinPool-1-worker-3] calculate 5 -> 50
13:19:59.797 [ForkJoinPool-1-worker-4] calculate 1 -> 10
13:19:59.797 [ForkJoinPool-1-worker-1] calculate 7 -> 70
13:19:59.797 [ForkJoinPool-1-worker-2] calculate 3 -> 30
13:20:00.799 [ForkJoinPool-1-worker-3] calculate 6 -> 60
13:20:00.799 [ForkJoinPool-1-worker-4] calculate 2 -> 20
13:20:00.799 [ForkJoinPool-1-worker-2] calculate 4 -> 40
13:20:00.799 [ForkJoinPool-1-worker-1] calculate 8 -> 80
13:20:01.810 [ForkJoinPool-1-worker-1] [처리 완료] [7, 8] -> sum: 150
13:20:01.810 [ForkJoinPool-1-worker-2] [처리 완료] [3, 4] -> sum: 70
13:20:01.811 [ForkJoinPool-1-worker-4] [처리 완료] [1, 2] -> sum: 30
13:20:01.811 [ForkJoinPool-1-worker-3] [처리 완료] [5, 6] -> sum: 110
13:20:01.819 [ForkJoinPool-1-worker-1] LEFT[110]RIGHT[150] -> sum: 260
13:20:01.819 [ForkJoinPool-1-worker-2] LEFT[30]RIGHT[70] -> sum: 100
13:20:01.819 [ForkJoinPool-1-worker-1] LEFT[100]RIGHT[260] -> sum: 360
13:20:01.824 [     main] time : 2036ms, sum : 360
13:20:01.825 [     main] pool: java.util.concurrent.ForkJoinPool@32efd790[Terminated, parallelism = 10, size = 0, active = 0, running = 0, steals = 4, tasks = 0, submissions = 0]
  • 임계값을 4에서 2로 낮춘 결과, 작업이 더 잘게 분할되어 더 많은 스레드가 병렬로 작업을 처리하는 것을 확인할 수 있다. 여기서는 총 4개의 작업으로 분할되고, 2초의 시간이 소요되었다.

 

초기 분할

  • 전체 배열 [1,2,3,4,5,6,7,8]이 먼저 [1,2,3,4][5,6,7,8] 두 부분으로 분할된다.
  • 결과적으로 w1 스레드가 오른쪽 부분 [5,6,7,8]을 담당하고, w2 스레드가 왼쪽 부분 [1,2,3,4]를 담당한다.
  • w1fork(LEFT[1,2,3,4])를 호출해서 왼쪽 부분을 w2에 맡긴다.
  • w1compute(RIGHT[5,6,7,8])을 호출해서 오른쪽 리스트를 스스로 담당한다 (재귀 호출)

추가 분할

  • 임계값이 2로 설정되었으므로, 크기가 4인 두 부분은 다 각각 다시 분할된다.
  • [5,6,7,8][5,6][7,8]로 분할된다.
  • w1fork(LEFT[5,6])을 호출해서 왼쪽 부분을 w3에게 맡긴다.
  • w1compute(RIGHT[7,8])를 호출해서 오른쪽 리스트를 스스로 담당한다 (재귀 호출)
  • [1,2,3,4][1,2][3,4]로 분할된다.
  • w2fork(LEFT[1,2])를 호출해서 왼쪽 부분을 w4에게 맡긴다.
  • w2compute(RIGHT[3,4])를 호출해서 오른쪽 리스트를 스스로 담당한다.

병렬 처리

  • 각 작업 단위는 이제 임계값보다 작거나 같으므로 더 이상 분할되지 않고 처리된다.
  • 임계값이 2 이하인 4개의 작업을 4개의 스레드가 동시에 처리한다.
  • w1: [7,8]
  • w2: [3,4]
  • w3: [5,6]
  • w4: [1,2]

결과 결합

  • w1w3의 계산 결과를 기다린다. 그리고 110과 150의 결과를 결합하여 260을 얻고 반환한다.
  • w2w4의 계산 결과를 기다린다. 그리고 30과 70의 결과를 결합하여 100을 얻고 반환한다.
  • 마지막으로 w1w2의 계산 결과를 기다린다. 그리고 100과 260을 결합하여 최종 결과 360을 얻고 반환한다.

효율성 향상

  • 임계값을 낮춤으로써, 더 많은 스레드(총 4개)가 병렬로 작업을 처리했다.
  • 이전 실행(임계값 4)에서는 2개의 스레드만 사용되었다.
  • 로그를 보면 계산이 거의 동시에 시작되어 거의 동시에 완료된 것을 확인할 수 있다.

 

작업 훔치기 알고리즘

지금까지 설명을 단순화하기 위해 작업 훔치기(Work Stealing) 알고리즘은 설명하지 않았다. 이번에는 작업 훔치기에 대해 자세히 알아보자.

  • Fork/Join 풀의 스레드는 각자 자신의 작업 큐를 가진다.
  • 덕분에 작업을 큐에서 가져가기 위한 스레드 간 경합이 줄어든다.
  • 그리고 자신의 작업이 없는 경우, 그래서 스레드가 할 일이 없는 경우에 다른 스레드의 작업 큐에 대기중인 작업을 훔쳐서 대신 처리한다.

이번 예제의 작업 훔치기에 대해서 그림으로 자세히 알아보자.

 

 

13:19:59.782 [     main] [생성] [1, 2, 3, 4, 5, 6, 7, 8]
  • ForkJoinPool에 작업을 요청하면 ForkJoinPool 내부에 있는 외부 작업 큐에 작업이 저장된다.

  • 포크 조인 풀의 스레드는 각자 자신의 작업 큐를 가진다.
  • 포크 조인 풀의 스레드는 만약, 자신이 할 일이 없고, 자신의 작업 큐에도 작업이 없는 경우 다른 작업 큐에 있는 작업을 훔쳐서 대신 처리할 수 있다.
  • w1 스레드는 자신이 처리할 일이 없으므로 다른 작업 큐의 작업을 훔친다. 여기서는 외부 작업 큐에 들어 있는 작업을 훔쳐서 대신 처리한다.

  • w1은 훔친 작업의 compute()를 호출하면서 작업을 시작한다.

13:19:59.792 [ForkJoinPool-1-worker-1] [분할] [1, 2, 3, 4, 5, 6, 7, 8] -> LEFT: [1, 2, 3, 4], RIGHT: [5, 6, 7, 8]
  • w1은 작업의 크기가 크다고 평가하고 작업을 둘로 분할한다.
  • [1,2,3,4]의 작업은 fork를 호출해서 비동기로 다른 스레드가 실행해주길 기대한다.
  • [5,6,7,8]의 작업은 compute를 호출해서 스스로 처리한다. (재귀 호출)
  • 사실 fork()는 스레드 자신의 작업 큐에 작업을 넣어두는 것이다. 이후에 자신이 여유가 되면 스스로 보관한 작업을 처리하고, 자신이 여유가 없고 쉬는 스레드가 있다면 쉬는 스레드가 작업을 훔쳐가서 대신 처리한다.

  • w1compute([5,6,7,8])을 호출했으므로, 스스로 [5,6,7,8]을 처리해야 한다.
  • w1의 작업 큐에 있는 [1,2,3,4] 작업은 아직 다른 스레드에서 훔쳐가지 않았다.
  • 이번 시나리오에서는 아직 훔쳐가지 않았지만, 실행 상황에 따라 이 시점에 작업을 훔쳐갈 수도 있다.

13:19:59.792 [ForkJoinPool-1-worker-1] [분할] [5, 6, 7, 8] -> LEFT: [5, 6], RIGHT: [7, 8]
  • w1[5,6,7,8] 작업을 분할한다.
  • [5,6]fork를 통해서 자신의 작업 큐에 보관한다.
  • [7,8]compute를 호출해서 스스로 처리한다.

  • w1[7,8]의 처리를 시작한다.

  • w1의 작업 큐에 작업이 2개나 대기중이다. 쉬고 있는 w2w1의 작업 [1,2,3,4]를 훔친다.
  • 참고로 여기에 있는 큐는 데크에 가깝다. 따라서 양쪽으로 넣고 뺄 수 있는 구조이다.
  • 스레드 스스로 작업을 작업 큐에 보관하거나 꺼낼 때는 위에서, 다른 곳에서 훔칠 때는 아래 방향에서 훔친다. 이런 구조 덕분에 경합이 덜 발생한다. 

  • w2compute()를 호출해서 [1,2,3,4]를 처리한다.

13:19:59.792 [ForkJoinPool-1-worker-2] [분할] [1, 2, 3, 4] -> LEFT: [1, 2], RIGHT: [3, 4]
  • w2는 작업의 크기가 크다고 평가하고 작업을 둘로 분할한다.
  • [1,2]의 작업은 fork를 호출해서 자신의 작업 큐에 넣어둔다.
  • [3,4]의 작업은 compute를 호출해서 스스로 처리한다 (재귀 호출)

  • 작업 큐에 남아있는 작업들을 w3, w4 스레드가 훔쳐가서 실행한다.
  • w3: w1의 작업 큐 [5,6]을 훔쳐서 처리
  • w4: w2의 작업 큐 [1,2]를 훔쳐서 처리

13:19:59.792 [ForkJoinPool-1-worker-1] [처리 시작] [7, 8]
13:19:59.792 [ForkJoinPool-1-worker-3] [처리 시작] [5, 6]
13:19:59.792 [ForkJoinPool-1-worker-2] [처리 시작] [3, 4]
13:19:59.792 [ForkJoinPool-1-worker-4] [처리 시작] [1, 2]
  • 결과적으로 4개의 작업이 4개의 스레드에 분할되어 동시에 수행된다.
13:20:01.825 [     main] pool: java.util.concurrent.ForkJoinPool@32efd790[Terminated, parallelism = 10, size = 0, active = 0, running = 0, steals = 4, tasks = 0, submissions = 0]
  • 이번 작업에서 총 4번의 훔치기가 있었다.
  • 마지막에 출력한 ForkJoinPool의 로그를 확인해보면, steals = 4 항목을 확인할 수 있다.

 

작업 훔치기 알고리즘

이 예제에서는 작업량이 균등하게 분배되었지만, 실제 상황에서 작업량이 불균형할 경우 작업 훔치기 알고리즘이 동작하여 유휴 스레드가 다른 바쁜 스레드의 작업을 가져와 처리함으로써 전체 효율성을 높일 수 있다. 

 

정리

임계값을 낮춤으로써 작업이 더 잘게 분할되고, 그 결과 더 많은 스레드가 병렬로 작업을 처리할 수 있게되었다. 이는 Fork/Join 프레임워크의 핵심 개념인 분할 정복 전략을 명확하게 보여준다. 적절한 임계값 설정은 병렬 처리의 효율성에 큰 영향을 미치므로, 작업의 특성과 시스템 환경에 맞게 조정하는 것이 중요하다.

 

Fork/Join 적절한 작업 크기 선택

너무 작은 단위로 작업을 분할하면, 스레드 생성과 관리에 드는 오버헤드가 커질 수 있으며, 너무 큰 단위로 분할하면 병렬 처리의 이점을 충분히 활용하지 못할 수 있다. 

 

이 예제에서는 스레드 풀의 스레드가 10개로 충분히 남기 때문에 1개 단위로 더 잘게 쪼개는 것이 더 나은 결과를 보여줄 것이다. 이렇게 하면 8개의 작업을 8개의 스레드가 동시에 실행할 수 있다. 따라서 1초만에 작업을 완료할 수 있다.

 

하지만, 예를 들어, 1 - 1000까지 처리해야 하는 작업이라면 어떨까? 1개 단위로 너무 잘게 쪼개면 1000개의 작업으로 너무 잘게 분할된다. 스레드가 10개이므로 한 스레드당 100개의 작업을 처리해야 한다. 이 경우 스레드가 작업을 찾고 관리하는 부분도 늘어나고, 분할과 결과를 합하는 과정의 오버헤드도 너무 크다. 1000개로 쪼개고 쪼갠 1000개를 합쳐야 한다.

 

예) 1 - 1000까지 처리해야 하는 작업, 스레드는 10개

  • 1개 단위로 쪼개는 경우: 1000개의 분할과 결합이 필요. 한 스레드당 100개의 작업 처리
  • 10개 단위로 쪼개는 경우: 100개의 분할과 결합이 필요. 한 스레드당 10개의 작업 처리
  • 100개 단위로 쪼개는 경우: 10개의 분할과 결합이 필요. 한 스레드당 1개의 작업 처리
  • 500개 단위로 쪼개는 경우: 2개의 분할과 결합이 필요. 스레드 최대 2개 사용 가능

 

작업 시간이 완전히 동일하게 처리된다고 가정하면, 이상적으로는 한 스레드당 1개의 작업을 처리하는 것이 좋다. 왜냐하면 스레드를 100% 사용하면서 분할과 결합의 오버헤드도 최소화할 수 있기 때문이다.

하지만, 작업 시간이 다른 경우를 고려한다면 한 스레드당 1개의 작업 보다는 더 잘게 쪼개어 두는 것이 좋다. 왜냐하면 ForkJoinPool은 스레드의 작업이 끝나면 다른 스레드가 처리하지 못하고 대기하는 작업을 훔쳐서 처리하는 기능을 제공하기 때문이다. 따라서 쉬는 스레드 없이 최대한 많은 스레드를 활용할 수 있다. 

 

그리고 실질적으로는 작업 시간이 완전히 균등하지 않은 경우가 많다. 작업별로 처리 시간도 다르고, 시스템 환경에 따라 스레드 성능도 달라질 수 있다. 이런 상황에서 최적의 임계값 선택을 위해 고려해야 할 요소들은 다음과 같다.

  • 작업의 복잡성: 작업이 단순하면 분할 오버헤드가 더 크게 작용한다. 작업이 복잡할수록 더 작은 단위로 나누는 것이 유리할 수 있다. 예를 들어, 1 + 2 + 3 + 4의 아주 단순한 연산을 1 + 2, 3 + 4로 분할하게 되면 분할하고 합하는 비용이 더 든다.
  • 작업의 균일성: 작업 처리 시간이 불균일할수록 작업 훔치기(Work Stealing)가 효과적으로 작동하도록 적절히 작은 크기로 분할하는 것이 중요하다.
  • 시스템 하드웨어: 코어 수, 캐시 크기, 메모리 대역폭 등 하드웨어 특성에 따라 최적의 작업 크기가 달라진다.
  • 스레드 전환 비용: 너무 작은 작업은 스레드 관리 오버헤드가 증가할 수 있다.

적절한 작업의 크기에 대한 정답은 없지만, CPU 바운드 작업이라고 가정할 때, CPU 코어수에 맞추어 스레드를 생성하고, 작업 수는 스레드 수에 4 ~ 10배 정도로 생성하자. 물론 작업의 성격에 따라 다르다. 그리고 성능 테스트를 통해 적절한 값으로 조절하면 된다.

 

Fork/Join 프레임워크 - 3 - 공용 풀

자바 8에서는 공용 풀(Common Pool)이라는 개념이 도입되었는데, 이는 Fork/Join 작업을 위한 자바가 제공하는 기본 스레드 풀이다.

// 자바 8 이상에서는 공용 풀(common pool) 사용 가능
ForkJoinPool commonPool = ForkJoinPool.commonPool();

 

Fork/Join 공용 풀의 특징

  • 시스템 전체에서 공유: 애플리케이션 내에서 단일 인스턴스로 공유되어 사용된다.
  • 자동 생성: 별도로 생성하지 않아도 ForkJoinPool.commonPool()을 통해 접근할 수 있다.
  • 편리한 사용: 별도의 풀을 만들지 않고도 RecursiveTask / RecursiveAction을 사용할 때 기본적으로 이 공용 풀이 사용된다.
  • 병렬 스트림 활용: 자바 8의 병렬 스트림은 내부적으로 이 공용 풀을 사용한다.
  • 자원 효율성: 여러 곳에서 별도의 풀을 생성하는 대신 공용 풀을 사용함으로써 시스템 자원을 효율적으로 관리할 수 있다.
  • 병렬 수준 자동 설정: 기본적으로 시스템의 가용 프로세서 수에서 1을 뺀 값으로 병렬 수준(parallelism)이 설정된다. 예를 들어, CPU 코어가 14개라면 13개의 스레드가 사용된다. 

Fork/Join 공용 풀은 쉽게 이야기해서, 개발자가 편리하게 Fork/Join 풀을 사용할 수 있도록 자바가 기본으로 제공하는 Fork/Join 풀의 단일 인스턴스이다.

 

어떻게 사용하는지 코드로 알아보자.

package parallel.forkjoin;

import java.util.List;
import java.util.concurrent.ForkJoinPool;
import java.util.stream.IntStream;

import static util.MyLogger.log;

public class ForkJoinMain2 {
    public static void main(String[] args) {
        int processorCount = Runtime.getRuntime().availableProcessors();
        ForkJoinPool commonPool = ForkJoinPool.commonPool();
        log("processorCount: " + processorCount + ", commonPool: " + commonPool.getParallelism());

        List<Integer> data = IntStream.rangeClosed(1, 8)
                .boxed()
                .toList();

        log("[생성] " + data);
        SumTask sumTask = new SumTask(data);

        Integer result = sumTask.invoke();

        log("sum : " + result);
    }
}
  • 이 예제에서는 이전 예제와 달리 명시적으로 ForkJoinPool 인스턴스를 생성하지 않고 대신 공용 풀을 사용한다.
  • 참고로, ForkJoinPool.commonPool() 코드는 단순히 공용 풀 내부의 상태를 확인하기 위해 호출했다. 해당 코드가 없어도 공용 풀을 사용한다.

공용 풀을 통한 실행

이전에 사용했던 예제에서는 다음과 같이 ForkJoinPool을 생성한 다음에 pool.invoke(sumTask)를 통해 풀에 직접 작업을 요청했다.

ForkJoinPool pool = new ForkJoinPool(10);
SumTask task = new SumTask(data);
int result = pool.invoke(task);

 

이번 예제를 보면, 풀에 작업을 요청하는 것이 아니라 sumTask.invoke()를 통해 작업(RecursiveTask)에 있는 invoke()를 직접 호출했다. 따라서 코드만 보면 풀을 전혀 사용하지 않는것처럼 보인다.

SumTask task = new SumTask(data);
Integer result = task.invoke(); // 공용 풀 사용
  • 여기서 사용한 invoke() 메서드는 현재 스레드(여기서는 메인 스레드)에서 작업을 시작하지만, fork()로 작업 분할 후에는 공용 풀의 워커 스레드들이 분할된 작업을 처리한다.
  • 메인 스레드가 스레드 풀이 아닌 RecursiveTaskinvoke()를 직접 호출하면 메인 스레드가 작업의 compute()를 호출하게 된다. 이때 내부에서 fork()를 호출하면 공용 풀의 워커 스레드로 작업이 분할된다.
  • 메인 스레드는 최종 결과가 나올때까지 대기(블로킹)해야 한다. 따라서, 그냥 대기하는 것보다는 작업을 도와주는 편이 더 효율적이다.
    • invoke(): 호출 스레드가 작업을 도우면서 대기(블로킹)한다. 작업의 결과를 반환 받는다.
    • fork(): 작업을 비동기로 호출하려면 invoke() 대신에 fork()를 호출하면 된다. Future(ForkJoinTask)를 반환 받는다.

실행 결과

14:33:27.659 [     main] processorCount: 12, commonPool: 11
14:33:27.662 [     main] [생성] [1, 2, 3, 4, 5, 6, 7, 8]
14:33:27.667 [     main] [분할] [1, 2, 3, 4, 5, 6, 7, 8] -> LEFT: [1, 2, 3, 4], RIGHT: [5, 6, 7, 8]
14:33:27.668 [     main] [분할] [5, 6, 7, 8] -> LEFT: [5, 6], RIGHT: [7, 8]
14:33:27.668 [ForkJoinPool.commonPool-worker-1] [분할] [1, 2, 3, 4] -> LEFT: [1, 2], RIGHT: [3, 4]
14:33:27.668 [     main] [처리 시작] [7, 8]
14:33:27.668 [ForkJoinPool.commonPool-worker-2] [처리 시작] [5, 6]
14:33:27.668 [ForkJoinPool.commonPool-worker-1] [처리 시작] [3, 4]
14:33:27.668 [ForkJoinPool.commonPool-worker-3] [처리 시작] [1, 2]
14:33:27.670 [     main] calculate 7 -> 70
14:33:27.670 [ForkJoinPool.commonPool-worker-3] calculate 1 -> 10
14:33:27.670 [ForkJoinPool.commonPool-worker-1] calculate 3 -> 30
14:33:27.670 [ForkJoinPool.commonPool-worker-2] calculate 5 -> 50
14:33:28.671 [ForkJoinPool.commonPool-worker-3] calculate 2 -> 20
14:33:28.671 [ForkJoinPool.commonPool-worker-2] calculate 6 -> 60
14:33:28.671 [ForkJoinPool.commonPool-worker-1] calculate 4 -> 40
14:33:28.675 [     main] calculate 8 -> 80
14:33:29.683 [ForkJoinPool.commonPool-worker-3] [처리 완료] [1, 2] -> sum: 30
14:33:29.683 [ForkJoinPool.commonPool-worker-2] [처리 완료] [5, 6] -> sum: 110
14:33:29.683 [ForkJoinPool.commonPool-worker-1] [처리 완료] [3, 4] -> sum: 70
14:33:29.683 [     main] [처리 완료] [7, 8] -> sum: 150
14:33:29.687 [ForkJoinPool.commonPool-worker-1] LEFT[30]RIGHT[70] -> sum: 100
14:33:29.688 [     main] LEFT[110]RIGHT[150] -> sum: 260
14:33:29.688 [     main] LEFT[100]RIGHT[260] -> sum: 360
14:33:29.688 [     main] sum : 360

 

스레드 수

14:33:27.659 [     main] processorCount: 12, commonPool: 11
  • processorCount = 12 현재 나의 PC의 CPU 코어 수이다.
  • parallelism = 11 동시에 처리할 수 있는 작업 수준(스레드 수와 관련)
  • 현재 CPU 코어가 12개이다. 따라서 공용 풀은 CPU - 1의 수만큼 스레드를 생성한다.
  • 여기서는 최대 11개의 스레드를 생성해서 사용한다.

 

작업 실행 과정

  • 메인 스레드와 워커 스레드들이 함께 작업을 처리한다.
  • 워커 스레드 이름이 ForkJoinPool.commonPool-worker-1, 2로 표시된다. 
  • 메인 스레드도 작업 처리에 참여하는 것을 볼 수 있다. ([main] 표시)

 

정리

  • 공용 풀은 JVM이 종료될때까지 계속 유지되므로, 별도로 풀을 종료하지 않아도 된다.
  • 이렇게 공용 풀(ForkJoinPool.commonPool)을 활용하면, 별도로 풀을 생성/관리하는 코드를 작성하지 않아도 간편하게 병렬 처리를 구현할 수 있다.

 

공용 풀 vs 커스텀 풀

이전 예제에서는 다음과 같이 커스텀 Fork/Join 풀을 생성했다.

ForkJoinPool pool = new ForkJoinPool();
Integer result = pool.invoke(task);

 

반면, 이번 예제에서는 공용 풀을 사용했다.

Integer result = task.invoke();

 

차이점

  • 자원 관리: 커스텀 풀은 명시적으로 생성하고 관리해야 하지만, 공용 풀은 시스템에서 자동으로 관리된다.
  • 재사용성: 공용 풀은 여러 곳에서 공유할 수 있어 자원을 효율적으로 사용할 수 있다.
  • 설정 제어: 커스텀 풀은 병렬 수준(스레드의 숫자), 스레드 팩토리 등을 세부적으로 제어할 수 있지만, 공용 풀은 기본 설정을 사용한다.
  • 라이프사이클: 커스텀 풀은 명시적으로 종료해야 하지만, 공용 풀은 JVM이 관리한다. 따라서 종료하지 않아도 된다.

설정 변경

공용 풀 설정은 시스템 속성으로 변경할 수는 있지만 권장하지 않는다.

-Djava.util.concurrent.ForkJoinPool.common.parallelism=3
System.setProperty("java.util.concurrent.ForkJoinPool.common.parallelism","3");

 

공용 풀이 CPU - 1 만큼 스레드를 생성하는 이유

기본적으로 자바의 Fork/Join 공용 풀은 시스템의 가용 CPU 코어 수(Runtime.getRuntime().availableProcessors())에서 1을 뺀 값을 병렬 수준으로 사용한다. 예를 들어, CPU가 14코어라면 공용 풀은 최대 13개의 워커 스레드를 생성한다. 그 이유는 다음과 같다.

  • 메인 스레드의 참여: Fork/Join 작업은 공용 풀의 워커 스레드뿐만 아니라 메인 스레드도 연산에 참여할 수 있다. 메인 스레드가 단순히 대기하지 않고 직접 작업을 도와주기 때문에, 공용 풀에서 스레드를 14개까지 만들 필요 없이 13개의 워커 스레드 + 1개의 메인 스레드로 충분히 CPU 코어를 활용할 수 있다.
  • 다른 프로세스와의 자원 경쟁 고려: 애플리케이션이 실행되는 환경에서는 OS나 다른 애플리케이션, 혹은 GC같은 내부 작업들도 CPU를 사용해야 한다. 모든 코어를 최대치로 점유하도록 설정하면 다른 중요한 작업이 지연되거나, 컨텍스트 스위칭 비용이 증가할 수 있다. 따라서 하나의 코어를 여유분으로 남겨 두어 전체 시스템 성능을 보다 안정적으로 유지하려는 목적이 있다.
  • 효율적인 자원 활용: 일반적으로는 CPU 코어 수와 동일하게 스레드를 만들더라도 성능상 큰 문제는 없지만, 공용 풀에서 CPU 코어 수 - 1을 기본값으로 설정함으로써, 특정 상황(다른 작업 스레드나 OS 레벨 작업)에서도 병목을 일으키지 않는 선에서 효율적으로 CPU를 활용할 수 있다.

 

자바 병렬 스트림

드디어 자바의 병렬 스트림(parallel())을 사용해보자. 병렬 스트림은 Fork/Join 공용 풀을 사용해서 병렬 연산을 수행한다.

 

예제4

package parallel;

import java.util.concurrent.ForkJoinPool;
import java.util.stream.IntStream;

import static util.MyLogger.log;

public class ParallelMain4 {
    public static void main(String[] args) {
        int processorCount = Runtime.getRuntime().availableProcessors();
        ForkJoinPool commonPool = ForkJoinPool.commonPool();
        log("processorCount: " + processorCount + ", commonPool: " + commonPool.getParallelism());

        long startTime = System.currentTimeMillis();

        int sum = IntStream.rangeClosed(1, 8)
                .parallel()
                .map(HeavyJob::heavyTask)
                .reduce(0, Integer::sum);

        long endTime = System.currentTimeMillis();

        log("time : " + (endTime - startTime) + "ms, sum : " + sum);
    }
}
  • 그 복잡한 코드 다 빼고 그냥 Stream API에 parallel()만 추가했다.
  • 실행 결과를 보면 여러 스레드가 병렬로 해당 업무를 처리한 것을 알 수 있다.

실행 결과

15:00:09.356 [     main] processorCount: 12, commonPool: 11
15:00:09.359 [ForkJoinPool.commonPool-worker-5] calculate 5 -> 50
15:00:09.359 [ForkJoinPool.commonPool-worker-1] calculate 3 -> 30
15:00:09.359 [ForkJoinPool.commonPool-worker-2] calculate 8 -> 80
15:00:09.359 [ForkJoinPool.commonPool-worker-6] calculate 4 -> 40
15:00:09.359 [     main] calculate 6 -> 60
15:00:09.360 [ForkJoinPool.commonPool-worker-7] calculate 7 -> 70
15:00:09.360 [ForkJoinPool.commonPool-worker-3] calculate 2 -> 20
15:00:09.360 [ForkJoinPool.commonPool-worker-4] calculate 1 -> 10
15:00:10.373 [     main] time : 1008ms, sum : 360
  • 로그를 보면, ForkJoinPool.commonPool-worker-N 스레드들이 동시에 일을 처리하고 있다.
  • 예제1에서 8초 이상 걸렸던 작업이, 이 예제에서는 모두 병렬로 실행되어 시간이 약 1초로 크게 줄어든다.
    • 만약, CPU 코어가 4개라면 공용 풀에는 3개의 스레드가 생성되니까 시간이 더 걸릴수도 있다.
  • 직접 스레드를 만들 필요 없이 스트림에 parallel() 메서드만 호출하면, 스트림이 자동으로 병렬 처리된다.

어떻게 복잡한 멀티스레드 코드 없이, parallel() 단 한 줄만 선언했는데, 해당 작업들이 병렬로 처리될 수 있을까? 바로 앞서 설명한 공용 ForkJoinPool을 사용하기 때문이다. 

스트림에서 parallel()을 선언하면 스트림은 공용 ForkJoinPool을 사용하고, 내부적으로 병렬 처리 가능한 스레드 숫자와 작업의 크기 등을 확인하면서, Spliterator를 통해 데이터를 자동으로 분할한다. 분할 방식은 데이터 소스의 특성에 따라 최적화되어 있다. 그리고 공용 풀을 통해 작업을 적절한 수준으로 분할(Fork), 처리(Execute)하고, 그 결과를 모은다(Join).

 

이때, 요청 스레드(여기서는 메인 스레드)도 어차피 결과가 나올 때 까지 기다려야 하기 때문에, 작업에 참여해서 작업을 도운다.

 

개발자가 스트림을 병렬로 처리하고 싶다고 parallel()로 선언만 하면, 실제 어떻게 할지는 자바 스트림이 내부적으로 알아서 처리하는 것이다! 코드를 보면 복잡한 멀티스레드 코드 하나 없이 parallel() 단 한 줄만 추가했다. 이것이 바로 람다 스트림을 활용한 선언적 프로그래밍 방식의 큰 장점이다.

 

병렬 스트림 사용시 주의점 - 1

스트림에 parallel()을 추가하면 병렬 스트림이 된다. 병렬 스트림은 Fork/Join 공용 풀을 사용한다. Fork/Join 공용 풀은 CPU 바운드 작업(계산 집약적인 작업)을 위해 설계되었다. 따라서 스레드가 주로 대기해야 하는 I/O 바운드 작업에는 적합하지 않다.

  • I/O 바운드 작업은 주로 네트워크 호출을 통한 대기가 발생한다. 예)외부 API 호출, 데이터베이스 조회

 

주의 사항 - Fork/Join 프레임워크는 CPU 바운드 작업에만 사용해라!

Fork/Join 프레임워크는 주로 CPU 바운드 작업(계산 집약적인 작업)을 처리하기 위해 설계되었다. 이러한 작업은 CPU 사용률이 높고 I/O 대기 시간이 적다. CPU 바운드 작업의 경우, 물리적인 CPU 코어와 비슷한 수의 스레드를 사용하는 것이 최적의 성능을 발휘할 수 있다. 스레드 수가 코어 수보다 많아지면 컨텍스트 스위칭 비용이 증가하고, 스레드 간 경쟁으로 인해 오히려 성능이 저하될 수 있기 때문이다.

 

따라서, I/O 작업처럼 블로킹 대기 시간이 긴 작업을 ForkJoinPool에서 처리하면 다음과 같은 문제가 발생한다.

  • 스레드 블로킹에 따른 CPU 낭비
    • ForkJoinPool은 CPU 코어 수에 맞춰 제한된 개수의 스레드를 사용한다. (특히 공용 풀)
    • I/O 작업으로 스레드가 블로킹되면 CPU가 놀게 되어, 전체 병렬 처리 효율이 크게 떨어진다.
  • 컨텍스트 스위칭 오버헤드 증가
    • I/O 작업 때문에 스레드를 늘리면, 실제 연산보다 대기 시간이 길어지는 상황이 발생할 수 있다.
    • 스레드가 많아질수록 컨텍스트 스위칭 비용도 증가하여 오히려 성능이 떨어질 수 있다.
  • 작업 훔치기 기법 무력화
    • ForkJoinPool이 제공하는 작업 훔치기 알고리즘은, CPU 바운드 작업에서 빠르게 작업 단위를 계속 처리하도록 설계되었다. (작업을 훔쳐서 쉬는 스레드 없이 계속 작업)
    • I/O 대기 시간이 많은 작업은 스레드가 I/O로 인해 대기하고 있는 경우가 많아, 작업 훔치기가 빛을 발휘하기 어렵고, 결과적으로 병렬 처리의 장점을 살리기 어렵다.
  • 분할-정복 이점 감소
    • Fork/Join 방식을 통해 작업을 잘게 나누어도, I/O 병목이 발생하면 CPU 병렬화 이점이 크게 줄어든다. 
    • 오히려 분할된 작업들이 각기 I/O 대기를 반복하면서, fork(), join()에 따른 오버헤드만 증가할 수 있다.

 

정리

공용 풀(Common Pool)은 Fork/Join 프레임워크의 편리한 기능으로, 별도의 풀 생성 없이도 효율적인 병렬 처리를 가능하게 한다. 하지만, 블로킹 작업이나 특수한 설정이 필요한 경우에는 커스텀 풀을 고려해야 한다. 

 

CPU 바운드 작업이라면 ForkJoinPool을 통해 병렬 계산을 극대화할 수 있지만, I/O 바운드 작업은 별도의 전용 스레드 풀을 사용하는 편이 더 적합하다. 예) Executors.newFixedThreadPool()

 

병렬 스트림 - 예제5

예제를 통해 병렬 스트림 사용 시 주의점을 알아보자. 특히 여러 요청이 동시에 들어올 때 공용 풀에서 어떤 문제가 발생할 수 있는지 알아보자. 

 

이 예제는 다음과 같은 시나리오를 시뮬레이션한다.

  • 여러 사용자가 동시에 서버를 호출하는 상황
  • 각 요청은 병렬 스트림을 사용하여 몇가지 무거운 작업을 처리
  • 모든 요청이 동일한 공용 풀(ForkJoinPool.commonPool)을 공유
package parallel;

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ForkJoinPool;
import java.util.stream.IntStream;

import static util.MyLogger.log;

public class ParallelMain5 {
    public static void main(String[] args) throws InterruptedException {
        System.setProperty("java.util.concurrent.ForkJoinPool.common.parallelism", "3");

        // 요청 풀 추가
        ExecutorService requestPool = Executors.newFixedThreadPool(100);
        int nThreads = 3;
        for (int i = 1; i <= nThreads; i++) {
            String requestName = "request" + i;
            requestPool.submit(() -> logic(requestName));
            Thread.sleep(100);
        }

        requestPool.close();
    }

    private static void logic(String requestName) {
        log("[" + requestName + "] START");
        long startTime = System.currentTimeMillis();

        int sum = IntStream.rangeClosed(1, 4)
                .parallel()
                .map(i -> HeavyJob.heavyTask(i, requestName))
                .reduce(0, Integer::sum);

        long endTime = System.currentTimeMillis();

        log("[" + requestName + "] time : " + (endTime - startTime) + "ms, sum: " + sum);
    }
}
  • CPU 코어가 4개라고 가정하자. 시스템 속성을 사용해 공용 풀의 병렬 수준을 3으로 제한했다. 즉, 공용 풀의 스레드 수가 3개가 된다.
  • 예제를 단순화 하기 위해 1 ~ 4 범위의 작업을 처리한다. IntStream.rangeClosed(1, 4)
  • requestPool은 여러 사용자 요청을 시뮬레이션하기 위한 스레드 풀이다.
  • 각 요청은 logic() 메서드 안에서 parallel() 스트림을 사용하여 작업을 처리한다. 이때 공용 풀이 사용된다.

실행 결과

16:38:51.888 [pool-1-thread-1] [request1] START
16:38:51.897 [ForkJoinPool.commonPool-worker-1] [request1] 2 -> 20
16:38:51.897 [pool-1-thread-1] [request1] 3 -> 30
16:38:51.897 [ForkJoinPool.commonPool-worker-3] [request1] 1 -> 10
16:38:51.897 [ForkJoinPool.commonPool-worker-2] [request1] 4 -> 40
16:38:51.976 [pool-1-thread-2] [request2] START
16:38:51.976 [pool-1-thread-2] [request2] 3 -> 30
16:38:52.079 [pool-1-thread-3] [request3] START
16:38:52.079 [pool-1-thread-3] [request3] 3 -> 30
16:38:52.898 [ForkJoinPool.commonPool-worker-2] [request2] 2 -> 20
16:38:52.898 [ForkJoinPool.commonPool-worker-1] [request2] 4 -> 40
16:38:52.898 [ForkJoinPool.commonPool-worker-3] [request3] 2 -> 20
16:38:52.901 [pool-1-thread-1] [request1] time : 1008ms, sum: 100
16:38:52.977 [pool-1-thread-2] [request2] 1 -> 10
16:38:53.080 [pool-1-thread-3] [request3] 4 -> 40
16:38:53.899 [ForkJoinPool.commonPool-worker-1] [request3] 1 -> 10
16:38:53.978 [pool-1-thread-2] [request2] time : 2002ms, sum: 100
16:38:54.901 [pool-1-thread-3] [request3] time : 2822ms, sum: 100
  • 실행 결과를 보아하니, 첫번째 요청은  1008ms 안에 끝났는데, 동일한 작업을 하는데도 2번째 요청과 3번째 요청은 2초가 넘어갔다. 
  • 심지어 3번째 요청은 거의 3초에 육박한다. 왜 이런 현상이 일어날까?
  • 다음 그림을 보자.

 

 

  • 공용 풀의 제한된 병렬성
    • 공용 풀은 병렬 수준이 3으로 설정되어 있어, 최대 3개의 작업만 동시에 처리할 수 있다. 여기에 요청 스레드도 자신의 작업에 참여하므로 각 작업당 총 4개의 스레드만 사용된다.
    • 따라서 총 12개의 요청(각각 4개의 작업)을 처리하는데 필요한 스레드 자원이 부족하다.
  • 처리 시간의 불균형
    • request1: 1008ms (약 1초)
    • request2: 2002ms (약 2초)
    • request3: 2822ms (약 2.8초)
    • 첫번째 요청은 거의 모든 공용 풀 워커를 사용할 수 있었지만, 이후 요청들은 제한된 공용 풀 자원을 두고 경쟁해야 한다. 따라서 완료 시간이 점점 느려진다.
  • 스레드 작업 분배
    • 일부 작업은 요청 스레드(pool-1-thread-N)에서 직접 처리되고, 일부는 공용풀(ForkJoinPool.commonPool-worker-N)에서 처리된다.
    • 요청 스레드가 작업을 도와주지만, 공용 풀의 스레드가 매우 부족하기 때문에 한계가 있다.

 

요청이 증가할수록 이 문제는 더 심각해진다. nThreads의 숫자를 늘려서 동시 요청을 늘리면, 응답 시간이 확연하게 늘어나는 것을 확인할 수 있다. 

 

핵심 문제점

  • 공용 풀 병목 현상: 모든 병렬 스트림이 동일한 공용 풀을 공유하므로, 요청이 많아질수록 병목 현상이 발생한다.
  • 자원 경쟁: 여러 요청이 제한된 스레드 풀을 두고 경쟁하면서 요청의 성능이 저하된다.
  • 예측 불가능한 성능: 같은 작업이라도 동시에 실행되는 다른 작업의 수에 따라 처리 시간이 크게 달라진다.

특히, 실무 환경에서는 주로 여러 요청을 동시에 처리하는 애플리케이션 서버를 사용하게 된다. 이때 수많은 요청이 공용 풀을 사용하는 것은 매우 위험할 수 있다. 따라서, 병렬 스트림을 남용하면 전체 시스템 성능이 저하될 수 있다.

 

참고로, 이번 예제에서 사용한 heavyTask()는 1초간 스레드가 대기하는 작업이다. 따라서, I/O 바운드 작업에 가깝다. 이런 종류의 작업은 Fork/Join 공용 풀보다는 별도의 풀을 사용하는 것이 좋다. 

 

주의! 실무에서 공용 풀은 절대 I/O 바운드 작업을 하면 안된다.

실무에서 공용 풀에 I/O 바운드 작업을 해서 장애가 나는 경우가 있다. CPU 코어가 4개라면 공용 풀은 3개의 스레드만 사용한다. 그리고 공용 풀은 애플리케이션 전체에서 사용된다. 공용 풀에 있는 스레드 3개가 I/O 바운드 작업으로 대기하는 순간, 애플리케이션에서 공용 풀을 사용하는 모든 요청이 다 밀리게 된다.

 

예를 들어, 병렬 스트림을 사용한답시고 공용 풀을 통해 외부 API를 호출하거나, 데이터베이스를 호출하고 기다리는 경우가 있다. 만약, 외부 API나 데이터베이스의 응답이 늦게 온다면 공용 풀의 3개의 스레드가 모두 I/O 응답을 대기하게 된다. 그리고 나머지 모든 요청이 공용 풀의 스레드를 기다리며 다 밀리게 되는 무시무시한 일이 발생한다. 

 

공용 풀은 반드시 CPU 바운드(계산 집약적인) 작업에만 사용해야 한다!

병렬 스트림은 처음부터 Fork/Join 공용 풀을 사용해서 CPU 바운드 작업에 맞도록 설계되어 있다. 따라서, 이런 부분을 잘 모르고 실무에서 병렬 스트림에 I/O 대기 작업을 하는 것은 매우 위험한 일이다. 특히 병렬 스트림의 경우 단순히 parallel() 한 줄만 추가하면 병렬 처리가 되기 때문에, 어떤 스레드가 사용되는지도 제대로 이해하지 못하고 사용하는 경우가 있다. 병렬 스트림은 반드시 CPU 바운드 작업에만 사용하자!

 

그렇다면, 여러 작업을 병렬로 처리해야 하는데, I/O 바운드 작업이 많을 때는 어떻게 하면 좋을까? 이때는 스레드를 직접 사용하거나, ExecutorService 등을 통해 별도의 스레드 풀을 사용해야 한다.

 

병렬 스트림 - 예제6

위에서 병렬 스트림을 썼을때 문제를 잘 보았다. 저 문제를 별도의 스레드 풀을 사용해서 해결해보자.

package parallel;

import java.util.List;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.stream.IntStream;

import static util.MyLogger.log;

public class ParallelMain7 {
    public static void main(String[] args) throws InterruptedException {
        // 요청 풀 추가
        ExecutorService requestPool = Executors.newFixedThreadPool(100);
        // 로직 처리 전용 풀
        ExecutorService logicPool = Executors.newFixedThreadPool(400);

        int nThreads = 3;
        for (int i = 1; i <= nThreads; i++) {
            String requestName = "request" + i;
            requestPool.submit(() -> logic(requestName, logicPool));
            Thread.sleep(100);
        }

        requestPool.close();
        logicPool.close();
    }

    private static void logic(String requestName, ExecutorService es) {
        log("[" + requestName + "] START");
        long startTime = System.currentTimeMillis();

        List<Future<Integer>> futures = IntStream.rangeClosed(1, 4)
                .mapToObj(i -> es.submit(() -> HeavyJob.heavyTask(i, requestName)))
                .toList();

        int sum = futures.stream()
                .mapToInt(f -> {
                    try {
                        return f.get();
                    } catch (Exception e) {
                        throw new RuntimeException(e);
                    }
                }).sum();

        long endTime = System.currentTimeMillis();

        log("[" + requestName + "] time : " + (endTime - startTime) + "ms, sum: " + sum);
    }
}

변경사항

  • 전용 로직 풀 추가, ExecutorService logicPool = Executors.newFixedThreadPool(400)
  • 병렬 스트림 대신 커스텀 스레드 풀 사용
  • 결과 취합 방식: Future.get()을 사용

실행 결과

10:56:15.235 [pool-1-thread-1] [request1] START
10:56:15.245 [pool-2-thread-4] [request1] 4 -> 40
10:56:15.245 [pool-2-thread-2] [request1] 2 -> 20
10:56:15.245 [pool-2-thread-1] [request1] 1 -> 10
10:56:15.245 [pool-2-thread-3] [request1] 3 -> 30
10:56:15.317 [pool-1-thread-2] [request2] START
10:56:15.318 [pool-2-thread-5] [request2] 1 -> 10
10:56:15.318 [pool-2-thread-6] [request2] 2 -> 20
10:56:15.318 [pool-2-thread-8] [request2] 4 -> 40
10:56:15.318 [pool-2-thread-7] [request2] 3 -> 30
10:56:15.422 [pool-1-thread-3] [request3] START
10:56:15.423 [pool-2-thread-9] [request3] 1 -> 10
10:56:15.423 [pool-2-thread-11] [request3] 3 -> 30
10:56:15.423 [pool-2-thread-10] [request3] 2 -> 20
10:56:15.423 [pool-2-thread-12] [request3] 4 -> 40
10:56:16.258 [pool-1-thread-1] [request1] time : 1012ms, sum: 100
10:56:16.319 [pool-1-thread-2] [request2] time : 1002ms, sum: 100
10:56:16.424 [pool-1-thread-3] [request3] time : 1001ms, sum: 100
  • 일관된 처리 시간: 모든 요청이 1초 내외로 처리되었다.
  • 독립적인 스레드 할당
  • 확장성 향상: 400개의 스레드를 가진 풀을 사용함으로써, 동시에 여러 요청을 효율적으로 처리한다. 또한, 공용 풀 병목 현상도 발생하지 않는다.

 

정리를 하자면

단일 스트림으로 시작해서, 직접 스레드 생성, 스레드 풀, Fork/Join 프레임워크, 그리고 자바 병렬 스트림까지 차근차근 살펴보았다. 

 

병렬 스트림 사용 시 주의사항

  • 반드시 CPU 바운드 작업(계산 집약적)에만 사용할 것
  • I/O 바운드 작업(DB 조회, 외부 API 호출 등)은 오랜 대기 시간이 발생하므로 제한된 스레드만 사용하는 Fork/Join 공용 풀과 궁합이 맞지 않다.
  • 서버 환경에서 여러 요청이 동시에 병렬 스트림을 사용하면 공용 풀이 빠르게 포화되어 전체 성능이 저하될 수 있다. 특히 I/O 바운드 작업을 병렬 스트림으로 사용하면 더 큰 문제가 된다.

결론

  • I/O 바운드 작업처럼 대기가 긴 경우에는 전용 스레드 풀(ExecutorService)을 만들어 사용해야 한다.
  • 실무에서 병렬 스트림을 사용할 일이 거의 없다.

CompletableFuture도 같은 맥락

실무에서 자주 하는 실수가 병렬 스트림을 I/O 바운드 작업에 사용하거나, 또는 CompletableFuture를 사용할 때 발생한다.

  • CompletableFuture는 실무에서 복잡한 멀티 스레드 코드를 작성할 때 도움이 된다.
  • CompletableFuture를 생성할 때는 별도의 스레드 풀을 반드시 지정해야 하는데, 그렇지 않으면 Fork/Join 공용 풀이 대신 사용된다. 이 때문에 많은 장애가 발생한다. 그래서 CompletableFuture를 사용할 때는 반드시! 커스텀 풀을 지정해서 사용하자.
package parallel.forkjoin;

import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

import static util.MyLogger.log;

public class CompletableFutureMain {
    public static void main(String[] args) throws InterruptedException {
        CompletableFuture.runAsync(() -> log("Fork/Join"));
        Thread.sleep(1000);

        ExecutorService es = Executors.newFixedThreadPool(100);
        CompletableFuture.runAsync(() -> log("Custom Pool"), es);
        es.close();
    }
}
  • 첫번째 실행에서는 별도의 스레드 풀을 지정하지 않았다.
  • 두번째 실행에서는 별도의 스레드 풀(es)을 지정했다.

실행 결과

11:04:57.303 [ForkJoinPool.commonPool-worker-1] Fork/Join
11:04:58.288 [pool-1-thread-1] Custom Pool
  • CompletableFuture에 스레드 풀을 지정하지 않으면 보는것과 같이 Fork/Join 공용 풀이 사용되는 것을 확인할 수 있다.

 

그래서 꼭 기억하고 있어야 하는건, 병렬 처리를 하는데 풀을 안쓴다? 풀을 따로 지정하지 않았다? 공용 풀을 쓴다고 생각해라. 그리고 어떻게 스레드 풀을 지정해야하지?를 고민해야 한다. 반드시!

728x90
반응형
LIST
728x90
반응형
SMALL

참고자료

 

김영한의 실전 자바 - 고급 3편, 람다, 스트림, 함수형 프로그래밍 강의 | 김영한 - 인프런

김영한 | , [사진]국내 개발 분야 누적 수강생 1위,제대로 만든 김영한의 실전 자바[사진][임베딩 영상]단순히 자바 문법을 안다? 이걸로는 안됩니다!전 우아한형제들 기술이사, 누적 수강생 40만

www.inflearn.com

 

디폴트 메서드가 등장한 이유

자바8에서 디폴트 메서드가 등장하기 전에는 인터페이스에 메서드를 새로 추가하는 순간, 이미 배포된 기존 구현 클래스들이 해당 메서드를 구현하지 않았기 때문에 전부 컴파일 에러를 일으키게 되는 문제가 있었다.

 

이 때문에 특정 인터페이스를 이미 많은 클래스에서 구현하고 있는 상황에서, 인터페이스에 새 기능을 추가하려면 기존 코드를 일일이 모두 수정해야 했다. 

 

디폴트 메서드는 이러한 문제를 해결하기 위해 등장했다. 자바 8부터는 인터페이스에서 메서드 본문을 가질 수 있도록 허용해 주어, 기존 코드를 깨뜨리지 않고 새 기능을 추가할 수 있게 됐다.

 

 

예제1

package defaultmethod.ex1;

public interface Notifier {
    void notify(String message);
}
package defaultmethod.ex1;

public class AppPushNotifier implements Notifier {
    @Override
    public void notify(String message) {
        System.out.println("[APP] " + message);
    }
}
package defaultmethod.ex1;

public class EmailNotifier implements Notifier {
    @Override
    public void notify(String message) {
        System.out.println("[EMAIL] " + message);
    }
}
package defaultmethod.ex1;

public class SMSNotifier implements Notifier {
    @Override
    public void notify(String message) {
        System.out.println("[SMS] " + message);
    }
}
  • 알림 기능을 처리하는 Notifier 인터페이스와 세 가지 구현체 EmailNotifier, AppPushNotifier, SMSNotifier가 있다고 하자. Notifier는 단순히 메시지를 알리는 notify() 메서드 한 가지만 정의하고 있고, 각 구현체는 해당 기능을 구현한다.
package defaultmethod.ex1;

import java.util.List;

public class NotifierMainV1 {
    public static void main(String[] args) {
        List<Notifier> notifiers = List.of(new EmailNotifier(), new SMSNotifier(), new AppPushNotifier());
        notifiers.forEach(n -> n.notify("서비스 가입을 환영합니다."));
    }
}
  • 다음은 이 기능을 사용하는 코드이다.

실행 결과

[EMAIL] 서비스 가입을 환영합니다.
[SMS] 서비스 가입을 환영합니다.
[APP] 서비스 가입을 환영합니다.

지금까지는 아무런 문제없이 잘 동작한다. 이제 요구사항을 추가해보자.

 

 

예제2

 

인터페이스에 새로운 메서드 추가 시 발생하는 문제

요구사항이 추가되었다. 알림을 미래의 특정 시점에 자동으로 발송하는 스케쥴링 기능을 추가해야 한다고 해보자. 그래서 Notifier 인터페이스에 scheduleNotification() 메서드를 추가하자.

package defaultmethod.ex2;

import java.time.LocalDateTime;

public interface Notifier {
    void notify(String message);
    
    void scheduleNotification(String message, LocalDateTime scheduleTime);
}
  • 이제 기존의 EmailNotifier, SMSNotifier, AppPushNotifier 클래스를 살펴보자.
package defaultmethod.ex2;

import java.time.LocalDateTime;

public class EmailNotifier implements Notifier {
    @Override
    public void notify(String message) {
        System.out.println("[EMAIL] " + message);
    }

    @Override
    public void scheduleNotification(String message, LocalDateTime scheduleTime) {
        System.out.println("[EMAIL 전용 스케쥴링] message: " + message + ", time : " + scheduleTime);
    }
}
  • 이렇게 EmailNotifier는 새로운 메서드를 구현했다.
  • 하지만, SMSNotifier, AppPushNotifier는 아직 구현하지 않았기 때문에 컴파일 오류가 발생한다.
package defaultmethod.ex2;

import java.time.LocalDateTime;
import java.util.List;

public class NotifierMainV2 {
    public static void main(String[] args) {
        List<Notifier> notifiers = List.of(new EmailNotifier(), new SMSNotifier(), new AppPushNotifier());
        notifiers.forEach(n -> n.notify("서비스 가입을 환영합니다."));

        LocalDateTime plusOneDays = LocalDateTime.now().plusDays(1);
        notifiers.forEach(n -> n.scheduleNotification("Hello!", plusOneDays));
    }
}

실행 결과 - 컴파일 오류

java: defaultmethod.ex2.AppPushNotifier is not abstract and does not override
abstract method scheduleNotification(java.lang.String,java.time.LocalDateTime)
in defaultmethod.ex2.Notifier

java: defaultmethod.ex2.SMSNotifier is not abstract and does not override
abstract method scheduleNotification(java.lang.String,java.time.LocalDateTime)
in defaultmethod.ex2.Notifier
  • Notifier 인터페이스에 scheduleNotification() 메서드가 새로 추가됨에 따라, 기존에 존재하던 SMSNotifier, AppPushNotifier 구현 클래스들이 강제로 이 메서드를 구현하도록 요구된다.
  • 규모가 작은 예제에서는, 그럼 나머지 두 클래스도 재정의하면 된다고 생각할 수 있다.
  • 하지만, 실무 환경에서 해당 인터페이스를 구현한 클래스가 수십 - 수백개라면 이 전부를 수정해서 새 메서드를 재정의해야 한다.
  • 심지어, 우리가 만들지 않은 외부 라이브러리에서 Notifier를 구현한 클래스가 있다면 그것까지 전부 깨질 수 있어 호환성을 깨뜨리는 매우 심각한 문제가 된다. 

 

예제3

디폴트 메서드로 문제 해결

자바 8부터 이러한 하위 호환성 문제를 해결하기 위해 디폴트 메서드가 추가되었다. 인터페이스에 메서드를 새로 추가하면서, 기본 구현을 제공할 수 있는 기능이다. 예를 들어, Notifier 인터페이스에 scheduleNotification() 메서드를 default 키워드로 작성하고 기본 구현을 넣어두면, 구현 클래스들은 이 메서드를 굳이 재정의하지 않아도 된다.

package defaultmethod.ex2;

import java.time.LocalDateTime;

public interface Notifier {
    void notify(String message);

    default void scheduleNotification(String message, LocalDateTime scheduleTime) {
        System.out.println("[기본 스케쥴링] message : " + message + ", time : " + scheduleTime);
    }
}
  • 이제 EmailNotifier처럼 재정의한 특별한 로직을 쓰고 싶다면, 여전히 재정의하면 되고 SMSNotifier, AppPushNotifier처럼 재정의하지 않으면 인터페이스에 작성된 기본 구현을 사용하게 된다.
package defaultmethod.ex2;

import java.time.LocalDateTime;
import java.util.List;

public class NotifierMainV2 {
    public static void main(String[] args) {
        List<Notifier> notifiers = List.of(new EmailNotifier(), new SMSNotifier(), new AppPushNotifier());

        LocalDateTime plusOneDays = LocalDateTime.now().plusDays(1);
        notifiers.forEach(n -> n.scheduleNotification("Hello!", plusOneDays));
    }
}

실행 결과

[EMAIL 전용 스케쥴링] message: Hello!, time : 2025-04-02T16:49:22.632873
[기본 스케쥴링] message : Hello!, time : 2025-04-02T16:49:22.632873
[기본 스케쥴링] message : Hello!, time : 2025-04-02T16:49:22.632873
  • 결과적으로 새 메서드가 추가되었음에도 불구하고, 해당 인터페이스를 구현하는 기존 클래스들이 큰 수정 없이도 (또는 아예 수정 없이도) 동작을 유지할 수 있게 됐다.

 

디폴트 메서드 소개

자바는 처음부터 인터페이스와 구현을 명확하게 분리한 언어였다. 자바가 처음 등장했을 때부터 인터페이스는 구현 없이 메서드의 시그니처만을 정의하는 용도로 사용되었다.

  • 인터페이스 목적: 코드의 계약을 정의하고, 클래스가 어떤 메서드를 반드시 구현하도록 강제하여 명세와 구현을 분리하는 것이 주된 목적이었다.
  • 엄격한 규칙: 인터페이스에 선언되는 메서드는 기본적으로 모두 추상 메서드였으며, 인터페이스 내에서 구현 내용을 포함할 수 없었다. 오직 static final 필드와 abstract 메서드 선언만 가능했다. 
  • 결과: 이렇게 인터페이스가 엄격하게 구분됨으로써, 클래스는 여러 인터페이스를 구현할 수 있게 되고, 각각의 메서드는 클래스 내부에서 구체적으로 어떻게 동작할지를 자유롭게 정의할 수 있었다. 이를 통해 객체지향적인 설계와 다형성을 극대화할 수 있었다.

 

자바 8 이전까지는 인터페이스에 새로운 메서드를 추가하면, 해당 인터페이스를 구현한 모든 클래스에서 그 메서드를 구현해야 했다. 자바가 기본으로 제공하는 수많은 인터페이스를 생각해보자. 예를 들어, Collection, List 같은 인터페이스는 이미 많은 개발자들이 해당 인터페이스를 구현해서 사용한다. 또는 많은 라이브러리들도 해당 인터페이스를 구현한 구현체를 제공한다. 예를 들어 특정 상황에 최적화된 컬렉션이 있을 수 있다.

 

이런 상황에서 만약, 자바가 버전 업을 하면서 해당 인터페이스에 새로운 기능을 추가한다면 어떻게 될까? 새로운 자바 버전으로 업데이트 하는 순간 전 세계에서 컴파일 오류들이 발생할 것이다. 이런 문제를 방지하기 위해 자바는 하위호환성을 그 무엇보다 큰 우선순위에 둔다.

 

결국 인터페이스의 이런 엄격한 규칙 때문에, 그 동안 자바 인터페이스에 새로운 기능을 추가하지 못하는 일이 발생하게 되었다.

 

이런 문제를 해결하기 위해, 자바 8에서 디폴트 메서드가 도입되었다. 결과적으로 인터페이스의 엄격함은 약간은 유연하게 변경되었다.

 

디폴트 메서드의 도입 이유

  • 하위 호환성 보장: 인터페이스에 새로운 메서드를 추가하더라도, 기존 코드가 깨지지 않도록 하기 위한 목적으로 디폴트 메서드가 도입되었다. 인터페이스에 디폴트 구현을 제공하면, 기존에 해당 인터페이스를 구현하던 클래스들은 추가로 재정의하지 않아도 정상 동작하게 된다.
  • 라이브러리 확장성: 자바가 제공하는 표준 라이브러리에 정의된 인터페이스(Collection, List, ...)에 새 메서드를 추가하면서, 사용자들이나 서드파티 라이브러리 구현체가 일일이 수정하지 않아도 되도록 만들었다. 이를 통해 자바 표준 라이브러리 자체도 적극적으로 개선할 수 있게 되었다. (예: List 인터페이스에 sort(...) 메서드가 추가되었지만, 기존의 모든 List 구현체를 수정하지 않아도 된다)
  • 람다와 스트림 API 연계: 자바 8에서 함께 도입된 람다와 스트림 API를 보다 편리하게 활용하기 위해 인터페이스에서 구현 로직을 제공할 필요가 있었다.
    • Collection 인터페이스에 stream() 디폴트 메서드 추가
    • Iterable 인터페이스에 forEach() 디폴트 메서드 추가
  • 설계 유연성 향상: 디폴트 메서드를 통해 인터페이스에서도 일부 공통 동작 방식을 정의할 수 있게 되었다. 이는 추상 클래스와의 경계를 어느정도 유연하게 만들지만, 동시에 지나치게 복잡한 기능을 인터페이스에 넣는 것은 오히려 설계를 혼란스럽게 만들 수 있으므로 주의해야 한다.

디폴트 메서드 정리

  • 자바 8에서 처음 등장한 기능
  • 인터페이스 내에서 선언되는 메서드이지만, 몸통을 가질 수 있음
  • default 키워드를 사용
  • 추가된 이유: 이미 배포된 인터페이스에 새 메서드를 추가할 때 발생하는 하위 호환성 문제를 해결하기 위해

 

디폴트 메서드의 올바른 사용법

디폴트 메서드는 강력한 기능이지만, 잘못 사용하면 오히려 코드가 복잡해지고 유지보수하기 어려워질 수 있다. 다음은 디폴트 메서드를 사용할 때 고려해야 할 주요 사항이다. 

 

  • 하위 호환성을 위해 최소한으로 사용
    • 디폴트 메서드는 주로 이미 배포된 인터페이스에 새로운 메서드를 추가하면서 기존 구현체 코드를 깨뜨리지 않기 위한 목적으로 만들어졌다.
    • 새 메서드가 필요한 상황이고, 기존 구현 클래스가 많은 상황이 아니라면, 원칙적으로는 각각 구현하자.
    • 불필요한 디폴트 메서드 남용은 코드 복잡도를 높일 수 있다.
  • 인터페이스는 여전히 추상화의 역할
    • 디폴트 메서드를 통해 인터페이스에 로직을 넣을 수 있다 하더라도, 가능한 한 로직은 구현 클래스나 별도 클래스에 두고, 인터페이스는 계약의 역할에 충실한 것이 좋다.
    • 디폴트 메서드는 어디까지나 하위 호환을 위한 기능이나, 공통으로 쓰기 쉬운 간단한 로직을 제공하는 정도가 이상적이다.
  • 디폴트 메서드에 상태(state)를 두지 않기
    • 인터페이스는 일반적으로 상태 없이 동작만 정의하는 추상화 계층이다.
    • 인터페이스에 정의하는 디폴트 메서드도 "구현"을 일부 제공할 뿐, 인스턴스 변수를 활용하거나, 여러 차례 호출 시 상태에 따라 동작이 달라지는 등의 동작은 지양해야 한다.
    • 이런 로직이 필요하다면 클래스로 옮기는 것이 더 적절하다.
  • 다중 상속(충돌) 문제
    • 하나의 클래스가 여러 인터페이스를 구현하는 상황에서, 서로 다른 인터페이스에 동일한 시그니처의 디폴트 메서드가 존재하면 충돌이 일어난다.
    • 이 경우 구현 클래스에서 반드시 메서드를 재정의해야 한다. 그리고 직접 구현 로직을 작성하거나, 어떤 인터페이스의 디폴트 메서드를 쓸 것인지를 명시해주어야 한다.
    • 아래 예시를 보자.
interface A {
    default void hello() {
        System.out.println("Hello from A");
    }
}

interface B {
    default void hello() {
        System.out.println("Hello from B");
    }
}

public class MyClass implements A, B {
    @Override
    public void hello() {
        // 반드시 충돌을 해결해야 함
        // 1. 직접 구현
        // 2. A.super.hello();
        // 3. B.super.hello();
    }
}
  • 세가지 방법이 있다. 
  • 1. 직접 해당 메서드를 구현
  • 2. A 인터페이스의 hello()를 호출
  • 3. B 인터페이스의 hello()를 호출

 

정리

디폴트 메서드는 하위 호환성 문제를 해결하기 위해 인터페이스에 메서드 구현부를 둘 수 있도록 한 기능이다. 따라서, 목적에 부합하지 않는다면 디폴트 메서드는 사용하지 않는 것을 권한다. 즉, 거의 사용할 일이 없다. "디폴트 메서드를 사용해야 하나? 말아야 하나?"라는 고민이 든다면 사용하지 말자. 

 

가급적 매우 간단한 공통 기본 동작이나, 이미 사용중인 인터페이스를 확장할 때만 제한적으로 사용해야 한다.

 

 

728x90
반응형
LIST
728x90
반응형
SMALL

참고자료

 

김영한의 실전 자바 - 고급 3편, 람다, 스트림, 함수형 프로그래밍 강의 | 김영한 - 인프런

김영한 | , [사진]국내 개발 분야 누적 수강생 1위,제대로 만든 김영한의 실전 자바[사진][임베딩 영상]단순히 자바 문법을 안다? 이걸로는 안됩니다!전 우아한형제들 기술이사, 누적 수강생 40만

www.inflearn.com

 

Optional이 좋아보여도, 무분별하게 사용하면 오히려 코드 가독성과 유지보수에 도움이 되지 않을 수 있다. Optional은 주로 메서드의 반환값에 대해 값이 없을 수도 있음을 표현하기 위해 도입되었다.

 

⭐️ 여기서 핵심은, 메서드의 반환값에 Optional을 사용하라는 것이다.

 

1. 필드에는 가급적 사용하지 말기

잘못된 예시

public class Address {
    private Optional<String> street;
    ...
}
  • 이렇게 되면 다음과 같은 3가지 상황이 발생한다.
    • name = null
    • name = Optional.empty()
    • name = Optional.of(value)
  • Optional 자체도 참조 타입이기 때문에, 혹시라도 개발자가 부주의로 Optional 필드에 null을 할당하면, 그 자체가 NullPointerException을 발생시킬 여지를 남긴다.
  • 값이 없을수도 있음을 명시하기 위해 사용하는 것이 Optional인데 정작 필드 자체가 null이면 혼란이 가중된다.

 

2. 메서드 매개변수로 Optional 사용하지 말기

잘못된 예시

public void processOrder(Optional<Long> orderId) {
    if (orderId.isPresent()) {
        System.out.println("Order ID: " + orderId.get());
    } else {
        System.out.println("Order ID is empty!");
    }
}
  • 매개변수로 Optional을 선언하면, 결국 이게 그냥 null 처리하는 코드랑 무엇이 다른가?
  • 반면, 호출하는 쪽은 그냥 processOrder(null)처럼 익숙한 코드를 호출했으면 되는거를 이렇게 코드를 작성하면 processOrder(Optional.empty())와 같은 익숙하지 않은 코드를 작성해야 한다.

 

3. 컬렉션이나 배열 타입을 Optional로 감싸지 말기

잘못된 예시

public Optional<List<String>> getUserRoles(String userId) {
    List<String> userRolesList ...;
    if (foundUser) {
        return Optional.of(userRolesList);
    } else {
        return Optional.empty();
    }
}
  • 컬렉션 자체가 이미 비어있는 상태를 표현할 수 있게 설계됐다. 그래서 그냥 userRolesList.isEmpty()를 호출하면 되는거를 Optional로 감싸면, 이중으로 비어있는지를 확인해야 한다.

그리고, 논외지만 추가적으로 리스트를 반환할 때 빈 리스트를 반환해야하지, null을 반환하면 안된다. 그러니까 아래 예시를 보자.

public List<String> getList() {
    if (...) {
        return 리스트;
    }
    return null;
}
  • 이 코드보면, 어떤 조건이 만족할땐 리스트를 반환하지만 그렇지 않은 경우 null을 반환한다. 근데 이게 정말 안 좋은 방식인게 이거 호출하는 쪽은 리스트를 반환한다고 하면 보통 null이 오는 것을 고려하지 않는다.
  • 왜냐? 리스트는 이미 빈 값을 표현할 수 있기 때문에 값이 없으면 빈 리스트가 돌아올 것을 예상하지 null 자체를 예상하지 않는다. 그래서 이 자체로 NPE(NullPointerException)을 유발시킬 수 있다.

 

4. isPresent()get() 조합을 직접 사용하지 않기

잘못된 예시

public static void main(String[] args) {
    Optional<String> optStr = Optional.ofNullable("Hello");
    if (optStr.isPresent()) {
        System.out.println(optStr.get());
    } else {
        System.out.println("Nothing");
    }
}
  • Optionalget() 메서드는 가급적 사용하지 않아야 한다. 
  • 위 코드는 사실상 null 체크와 다를 바가 없으며, isPresent()를 깜빡하면 NoSuchElementException 같은 예외가 발생할 위험이 있다.
  • 대신 orElse(), orElseGet(), orElseThrow(), ifPresentOrElse(), map(), filter() 등의 메서드를 활용하면 간결하고 안전하게 처리할 수 있다.

물론, "언제나 항상"은 없다. 위 코드처럼 get()을 사용해야 할 경우가 있을 수도 있다. 그럴땐 꼭 isPresent()와 함께 사용해야 한다.

 

5. orElse() vs orElseGet() 차이를 분명히 이해하기

  • orElse(T other)는 항상 other를 즉시 생성하거나 계산한다. 즉, Optional 값이 존재해도 불필요한 연산, 객체 생성이 일어날 수 있다. (즉시 평가)
  • orElseGet(Supplier<? extends T> supplier)는 필요할 때만(빈 Optional 일 때만) Supplier를 호출한다. 값이 이미 존재하는 경우에는 Supplier가 실행되지 않으므로, 비용이 큰 연산을 뒤로 미룰 수 있다. (지연 평가)

 

정리하자면, 비용이 크지 않은(또는 간단한 상수) 대체값이라면 간단하게 orElse()를 사용하자. 반면, 복잡하고 비용이 큰 객체 생성이 필요한 경우, 그리고 Optional 값이 이미 존재할 가능성이 높다면 orElseGet()을 사용하자.

 

 

6. 무조건 Optional이 좋은 것은 아니다.

다음과 같은 경우에 오히려 불필요할 수 있다.

  • 항상 값이 있는 상황 - 비즈니스 로직상 null이 될 수 없는 경우, 그냥 일반 타입을 사용하거나 방어 코드로 예외를 던지는 편이 낫다.
  • "값이 없으면 예외를 던지는 것"이 더 자연스러운 상황 - 예를 들어 찜질방에 A 고객이라는 유저 정보가 있는데 해당 유저의 락커가 없는 경우? 뭔가 문제가 있다. 
  • 흔히 비는 경우가 아니라 흔히 채워져 있는 경우
  • 성능이 극도로 중요한 로우레벨 코드 - Optional은 래퍼 객체를 생성하므로, 수많은 객체가 단기간에 생겨나는 영역에서는 성능 영향을 줄 수 있다. (일반적인 비즈니스 로직에서는 문제가 되지 않는다)

 

 

728x90
반응형
LIST
728x90
반응형
SMALL

참고자료

 

김영한의 실전 자바 - 고급 3편, 람다, 스트림, 함수형 프로그래밍 강의 | 김영한 - 인프런

김영한 | , [사진]국내 개발 분야 누적 수강생 1위,제대로 만든 김영한의 실전 자바[사진][임베딩 영상]단순히 자바 문법을 안다? 이걸로는 안됩니다!전 우아한형제들 기술이사, 누적 수강생 40만

www.inflearn.com

 

OptionalorElse()orElseGet()에서 알 수 있는, 즉시 평가와 지연 평가에 대해 좀 더 자세히 알아보자.

 

즉시 평가 (Eager evaluation)

  • 값(혹은 객체)을 바로 생성하거나 계산해 버리는 것

지연 평가 (Lazy evaluation)

  • 값이 실제로 필요할 때(즉, 사용될 때)까지 계산을 미루는 것

여기서 평가라는 것은 쉽게 이야기해서 계산이라고 생각하면 된다. 둘의 차이를 이해하기 위해 간단한 로거 예제를 만들어보자.

package optional.logger;

import java.util.function.Supplier;

public class Logger {
    private boolean isDebug = false;

    public boolean isDebug() {
        return isDebug;
    }

    public void setDebug(boolean debug) {
        isDebug = debug;
    }

    public void debug(Object message) {
        if (isDebug) {
            System.out.println("[DEBUG] " + message);
        }
    }
}
  • 이 로거의 사용 목적은 일반적인 상황에서는 로그를 남기지 않다가, 디버깅이 필요한 경우에만 디버깅용 로그를 추가로 출력하는 것이다.
  • debug()에 전달한 메시지는 isDebug값을 true로 설정한 경우에만 메시지를 출력한다.
package optional.logger;

public class LogMain1 {
    public static void main(String[] args) {
        Logger logger = new Logger();
        logger.setDebug(true);
        logger.debug(10 + 20);

        System.out.println("디버그 모드 끄기");
        logger.setDebug(false);
        logger.debug(100 + 200);
    }
}

실행 결과

[DEBUG] 30
디버그 모드 끄기
  • debug(10 + 20)은 디버그 모드가 켜져있기 때문에 출력된다.
  • debug(100 + 200)은 디버그 모드가 꺼져있기 때문에 출력되지 않는다.

그런데, 이 과정에서는 불필요한 연산을 하는 큰 문제가 있다. 자바는 연산식을 보면 기본적으로 즉시 평가를 한다. 

// 자바 언어의 연산자 우선순위상 메서드를 호출하기 전에 괄호 안의 내용이 먼저 계산된다.
logger.debug(10 + 20); // 1. 여기서는 10 + 20이 즉시 평가된다.
logger.debug(30); // 2. 10 + 20 연산의 평가 결과는 30이 된다.
debug(30) // 3. 메서드를 호출한다. 이때 계산된 30의 값이 인자로 전달된다.
  • 자바는 10 + 20 이라는 연산을 처리할 순서가 되면 그때 바로 즉시 평가(계산)을 한다. 우리에게는 너무 자연스러운 방식이기 때문에 아무런 문제가 될 것이 없어보인다. 그런데 이런 방식이 때로는 문제가 되는 경우가 있다.
System.out.println("=== 디버그 모드 끄기 ===");
logger.setDebug(false);
logger.debug(100 + 200);
  • 이 연산은 debug 모드가 꺼져있기 때문에 출력되지 않는다. 따라서 100 + 200 연산은 어디에도 사용되지 않는다. 하지만 이 연산은 계산된 후에 버려진다.
// 자바 언어의 연산자 우선순위상 메서드를 호출하기 전에 괄호 안의 내용이 먼저 계산된다.
logger.debug(100 + 200); // 1. 여기서는 100 + 200이 즉시 평가된다.
logger.debug(300); // 2. 100 + 200 연산의 평가 결과는 300이 된다.
debug(300) // 3. 메서드를 호출한다. 이때 계산된 300의 값이 인자로 전달된다.
public void debug(Object message = 300) { // 4. message에 계산된 300이 할당된다.
    if (isDebug) { // 5. debug 모드가 꺼져있으므로 false이다.
        System.out.println("[DEBUG] " + message); // 6. 실행되지 않는다.
    }
}
  • 이 연산의 결과 300은 debug 모드가 꺼져있기 때문에 출력되지 않는다. 따라서 앞서 계산한 100 + 200 연산은 어디에도 사용되지 않는다. 결과적으로 연산은 계산된 후에 버려진다.
  • 결과적으로 100 + 200 연산은 미래에 전혀 사용하지 않을 값을 계산해서 아까운 CPU 전기만 낭비한 것이다. 그런데 정말 사용하지도 않을 100 + 200 연산을 처리한 것일까? 눈으로 확인할 수 없으니 믿을 수가 없다!

 

100 + 200 연산을 메서드 호출로 변경해서, 실제 호출되는지 확인해보자.

package optional.logger;

public class LogMain2 {
    public static void main(String[] args) {
        Logger logger = new Logger();
        logger.setDebug(true);
        logger.debug(value100() + value200());

        System.out.println("디버그 모드 끄기");
        logger.setDebug(false);
        logger.debug(value100() + value200());

    }

    static int value100() {
        System.out.println("value100 호출");
        return 100;
    }

    static int value200() {
        System.out.println("value200 호출");
        return 200;
    }
}

실행 결과

value100 호출
value200 호출
[DEBUG] 300
디버그 모드 끄기
value100 호출
value200 호출
  • 로그를 보면, 디버그 모드를 끈 경우에도 value100(), value200()이 실행된 것을 확인할 수 있다. 따라서 메서드를 호출하기 전에 괄호 안의 내용이 먼저 평가되는 것을 확인할 수 있다.

 

그렇다면, debug 모드가 켜져있을 때는 해당 연산을 처리하고, debug 모드가 꺼져있을 때는 해당 연산을 처리하지 않으려면 어떻게 해야할까? 가장 간단한 방법은 디버그 모드를 출력할 때마다 매번 if문을 사용해서 체크하는 방법이 있다.

 

기존 코드

logger.debug(value100() + value200());

 

if문으로 debug 메서드 실행 여부를 체크하는 코드

if (logger.isDebug()) {
    logger.debug(value100() + value200());
}

 

확인을 위해 코드 마지막에 다음 코드를 추가해보자.

System.out.println("디버그 모드 체크");
if (logger.isDebug()) {
    logger.debug(value100() + value200());
}

 

실행 결과

value100 호출
value200 호출
[DEBUG] 300
디버그 모드 끄기
value100 호출
value200 호출
디버그 모드 체크
  • 실행 결과를 보면 디버그 모드 체크 이후에 아무런 로그가 남지 않았다. 따라서 debug() 메서드가 실행되지 않은 것을 확인할 수 있다. 이렇게 하면 코드는 지저분해지겠지만, if문 덕분에 디버그 모드가 꺼져있을 때 필요없는 연산을 계산하지 않아도 된다. 
  • 하지만 이렇게 하려면 디버그를 출력할 때마다 계속 if문을 사용해야 한다. 코드 한 줄을 작성하는데 코드 2줄이 더 필요하다!

 

그냥 깔끔하게 딱 한 줄로, 필요 없는 연산일때는 연산하지 않고 필요할 때만 연산하는 방법은 없을까? 즉, 연산을 정의하는 시점과 해당 연산을 실행하는 시점을 분리하는 것이다. 그래서 연산의 실행을 최대한 지연해서 평가(계산)해야 한다.

 

지연 평가

자바 언어에서는 연산을 정의하는 시점과 해당 연산을 실행하는 시점을 분리하는 방법은 여러가지가 있다.

  • 익명 클래스를 만들고, 메서드를 나중에 호출
  • 람다를 만들고, 해당 람다를 나중에 호출

Logger에 람다(Supplier)를 받는 debug 메서드를 하나 추가해보자.

package optional.logger;

import java.util.function.Supplier;

public class Logger {
    ...

    public void debug(Supplier<?> supplier) {
        if (isDebug) {
            System.out.println("[DEBUG] " + supplier.get());
        }
    }
}
  • Supplier를 통해서 람다를 받도록 한다.
  • Supplierget()을 실행할 때, 해당 람다를 평가(연산)한다. 그리고 그 결과를 반환한다.
package optional.logger;

public class LogMain3 {
    public static void main(String[] args) {
        Logger logger = new Logger();
        logger.setDebug(true);
        logger.debug(() -> value100() + value200());

        System.out.println("디버그 모드 끄기");
        logger.setDebug(false);
        logger.debug(() -> value100() + value200());
    }

    static int value100() {
        System.out.println("value100 호출");
        return 100;
    }

    static int value200() {
        System.out.println("value200 호출");
        return 200;
    }
}

실행 결과

value100 호출
value200 호출
[DEBUG] 300
디버그 모드 끄기
  • 실행 결과를 보면 디버그 모드가 꺼져있을 때, value100(), value200()이 실행되지 않은 것을 확인할 수 있다. 

디버그 모드가 꺼져있을 때

logger.debug(() -> value100() + value200()) // 1. 람다를 생성한다. 이때 람다가 실행되지는 않는다.

logger.debug(() -> value100() + value200()) // 2. debug()를 호출하면서 인자로 람다를 전달한다.
public void debug(Supplier<?> supplier) { // 3. supplier에 람다가 전달된다. (람다 아직 실행 X)
    if (isDebug) { // 4. 디버그 모드가 아니므로 if문이 수행되지 않는다.
        System.out.println("[DEBUG] " + supplier.get()); // 이 코드는 수행되지 않고 람다도 실행되지 않는다.
    }
}

 

 

정리를 하자면

  • 람다를 사용해서 연산을 정의하는 시점과 실행(평가)하는 시점을 분리했다. 따라서 값이 실제로 필요할 때까지 계산을 미룰 수 있었다.
  • 람다를 활용한 지연 평가 덕분에 꼭 필요한 계산만 처리할 수 있었다.
  • 즉시 평가: 값(혹은 객체)을 바로 생성하거나 계산해 버리는 것
  • 지연 평가: 값이 실제로 필요할 때(즉, 사용될 때)까지 계산을 미루는 것

 

orElse() vs orElseGet()

우리는 즉시 평가와 지연 평가에 대해서 알아보았다. 이제 orElse()orElseGet()의 차이를 이해할 수 있을 것이다. 

orElse()는 보통 데이터를 받아서 인자가 즉시 평가되고, orElseGet()은 람다를 받아서 인자가 지연 평가된다.

 

두 메서드의 차이

  • orElse(T other)는 빈 값이면 other를 반환하는데, other를 "항상" 미리 계산한다.
    • 따라서 other를 생성하는 비용이 큰 경우, 실제로 값이 있을 때도 쓸데없이 생성 로직이 실행되어 비효율적일 수 있다.
    • orElse()에 넘기는 표현식은 호출 즉시 평가하므로 즉시 평가가 적용된다.
  • orElseGet(Supplier supplier)는 빈 값이면 supplier를 통해 값을 생성하기 때문에, 값이 있을 때는 supplier가 호출되지 않는다.
    • 생성 비용이 높은 객체를 다룰 때는 orElseGet()이 더 효율적이다.
    • orElseGet()에 넘기는 표현식은 필요할 때만 평가하므로 지연 평가가 적용된다.

 

사용 용도

  • orElse(T other)
    • 값이 이미 존재할 가능성이 높거나, 혹은 orElse()에 넘기는 객체(또는 메서드)가 생성 비용이 크지 않은 경우 사용해도 괜찮다.
    • 연산이 없는 상수나, 변수의 경우 사용해도 괜찮다.
  • orElseGet(Supplier supplier)
    • 주로 orElse()에 넘길 값의 생성 비용이 큰 경우, 혹은 값이 들어있을 확률이 높아 굳이 매번 대체 값을 계산할 필요가 없는 경우에 사용한다.

 

정리하면, 단순한 대체 값을 전달하거나 코드가 매우 간단하다면 orElse()를 사용하고, 객체 생성 비용이 큰 로직이 들어있고 Optional에 값이 이미 존재할 가능성이 높다면 orElseGet()을 고려해볼 수 있다.

 

 

728x90
반응형
LIST
728x90
반응형
SMALL

Optional 값 획득

isPresent(), isEmpty()

  • 값이 있으면(없으면) true, 없으면(있으면) false.

get()

  • 값이 있는 경우 그 값을 반환
  • 값이 없으면 NoSuchElementException 발생
  • 직접 사용 시 주의해야 하며, 가급적이면 orElse, orElseXxx 계열 메서드를 사용하는 것이 안전

orElse(T other)

  • 값이 있으면 그 값을 반환
  • 값이 없으면 other를 반환
  • 즉시 평가 (즉, other에 대한 연산을 바로 수행)

orElseGet(Supplier<? extends T> supplier)

  • 값이 있으면 그 값을 반환
  • 값이 없으면 supplier 호출하여 생성된 값 반환
  • 지연 평가 (필요한 시점에만 supplier.get()가 실행)

orElseThrow(...)

  • 값이 있으면 그 값을 반환
  • 값이 없으면 지정한 예외를 던짐

or(Supplier<? extends Optional<? extends T>> supplier)

  • 값이 있으면 해당 값의 Optional을 그대로 반환
  • 값이 없으면 supplier가 제공하는 다른 Optional을 반환
  • 값 대신 Optional을 반환한다는 특징

 

Optional 값 처리

ifPresent(Consumer<? super T> action)

  • 값이 존재하면 action 실행
  • 값이 없으면 아무것도 안 함

ifPresentOrElse(Consumer<? super T> action, Runnable emptyAction)

  • 값이 존재하면 action 실행
  • 값이 없으면 emptyAction 실행

map(Function<? super T, ? extends U> mapper)

  • 값이 있으면 mapper를 적용한 결과 (Optional<U>) 반환
  • 값이 없으면 Optional.empty() 반환

flatMap(Function<? super T, ? extends Optional<? extends U>> mapper)

  • map과 유사하지만, Optional을 반환할 때 중첩되지 않고 평탄화(flat)해서 반환

filter(Predicate<? super T> predicate)

  • 값이 있고 조건을 만족하면 그대로 반환
  • 조건 불만족이거나 비어있으면 Optional.empty() 반환

stream()

  • 값이 있으면 단일 요소를 담은 Stream<T> 반환
  • 값이 없으면 빈 스트림 반환

 

내가 Optional을 사용할 때 map, filter를 쓸 일이 있나? 라는 생각을 했는데 정말 어리석은 생각이었다. 어떤 메서드의 반환 타입이 Optional<T>라면, 이거 정말 깔끔하게 한줄로도 표현이 가능할 수 있다.

 

예제1

private Optional<String> getUserStreet(User user) {
    return Optional.ofNullable(user)
            .map(User::getAddress)
            .map(Address::getStreet);
}

 

예제2

private String getDeliveryStatus(Long orderId) {
    return findOrder(orderId)
            .map(Order::getDelivery)
            .filter(Predicate.not(Delivery::isCanceled))
            .map(Delivery::getStatus)
            .orElse("배송X");
}

private Optional<Order> findOrder(Long orderId) {
    return Optional.ofNullable(orderRepository.get(orderId));
}
  • Optionalmap, filter에서 값이 없으면 그냥 Optional.empty()로 되기 때문에 메서드 체인으로 정말 깔끔하게 처리가 가능했다.
  • 역시 사람은.. 겸손해야 하고 계속해서 배워야 한다. 
  • 이걸 배우기 전에는 아마 이렇게 작성했을 것이다.
private String getDeliveryStatus(Long orderId) {
    Order order = findOrder(orderId).orElseThrow();

    if (order.getDelivery() != null) {
        ...
    }
}

 

728x90
반응형
LIST
728x90
반응형
SMALL

참고자료

 

김영한의 실전 자바 - 고급 3편, 람다, 스트림, 함수형 프로그래밍 강의 | 김영한 - 인프런

김영한 | , [사진]국내 개발 분야 누적 수강생 1위,제대로 만든 김영한의 실전 자바[사진][임베딩 영상]단순히 자바 문법을 안다? 이걸로는 안됩니다!전 우아한형제들 기술이사, 누적 수강생 40만

www.inflearn.com

 

Collectors

스트림이 중간 연산을 거쳐 최종 연산으로써 데이터를 처리할 때, 그 결과물이 필요한 경우가 많다. 대표적으로 "리스트나 맵 같은 자료구조에 담고싶다"거나 "통계 데이터를 내고 싶다"는 식의 요구가 있을 때, 이 최종 연산에 Collectors를 활용한다.

 

collect연산(예: stream.collect(...))은 반환값을 만들어내는 최종 연산이다. collect(Collector<? super T, A, R> collector)형태를 주로 사용하고, Collectors 클래스 안에 준비된 여러 메서드를 통해서 다양한 수집 방식을 적용할 수 있다.

 

Collectors의 주요 기능

List 수집

  • Collectors.toList()
  • Collectors.toUnmodifiableList() : 불변 리스트

Set 수집

  • Collectors.toSet()
  • Collectors.toCollection(HashSet::new) : 특정 Set 타입으로 모으려면 toCollection 사용

Map 수집

  • Collectors.toMap(keyMapper, valueMapper)
  • Collectors.toMap(keyMapper, valueMapper, mergeFunction, mapSupplier)
  • 중복 키가 발생할 경우, mergeFunction 으로 해결할 수 있고, mapSupplier로 맵 타입을 지정할 수 있다.

그룹화

  • Collectors.groupingBy(classifier)
  • Collectors.groupingBy(classifier, downstreamCollector)
  • 특정 기준 함수(classifier)에 따라 그룹별로 스트림 요소를 묶는다. 각 그룹에 대해 추가로 적용할 다운스트림 컬렉터를 지정할 수 있다.

분할

  • Collectors.partitioningBy(predicate)
  • Collectors.partitioningBy(predicate, downstreamCollector)
  • predicate 결과가 truefalse로 나뉘어, 2개 그룹으로 분할한다.

통계

  • Collectors.counting()
  • Collectors.summingInt()
  • Collectors.averagingInt()
  • Collectors.summarizingInt()
  • 요소의 개수, 합계, 평균, 최소, 최대값을 구하거나, IntSummaryStatistics 같은 통계 객체로도 모을 수 있다.

리듀싱

  • Collectors.reducing()
  • 스트림의 reduce()와 유사하게, Collector 환경에서 요소를 하나로 합치는 연산을 할 수 있다.

문자열 연결

  • Collectors.joining(delimiter, prefix, suffix)
  • 문자열 스트림을 하나로 합쳐서 연결한다. 구분자(delimiter), 접두사(prefix), 접미사(suffix)등을 붙일 수 있다.

매핑

  • Collectors.mapping(mapper, downstream)
  • 각 요소를 다른 값으로 변환(mapper)한뒤 다운스트림 컬렉터로 넘긴다.

 

기본 수집 - 예시 코드

package stream.collectors;

import java.util.List;
import java.util.Set;
import java.util.TreeSet;
import java.util.stream.Stream;

import static java.util.stream.Collectors.*;

public class Collectors1Basic {
    public static void main(String[] args) {
        // 기본 기능
        List<String> list = Stream.of("Java", "Spring", "JPA")
                .collect(toList());
        System.out.println("list = " + list);
        list.set(0, "Python");
        System.out.println("list = " + list);

        // 수정 불가능 리스트
        List<Integer> unmodifiableList = Stream.of(1, 2, 3)
                        .toList();
        System.out.println("unmodifiableList = " + unmodifiableList);

        // Set
        Set<Integer> set = Stream.of(3, 1, 2, 2, 3, 3, 3)
                .collect(toSet());
        System.out.println("set = " + set);
        // Set 타입 지정 
        TreeSet<Integer> treeSet = Stream.of(3, 4, 5, 2, 1)
                .collect(toCollection(TreeSet::new));
        System.out.println("treeSet = " + treeSet);
    }
}

실행 결과

list = [Java, Spring, JPA]
list = [Python, Spring, JPA]
unmodifiableList = [1, 2, 3]
set = [1, 2, 3]
treeSet = [1, 2, 3, 4, 5]
  • Collectors.toList()는 수정 가능한 ArrayList로 수집한다.
  • Collectors.toUnmodifiableList()는 자바 10부터 제공하는 불변 리스트를 만들어서 수정할 수 없다.
  • Collectors.toSet()은 중복을 제거한 채로 Set에 수집한다.
  • Collectors.toCollection(TreeSet::new)처럼 toCollection()을 사용하면 원하는 컬렉션 구현체를 직접 지정할 수 있다. 

참고로, Collectors.toUnmodifiableList() 대신 자바 16부터는 Stream.toList()를 바로 호출할 수 있다. 얘는 마찬가지로 불변 리스트를 제공한다. 

 

또한, Collectors를 사용할 때는 static import를 추천한다.

 

 

Map 수집

package stream.collectors;

import java.util.LinkedHashMap;
import java.util.Map;
import java.util.stream.Stream;

import static java.util.stream.Collectors.toMap;

public class Collectors2Map {
    public static void main(String[] args) {
        Map<String, Integer> map1 = Stream.of("Apple", "Banana", "Tomato")
                .collect(toMap(
                                name -> name,
                                String::length
                        )
                );
        System.out.println("map1 = " + map1);

        // 키 중복 예외 발생 코드
        /*Map<String, Integer> map2 = Stream.of("Apple", "Apple", "Banana", "Tomato")
                .collect(toMap(
                                name -> name,
                                String::length
                        )
                );
        System.out.println("map2 = " + map2);*/

        // 키 중복 대안 (병합)
        Map<String, Integer> map3 = Stream.of("Apple", "Apple", "Banana", "Tomato")
                .collect(toMap(
                                name -> name,
                                String::length,
                                (oldValue, newValue) -> oldValue
                        )
                );
        System.out.println("map3 = " + map3);

        // Map 타입 지정
        Map<String, Integer> map4 = Stream.of("Apple", "Apple", "Banana", "Tomato")
                .collect(toMap(
                                name -> name,
                                String::length,
                                (oldValue, newValue) -> oldValue,
                                LinkedHashMap::new
                        )
                );
        System.out.println("map4 = " + map4);
    }
}

실행 결과

map1 = {Apple=5, Tomato=6, Banana=6}
map3 = {Apple=5, Tomato=6, Banana=6}
map4 = {Apple=5, Banana=6, Tomato=6}
  • Collectors.toMap(keyMapper, valueMapper): 각 요소에 대한 키, 값을 지정해서 Map을 만든다.
  • 키가 중복될 경우 IllegalStateException이 발생한다. 
  • 그래서, 중복의 경우 세번째 인자로 mergeFunction을 넘기는데 위와 같이 사용한다. (oldValue, newValue) -> oldValue. 이렇게 하면 이전에 같은 키로 저장된 값과 이번에 같은 키의 값 중 이전 값으로 처리한다는 의미가 된다. 두 값을 합칠 수도 있다. 이렇게. (oldValue, newValue) -> oldValue + newValue.
  • 마지막 인자로 LinkedHashMap::new 같은 걸 넘기면, 결과를 LinkedHashMap으로 얻을 수 있다.

 

그룹과 분할 수집

package stream.collectors;

import java.util.List;
import java.util.Map;

import static java.util.stream.Collectors.groupingBy;
import static java.util.stream.Collectors.partitioningBy;

public class Collectors3Group {
    public static void main(String[] args) {
        // 첫 글자 알파벳을 기준으로 그룹화
        List<String> names = List.of("Apple", "Avocado", "Banana", "Blueberry", "Cherry");

        Map<String, List<String>> grouped = names.stream()
                .collect(groupingBy(
                                name -> name.substring(0, 1)
                        )
                );
        System.out.println("grouped = " + grouped);

        // 짝수인지 여부로 분할
        List<Integer> numbers = List.of(1, 2, 3, 4, 5, 6);
        Map<Boolean, List<Integer>> partitioned = numbers.stream()
                .collect(partitioningBy(n -> n % 2 == 0));
        System.out.println("partitioned = " + partitioned);
    }
}

실행 결과

grouped = {A=[Apple, Avocado], B=[Banana, Blueberry], C=[Cherry]}
partitioned = {false=[1, 3, 5], true=[2, 4, 6]}
  • Collectors.groupingBy(...)는 특정 기준(여기서는 첫 글자)에 따라 스트림 요소를 여러 그룹으로 묶는다. 결과는 Map<기준, List<요소>> 형태이다.
  • Collectors.partitioningBy(...)는 단순하게 truefalse 두 그룹으로 나눈다. 예제에서는 짝수(true), 홀수(false)로 분할됐다.

 

최솟값 최댓값 수집

package stream.collectors;

import java.util.stream.IntStream;
import java.util.stream.Stream;

import static java.util.stream.Collectors.maxBy;

public class Collectors4MinMax {
    public static void main(String[] args) {
        // 이렇게 사용할 때는 downstream 을 사용할 때 용이
        Integer max1 = Stream.of(1, 2, 3)
                .collect(maxBy(Integer::compareTo))
                .get();
        System.out.println("max1 = " + max1);

        // 일반적으로는 그냥 max()를 사용하면 더 편리
        Integer max2 = Stream.of(1, 2, 3)
                .max(Integer::compareTo).get();
        System.out.println("max2 = " + max2);

        // 기본형 특화 스트림은 매우 간단
        int max3 = IntStream.of(1, 2, 3).max().getAsInt();
        System.out.println("max3 = " + max3);
    }
}

실행 결과

max1 = 3
max2 = 3
max3 = 3
  • Collectors.maxBy(...)이나 Collectors.minBy(...)를 통해 최소, 최댓값을 구할 수 있다. 다만 스트림 자체가 제공하는 max(), min() 메서드를 사용하면 더 간단하다. Collectors가 제공하는 이 기능들은 다운 스트림 컬렉터에서 유용하게 사용할 수 있다.
  • 기본형 특화 스트림(IntStream 등)을 쓰면, .max().getAsInt()처럼 바로 기본형으로 결과를 얻을 수 있다.

 

통계 수집

package stream.collectors;

import java.util.IntSummaryStatistics;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
import java.util.stream.Stream;

import static java.util.stream.Collectors.*;

public class Collectors4Summing {
    public static void main(String[] args) {
        // 이것도 다운스트림 컬렉터에서 유용하게 사용
        Long counting = Stream.of(1, 2, 3)
                .collect(counting());
        System.out.println("counting = " + counting);

        // 일반적으로는 count() 그냥 쓰면 편리
        long count = Stream.of(1, 2, 3)
                .count();
        System.out.println("count = " + count);

        System.out.println("-------------------------------");

        // 평균 구하기 (averagingInt)
        Double average1 = Stream.of(1, 2, 3)
                .collect(averagingInt(i -> i));
        System.out.println("average1 = " + average1);

        // 기본형 특화 스트림으로 변형
        double average2 = Stream.of(1, 2, 3)
                .mapToInt(i -> i)
                .average().getAsDouble();
        System.out.println("average2 = " + average2);

        // 처음부터 기본형 특화 스트림 사용
        double average3 = IntStream.of(1, 2, 3)
                .average()
                .getAsDouble();
        System.out.println("average3 = " + average3);

        System.out.println("-------------------------------");

        // 통계
        IntSummaryStatistics stats = Stream.of("Apple", "Banana", "Tomato")
                .collect(summarizingInt(String::length));
        System.out.println("stats.getCount() = " + stats.getCount());
        System.out.println("stats.getSum() = " + stats.getSum());
        System.out.println("stats.getAverage() = " + stats.getAverage());
        System.out.println("stats.getMin() = " + stats.getMin());
        System.out.println("stats.getMax() = " + stats.getMax());
    }
}

실행 결과

counting = 3
count = 3
-------------------------------
average1 = 2.0
average2 = 2.0
average3 = 2.0
-------------------------------
stats.getCount() = 3
stats.getSum() = 17
stats.getAverage() = 5.666666666666667
stats.getMin() = 5
stats.getMax() = 6
  • Collectors.counting()은 요소 개수를 구한다.
  • Collectors.averagingInt()는 요소들의 평균을 구한다.
  • Collectors.summarizingInt()는 합계, 최솟값, 최댓값, 평균 등 다양한 통계 정보를 담은 IntSummaryStatistics 객체를 얻는다.
  • 자주 쓰이는 통계 메서드로 Collectors.summingInt(), Collectors.maxBy(), Collectors.minBy(), Collectors.counting() 등이 있다.
  • Collectors의 일부 기능은 스트림에서 직접 제공하는 기능과 중복된다. Collectors의 기능들은 뒤에서 설명할 다운 스트림 컬렉터에서 유용하게 사용할 수 있다.

 

리듀싱 수집

package stream.collectors;

import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;

import static java.util.stream.Collectors.*;
import static java.util.stream.Collectors.reducing;

public class Collectors5Reducing {
    public static void main(String[] args) {
        List<String> names = List.of("a", "b", "c", "d");

        // 컬렉션의 reducing은 주로 다운스트림에 사용
        String joined1 = names.stream()
                .collect(reducing(
                        (s1, s2) -> s1 + ", " + s2)
                )
                .get();
        System.out.println("joined1 = " + joined1);

        Optional<String> reduce = names.stream()
                .reduce((s1, s2) -> s1 + ", " + s2);
        System.out.println("reduce.get() = " + reduce.get());

        String joined2 = names.stream()
                .collect(joining(", ", "[", "]"));
        System.out.println("joined2 = " + joined2);
    }
}

실행 결과

joined1 = a, b, c, d
reduce.get() = a, b, c, d
joined2 = [a, b, c, d]
  • Collectors.reducing(...)은 최종적으로 하나의 값으로 요소들을 합치는 방식을 지정한다. 여기서는 문자열들을 ',' 로 이어붙였다.
  • 스트림 자체의 reduce(...)와 유사한 기능이다.
  • 문자열을 이어 붙일 때는 Collectors.joining()이나 String.join()을 쓰는게 더 간편하다.

 

다운 스트림 컬렉터

다운 스트림 컬렉터가 필요한 이유

  • groupingBy(...)를 사용하면 일단 요소가 그룹별로 묶이지만, 그룹 내 요소를 구체적으로 어떻게 처리할지는 기본적으로 Collectors.toList()만 적용된다.
  • 그런데, 실무에서는 "그룹별 총합, 평균, 최대/최소값, 매핑된 결과, 통계"등을 바로 얻고 싶을 때가 많다.
  • 예를 들어, "학년별로 학생들을 그룹화한 뒤, 각 학년 그룹에서 평균 점수를 구하고 싶다"는 상황에서는 단순히 List<Student>로 끝나는 게 아니라, 그룹 내 학생들의 점수를 합산하고 평균을 내는 동작이 더 필요하다.
  • 이처럼, 그룹화된 이후 각 그룹 내부에서 추가적인 연산 또는 결과물(예: 평균, 합계, 최댓값, 최솟값, 통계, 다른 타입으로 변환 등)을 정의하는 역할을 하는 것이 바로 다운 스트림 컬렉터이다.
  • 이때, 다운 스트림 컬렉터를 활용하면, "그룹 내부"를 다시 한번 모으거나 집계하여 원하는 결과를 얻을 수 있다.
    • 예: groupingBy(분류함수, counting()) → 그룹별 개수
    • 예: groupingBy(분류함수, summingInt(Student::getScore)) → 그룹별 점수 합계
    • 예: groupingBy(분류함수, mapping(Student::getName, toList())) → 그룹별 학생 이름 리스트

 

그림 예시

  • 각 학년별로 그룹화한 뒤, 그룹화한 학년별 점수의 합을 구하는 방법

 

다운 스트림 컬렉터의 종류

Collector 사용 메서드 예시 설명 예시 반환 타입
counting() Collectors.counting() 그룹 내(혹은 스트림 내) 요소들의 개수를 센다. Long
summingInt() 등 Collectors.summingInt(...),
Collectors.summingLong(...)
그룹 내 요소들의 특정 정수형 속성을 모두 합산한다. Integer, Long 등
averagingInt() 등 Collectors.averagingInt(...),
Collectors.averagingDouble(...)
그룹 내 요소들의 특정 속성 평균값을 구한다. Double
minBy(), maxBy() Collectors.minBy(Comparator),
Collectors.maxBy(Comparator)
그룹 내 최소, 최댓값을 구한다. Optional<T>
summarizingInt() 등 Collectors.summarizingInt(...),
Collectors.summarizingLong(...)
개수, 합계, 평균, 최소, 최댓값을 동시에 구할 수 있는 SummaryStatistics 객체를 반환한다. IntSummaryStatistics 등
mapping() Collectors.mapping(변환함수, 다운스트림) 각 요소를 다른 값으로 변환한 뒤, 변환된 값들을 다시 다른 Collector로 수집할 수 있게 한다. 다운스트림 반환 타입에 따라 달라짐
collectingAndThen() Collectors.collectingAndThen(다른 컬렉터, 변환 함수) 다운 스트림 컬렉터의 결과를 최종적으로 한번 더 가공(후처리)할 수 있다. 후처리 후의 타입
reducing() Collectors.reducing(초기값, 변환 함수, 누적 함수),
Collectors.reducing(누적 함수)
스트림의 recuce()와 유사하게, 그룹 내 요소들을 하나로 합치는 로직을 정의할 수 있다. 누적 로직에 따라 달라짐
toList(), toSet() Collectors.toList(),
Collectors.toSet()
그룹 내(혹은 스트림 내) 요소를 리스트나 집합으로 수집한다. toCollection(...)으로 구현체 지정 가능 List<T>, Set<T>

 

 

다운 스트림 컬렉터 예제 1

package stream.collectors;

public class Student {
    private String name;
    private int grade;
    private int score;

    public Student(String name, int grade, int score) {
        this.name = name;
        this.grade = grade;
        this.score = score;
    }

    public String getName() {
        return name;
    }

    public int getGrade() {
        return grade;
    }

    public int getScore() {
        return score;
    }

    @Override
    public String toString() {
        return "Student{" +
                "name='" + name + '\'' +
                ", grade=" + grade +
                ", score=" + score +
                '}';
    }
}

 

 

package stream.collectors;

import java.util.List;
import java.util.Map;

import static java.util.stream.Collectors.*;

public class DownStreamMain1 {
    public static void main(String[] args) {
        List<Student> students = List.of(
                new Student("Kim", 1, 85),
                new Student("Park", 1, 70),
                new Student("Lee", 2, 70),
                new Student("Han", 2, 90),
                new Student("Hoon", 3, 90),
                new Student("Ha", 3, 89)
        );

        // 1단계: Grade 별로 그룹화
        Map<Integer, List<Student>> collect1_1 = students.stream()
                .collect(groupingBy(
                                Student::getGrade,
                                toList()
                        )
                );
        System.out.println("collect1_1 = " + collect1_1);

        // 1단계: 다운 스트림에서 toList() 생략 가능
        Map<Integer, List<Student>> collect1_2 = students.stream()
                .collect(groupingBy(Student::getGrade));
        System.out.println("collect1_2 = " + collect1_2);

        // 2단계: 학년별로 학생들의 이름을 출력
        Map<Integer, List<String>> collect2 = students.stream()
                .collect(groupingBy(
                                Student::getGrade,
                                mapping( // 다운스트림 1: 학생 -> 이름 변환
                                        Student::getName,
                                        toList() // 다운스트림 2: 변환된 값을 List 로 수집
                                )
                        )
                );
        System.out.println("collect2 = " + collect2);

        // 3단계: 학년별로 학생들의 수를 출력
        Map<Integer, Long> collect3 = students.stream()
                .collect(groupingBy(
                                Student::getGrade,
                                counting()
                        )
                );
        System.out.println("collect3 = " + collect3);

        // 4단계: 학년별로 학생들의 평균 성적 출력
        Map<Integer, Double> collect4 = students.stream()
                .collect(groupingBy(
                                Student::getGrade,
                                averagingDouble(Student::getScore)
                        )
                );
        System.out.println("collect4 = " + collect4);
    }
}

실행 결과

collect1_1 = {1=[Student{name='Kim', grade=1, score=85}, Student{name='Park', grade=1, score=70}], 2=[Student{name='Lee', grade=2, score=70}, Student{name='Han', grade=2, score=90}], 3=[Student{name='Hoon', grade=3, score=90}, Student{name='Ha', grade=3, score=89}]}
collect1_2 = {1=[Student{name='Kim', grade=1, score=85}, Student{name='Park', grade=1, score=70}], 2=[Student{name='Lee', grade=2, score=70}, Student{name='Han', grade=2, score=90}], 3=[Student{name='Hoon', grade=3, score=90}, Student{name='Ha', grade=3, score=89}]}
collect2 = {1=[Kim, Park], 2=[Lee, Han], 3=[Hoon, Ha]}
collect3 = {1=2, 2=2, 3=2}
collect4 = {1=77.5, 2=80.0, 3=89.5}

 

 

다운 스트림 컬렉터 예제 2

package stream.collectors;

import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.Optional;

import static java.util.stream.Collectors.*;

public class DownStreamMain2 {
    public static void main(String[] args) {
        List<Student> students = List.of(
                new Student("Kim", 1, 85),
                new Student("Park", 1, 70),
                new Student("Lee", 2, 70),
                new Student("Han", 2, 90),
                new Student("Hoon", 3, 90),
                new Student("Ha", 3, 89)
        );

        // 1단계: 학년별로 학생들을 그룹화해라.
        Map<Integer, List<Student>> collect1 = students.stream()
                .collect(groupingBy(Student::getGrade));
        System.out.println("collect1 = " + collect1);

        // 2단계: 학년별로 가장 점수가 높은 학생을 구해라. reducing 사용
        Map<Integer, Optional<Student>> collect2 = students.stream()
                .collect(groupingBy(
                                Student::getGrade,
                                reducing((s1, s2) -> s1.getScore() > s2.getScore() ? s1 : s2)
                        )
                );
        System.out.println("collect2 = " + collect2);

        // 3단계: 학년별로 가장 점수가 높은 학생을 구해라. maxBy 사용
        Map<Integer, Optional<Student>> collect3 = students.stream()
                .collect(groupingBy(
                                Student::getGrade,
                                maxBy(Comparator.comparing(Student::getScore))
                        )
                );
        System.out.println("collect3 = " + collect3);

        // 4단계: 학년별로 가장 점수가 높은 학생의 이름을 구해라 (collectingAndThen + maxBy)
        Map<Integer, String> collect4 = students.stream()
                .collect(groupingBy(
                                Student::getGrade,
                                collectingAndThen(
                                        maxBy(Comparator.comparing(Student::getScore)),
                                        sO -> sO.map(Student::getName).orElse(null)
                                )
                        )
                );
        System.out.println("collect4 = " + collect4);
    }
}

실행 결과

collect1 = {1=[Student{name='Kim', grade=1, score=85}, Student{name='Park', grade=1, score=70}], 2=[Student{name='Lee', grade=2, score=70}, Student{name='Han', grade=2, score=90}], 3=[Student{name='Hoon', grade=3, score=90}, Student{name='Ha', grade=3, score=89}]}
collect2 = {1=Optional[Student{name='Kim', grade=1, score=85}], 2=Optional[Student{name='Han', grade=2, score=90}], 3=Optional[Student{name='Hoon', grade=3, score=90}]}
collect3 = {1=Optional[Student{name='Kim', grade=1, score=85}], 2=Optional[Student{name='Han', grade=2, score=90}], 3=Optional[Student{name='Hoon', grade=3, score=90}]}
collect4 = {1=Kim, 2=Han, 3=Hoon}

 

 

정리

다운 스트림 컬렉터를이해하면, groupingBy(...)partitioningBy(...)로 그룹화, 분할을 한 뒤 내부 요소를 어떻게 가공하고 수집할지 자유롭게 설계할 수 있다. 

728x90
반응형
LIST
728x90
반응형
SMALL

스트림 API에는 기본형 특화 스트림이 존재한다. 자바에서는 IntStream, LongStream, DoubleStream 세 가지 형태를 제공하여 기본 자료형에 특화된 기능을 사용할 수 있게 한다.

 

예를 들어, IntStream은 합계 계산, 평균, 최솟값, 최댓값 등 정수와 관련된 연산을 좀 더 편리하게 제공하고, 오토박싱, 언박싱 비용을 줄여 성능도 향상시킬 수 있다.

 

기본형 특화 스트림의 종류

스트림 타입 대상 원시 타입 생성 예시
IntStream int IntStream.of(1, 2, 3),
IntStream.range(1, 10),
mapToInt(...)
LongStream long LongStream.of(10L, 20L),
LongStream.range(1, 10),
mapToLong(...)
DoubleStream double DoubleStream.of(3.14, 2.78),
DoubleStream.generate(Math::random),
mapToDouble(...)

 

기본형 특화 스트림의 숫자 범위 생성 기능

  • range(int startInclusive, int endExclusive): 시작값 이상, 끝 값 미만
  • rangeClosed(int startInclusive, int endInclusive): 시작값 이상, 끝 값 포함

 

주요 기능 및 메서드

기본형 특화 스트림은 합계, 평균 등 자주 사용하는 연산을 편리한 메서드로 제공한다. 또한 타입 변환과 박싱/언박싱 메서드도 제공하여 다른 스트림과 연계해 작업하기 수월하다.

 

참고로, 기본형 특화 스트림은 int, long, double 같은 숫자를 사용한다. 따라서 숫자 연산에 특화된 메서드를 제공할 수 있다.

 

메서드/기능 설명 예시
sum() 모든 요소의 합계를 구한다. int total = IntStream.of(1,2,3).sum();
average() 모든 요소의 평균을 구한다. double avg = IntStream.range(1, 5).average().getAsDouble();
summaryStatistics() 최솟값, 최댓값, 합계, 개수, 평균 등이 담긴 객체 변환 IntSummaryStatistics stats = IntStream.range(1, 5).summaryStatistics();
mapToLong(), mapToDouble(), mapToInt() 타입 변환: IntStream -> LongStream, DoubleStream LongStream ls = IntStream.of(1, 2).mapToLong(i -> i * 10L);
mapToObj() 객체 스트림으로 변환: 기본형 -> 참조형 Stream<String> s = IntStream.range(1, 5).mapToObj(i -> "No: " + i);
boxed() 기본형 특화 스트림을 박싱된 객체 스트림으로 변환 Stream<Integer> si = IntStream.range(1, 5).boxed();
sum(), min(), max(), count() 합계, 최소값, 최대값, 개수를 반환 long cnt = LongStream.of(1,2,3).count();

 

예시 코드

package stream.operation;

import java.util.IntSummaryStatistics;
import java.util.stream.DoubleStream;
import java.util.stream.IntStream;
import java.util.stream.LongStream;
import java.util.stream.Stream;

public class PrimitiveStreamMain {
    public static void main(String[] args) {
        // 기본형 특화 스트림 (IntStream, LongStream, DoubleStream)
        IntStream intStream = IntStream.of(1, 2, 3, 4, 5);
        intStream.forEach(i -> System.out.print(i + " "));
        System.out.println();

        // 범위 생성 메서드
        IntStream range1 = IntStream.range(1, 6); // [1,2,3,4,5]
        IntStream range2 = IntStream.rangeClosed(1, 6); // [1,2,3,4,5,6]
        range1.forEach(i -> System.out.print(i + " "));
        System.out.println();
        range2.forEach(i -> System.out.print(i + " "));
        System.out.println();

        // 통계 관련 메서드
        int sum = IntStream.range(1, 6).sum();
        System.out.println("sum = " + sum);

        double avg = IntStream.range(1, 6).average().getAsDouble();
        System.out.println("avg = " + avg);

        IntSummaryStatistics stats = IntStream.range(1, 6).summaryStatistics();
        System.out.println("합계: " + stats.getSum());
        System.out.println("평균: " + stats.getAverage());
        System.out.println("최댓값: " + stats.getMax());
        System.out.println("최소값: " + stats.getMin());
        System.out.println("개수: " + stats.getCount());

        // 타입 변환 메서드
        LongStream longStream = IntStream.range(1, 5).asLongStream();
        DoubleStream doubleStream = IntStream.range(1, 5).asDoubleStream();

        Stream<Integer> boxed = IntStream.range(1, 5).boxed();

        // 기본형 특화 매핑
        LongStream mappedToLong = IntStream.range(1, 5).mapToLong(i -> i * 10L);
        DoubleStream mappedToDouble = IntStream.range(1, 5).mapToDouble(i -> i * 1.5);
        Stream<String> mappedToObj = IntStream.range(1, 5).mapToObj(i -> "Number " + i);

        // 객체 스트림 -> 기본형 특화 스트림으로 매핑
        Stream<Integer> integerStream = Stream.of(1, 2, 3, 4, 5);
        IntStream intStream2 = integerStream.mapToInt(i -> i);
        int sum2 = intStream2.sum();
        System.out.println("sum2 = " + sum2);
    }
}

 

 

어디다가 써요?

  • 예를 들어, 기본 스트림을 사용하는데 전부 데이터가 숫자이고 이 합계를 구하고 싶다면 다음 코드가 가장 사용하기 좋은 예시가 된다.
// 객체 스트림 -> 기본형 특화 스트림으로 매핑
int newSum = Stream.of(1, 2, 3, 4, 5)
                .mapToInt(i -> i)
                .sum();
System.out.println("newSum = " + newSum);
  • 또 다른 예로, range(), rangeClosed() 같은 메서드를 사용하면 범위를 쉽게 다룰 수 있어 반복문 대신에 자주 사용된다.
  • summaryStatistics()를 이용하면 합계, 평균, 최소값, 최대값 등 통계 정보를 한번에 구할 수 있어 편리하다.

 

 

728x90
반응형
LIST
728x90
반응형
SMALL

Stream API 중 이해가 부족한 중간 연산을 정리하는 시간을 가져보자.

 

takeWhile

자바9부터 지원하는 중간 연산인데, 쉽게 말해 "어디까지 취할것인가?"에 대한 조건을 만들어낸다고 보면 된다.

System.out.println("takeWhile - Stream 데이터를 어디까지만 take 할 지 (정렬된 요소들에 대해서 사용해야 효과적)");
Stream.of(1,2,3,4,5,6,7,8,9)
        .takeWhile(n -> n < 5)
        .forEach(n -> System.out.println(n + " "));
takeWhile - Stream 데이터를 어디까지만 take 할 지 (정렬된 요소들에 대해서 사용해야 효과적)
1 
2 
3 
4
  • 위 코드에서처럼 1부터 9까지를 Stream으로 처리할 때 takeWhile(n -> n < 5)라는 중간 연산이 있으면, 5보다 작은 숫자에 대해서만 처리를 하다가 5보다 크거나 같은 값을 만나게 되면 그때 딱 이 스트림을 종료한다.
  • 정렬된 데이터에 대해서 필요할 때 사용하면 효과적일 것 같다.

 

dropWhile

이 역시 자바9부터 지원하는 중간 연산인데, 쉽게 말해 "어디까지 스킵할것인가?"에 대한 조건을 만들어낸다고 보면 된다.

System.out.println("dropWhile - Stream 데이터를 어디까지 skip 할 지 (정렬된 요소들에 대해서 사용해야 효과적)");
Stream.of(1,2,3,4,5,6,7,8,9)
        .dropWhile(n -> n < 5)
        .forEach(n -> System.out.println(n + " "));
dropWhile - Stream 데이터를 어디까지 skip 할 지 (정렬된 요소들에 대해서 사용해야 효과적)
5 
6 
7 
8 
9
  • 위 코드에서처럼 1부터 9까지 Stream으로 처리할 때 dropWhile(n -> n < 5)라는 중간 연산이 있으면, 5보다 작은 숫자에 대해서는 Skip 처리하다가 5보다 크거나 같은 값을 만나면 그때부터 쭉 진행한다.

 

FlatMap

flatMap은 각 요소를 스트림으로 변환한 뒤, 그 결과를 하나의 스트림으로 평탄화 해준다.

[
    [1, 2],
    [3, 4],
    [5, 6]
]
  • 위와 같은 리스트 안에 리스트가 있다고 가정해보자.

flatMap을 사용하면 이 데이터를 다음과 같이 쉽게 평탄화(flatten)할 수 있다.

[1,2,3,4,5,6]

 

package stream.operation;

import java.util.ArrayList;
import java.util.Collection;
import java.util.List;

public class MapVsFlatMapMain {
    public static void main(String[] args) {
        List<List<Integer>> outer = List.of(
                List.of(1, 2),
                List.of(3, 4),
                List.of(5, 6)
        );
        System.out.println("outer = " + outer);

        // flatMap
        List<Integer> flatMapResult = outer.stream()
                .flatMap(list -> list.stream())
                .peek(System.out::println)
                .toList();
        System.out.println("flatMapResult = " + flatMapResult);
    }
}
outer = [[1, 2], [3, 4], [5, 6]]
1
2
3
4
5
6
flatMapResult = [1, 2, 3, 4, 5, 6]

 

728x90
반응형
LIST
728x90
반응형
SMALL

참고자료

 

김영한의 실전 자바 - 고급 3편, 람다, 스트림, 함수형 프로그래밍 강의 | 김영한 - 인프런

김영한 | , [사진]국내 개발 분야 누적 수강생 1위,제대로 만든 김영한의 실전 자바[사진][임베딩 영상]단순히 자바 문법을 안다? 이걸로는 안됩니다!전 우아한형제들 기술이사, 누적 수강생 40만

www.inflearn.com

 

람다 vs 익명 클래스 1

자바에서 익명 클래스와 람다 표현식은 모두 간단하게 기능을 구현하거나, 일회성으로 사용할 객체를 만들 때 유용하지만, 그 사용 방식과 의도에는 차이가 있다.

 

1. 문법 차이

익명 클래스

Runnable anonymous = new Runnable() {

    private String message = "익명 클래스";

    @Override
    public void run() {
        System.out.println("[익명 클래스] this: " + this);
        System.out.println("[익명 클래스] this.class: " + this.getClass());
        System.out.println("[익명 클래스] this.message: " + this.message);
    }
};
  • 익명 클래스는 클래스를 선언하고 즉시 인스턴스를 생성하는 방식이다.
  • 반드시 new 인터페이스명() {...} 형태로 작성해야 하며, 메서드를 오버라이드해서 구현한다.
  • 익명 클래스도 하나의 클래스이다.

람다 표현식

Runnable lambda = () -> {
    System.out.println("[람다] this:" + this);
    System.out.println("[람다] this.class:" + this.getClass());
    System.out.println("[람다] this.message:" + this.message);
};
  • 람다 표현식은 함수를 간결하게 표현할 수 있는 방식이다.
  • 함수형 인터페이스(메서드가 하나인 인터페이스)를 간단히 구현할 때 주로 사용된다.
  • 람다는 -> 연산자를 사용하여 표현하며, 매개변수와 실행할 내용을 간결하게 작성할 수 있다.
  • 물론 람다도 인스턴스가 생성된다.

 

2. 코드의 간결함

  • 익명 클래스: 문법적으로 더 복잡하고 장황하다. new 인터페이스명() 같은 형태와 함께 메서드를 오버라이드 해야하므로 코드의 양이 상대적으로 많다.
  • 람다 표현식: 간결하며, 불필요한 코드를 최소화한다. 또한 많은 생략 기능을 지원해서 핵심 코드만 작성할 수 있다.

 

3. 상속 관계

  • 익명 클래스: 일반적인 클래스처럼 다양한 인터페이스와 클래스를 구현하거나 상속할 수 있다. 즉, 여러 메서드를 가진 인터페이스를 구현할 때도 사용할 수 있다.
  • 람다 표현식: 메서드를 딱 하나만 가지는 함수형 인터페이스만을 구현할 수 있다.
    • 람다 표현식은 클래스를 상속할 수 없다. 오직 함수형 인터페이스만 구현할 수 있으며, 상태(필드, 멤버 변수)나 추가적인 메서드 오버라이딩은 불가능하다.
    • 람다는 단순히 함수를 정의하는 것으로, 상태나 추가적인 상속 관계를 필요로 하지 않는 상황에서만 사용할 수 있다.

 

4. 호환성

  • 익명 클래스: 자바의 오래된 버전에서도 사용할 수 있다.
  • 람다 표현식: 자바 8부터 도입됐기 때문에 그 이전 버전에서는 사용할 수 없다.

 

5. this 키워드의 의미

  • 익명 클래스: this익명 클래스 자기 자신을 가리킨다. 외부 클래스와 별도의 컨텍스트를 가진다.
  • 람다 표현식: this람다를 선언한 클래스의 인스턴스를 가리킨다. 즉, 람다 표현식은 별도의 컨텍스트를 가지는 것이 아니라, 람다를 선언한 클래스의 컨텍스트를 유지한다.
    • 쉽게 말해, 람다 내부의 this는 람다가 선언된 외부 클래스의 this와 동일하다.

예제 코드

package lambda.lambda6;

public class OuterMain {

    private String message = "외부 클래스";

    public void execute() {
        Runnable anonymous = new Runnable() {

            private String message = "익명 클래스";

            @Override
            public void run() {
                System.out.println("[익명 클래스] this: " + this);
                System.out.println("[익명 클래스] this.class: " + this.getClass());
                System.out.println("[익명 클래스] this.message: " + this.message);
            }
        };

        Runnable lambda = () -> {
            System.out.println("[람다] this:" + this);
            System.out.println("[람다] this.class:" + this.getClass());
            System.out.println("[람다] this.message:" + this.message);
        };

        anonymous.run();
        System.out.println("------------------------------");
        lambda.run();
    }

    public static void main(String[] args) {
        OuterMain outerMain = new OuterMain();
        System.out.println("[외부 클래스]: " + outerMain);
        System.out.println("------------------------------");
        outerMain.execute();
    }
}
[외부 클래스]: lambda.lambda6.OuterMain@647dd0b5
------------------------------
[익명 클래스] this: lambda.lambda6.OuterMain$1@e0caac5d
[익명 클래스] this.class: class lambda.lambda6.OuterMain$1
[익명 클래스] this.message: 익명 클래스
------------------------------
[람다] this:lambda.lambda6.OuterMain@647dd0b5
[람다] this.class:class lambda.lambda6.OuterMain
[람다] this.message:외부 클래스
  • 외부 클래스는 OuterMain@647dd0b5를 가리키고 있다.
  • 익명 클래스는 OuterMain$1@e0caac5d를 가리키고 있다. 또한, this.message는 익명 클래스 내부에 선언한 message를 출력한다.
  • 람다는 외부 클래스와 동일한 OuterMain@647dd0b5를 가리키고 있다. 

 

6. 캡처링(Capturing)

  • 익명 클래스: 외부 변수에 접근할 수 있지만, 지역 변수는 반드시 final 혹은 사실상 final인 변수만 캡처할 수 있다.
  • 람다 표현식: 람다도 익명 클래스와 같이 캡처링을 지원한다. 지역 변수는 반드시 final 혹은 사실상 final인 변수만 캡처할 수 있다.
package lambda.lambda6;

public class CaptureMain {
    public static void main(String[] args) {
        final int final1 = 10;
        int final2 = 20;
        int changedVar = 30;

        Runnable runnable = new Runnable() {
            @Override
            public void run() {
                System.out.println("익명 클래스 - final1 : " + final1);
                System.out.println("익명 클래스 - final2 : " + final2);
                // 컴파일 오류
                // System.out.println("익명 클래스 - changedVar : " + changedVar);
            }
        };

        Runnable lambda = () -> {
            System.out.println("익명 클래스 - final1 : " + final1);
            System.out.println("익명 클래스 - final2 : " + final2);
            // 컴파일 오류
            // System.out.println("익명 클래스 - changedVar : " + changedVar);
        };

        changedVar++;

        runnable.run();
        lambda.run();
    }
}

 

 

람다 vs 익명 클래스 2

7. 생성 방식

생성 방식은 자바 내부 동작 방식으로 크게 중요하지는 않지만, 이런게 있구나 하고 참고해볼만 하다.

  • 익명 클래스: 새로운 클래스를 정의하여 객체를 생성하는 방식이다.
    • 즉, 컴파일 시 새로운 내부 클래스로 변환된다. 예를 들어, OuterClass$1.class와 같이 이름이 지정된 클래스 파일이 생성된다. 
    • 이 방식은 클래스가 메모리 상에서 별도로 관리되므로, 메모리 상에 약간의 추가 오버헤드가 발생한다 (매우 미미)
  • 람다: 내부적으로 invokeDynamic 이라는 매커니즘을 사용하여 컴파일 타임에 실제 클래스 파일을 생성하지 않고 런타임 시점에 동적으로 필요한 코드를 처리한다.
    • 따라서, 람다는 익명 클래스보다 메모리 관리가 더 효율적이며, 생성된 클래스 파일이 없으므로 클래스 파일 관리의 복잡성도 줄어든다.

쉽게 정리하면 다음과 같다.

익명 클래스

  • 컴파일 시 실제로 OuterClass$1.class와 같은 클래스 파일이 생성된다.
  • 일반적인 클래스와 같은 방식으로 작동한다.
  • 해당 클래스 파일을 JVM에 불러서 사용하는 과정이 필요하다.

람다

  • 컴파일 시점에 별도의 클래스 파일이 생성되지 않는다.
  • 자바를 실행하는 실행 시점에 동적으로 필요한 코드를 처리한다.

참고로, 이 부분은 자바 스펙에 명시된 것이 아니기 때문에 자바 버전과 자바 구현 방식에 따라 내용이 달라질 수는 있다. 따라서 대략 이런 방식으로 작동하는구나라고 참고만 해두자.

원본 코드

public class FunctionMain {
    public static void main(String[] args) {
        Function<String, Integer> function = x -> x.length();
        System.out.println("function1 = " + function.apply("hello"));
    }
}
  • 람다가 포함된 코드가 있다면 자바는 다음과 같이 컴파일 한다.
public class FunctionMain {
    public static void main(String[] args) {
        Function<String, Integer> function = 람다 인스턴스 생성(구현 코드는 lambda1() 연결)
        System.out.println("function1 = " + function.apply("hello"));
    }
    // 람다를 private 메서드로 추가
    private Integer lambda1(String x) {
        return x.length();
    }
}
  • 컴파일 단계에서 람다를 별도의 클래스로 만드는 것이 아니라, private 메서드로 만들어 숨겨둔다.
    • 참고로 자바 내부에서 일어나는 일이므로 개발자가 이렇게 만들어진 코드를 확인하기는 어렵다.
  • 그리고 실행 시점에 동적으로 람다 인스턴스를 생성하고, 해당 인스턴스의 구현 코드로 앞서 만든 lambda1() 메서드가 호출되도록 연결한다.

 

이론적으로는 람다가 별도의 클래스 파일도 만들지 않고, 더 가볍기 때문에 약간의 메모리와 성능의 이점이 있지만, 이런 부분은 매우 미미하기 때문에 실무 관점에서 익명 클래스와 람다의 성능 차이는 거의 없다고 보면 된다.

 

8. 상태 관리

익명 클래스

  • 익명 클래스는 인스턴스 내부에 상태(필드, 멤버 변수)를 가질 수 있다. 예를 들어, 익명 클래스 내부에 멤버 변수를 선언하고 해당 변수의 값을 변경하거나 상태를 관리할 수 있다.
  • 이처럼 상태를 필요로 하는 경우 익명 클래스가 유리하다.

람다

  • 클래스는 그 내부에 상태(필드, 멤버 변수)기능(메서드)을 가진다. 반면에 함수는 그 내부에 상태(필드)를 가지지 않고 기능만 제공한다.
  • 함수인 람다는 기본적으로 필드(멤버 변수)가 없으므로 스스로 상태를 유지하지는 않는다.

 

9. 익명 클래스와 람다의 용도 구분

익명 클래스

  • 상태를 유지하거나 다중 메서드를 구현할 필요가 있는 경우
  • 기존 클래스 또는 인터페이스를 상속하거나 구현할 때
  • 복잡한 인터페이스 구현이 필요할 때

람다

  • 상태를 유지할 필요가 없고, 간결함이 중요한 경우
  • 단일 메서드만 필요한 간단한 함수형 인터페이스 구현 시
  • 더 나은 성능(매우 미미하지만)과 간결한 코드가 필요한 경우

 

정리

  • 대부분의 경우 익명 클래스를 람다로 대체할 수 있다. 하지만, 여러 메서드를 가진 인터페이스나 클래스의 경우에는 여전히 익명 클래스가 필요할 수 있다.
  • 자바 8 이후에는 익명 클래스, 람다 둘 다 선택할 수 있는 경우라면 익명 클래스 보다는 람다를 선택하는 것이 간결한 코드, 가독성 관점에서 대부분 더 나은 선택이다.

 

728x90
반응형
LIST
728x90
반응형
SMALL

참고자료

 

김영한의 실전 자바 - 고급 3편, 람다, 스트림, 함수형 프로그래밍 강의 | 김영한 - 인프런

김영한 | , [사진]국내 개발 분야 누적 수강생 1위,제대로 만든 김영한의 실전 자바[사진][임베딩 영상]단순히 자바 문법을 안다? 이걸로는 안됩니다!전 우아한형제들 기술이사, 누적 수강생 40만

www.inflearn.com

 

자바 8 이후로 람다가 도입됐고, 람다가 필요한 이유에 대해서 알아보자. 아래 코드를 보자.

package lambda.start;

public class Ex0Main {
    public static void helloJava() {
        System.out.println("프로그램 시작");
        System.out.println("Hello Java");
        System.out.println("프로그램 종료");
    }

    public static void helloSpring() {
        System.out.println("프로그램 시작");
        System.out.println("Hello Spring");
        System.out.println("프로그램 종료");
    }

    public static void main(String[] args) {
        helloJava();
        helloSpring();
    }
}
프로그램 시작
Hello Java
프로그램 종료
프로그램 시작
Hello Spring
프로그램 종료
  • 코드의 중복이 보일 것이다. 코드를 리팩토링해서 코드의 중복을 제거해보자.

리팩토링 후

package lambda.start;

public class Ex0RefMain {
    public static void hello() {
        System.out.println("프로그램 시작"); // 변하지 않는 부분

        System.out.println("Hello Java");
        System.out.println("Hello Spring");

        System.out.println("프로그램 종료"); // 변하지 않는 부분
    }

    public static void hello(String str) {
        System.out.println("프로그램 시작");
        System.out.println(str);
        System.out.println("프로그램 종료");
    }

    public static void main(String[] args) {
        hello("hello Java");
        hello("hello Spring");
    }
}
  • 기존 코드에서 변하는 부분과 변하지 않는 부분을 분리해야 한다.
  • 여기서 핵심은 변하는 부분과 변하지 않는 부분을 분리하는 것이다. 변하는 부분은 그대로 유지하고 변하지 않는 부분을 어떻게 해결할 것인가에 집중하면 된다.

단순한 문제였지만, 프로그래밍에서 중복을 제거하고 좋은 코드를 유지하는 핵심은 변하는 부분과 변하지 않는 부분을 분리하는 것이다. 여기서는 변하지 않는 "프로그램 시작", "프로그램 종료"를 출력하는 부분은 그대로 유지하고, 상황에 따라 변화가 필요한 문자열은 외부에서 전달 받아서 처리했다.

 

이렇게 변하는 부분과 변하지 않는 부분을 분리하고, 변하는 부분을 외부에서 전달 받으면, 메서드의 재사용성을 높일 수 있다. 리팩토링 전과 후를 비교해보자. hello(String str) 메서드의 재사용성은 매우 높아졌다. 여기서 핵심은 변하는 부분을 메서드 내부에서 가지고 있는 것이 아니라, 외부에서 전달 받는다는 점이다.

 

값 매개변수화(Value Parameterization)

여기서 변하는 부분은 "Hello Java", "Hello Spring"같은 문자값(Value)이다. 

System.out.println("Hello Java");
System.out.println("Hello Spring");

 

이번 예제에서는 String str 매개변수(파라미터)를 사용해서 문자값을 매개변수로 만들었다.

public static void hello(String str) {
    System.out.println("프로그램 시작");
    System.out.println(str); // str: 변하는 부분
    System.out.println("프로그램 종료");
}
  • 문자값(Value), 숫자값(Value)처럼 구체적인 값을 메서드(함수)안에 두는 것이 아니라, 매개변수(파라미터)를 통해 외부에서 전달 받도록 해서, 메서드의 동작을 달리하고, 재사용성을 높이는 방법을 값 매개변수화(Value Parameterization)라 한다.

 

이번에는 비슷한 다른 문제를 한번 풀어보자.

public class Ex1Main {
    public static void helloDice() {
        long startNs = System.nanoTime();
        //코드 조각 시작
        int randomValue = new Random().nextInt(6) + 1;
        System.out.println("주사위 = " + randomValue);
        //코드 조각 종료
        long endNs = System.nanoTime();
        System.out.println("실행 시간: " + (endNs - startNs) + "ns");
    }

    public static void helloSum() {
        long startNs = System.nanoTime();
        //코드 조각 시작
        for (int i = 1; i <= 3; i++) {
            System.out.println("i = " + i);
        }
        //코드 조각 종료
        long endNs = System.nanoTime();
        System.out.println("실행 시간: " + (endNs - startNs) + "ns");
    }

    public static void main(String[] args) {
        helloDice();
        helloSum();
    }
}
주사위 = 2
실행 시간: 2882959ns
i = 1
i = 2
i = 3
실행 시간: 191083ns

 

이 코드를 이전에 리팩토링 한 예와 같이 하나의 메서드에서 실행할 수 있도록 리팩토링 해보자. 참고로 이전 문제는 변하는 문자열 값을 매개변수화 해서 외부에서 전달하면 되었다. 이번에는 문자열 같은 단순한 값이 아니라 "코드 조각"을 전달해야 한다.

 

리팩토링 후

@FunctionalInterface
public interface Procedure {
    void run();
}
// 정적 중첩 클래스 사용
public class Ex1RefMainV1 {
    
    public static void hello(Procedure procedure) {
        long startNs = System.nanoTime();
        //코드 조각 시작
        procedure.run();
        //코드 조각 종료
        long endNs = System.nanoTime();
        System.out.println("실행 시간: " + (endNs - startNs) + "ns");
    }

    static class Dice implements Procedure {
        @Override
        public void run() {
            int randomValue = new Random().nextInt(6) + 1;
            System.out.println("주사위 = " + randomValue);
        }
    }

    static class Sum implements Procedure {
        @Override
        public void run() {
            for (int i = 1; i <= 3; i++) {
                System.out.println("i = " + i);
            }
        }
    }

    public static void main(String[] args) {
        Procedure dice = new Dice();
        Procedure sum = new Sum();
        hello(dice);
        hello(sum);
    }
}

여기서는 단순히 데이터를 전달하는 수준을 넘어서, 코드 조각을 전달해야 한다. 

 

어떻게 외부에서 코드 조각을 전달할 수 있을까?

코드 조각은 보통 메서드에 정의한다. 따라서 코드 조각을 전달하기 위해서는 메서드가 필요하다. 그런데 지금까지 학습한 내용으로는 메서드만 전달할 수 있는 방법이 없다. 대신에 인스턴스를 전달하고, 인스턴스에 있는 메서드를 호출하면 된다. 이 문제를 해결하기 위해 인터페이스를 정의하고 구현 클래스를 만들었다. 

 

물론 정적 중첩 클래스가 아니라 외부에 따로 클래스를 만들어도 상관없다. 리팩토링한 hello() 메서드에는 Procedure 매개변수(파라미터)를 통해 인스턴스를 전달할 수 있다. 이 인스턴스의 run() 메서드를 실행하면 필요한 코드 조각을 실행할 수 있다. 이때, 다형성을 활용해서 외부에서 전달되는 인스턴스에 따라 각각 다른 코드 조각이 실행된다. 

 

실행 결과

주사위 = 4
실행 시간: 3570583ns
i = 1
i = 2
i = 3
실행 시간: 172542ns

 

동작 매개변수화 (Behavior Parameterization)

값 매개변수화는 문자값, 숫자값처럼 구체적인 값을 메서드(함수)안에 두는 것이 아니라, 매개변수(파라미터)를 통해 외부에서 전달 받도록 해서, 메서드의 동작을 달리하고, 재사용성을 높이는 방법이었다. 

 

동작 매개변수화는, 코드 조각(코드의 동작 방법, 로직, Behavior)을 메서드(함수)안에 두는 것이 아니라, 매개변수(파라미터)를 통해서 외부에서 전달 받도록 해서, 메서드의 동작을 달리하고, 재사용성을 높이는 방법이다. 동작 매개변수화, 동작 파라미터화, 행동 매개변수화, 행위 파라미터화 등 다양하게 불린다.

 

자바에서 동작 매개변수화를 하려면 클래스를 정의하고 해당 클래스를 인스턴스로 만들어서 전달해야 한다. 자바8에서 등장한 람다를 사용하면 코드 조각을 매우 편리하게 전달할 수 있다!

 

 

익명 클래스 사용

람다를 사용하기 전에 기존 자바로 할 수 있는 다양한 방법들을 먼저 알아보자.

package lambda.start;

import lambda.Procedure;

import java.util.Random;

// 익명 클래스 사용
public class Ex1RefMainV2 {
    public static void hello(Procedure procedure) {
        long startNs = System.nanoTime();
        //코드 조각 시작
        procedure.run();
        //코드 조각 종료
        long endNs = System.nanoTime();
        System.out.println("실행 시간: " + (endNs - startNs) + "ns");
    }

    public static void main(String[] args) {
        Procedure dice = new Procedure() {
            @Override
            public void run() {
                int randomValue = new Random().nextInt(6) + 1;
                System.out.println("주사위 = " + randomValue);
            }
        };
        Procedure sum = new Procedure() {
            @Override
            public void run() {
                for (int i = 1; i <= 3; i++) {
                    System.out.println("i = " + i);
                }
            }
        };
        hello(dice);
        hello(sum);
    }
}
  • 익명 클래스를 사용해서, 번거롭게 클래스를 계속 만들지 않는 것이다.
  • 여기서 더 나아가서, 참조값을 변수에 담지 않고 매개변수에 바로 전달도 가능하다.
package lambda.start;

import lambda.Procedure;

import java.util.Random;

// 익명 클래스 사용, 변수 제거, 익명 클래스의 참조값을 매개변수(파라미터)에 직접 전달
public class Ex1RefMainV3 {
    public static void hello(Procedure procedure) {
        long startNs = System.nanoTime();
        //코드 조각 시작
        procedure.run();
        //코드 조각 종료
        long endNs = System.nanoTime();
        System.out.println("실행 시간: " + (endNs - startNs) + "ns");
    }

    public static void main(String[] args) {
        hello(new Procedure() {
            @Override
            public void run() {
                int randomValue = new Random().nextInt(6) + 1;
                System.out.println("주사위 = " + randomValue);
            }
        });
        hello(new Procedure() {
            @Override
            public void run() {
                for (int i = 1; i <= 3; i++) {
                    System.out.println("i = " + i);
                }
            }
        });
    }
}

 

 

람다(Lambda)

자바에서 메서드의 매개변수에 인수로 전달할 수 있는 것은 크게 2가지이다. 

  • int, double과 같은 기본형 타입
  • Procedure, Member와 같은 참조형 타입(인스턴스)

결국 메서드에 인수로 전달할 수 있는 것은 간단한 값이나, 인스턴스의 참조이다. 지금처럼 코드 조각을 전달하기 위해 클래스를 정의하고 메서드를 만들고 또 인스턴스까지 생성하는 복잡한 과정을 거쳐야 할까? 생각해보면 클래스나 인스턴스와 관계 없이 다음과 같이 직접 코드 블럭을 전달할 수 있다면 더 간단하지 않을까?

public static void main(String[] args) {
        hello({ // 랜덤 값을 출력하는 코드 블럭
            int randomValue = new Random().nextInt(6) + 1;
            System.out.println("주사위 = " + randomValue);
        });
        
        hello({ // 1 ~ 3 출력하는 코드 블럭
            for (int i = 1; i <= 3; i++) {
                System.out.println("i = " + i);
            }
        });
    }

 

자바 8에 들어서면서 큰 변화가 있었는데, 바로 람다라는 것을 통해 코드 블럭을 인수로 전달할 수 있게 되었다. 다음 코드로 확인해보자.

 

리팩토링 - 람다

package lambda.start;

import lambda.Procedure;

public class Ex1Main {

    public static void process(Procedure procedure) {
        long startMs = System.nanoTime();

        procedure.run();

        long endMs = System.nanoTime();
        System.out.println("실행 시간 = " + (endMs - startMs) + " ms");
    }

    public static void main(String[] args) {
        process(() -> {
            for (int i = 0; i < 3; i++) {
                System.out.println("i = " + i);
            }
        });
    }
}
  • 람다를 사용한 코드를 보면 클래스나 인스턴스를 정의하지 않고, 매우 간편하게 코드 블럭을 직접 정의하고, 전달하는 것을 확인할 수 있다.
  • 이것이 람다를 사용하는 이유이다. 람다는 함수이다. 따라서 람다를 제대로 이해하기 위해서는 먼저 함수에 대해서 알아야 한다. 함수와 메서드의 차이를 먼저 간단히 알아보자.

 

함수 vs 메서드

함수(Function)와 메서드(Method)는 둘 다 어떤 작업(로직)을 수행하는 코드의 묶음이다. 하지만 일반적으로 객체지향 프로그래밍(OOP) 관점에서 다음과 같은 차이가 있다. 

 

객체(클래스)와의 관계

  • 함수(Function)
    • 독립적으로 존재하며, 클래스와 직접적인 연관이 없다.
    • 객체지향 언어가 아닌 C 등의 절차적 언어에서는 모든 로직이 함수 단위로 구성된다.
    • 객체지향 언어라 하더라도 예를 들어, Python이나 JavaScript처럼 클래스 밖에서도 정의할 수 있는 함수 개념을 지원하는 경우 이를 그냥 함수라고 부른다.
  • 메서드(Method)
    • 클래스(또는 객체)에 속해 있는 "함수"이다.
    • 객체의 상태(필드, 프로퍼티 등)에 직접 접근하거나, 객체가 제공해야 할 기능을 구현할 수 있다.
    • Java, C++, Python 등 대부분의 객체지향 언어에서 클래스 내부에 정의된 함수는 보통 "메서드"라 부른다. 

 

호출 방식과 스코프

  • 함수(Function)
    • 호출 시에 객체 인스턴스가 필요 없다.
    • 보통 이름(매개변수) 형태로 호출된다.
    • 지역 변수, 전역 변수 등과 함께 동작하며, 클래스나 객체 특유의 속성은 다루지 못한다.
  • 메서드(Method)
    • 보통 객체.메서드이름(매개변수) 형태로 호출된다.
    • 호출될 때, 해당 객체의 필드나 다른 메서드에 접근 가능하며 이를 이용해 로직을 수행한다.
    • 인스턴스 메서드, 클래스(정적) 메서드, 추상 메서드 등 다양한 형태가 있을 수 있다.

 

정리

  • 메서드는 기본적으로 클래스(객체) 내부의 함수를 가리키며, 객체의 상태와 밀접한 관련이 있다.
  • 함수는 클래스(객체)와 상관없이, 독립적으로 호출 가능한 로직의 단위이다.
  • 메서드는 객체지향에서 클래스 안에 정의하는 특별한 함수라고 생각하면 된다.

 

람다를 사용하는 이유

  • 변하는 부분과 변하지 않는 부분을 분리하는 것이 좋은 프로그래밍 코드를 작성하는 핵심
  • 변하는 부분이 '값'이라면 메서드로도 충분히 이 작업을 수행할 수 있지만,
  • 변하는 부분이 '코드 조각'이라면? 자바에서는 인스턴스를 넘기고 해당 인스턴스의 메서드를 호출해야 한다.
  • 지금까지는 이 '코드 조각'을 넘기기 위해 다형성을 이용하여 각각의 인스턴스가 서로 다른 동작을 하지만 부모를 상속받게 하여 변하는 부분을 받는 메서드에서 이 인스턴스를 받아 처리했다.
  • 그런데, 코드 조각을 넘길때마다 이 작업은 너무 귀찮고 번거롭고 가시성이 떨어진다.
  • 코드 조각 자체(함수)를 의미하는 람다를 자바8에서 도입하여 번거롭게 인스턴스를 만들거나 익명 클래스를 사용하지 않아도 된다.

 

728x90
반응형
LIST

'JAVA의 가장 기본이 되는 내용' 카테고리의 다른 글

takeWhile, dropWhile, flatMap - Stream API  (0) 2025.03.31
람다 vs 익명 클래스  (0) 2025.03.30
Lombok은 어떻게 동작하는걸까?  (0) 2024.12.01
[Java 8] CompletableFuture  (0) 2024.11.30
[Java 8] Optional  (0) 2024.11.27

+ Recent posts