본문 바로가기

Java 프로그래밍 패러다임

같은 게시판 통계를 명령형, 객체지향, 함수형 스타일로 구현해 비교하고, 상황에 맞는 스타일을 고르는 기준을 익힙니다.

람다와 스트림을 익히고 나면 같은 계산을 쓰는 방법이 여럿 생깁니다. 공개된 게시글의 조회 수 합계를 구한다고 해 봅시다. for 문으로 게시글을 하나씩 확인하며 더할 수도 있고, 게시글 목록을 객체로 감싸 그 객체에 합계를 물어볼 수도 있으며, filter와 sum을 이은 스트림 한 줄로 끝낼 수도 있습니다. 세 코드는 같은 값을 내지만 읽는 사람에게 먼저 보여 주는 것이 서로 다릅니다.

이렇게 문제를 코드로 옮길 때 무엇을 앞에 두느냐에 대한 관점을 프로그래밍 패러다임(programming paradigm)이라고 합니다. 명령형(imperative) 코드는 어떤 순서로 어떤 변수를 바꿀지, 즉 '어떻게'를 적습니다. 선언형(declarative) 코드는 입력과 원하는 결과의 관계, 즉 '무엇'을 적고 원소를 하나씩 돌며 처리하는 방법은 라이브러리에 맡깁니다. 여기에 데이터와 상태를 어떻게 다루느냐에 따른 구분이 더해집니다. 객체지향(object-oriented)은 데이터와 그 데이터가 늘 지켜야 할 규칙을 한 객체에 묶고, 함수형(functional)은 상태를 바꾸지 않는 함수를 이어 붙여 결과를 만듭니다.

Java는 이 가운데 하나를 강요하지 않습니다. 클래스와 반복문도, 람다와 스트림도 모두 표준 문법이므로 '어느 패러다임이 옳은가'보다 '이 코드에는 어느 쪽이 읽기 좋은가'를 정해야 합니다. 이 글에서는 같은 통계를 세 방식으로 구현해 실행해 보고, 같은 입력을 넣었을 때 무엇이 같고 무엇이 다른지 확인한 다음 고르는 기준을 세웁니다. 35장의 나머지 글은 그중 함수형 쪽을 순수 함수, 불변 값, 함수 합성 순서로 더 깊이 다룹니다.

반복문으로 순서를 지시하는 명령형 코드

예제의 게시글은 분류(category), 조회 수(viewCount), 공개 여부(published)를 가진 레코드 PostMetric입니다. 명령형 코드는 할 일을 순서대로 지시합니다. 합계 변수를 0으로 만든 다음 게시글을 하나씩 꺼내고, 공개되지 않은 글은 건너뛰며 공개된 글의 조회 수만 합계에 더합니다. 목록을 다 돌면 합계를 돌려줍니다. 아래 publishedViewCount는 이 순서를 그대로 옮긴 메서드입니다.

src/ImperativeBoardViewCount.java
import java.util.List;

public final class ImperativeBoardViewCount {
    private record PostMetric(String category, int viewCount, boolean published) {}

    static int publishedViewCount(List<PostMetric> posts) {
        int total = 0;
        for (PostMetric post : posts) {
            if (!post.published()) {
                continue;
            }
            total += post.viewCount();
        }
        return total;
    }

    public static void main(String[] args) {
        List<PostMetric> posts =
                List.of(
                        new PostMetric("stream", 35, true),
                        new PostMetric("fork-join", 45, false),
                        new PostMetric("optional", 25, true));
        System.out.println("published-view-count=" + publishedViewCount(posts));
    }
}
실행 결과
published-view-count=60

공개된 글은 stream과 optional 두 개이므로 35와 25를 더한 60이 출력됩니다. total이 바뀌는 과정을 표로 따라가면 다음과 같습니다.

순서게시글공개 여부처리total
시작0
1stream, 35공개더함35
2fork-join, 45비공개continue로 건너뜀35
3optional, 25공개더함60

명령형 코드의 장점은 이 표처럼 실행 순서가 코드에 그대로 드러난다는 점입니다. 그래서 흐름을 세밀하게 제어해야 할 때는 명령형이 가장 읽기 쉬운 경우가 많습니다. 원하는 원소를 찾자마자 return으로 멈추는 코드나, 한 번 도는 동안 여러 값을 함께 모으는 코드가 그런 예입니다. 이런 단계를 publishedViewCount처럼 이름 붙인 메서드로 나누고 조립해 프로그램을 만드는 방식을 절차지향(procedural) 프로그래밍이라고 합니다. 절차지향은 명령형 코드를 정리하는 방법이고, Java에서는 static 메서드가 그 단위가 됩니다.

여기서 total은 세 번이나 바뀌지만 메서드 안의 지역 변수라는 점을 짚어 두겠습니다. 지역 변수는 호출할 때마다 새로 만들어지고 메서드가 끝나면 사라지므로, 호출하는 쪽은 결과 60만 받을 뿐 중간 상태를 볼 수도 바꿀 수도 없습니다. 그래서 같은 목록으로 몇 번을 호출해도 결과는 늘 60입니다. 함수 안에 대입문이 있다고 해서 예측하기 어려운 함수가 되는 것은 아니며, 문제는 바뀌는 상태가 메서드 밖에 있을 때 생깁니다.

데이터와 규칙을 한 객체에 묶는 객체지향 코드

게시판 통계에는 계산 말고도 지켜야 할 규칙이 있습니다. 분류는 비어 있으면 안 되고, 조회 수는 음수일 수 없습니다. 명령형 코드에서는 이런 검사를 데이터를 쓰는 쪽에서 매번 해야 하고, 한 곳이라도 빠뜨리면 잘못된 값이 계산에 섞여 들어갑니다. 객체지향은 규칙을 데이터 자체에 둡니다.

아래 PostMetric은 레코드의 컴팩트 생성자(compact constructor)에서 값을 검사하므로, 규칙을 어긴 값으로는 객체가 아예 만들어지지 않습니다. BoardSnapshot은 게시글 목록을 복사해 가지고 있다가 합계를 물으면 답합니다. 스냅샷(snapshot)은 어느 한 시점의 상태를 복사해 둔 것을 말합니다. main의 마지막 부분은 조회 수가 -1인 게시글을 일부러 만들어 보는 코드입니다.

src/ObjectOrientedBoardSnapshot.java
import java.util.List;

public final class ObjectOrientedBoardSnapshot {
    private record PostMetric(String category, int viewCount, boolean published) {
        private PostMetric {
            if (category.isBlank()) {
                throw new IllegalArgumentException("category");
            }
            if (viewCount < 0) {
                throw new IllegalArgumentException("viewCount");
            }
        }

        int publishedViewCount() {
            return published ? viewCount : 0;
        }
    }

    private record BoardSnapshot(List<PostMetric> posts) {
        private BoardSnapshot {
            posts = List.copyOf(posts);
        }

        int publishedViewCount() {
            int total = 0;
            for (PostMetric post : posts) {
                total += post.publishedViewCount();
            }
            return total;
        }
    }

    public static void main(String[] args) {
        BoardSnapshot snapshot =
                new BoardSnapshot(
                        List.of(
                                new PostMetric("lambda", 30, true),
                                new PostMetric("collector", 40, false),
                                new PostMetric("record", 20, true)));
        System.out.println("published-view-count=" + snapshot.publishedViewCount());

        try {
            new PostMetric("draft", -1, false);
        } catch (IllegalArgumentException e) {
            System.out.println("rejected=" + e.getMessage());
        }
    }
}
실행 결과
published-view-count=50
rejected=viewCount

첫 줄의 50은 공개된 lambda의 30과 record의 20을 더한 값입니다. 이번에는 계산을 두 객체가 나누어 맡습니다. 게시글 하나는 자신이 합계에 보탤 값을 알고 있어서 공개된 글이면 조회 수를, 아니면 0을 돌려줍니다. BoardSnapshot은 그 값을 모두 더하기만 하므로, 0을 돌려준 비공개 글 collector는 합계에 영향을 주지 않습니다.

둘째 줄이 객체지향의 핵심을 보여 줍니다. new PostMetric("draft", -1, false)는 생성자 안의 검사에서 IllegalArgumentException을 던지므로, 조회 수가 음수인 PostMetric은 프로그램 어디에도 존재할 수 없습니다. 그래서 PostMetric을 받은 코드는 조회 수를 다시 검사하지 않고 믿어도 됩니다. 이처럼 객체가 존재하는 동안 늘 참이어야 하는 조건을 불변식(invariant)이라고 합니다. 객체지향의 장점은 계산을 메서드로 옮기는 데 있는 것이 아니라, 불변식을 깨는 상태가 아예 만들어지지 못하게 막는 데 있습니다.

BoardSnapshot의 생성자가 List.copyOf로 목록을 복사하는 것도 같은 이유입니다. 생성자에 받은 목록을 그대로 쓰면, 호출한 쪽이 나중에 그 목록을 바꿀 때 스냅샷의 내용도 함께 바뀝니다. 복사해 두면 스냅샷은 만들어진 순간의 게시글만 갖게 됩니다. 이 차이가 실제로 어떤 결과를 내는지는 잠시 뒤 같은 입력으로 비교할 때 확인합니다.

다만 한 줄짜리 계산까지 모두 클래스로 감쌀 필요는 없습니다. 객체를 지나치게 잘게 나누면 읽는 사람이 이 클래스에서 저 클래스로 옮겨 다니며 흐름을 이어 붙여야 합니다. 객체가 제값을 하는 것은 오래 유지되는 상태나 지켜야 할 규칙이 있을 때입니다. 생성부터 폐기까지의 생명 주기를 관리해야 하거나 종류마다 동작이 달라지는 다형성이 필요할 때도 그렇고, 단순한 데이터 변환은 함수로 남겨 두는 편이 낫습니다.

원하는 결과를 적는 선언형 파이프라인

같은 계산을 스트림으로 쓰면 요구 사항 문장과 코드의 모양이 거의 같아집니다. '공개된 글만 고른다, 조회 수로 바꾼다, 모두 더한다'라는 세 단계가 filter, mapToInt, sum에 하나씩 대응합니다. 우리가 쓴 코드에는 바뀌는 변수가 하나도 없고, 원소를 하나씩 꺼내 더하는 일은 스트림 라이브러리가 맡습니다. '무엇'을 적고 '어떻게'는 맡기는 선언형 코드입니다.

src/DeclarativeBoardViewCount.java
import java.util.List;

public final class DeclarativeBoardViewCount {
    private record PostMetric(String category, int viewCount, boolean published) {}

    static int publishedViewCount(List<PostMetric> posts) {
        return posts.stream().filter(PostMetric::published).mapToInt(PostMetric::viewCount).sum();
    }

    public static void main(String[] args) {
        List<PostMetric> posts =
                List.of(
                        new PostMetric("pure-function", 50, true),
                        new PostMetric("side-effect", 20, true),
                        new PostMetric("composition", 30, false));
        System.out.println("published-view-count=" + publishedViewCount(posts));
    }
}
실행 결과
published-view-count=70

입력의 세 글 가운데 공개된 것은 pure-function과 side-effect입니다. filter(PostMetric::published)가 이 두 글만 남기고, mapToInt(PostMetric::viewCount)가 두 글을 조회 수 50과 20으로 바꿉니다. 마지막으로 sum()이 둘을 더해 70을 만듭니다. 그렇다고 누적이 사라진 것은 아닙니다. API 문서에 따르면 IntStream.sum()은 reduce(0, Integer::sum)과 같아서, 0에서 시작해 값을 하나씩 더하는 일을 라이브러리 안에서 할 뿐입니다.

함수형 스타일이라는 말은 이런 코드의 성질을 가리킵니다. 각 단계는 값을 받아 새 값을 돌려줄 뿐 원본 목록을 바꾸지 않으므로, publishedViewCount를 부른 뒤에도 posts는 그대로입니다. 파이프라인은 단계가 도메인 문장과 맞아떨어질 때 가장 읽기 좋습니다. 반대로 단계 사이에 복잡한 분기나 중간 종료, 단계별 오류 처리가 끼어들면 그 처리가 람다 안에 숨어 버려서 반복문보다 읽기 어려워질 수 있습니다.

지금까지 본 세 예제의 main은 서로 다른 게시글을 넣었으므로 합계도 서로 다릅니다. 아래 그림은 각 예제에서 공개된 글의 조회 수가 어떻게 더해졌는지 정리한 것일 뿐, 같은 입력으로 결과를 맞춰 본 것은 아닙니다.

세 게시판 예제의 입력과 공개 조회 수 합계

세 게시판 예제의 입력과 공개 조회 수 합계

세 구현의 서로 다른 입력

세 게시판 예제의 입력과 공개 조회 수 합계

세 main은 서로 다른 게시글을 사용합니다. 공개된 글만 더한 합계가 각각 60·50·70이며, 같은 입력의 동등성 시험은 아닙니다.

명령형

공개 게시글의 조회 수
35 + 25
합계
60

객체지향

공개 게시글의 조회 수
30 + 20
합계
50

Stream

공개 게시글의 조회 수
50 + 20
합계
70

같은 입력으로 세 방식 비교하기

세 방식이 정말 같은 계산인지 확인하려면 같은 입력을 넣어 봐야 합니다. 다음 프로그램은 세 구현을 한 파일에 모으고, 첫 번째 예제와 같은 게시글 세 개로 결과를 출력합니다. 그다음 원본 목록에 공개된 글 하나를 추가하고 다시 출력합니다.

src/SameInputComparison.java
import java.util.ArrayList;
import java.util.List;

public final class SameInputComparison {
    private record PostMetric(String category, int viewCount, boolean published) {}

    private record BoardSnapshot(List<PostMetric> posts) {
        private BoardSnapshot {
            posts = List.copyOf(posts);
        }

        int publishedViewCount() {
            return posts.stream().filter(PostMetric::published).mapToInt(PostMetric::viewCount).sum();
        }
    }

    static int imperative(List<PostMetric> posts) {
        int total = 0;
        for (PostMetric post : posts) {
            if (post.published()) {
                total += post.viewCount();
            }
        }
        return total;
    }

    static int declarative(List<PostMetric> posts) {
        return posts.stream().filter(PostMetric::published).mapToInt(PostMetric::viewCount).sum();
    }

    static void print(List<PostMetric> posts, BoardSnapshot snapshot) {
        System.out.println(
                "imperative=" + imperative(posts)
                        + ", object=" + snapshot.publishedViewCount()
                        + ", declarative=" + declarative(posts));
    }

    public static void main(String[] args) {
        List<PostMetric> posts = new ArrayList<>();
        posts.add(new PostMetric("stream", 35, true));
        posts.add(new PostMetric("fork-join", 45, false));
        posts.add(new PostMetric("optional", 25, true));

        BoardSnapshot snapshot = new BoardSnapshot(posts);
        print(posts, snapshot);

        posts.add(new PostMetric("record", 50, true));
        print(posts, snapshot);
    }
}
실행 결과
imperative=60, object=60, declarative=60
imperative=110, object=60, declarative=110

첫 줄의 세 결과는 모두 60입니다. 적는 방식은 달라도 '공개된 글의 조회 수를 더한다'는 같은 계산이기 때문입니다. 눈여겨볼 점은 이번에는 BoardSnapshot의 메서드 안을 스트림으로 썼다는 것입니다. 객체지향은 데이터와 규칙을 어디에 둘지를 정하고, 명령형과 선언형은 그 안의 계산을 어떻게 적을지를 정합니다. 둘은 서로 다른 질문에 대한 답이므로, 객체 안에 스트림을 쓰든 반복문을 쓰든 자연스럽게 함께 쓸 수 있습니다.

둘째 줄에서는 결과가 갈립니다. posts.add로 조회 수 50인 공개 글을 추가한 뒤, imperative와 declarative는 호출할 때 받은 지금의 목록으로 계산하므로 110을 냅니다. 반면 snapshot은 만들어질 때 List.copyOf로 복사해 둔 세 글을 계속 갖고 있으므로 여전히 60입니다. 함수는 받은 입력으로 계산하고, 객체는 자신이 가진 상태로 답한다는 차이가 그대로 드러난 것입니다.

어느 쪽이 맞는지는 요구 사항이 정합니다. '어제 자정 기준 통계'처럼 특정 시점의 값을 보관해야 한다면 상태를 가진 객체가 맞고, 지금 목록의 합계가 필요하다면 매번 최신 목록을 함수에 넘기면 됩니다. 그래서 스타일을 고를 때 가장 먼저 물어볼 것은 '이 값이 한 번의 호출보다 오래 살아야 하는가'입니다. 스냅샷을 만드는 방법과 주의할 점은 불변 값과 컬렉션 스냅샷에서 자세히 다룹니다.

더 깊이: 교과서의 분류표와 Java 코드

패러다임을 분류할 때는 흔히 명령형 아래에 절차지향과 객체지향을, 선언형 아래에 함수형과 논리형을 둡니다. 전통적인 객체지향 코드는 메서드 안에서 객체의 상태를 차례로 바꾸는 명령형 방식으로 쓰는 경우가 많았기 때문입니다. 이 분류는 각 패러다임의 뿌리를 이해하는 데는 유용하지만, 람다와 스트림이 들어온 Java 8 이후의 코드를 설명하기에는 거칩니다. 앞의 BoardSnapshot처럼 바뀌지 않는 객체가 선언형 계산을 메서드로 제공하는 코드는 어느 한 칸에 넣기 어렵습니다. 그래서 이 글에서는 '데이터를 어디에 둘 것인가'와 '계산을 어떻게 적을 것인가'를 따로 물었습니다.

객체에 남은 상태가 버그가 되는 경우

객체가 상태를 계속 갖고 있다는 성질은 스냅샷에서는 장점이었지만, 한 번의 계산에만 쓰일 중간값을 필드에 두면 버그가 됩니다. 아래 BoardReport는 호출할 때마다 합계를 새로 계산하는 것처럼 보입니다. 그런데 합계를 담는 totalViewCount가 필드여서, 호출이 끝나도 0으로 돌아가지 않습니다.

lab/ReusedMutableAccumulatorBug.java
import java.util.List;

public final class ReusedMutableAccumulatorBug {
    private static final class BoardReport {
        private int totalViewCount;

        int total(List<Integer> viewCounts) {
            for (int value : viewCounts) {
                totalViewCount += value;
            }
            return totalViewCount;
        }
    }

    public static void main(String[] args) {
        BoardReport report = new BoardReport();
        List<Integer> viewCounts = List.of(3, 4, 5);
        System.out.println("first-total=" + report.total(viewCounts));
        System.out.println("wrong-second-total=" + report.total(viewCounts));
    }
}
실행 결과
first-total=12
wrong-second-total=24

같은 목록을 두 번 넘겼는데 두 번째 결과가 두 배가 되었습니다. 두 번의 호출에서 필드 값이 어떻게 바뀌는지 따라가 보면 이유가 보입니다.

호출시작할 때3을 더한 뒤4를 더한 뒤5를 더한 뒤반환값
첫 번째0371212
두 번째1215192424

문제는 반복문이 아니라 합계의 수명입니다. 필드는 객체가 살아 있는 동안 계속 남아 있으므로, 두 번째 호출은 첫 호출이 남긴 12에서 출발합니다. 이렇게 되면 total(viewCounts)의 결과를 인수만 보고는 알 수 없고, 그 전에 몇 번 호출됐는지까지 알아야 합니다. 상태를 가진 람다와 재사용 문제에서 본 람다의 문제와 원인이 같습니다.

고치는 방법은 합계를 메서드의 지역 변수로 옮기는 것입니다. 그러면 앞의 명령형 예제처럼 호출할 때마다 0에서 시작합니다.

lab/LocalAccumulatorReport.java
import java.util.List;

public final class LocalAccumulatorReport {
    private static final class BoardReport {
        int total(List<Integer> viewCounts) {
            int totalViewCount = 0;
            for (int value : viewCounts) {
                totalViewCount += value;
            }
            return totalViewCount;
        }
    }

    public static void main(String[] args) {
        BoardReport report = new BoardReport();
        List<Integer> viewCounts = List.of(3, 4, 5);
        System.out.println("first-total=" + report.total(viewCounts));
        System.out.println("second-total=" + report.total(viewCounts));
    }
}
실행 결과
first-total=12
second-total=12

다만 24가 언제나 틀린 답은 아닙니다. 서비스가 시작된 뒤 지금까지의 누적 조회 수를 세는 객체라면, 호출할 때마다 값이 쌓이는 것이 바로 원하는 동작입니다. 그럴 때는 add(viewCounts)로 값을 쌓고 total()로 지금까지의 합을 읽는 식으로, 상태가 쌓인다는 사실을 메서드 이름과 구조에 드러내야 합니다. 그러니 메서드가 입력 하나의 합계를 구하는지, 누적 기록을 관리하는지부터 정합니다. 필드에는 객체가 오래 유지해야 할 정보와 규칙만 두고, 한 번의 계산에 쓰는 중간값은 지역 변수에 둡니다.

Java에서 여러 스타일을 함께 쓰는 방법

실제 Java 애플리케이션은 한 가지 스타일로만 쓰이지 않습니다. 앞의 비교에서처럼 객체 안에 스트림이 들어가고, 스트림을 쓰는 메서드가 반복문으로 쓴 메서드를 부르기도 합니다. 섞는 것 자체는 문제가 아니며, 각 부분이 하는 일에 맞는 스타일을 고르면 됩니다. 흔히 쓰는 나눔은 다음과 같습니다.

하는 일어울리는 스타일이유
게시글, 회원처럼 규칙과 식별 정보가 있는 데이터클래스, 레코드생성할 때 규칙을 검사하고, 상태를 바꾸는 길을 메서드로 좁힐 수 있습니다.
점수 계산, 정책 판단 같은 결정 규칙입력과 출력이 분명한 메서드인수만 보고 결과가 정해지므로 따로 실행해 보고 재사용하기 쉽습니다.
컬렉션의 선택, 변환, 집계스트림 파이프라인단계가 요구 사항 문장과 하나씩 대응합니다.
자원 닫기, 재시도, 조기 반환, 복잡한 오류 복구반복문, try-with-resources 같은 명령형 코드실행 순서가 곧 의미이므로 순서가 눈에 보여야 합니다.
저장, 출력, 외부 시스템 호출애플리케이션 바깥쪽에 모은 명령형 코드무엇이 언제 어떤 순서로 실행되는지 한곳에서 볼 수 있습니다.

레코드를 쓸 때는 하나 주의할 점이 있습니다. 레코드의 필드는 final이어서 다른 객체를 가리키도록 바꿀 수 없지만, 가리키는 대상이 ArrayList처럼 바뀔 수 있는 객체라면 그 내용은 여전히 바뀝니다. 레코드라고 해서 안쪽까지 불변이 되는 것은 아니라는 뜻이고, 앞의 BoardSnapshot이 생성자에서 List.copyOf로 바꿀 수 없는 목록을 만들어 둔 이유도 여기에 있습니다.

섞을 때의 원칙은 하는 일 단위로 섞는 것입니다. 한 메서드 안에서 반복문으로 목록을 만들다가 중간에 스트림의 forEach로 바깥 변수를 고치고 다시 반복문으로 돌아가면, 읽는 사람은 어느 방식으로 읽어야 할지 알 수 없습니다. 반대로 계산은 값만 다루는 메서드에, 저장과 출력은 바깥쪽 명령형 코드에 모아 두면 각 부분을 그 스타일대로 읽을 수 있습니다. 이 구조는 다음 글에서 직접 만들어 봅니다.

스트림으로 바꾼다고 함수형이 되지는 않는다

반복문을 스트림으로 바꾸기만 하면 함수형 코드가 된다고 생각하기 쉽습니다. 다음 코드는 공개된 글의 분류 이름을 모읍니다. 겉모양은 스트림이지만, 결과를 만드는 방식은 바깥 목록에 하나씩 add하는 반복문과 다르지 않습니다.

List<String> categories = new ArrayList<>();
posts.stream()
        .filter(PostMetric::published)
        .forEach(post -> categories.add(post.category())); // 바깥 목록을 바꾼다

함수가 값을 돌려주는 것 말고 바깥의 무언가를 바꾸는 일을 부수 효과(side effect)라고 합니다. 위 코드의 람다는 스트림 밖의 categories를 바꾸므로 부수 효과가 있습니다. java.util.stream 패키지 문서는 바로 이런 코드를 예로 들어 불필요한 부수 효과라고 설명합니다. 병렬로 실행하면 ArrayList가 스레드에 안전하지 않아 결과가 틀릴 수 있고, 그렇다고 동기화를 넣으면 스레드끼리 서로 기다리느라 병렬 실행의 이점이 사라진다는 것입니다. 결과는 파이프라인이 값으로 돌려주게 하면 됩니다.

List<String> categories = posts.stream()
        .filter(PostMetric::published)
        .map(PostMetric::category)
        .toList(); // 결과를 값으로 돌려받는다

첫 번째 예제의 게시글 세 개를 넣으면 두 코드 모두 [stream, optional]을 만듭니다. 차이는 결과가 아니라 구조에 있습니다. 두 번째 코드는 바깥의 어떤 것도 바꾸지 않으므로 순서대로 실행하든 병렬로 실행하든 같은 목록을 돌려줍니다. 반대로 앞에서 본 것처럼 지역 변수만 바꾸는 반복문도 바깥에서 보기에는 예측 가능한 계산입니다. 함수형인지를 가르는 기준은 문법이 아니라, 바깥에서 보이는 변경이 있느냐입니다.

어떤 스타일을 고를까

지금까지 살펴본 내용을 기준으로 정리하면 다음과 같습니다. 한 프로그램 안에서도 부분마다 답이 다를 수 있으므로, 메서드 하나하나에 이 질문을 던져 보면 됩니다.

  1. 요구 사항이 '골라서, 바꿔서, 모은다'로 읽히면 스트림 파이프라인을 먼저 검토합니다. 단계가 문장과 맞아떨어지므로 읽는 사람이 의도를 바로 알 수 있습니다.
  2. 중간에 멈추거나 단계마다 다르게 복구해야 하면, 또는 부수 효과가 여러 개 순서대로 일어나야 하면 명령형 흐름을 유지합니다. 순서가 곧 의미인 코드를 람다 안에 숨기면 그 순서가 보이지 않습니다.
  3. 데이터에 생성 규칙이나 식별 정보가 있고 그 상태가 호출보다 오래 살아야 하면, 객체가 그 상태를 갖게 합니다. 규칙을 한곳에서 검사하므로 잘못된 상태가 퍼지지 않습니다.
  4. 여러 곳에서 쓰는 판단 규칙은 입력과 출력이 분명한 함수로 뽑아냅니다. 숨은 상태가 없으므로 따로 실행해 보기 쉽고, 어디서 불러도 같은 결과를 냅니다.
함수형 스타일을 쓰려면 모든 객체와 반복문을 없애야 하나요?

아닙니다. Java 애플리케이션은 보통 객체로 도메인과 의존 관계를 구성하고, 그 안의 계산을 함수형 방식으로 단순하게 만듭니다. 바뀌지 않는 값 객체와 그 값만 보고 결과를 내는 메서드는 객체지향과 함수형이 만나는 지점입니다. 중요한 것은 반복문을 억지로 없애는 것이 아니라, 어느 코드가 상태를 바꾸고 어느 코드가 값만 계산하는지가 읽는 사람에게 보이게 하는 것입니다.

정리

  • 명령형은 실행 순서와 상태 변경('어떻게')을, 선언형은 입력과 결과의 관계('무엇')를 앞에 둡니다. 객체지향은 데이터와 규칙을 어디에 둘지를 정하는 관점이라서 어느 쪽과도 함께 쓸 수 있습니다.
  • 지역 변수만 바꾸는 반복문은 바깥에서 보기에 예측 가능한 계산입니다. 문제는 필드나 바깥 컬렉션처럼 메서드 밖의 상태가 바뀔 때 생깁니다.
  • 객체지향의 장점은 불변식을 깨는 상태를 만들지 못하게 막는 데 있습니다. 같은 입력을 넣어도 함수는 받은 입력으로, 객체는 가진 상태로 답합니다.
  • 스트림 안에서 바깥 목록을 바꾸면 모양만 바뀐 명령형 코드입니다. 결과는 toList()나 collect처럼 값으로 돌려받습니다.

연습 문제

1. 복사하지 않는 스냅샷

SameInputComparison에서 BoardSnapshot의 생성자 안에 있는 posts = List.copyOf(posts); 줄을 지우고 실행하면 출력의 둘째 줄은 어떻게 바뀔까요? 실행하기 전에 예상하고 이유를 설명해 보세요.

해설 보기
실행 결과
imperative=60, object=60, declarative=60
imperative=110, object=110, declarative=110

복사하지 않으면 레코드의 필드 posts가 main의 ArrayList와 같은 객체를 가리킵니다. 그래서 posts.add로 추가한 글이 스냅샷에서도 보이고, object도 110이 됩니다. 필드가 final이어도 가리키는 목록의 내용은 바뀔 수 있으므로, 레코드로 만들었다는 사실만으로는 이런 공유를 막지 못합니다. 스냅샷이 만들어진 순간의 값을 지키려면 생성자에서 복사해야 합니다.

2. 분류별 조회 수 합계

공개된 게시글만 골라 분류별 조회 수 합계를 Map으로 돌려주는 메서드를 스트림으로 작성하세요. 메서드 밖의 목록이나 Map은 바꾸지 말고, 결과 Map을 새로 만들어 돌려줍니다. 입력은 순서대로 java·30회·공개, spring·40회·비공개, java·25회·공개, database·35회·공개입니다. 코드를 쓰기 전에 어떤 키가 남고 각 합계가 얼마인지 먼저 적어 보세요.

해설 보기
exercise/PublishedViewCountByCategorySolution.java
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

public final class PublishedViewCountByCategorySolution {
    private record PostMetric(String category, int viewCount, boolean published) {}

    static Map<String, Integer> summarize(List<PostMetric> posts) {
        return posts.stream()
                .filter(PostMetric::published)
                .collect(
                        Collectors.groupingBy(
                                PostMetric::category, Collectors.summingInt(PostMetric::viewCount)));
    }

    public static void main(String[] args) {
        List<PostMetric> posts =
                List.of(
                        new PostMetric("java", 30, true),
                        new PostMetric("spring", 40, false),
                        new PostMetric("java", 25, true),
                        new PostMetric("database", 35, true));
        System.out.println(summarize(posts));
    }
}
실행 결과
{database=35, java=55}

filter를 통과하는 조회 수는 30, 25, 35입니다. groupingBy가 분류별로 묶고 summingInt가 그룹마다 조회 수를 더하므로, 같은 java인 앞의 두 값은 55가 되고 database는 35가 됩니다. spring은 그룹을 만들기 전에 filter에서 빠졌으므로 키가 생기지 않습니다. 두 합계를 더한 90은 공개 글 세 개의 전체 합계와도 맞습니다.

출력의 키 순서는 기대하면 안 됩니다. API 문서는 groupingBy가 돌려주는 Map의 구현 타입을 정해 두지 않았으므로, 다른 순서로 출력되어도 틀린 답이 아닙니다. 키 순서가 필요하다면 groupingBy의 세 인수 버전에 TreeMap::new를 넘기면 키가 정렬된 순서로 나옵니다. 그리고 이 메서드는 결과를 돌려줄 뿐 바깥 상태를 바꾸지 않으므로, 같은 목록으로 다시 불러도 합계가 두 배가 되는 일은 없습니다.

3. 공개 글이 없는 분류도 0으로 남기기

2번의 결과에 공개된 글이 하나도 없는 spring도 spring=0으로 넣고 싶습니다. summarize를 어떻게 바꾸면 될까요?

해설 보기

filter로 먼저 거르면 spring 글이 그룹을 만들기 전에 사라집니다. 그래서 거르는 일을 그룹 안으로 옮겨야 합니다. 2번 해설의 summarize를 다음처럼 바꾸면, groupingBy는 나타난 모든 분류로 그룹을 만들고 Collectors.filtering이 각 그룹 안에서 공개 글만 남깁니다.

static Map<String, Integer> summarize(List<PostMetric> posts) {
    return posts.stream()
            .collect(
                    Collectors.groupingBy(
                            PostMetric::category,
                            Collectors.filtering(
                                    PostMetric::published,
                                    Collectors.summingInt(PostMetric::viewCount))));
}
실행 결과
{spring=0, database=35, java=55}

spring 그룹은 만들어졌지만 그 안의 글이 모두 걸러졌으므로 합계가 0입니다. 같은 '거르고 묶기'라도 순서를 바꾸면 결과에 남는 키가 달라집니다. Collectors.filtering은 Java 9에 추가된 수집기입니다.

다음 글

순수 함수와 부수 효과 분리하기에서는 이 글에서 '입력과 출력이 분명한 함수'라고 부른 것을 순수 함수라는 이름으로 정확히 정의합니다. 그리고 현재 시각, 난수, 저장소 입출력처럼 계산 결과를 예측하기 어렵게 만드는 요소를 계산 바깥으로 빼내는 방법을 다룹니다.