본문 바로가기

순수 함수와 부수 효과 분리하기

같은 입력에 늘 같은 결과를 내는 순수 함수를 정의하고, 현재 시각·난수·저장소 입출력을 계산 밖으로 빼내 결과를 다시 확인할 수 있게 만드는 방법을 익힙니다.

앞 글의 BoardReport는 같은 목록을 넘겨도 호출할 때마다 다른 합계를 돌려줬습니다. 합계를 필드에 쌓았기 때문입니다. 이런 메서드는 인수만 보고는 결과를 알 수 없어서, 결과를 확인하려면 그 전에 무슨 일이 있었는지까지 따라가야 합니다. 반대로 인수만으로 결과가 정해지는 메서드는 언제, 어디서, 몇 번을 불러도 같은 답을 냅니다.

이 글에서는 그런 메서드, 곧 순수 함수를 정확히 정의합니다. 그런 다음 실제 프로그램에 꼭 필요한 현재 시각, 난수, 저장소 입출력을 계산에서 떼어 내는 방법을 차례로 살펴봅니다. 이런 요소를 아예 없앨 수는 없지만, 계산의 핵심과 분리해 바깥쪽 한곳에 모을 수는 있습니다.

순수 함수의 두 가지 조건

순수 함수(pure function)는 두 조건을 모두 만족하는 함수입니다. 첫째, 결과가 인수만으로 정해져서 같은 인수를 넣으면 언제 불러도 같은 값을 돌려줍니다. 둘째, 값을 돌려주는 것 말고는 바깥에 아무 흔적도 남기지 않습니다. 필드나 static 변수를 바꾸지 않고, 화면에 출력하지 않으며, 파일이나 데이터베이스에 쓰지도 않습니다.

두 조건을 깨는 원인은 서로 다릅니다. 첫째 조건은 인수 말고도 결과에 영향을 주는 숨은 입력이 있을 때 깨집니다. 현재 시각, 난수, 파일과 데이터베이스에서 읽은 값, 다른 코드가 바꿀 수 있는 static 필드가 그런 숨은 입력입니다. 둘째 조건을 깨는 것, 즉 값을 돌려주는 것 말고 바깥 상태를 바꾸는 일은 앞 글에서처럼 부수 효과(side effect)라고 부릅니다. 출력, 파일과 데이터베이스 쓰기, 필드나 인수로 받은 객체를 바꾸는 일이 모두 부수 효과입니다.

순수 함수가 다루기 쉬운 이유는 참조 투명성(referential transparency)에 있습니다. 함수 호출식을 그 결과값으로 바꿔 써도 프로그램의 의미가 달라지지 않는 성질입니다. 예를 들어 Math.max(3, 7)이 나오는 자리에 7을 써 넣어도 프로그램은 똑같이 동작하므로, 읽는 사람은 호출식을 결과값처럼 다루며 코드를 읽을 수 있습니다. 숨은 입력이나 부수 효과가 있는 함수에는 이런 치환이 통하지 않는데, 다음 예제가 그런 경우입니다.

호출할 때마다 점수가 바뀌는 함수

아래 score는 이름과 인수만 보면 게시글 조회 수로 점수를 계산하는 함수 같습니다. 그런데 static 필드 calls를 읽고 바꾸므로 두 조건을 모두 어깁니다. 결과에 더해지는 calls는 숨은 입력이고, calls++는 바깥 상태를 바꾸는 부수 효과입니다.

lab/ImpureBoardScoreBug.java
public final class ImpureBoardScoreBug {
    private static int calls;

    private static int score(int viewCount) {
        calls++;
        return viewCount * 2 + calls;
    }

    public static void main(String[] args) {
        int first = score(30);
        int second = score(30);
        System.out.println("first=" + first + ", second=" + second);
        System.out.println("wrong-same=" + (first == second));
    }
}
실행 결과
first=61, second=62
wrong-same=false

첫 호출에서는 calls가 1이 되어 30 * 2 + 1 = 61이 나오고, 둘째 호출에서는 2가 되어 62가 나옵니다. 같은 인수 30을 넣었는데 결과가 다르니 first == second는 false입니다. 참조 투명성도 깨집니다. 프로그램을 새로 시작한 상태라면 score(30) + score(30)은 61과 62를 더한 123이지만, 같은 뜻처럼 보이는 2 * score(30)은 61의 두 배인 122입니다. 식을 바꿔 쓰기만 해도 결과가 달라지므로, 이 함수가 나오는 코드는 호출 순서와 횟수를 모두 따라가며 읽어야 합니다.

이런 코드는 대개 점수 계산과 호출 횟수 세기라는 두 가지 일을 한 메서드에 넣어서 생깁니다. 점수에 필요한 값은 모두 인수로 받고, 호출 횟수 측정처럼 계산과 상관없는 일은 계산을 부르는 바깥 코드나 모니터링 도구에서 합니다. 그러면 점수 계산 자체는 호출 순서나 스레드와 상관없이 확인할 수 있습니다.

필요한 값을 모두 인수로 받는 점수 계산

게시글 점수가 조회 수, 공개 여부, 최근에 공개한 글 수로 정해진다면 세 값을 모두 입력으로 받으면 됩니다. 다음 예제는 세 값을 레코드 PostMetricInput에 담아 넘깁니다. 레코드는 만들어진 뒤 값이 바뀌지 않으므로 score가 실행되는 동안 입력이 달라질 걱정이 없고, 생성자가 음수를 거부하므로 score에는 올바른 입력만 들어옵니다.

src/PureBoardScoreRule.java
public final class PureBoardScoreRule {
    private record PostMetricInput(int viewCount, boolean published, int recentPublishedCount) {
        private PostMetricInput {
            if (viewCount < 0 || recentPublishedCount < 0) {
                throw new IllegalArgumentException("negative input");
            }
        }
    }

    static int score(PostMetricInput input) {
        int publicationBonus = input.published() ? 20 : 0;
        int activityBonus = Math.min(input.recentPublishedCount(), 7) * 3;
        return input.viewCount() * 2 + publicationBonus + activityBonus;
    }

    public static void main(String[] args) {
        PostMetricInput input = new PostMetricInput(30, true, 4);
        int first = score(input);
        int second = score(input);
        System.out.println("first=" + first + ", second=" + second);
        System.out.println("same=" + (first == second));
    }
}
실행 결과
first=92, second=92
same=true

점수는 세 항을 더한 값입니다. 기본 점수가 30 * 2 = 60, 공개 보너스가 20, 활동 보너스가 최근 공개 글 4개에 3점씩인 4 * 3 = 12이므로 합은 92입니다. 세 항이 모두 인수에서만 나오므로 다시 불러도, 다른 스레드에서 불러도 92입니다. 활동 보너스의 Math.min(..., 7)은 최근 공개 글이 7개를 넘으면 7개로 계산하는 정책이어서, 7개일 때와 8개일 때의 보너스가 똑같이 21입니다.

이런 함수는 확인하기도 쉽습니다. 테스트 도구 없이도 main에서 대표 입력 몇 개를 넣어 보면 동작을 확인할 수 있습니다. 여러 스레드가 동시에 불러도 서로 간섭할 상태가 없으므로, 병렬 스트림의 map처럼 여러 스레드에서 실행되는 곳에 넘겨도 안전합니다.

다만 순수하다고 해서 규칙이 옳다는 뜻은 아닙니다. 순수 함수는 틀린 답도 매번 똑같이 냅니다. 예를 들어 앞 예제의 입력에서 조회 수만 11억으로 바꾸면 viewCount * 2가 int의 최댓값 2,147,483,647을 넘어 -2,094,967,296으로 넘쳐 버리고, 여기에 보너스 32점이 더해져 score는 -2094967264라는 엉뚱한 점수를 언제나 똑같이 돌려줍니다. 넘침을 예외로 알리고 싶다면 넘칠 때 ArithmeticException을 던지는 Math.multiplyExact 같은 메서드를 씁니다. 그러니 조회 수 0, 최근 공개 글 7개와 8개, 비공개 글, 아주 큰 조회 수처럼 규칙이 바뀌는 지점 근처의 입력으로 규칙 자체도 따로 확인해야 합니다.

더 깊이: 예외를 던지는 함수도 순수할까

PostMetricInput의 생성자는 음수 입력에 예외를 던집니다. 엄격한 함수형 언어에서는 실패도 값으로 돌려주는 방식을 선호하지만, Java에서는 같은 입력에 언제나 같은 예외를 던지는 함수라면 순수 함수의 장점을 대부분 누릴 수 있습니다. 성공하든 실패하든 그 결과가 입력만으로 정해지고, 다시 불러도 같은 방식으로 실패하기 때문입니다. 반대로 네트워크 상태에 따라 성공하기도 하고 IOException을 던지기도 하는 메서드는 실패 여부가 숨은 입력에 달려 있으므로 순수하지 않습니다.

현재 시각은 Clock으로 받기

마감일이 지났는지 판단하는 코드가 계산 깊숙한 곳에서 LocalDate.now()를 부르면, 같은 마감일을 넣어도 실행한 날짜에 따라 결과가 달라집니다. 자정 직전과 직후에 실행했을 때, 서버의 시간대 설정이 바뀌었을 때도 마찬가지입니다. 이런 코드는 오늘 실행한 결과를 내일 다시 확인할 수 없습니다. 해결책은 '오늘'을 숨은 입력이 아니라 진짜 입력으로 만드는 것입니다.

java.time.Clock은 현재 순간과 시간대를 알려 주는 객체입니다. 날짜 API로 게시글 예약 일정 만들기에서 본 것처럼 LocalDate.now(clock)으로 그 시계 기준의 오늘 날짜를 얻을 수 있고, Clock.fixed로 만든 시계는 언제 실행해도 같은 순간을 가리킵니다. Clock의 API 문서도 현재 시각이 필요한 메서드에는 Clock을 넘기라고 권장합니다. 정적 메서드 대신 객체에서 현재 시각을 얻으면 필요할 때 다른 시계로 바꿔 끼울 수 있어 테스트가 쉬워진다는 것입니다.

src/BoardDeadlineWithClock.java
import java.time.Clock;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;

public final class BoardDeadlineWithClock {
    static boolean isOverdue(LocalDate dueDate, LocalDate today) {
        return dueDate.isBefore(today);
    }

    static boolean check(LocalDate dueDate, Clock clock) {
        LocalDate today = LocalDate.now(clock);
        return isOverdue(dueDate, today);
    }

    public static void main(String[] args) {
        Clock fixed = Clock.fixed(Instant.parse("2026-07-14T00:00:00Z"), ZoneId.of("UTC"));
        System.out.println("overdue=" + check(LocalDate.of(2026, 7, 13), fixed));
        System.out.println("today=" + check(LocalDate.of(2026, 7, 14), fixed));
    }
}
실행 결과
overdue=true
today=false

이 예제는 일을 둘로 나눕니다. isOverdue는 마감일과 오늘 날짜를 모두 인수로 받는 순수 함수이고, check는 시계에서 오늘 날짜를 읽어 isOverdue에 넘기기만 하는 얇은 바깥 코드입니다. 고정된 시계는 2026년 7월 14일 0시(UTC)를 가리키므로, 마감일이 7월 13일이면 오늘보다 앞서서 true이고 7월 14일이면 오늘과 같아서 false입니다. isBefore는 날짜가 엄격하게 앞설 때만 true이므로, 이 규칙에서 마감일 당일은 아직 기한이 지나지 않은 날입니다.

운영 환경에서는 check에 시스템 시계를 넘기고, 동작을 확인할 때는 고정된 시계를 넘기면 됩니다. 이때 시간대는 구현 세부 사항이 아니라 '오늘'을 정하는 업무 입력입니다. 같은 순간 2026-07-14T00:00:00Z라도 UTC에서는 7월 14일이지만, America/Los_Angeles에서는 아직 7월 13일 오후 5시입니다. 그래서 마감일이 7월 13일인 글은 UTC 시계로는 기한이 지났고 로스앤젤레스 시계로는 아직 마감일 당일입니다. 사용자 지역과 서버 지역 가운데 무엇을 기준으로 할지 먼저 정하고, 그 ZoneId로 시계를 만들어야 합니다.

난수는 고르는 규칙과 뽑는 일로 나누기

추천할 분류를 무작위로 고르는 기능도 같은 방식으로 나눌 수 있습니다. 난수를 뽑는 일은 생성기의 내부 상태를 한 칸 앞으로 옮기는 부수 효과이므로 순수하게 만들 수 없습니다. 하지만 '몇 번째를 고를지'를 인수로 받는 선택 규칙은 순수 함수로 떼어 낼 수 있습니다. 다음 예제의 select는 인덱스를 받아 분류를 고르는 순수 함수이고, recommend는 생성기에서 인덱스를 뽑아 select에 넘깁니다.

recommend가 받는 RandomGenerator는 Java 17에 추가된 난수 생성기의 공통 인터페이스입니다. System·Math·Random 클래스 사용하기에서 본 Random도 이 인터페이스를 구현하므로, 예제에서는 시드 42로 만든 Random 두 개를 넘깁니다.

src/SeededCategorySelection.java
import java.util.List;
import java.util.Random;
import java.util.random.RandomGenerator;

public final class SeededCategorySelection {
    static String select(List<String> categories, int index) {
        if (categories.isEmpty()) {
            throw new IllegalArgumentException("categories");
        }
        return categories.get(Math.floorMod(index, categories.size()));
    }

    static String recommend(List<String> categories, RandomGenerator random) {
        return select(categories, random.nextInt(categories.size()));
    }

    public static void main(String[] args) {
        List<String> categories = List.of("stream", "optional", "record");
        RandomGenerator first = new Random(42);
        RandomGenerator second = new Random(42);
        System.out.println("first=" + recommend(categories, first));
        System.out.println("again=" + recommend(categories, first));
        System.out.println("replayed=" + recommend(categories, second));
        System.out.println("select=" + select(categories, 2) + ", " + select(categories, 2));
    }
}
실행 결과
first=record
again=stream
replayed=record
select=record, record

첫 줄과 셋째 줄은 같은 시드 42로 만든 서로 다른 생성기에서 첫 번째 난수를 뽑은 결과입니다. 시드 42인 Random의 첫 nextInt(3)은 2이므로 둘 다 record가 나옵니다. 둘째 줄은 첫 번째 생성기를 한 번 더 쓴 결과인데, 첫 호출에서 생성기의 상태가 이미 한 칸 나아갔기 때문에 이번에는 0이 나와 stream이 골라졌습니다. 결국 recommend(categories, first)는 인수가 완전히 같은데도 결과가 달라졌으니 순수 함수가 아닙니다. 반면 마지막 줄의 select(categories, 2)는 몇 번을 불러도 record입니다.

이렇게 나누어 두면 난수에 기대는 기능도 다시 확인할 수 있습니다. 같은 알고리즘과 같은 시드로 만든 생성기에 같은 순서로 요청하면 같은 값이 나오기 때문입니다. 그래서 문제를 재현하거나 예제 결과를 확인할 때는 시드를 고정한 생성기를 넘기고, 운영 환경에서는 시드 없이 만든 생성기를 넘깁니다. 매개변수 타입이 인터페이스이므로 RandomGeneratorFactory.of("L64X128MixRandom").create(42L)처럼 다른 알고리즘의 생성기를 넘길 때도 recommend는 고칠 필요가 없습니다.

다만 비밀번호 재설정 토큰처럼 남이 맞히면 안 되는 값에는 일반 생성기 대신 SecureRandom을 써야 합니다. 재현할 수 있다는 성질은 예제와 디버깅에서는 장점이지만, 보안에서는 다음 값을 예측할 수 있다는 약점이 되기 때문입니다.

빈 목록도 짚어 둘 만합니다. 빈 목록으로 recommend를 부르면 select의 검사에 닿기도 전에 random.nextInt(0)이 IllegalArgumentException을 던집니다. nextInt(bound)는 bound가 양수가 아니면 예외를 던지기 때문입니다. 순수한 쪽에 검사를 넣어 두어도 그보다 먼저 실행되는 바깥 코드에서 실패할 수 있으므로, 입력 검사가 실제로 어느 시점에 일어나는지는 실행 순서를 따라가며 확인해야 합니다.

저장소 읽기와 쓰기를 바깥으로 모으기

데이터베이스에서 게시글을 읽고 보고서를 저장하는 일은 피할 수 없는 부수 효과입니다. 그렇다고 계산 코드가 저장소를 직접 부르면, 계산을 확인할 때마다 저장소가 있어야 하고 조회 실패나 재시도 같은 문제가 계산 안으로 섞여 들어옵니다. 그래서 흔히 읽기, 계산, 쓰기를 나눕니다. 바깥 코드가 저장소에서 값을 읽어 순수한 계산에 넘기고, 계산 결과를 받아 다시 저장하는 구조입니다.

아래 예제에서 PostMetricRepository는 사용자별 게시글 읽기와 보고서 저장이라는 두 가지 저장소 작업을 정한 인터페이스입니다. calculate는 게시글 목록만 받아 보고서를 만드는 순수 함수이고, refresh는 저장소에서 읽고 calculate로 계산한 뒤 저장하는 바깥 코드입니다. main은 실제 데이터베이스 대신, 고정된 목록을 돌려주고 저장 요청을 화면에 출력하는 메모리 저장소를 익명 클래스로 만들어 넘깁니다.

app/FunctionalCoreImperativeShell.java
import java.util.List;

public final class FunctionalCoreImperativeShell {
    private record PostMetric(int viewCount, boolean published) {}

    private record Report(int publishedCount, int totalViewCount) {}

    private interface PostMetricRepository {
        List<PostMetric> findByUser(long userId);

        void saveReport(long userId, Report report);
    }

    static Report calculate(List<PostMetric> posts) {
        int count = (int) posts.stream().filter(PostMetric::published).count();
        int viewCount = posts.stream().filter(PostMetric::published).mapToInt(PostMetric::viewCount).sum();
        return new Report(count, viewCount);
    }

    static Report refresh(long userId, PostMetricRepository repository) {
        List<PostMetric> posts = repository.findByUser(userId);
        Report report = calculate(posts);
        repository.saveReport(userId, report);
        return report;
    }

    public static void main(String[] args) {
        PostMetricRepository memory =
                new PostMetricRepository() {
                    @Override
                    public List<PostMetric> findByUser(long userId) {
                        return List.of(
                                new PostMetric(30, true),
                                new PostMetric(40, false),
                                new PostMetric(20, true));
                    }

                    @Override
                    public void saveReport(long userId, Report report) {
                        System.out.println("saved user=" + userId + ", report=" + report);
                    }
                };
        refresh(7, memory);
    }
}
실행 결과
saved user=7, report=Report[publishedCount=2, totalViewCount=50]

출력은 saveReport가 찍은 한 줄뿐입니다. 공개된 글은 조회 수 30과 20인 두 개이므로 publishedCount=2, totalViewCount=50이고, 공개되지 않은 40은 두 집계에서 모두 빠집니다. refresh는 계산한 보고서를 돌려주기도 하지만 main은 그 값을 따로 쓰지 않습니다. 아래 단계별 그림은 refresh(7, memory)가 읽기, 계산, 쓰기, 반환을 차례로 거치는 과정을 보여 줍니다.

refresh가 읽기·계산·쓰기를 순서대로 연결한다

findByUser의 외부 읽기 뒤 순수 calculate를 수행하고 saveReport로 외부 쓰기를 합니다. posts와 report가 경계를 지나는 실행이며 계산 중 입력 불변 조건도 유지해야 합니다.

refresh가 읽기·계산·쓰기를 순서대로 연결한다

FunctionalCoreImperativeShell의 저장소 호출 두 번과 그 사이 값 계산을 구분합니다.

효과 사이에 놓인 값 계산

refresh가 읽기·계산·쓰기를 순서대로 연결한다

refresh는 저장소에서 읽고 calculate로 계산한 뒤 저장합니다. main의 값은 공개 글 2개·조회 수 합계 50입니다.

refresh가 읽기·계산·쓰기를 순서대로 연결한다저장소 findByUser가 posts를 반환하고 calculate가 Report를 계산한 뒤 저장소 saveReport에 전달한다.저장소 읽기findByUser(userId)값 계산calculate(posts)저장소 쓰기saveReport(userId, report)postsreport
refresh가 읽기·계산·쓰기를 순서대로 연결한다위에서 아래로 저장소 읽기, 전달받은 posts 계산, Report 저장 순서이다.저장소 읽기findByUser(userId)값 계산calculate(posts)저장소 쓰기saveReport(userId, report)postsreport

main의 저장소는 고정 목록과 콘솔 출력으로 구성됩니다. DB·파일 저장이나 트랜잭션을 구현한 예제는 아닙니다.

이 예의 조건

  • 원 main의 refresh(7, memory)를 추적합니다. findByUser는 고정 메모리 목록을 반환하고 saveReport는 콘솔에 출력합니다.
  • DB·파일·트랜잭션 저장은 구현하지 않았습니다. calculate가 두 번 순회하는 동안 목록이 바뀌지 않는 입력 계약입니다.
관계도의 보충 설명
원 도표의 설명과 주석
구분내용
전체 읽기 정보refresh가 읽기·계산·쓰기를 순서대로 연결한다 refresh가 읽기·계산·쓰기를 순서대로 연결한다 FunctionalCoreImperativeShell의 저장소 호출 두 번과 그 사이 값 계산을 구분합니다. 효과 사이에 놓인 값 계산 refresh가 읽기·계산·쓰기를 순서대로 연결한다 refresh는 저장소에서 읽고 calculate로 계산한 뒤 저장합니다. main의 값은 공개 글 2개·조회 수 합계 50입니다. main의 저장소는 고정 목록과 콘솔 출력으로 구성됩니다. DB·파일 저장이나 트랜잭션을 구현한 예제는 아닙니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
user 7의 메모리 목록 읽기

refresh가 findByUser(7)을 호출하여 세 PostMetric을 받습니다.

  • userId: 7
  • posts: (30,true), (40,false), (20,true)
  • 보고서: 아직 계산 전
  • 순수 계산 전에 셸이 사용할 목록을 확보합니다.
공개 항목만 계산

calculate가 published 항목의 개수와 조회 수를 각각 집계합니다.

  • 공개 항목: 30과 20의 두 항목
  • 비공개 항목: 40은 두 집계에서 제외
  • Report: publishedCount=2, totalViewCount=50
  • 공개 조건으로 선택된 동일한 두 항목이 개수 2와 합계 50을 만듭니다.
콘솔 saveReport 호출

refresh가 userId 7과 계산한 Report를 메모리 저장소의 saveReport에 넘깁니다.

  • 인수: 7, Report(2,50)
  • 효과: saved user=7, report=Report[publishedCount=2, totalViewCount=50] 콘솔 출력
  • 영속 DB·파일·TX: 미구현
  • 원 예제의 쓰기 효과는 콘솔이며 영속 저장 완료로 확대하지 않습니다.
동일 Report 반환

saveReport가 정상 반환한 뒤 refresh가 계산한 보고서를 호출자에게 반환합니다.

  • refresh 반환: Report(publishedCount=2,totalViewCount=50)
  • main: 반환값을 별도 보관·출력하지 않음
  • 순서: 읽기 → 계산 → 콘솔 쓰기 → 반환
  • 계산 결과의 반환과 셸의 부수 효과를 구별합니다.

refresh가 읽기·계산·쓰기를 순서대로 연결한다

findByUser의 외부 읽기 뒤 순수 calculate를 수행하고 saveReport로 외부 쓰기를 합니다. posts와 report가 경계를 지나는 실행이며 계산 중 입력 불변 조건도 유지해야 합니다.

refresh가 읽기·계산·쓰기를 순서대로 연결한다

FunctionalCoreImperativeShell의 저장소 호출 두 번과 그 사이 값 계산을 구분합니다.

효과 사이에 놓인 값 계산

refresh가 읽기·계산·쓰기를 순서대로 연결한다

refresh는 저장소에서 읽고 calculate로 계산한 뒤 저장합니다. main의 값은 공개 글 2개·조회 수 합계 50입니다.

refresh가 읽기·계산·쓰기를 순서대로 연결한다저장소 findByUser가 posts를 반환하고 calculate가 Report를 계산한 뒤 저장소 saveReport에 전달한다.저장소 읽기findByUser(userId)값 계산calculate(posts)저장소 쓰기saveReport(userId, report)postsreport
refresh가 읽기·계산·쓰기를 순서대로 연결한다위에서 아래로 저장소 읽기, 전달받은 posts 계산, Report 저장 순서이다.저장소 읽기findByUser(userId)값 계산calculate(posts)저장소 쓰기saveReport(userId, report)postsreport

main의 저장소는 고정 목록과 콘솔 출력으로 구성됩니다. DB·파일 저장이나 트랜잭션을 구현한 예제는 아닙니다.

이 예의 조건

  • 원 main의 refresh(7, memory)를 추적합니다. findByUser는 고정 메모리 목록을 반환하고 saveReport는 콘솔에 출력합니다.
  • DB·파일·트랜잭션 저장은 구현하지 않았습니다. calculate가 두 번 순회하는 동안 목록이 바뀌지 않는 입력 계약입니다.

관계도의 보충 설명

원 도표의 설명과 주석
구분내용
전체 읽기 정보refresh가 읽기·계산·쓰기를 순서대로 연결한다 refresh가 읽기·계산·쓰기를 순서대로 연결한다 FunctionalCoreImperativeShell의 저장소 호출 두 번과 그 사이 값 계산을 구분합니다. 효과 사이에 놓인 값 계산 refresh가 읽기·계산·쓰기를 순서대로 연결한다 refresh는 저장소에서 읽고 calculate로 계산한 뒤 저장합니다. main의 값은 공개 글 2개·조회 수 합계 50입니다. main의 저장소는 고정 목록과 콘솔 출력으로 구성됩니다. DB·파일 저장이나 트랜잭션을 구현한 예제는 아닙니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
user 7의 메모리 목록 읽기

refresh가 findByUser(7)을 호출하여 세 PostMetric을 받습니다.

  • userId: 7
  • posts: (30,true), (40,false), (20,true)
  • 보고서: 아직 계산 전
  • 순수 계산 전에 셸이 사용할 목록을 확보합니다.
공개 항목만 계산

calculate가 published 항목의 개수와 조회 수를 각각 집계합니다.

  • 공개 항목: 30과 20의 두 항목
  • 비공개 항목: 40은 두 집계에서 제외
  • Report: publishedCount=2, totalViewCount=50
  • 공개 조건으로 선택된 동일한 두 항목이 개수 2와 합계 50을 만듭니다.
콘솔 saveReport 호출

refresh가 userId 7과 계산한 Report를 메모리 저장소의 saveReport에 넘깁니다.

  • 인수: 7, Report(2,50)
  • 효과: saved user=7, report=Report[publishedCount=2, totalViewCount=50] 콘솔 출력
  • 영속 DB·파일·TX: 미구현
  • 원 예제의 쓰기 효과는 콘솔이며 영속 저장 완료로 확대하지 않습니다.
동일 Report 반환

saveReport가 정상 반환한 뒤 refresh가 계산한 보고서를 호출자에게 반환합니다.

  • refresh 반환: Report(publishedCount=2,totalViewCount=50)
  • main: 반환값을 별도 보관·출력하지 않음
  • 순서: 읽기 → 계산 → 콘솔 쓰기 → 반환
  • 계산 결과의 반환과 셸의 부수 효과를 구별합니다.

이 구조에서 calculate는 저장소를 전혀 모르므로, 동작을 확인할 때는 목록 하나만 만들어 넘기면 됩니다. 조회에 실패하면 다시 시도할지, 저장을 트랜잭션으로 묶을지 같은 결정은 모두 refresh 쪽에서 내립니다. 이처럼 순수한 계산을 가운데에 두고 입출력을 바깥 껍질에 모으는 구조를 흔히 '함수형 코어, 명령형 셸(functional core, imperative shell)'이라고 부르며, 예제의 클래스 이름도 여기서 왔습니다.

이 구조에도 지켜야 할 전제가 있습니다. calculate는 받은 목록을 두 번 순회하면서 복사하거나 잠그지 않으므로, 계산하는 동안 다른 코드가 그 목록을 바꾸지 않아야 합니다. 저장소가 List.of처럼 바꿀 수 없는 목록을 돌려주거나 바깥 코드가 복사본을 넘기면 이 전제가 지켜집니다.

또 같은 목록으로 calculate를 다시 부르는 것과 refresh 전체를 다시 실행하는 것은 다릅니다. calculate는 몇 번을 불러도 같은 보고서를 내지만, refresh를 다시 실행하면 그사이 저장소의 데이터가 바뀌어 다른 보고서가 나올 수 있습니다. 같은 보고서를 두 번 저장해도 한 번 저장한 것과 결과가 같은지, 즉 저장이 멱등(idempotent)한지는 계산이 아니라 저장소의 규칙이 정합니다.

시간, 난수, 입출력을 어디에 둘까

지금까지의 예제를 정리하면, 실행할 때마다 달라지거나 바깥을 바꾸는 코드는 다음 원칙에 따라 계산 밖으로 옮깁니다. 원칙마다 이 글에서 본 예가 하나씩 있습니다.

  • 현재 시각, 난수, 환경 변수처럼 실행할 때마다 달라지는 값은 계산 안에서 직접 읽지 않습니다. isOverdue처럼 값 자체를 인수로 받거나, check와 recommend처럼 Clock이나 RandomGenerator 같은 제공자 객체로 받습니다.
  • 파일, 네트워크, 데이터베이스는 PostMetricRepository 같은 인터페이스 뒤에 두고, 사용 사례를 처리하는 바깥 코드에서만 부릅니다.
  • 로그와 지표 수집은 계산 바깥에서 합니다. ImpureBoardScoreBug처럼 호출 횟수를 세는 코드가 계산 안에 들어가면 결과까지 바뀔 수 있습니다.
  • 모든 입출력을 커다란 main 하나에 몰지 말고, refresh처럼 사용 사례마다 작은 바깥 코드를 만듭니다.
의존성을 인터페이스로 받으면 그 메서드가 순수해지나요?

아닙니다. check에 시스템 시계를 넘기면 같은 인수로 불러도 날짜가 바뀌는 순간 결과가 달라집니다. refresh도 저장소를 인터페이스로 받지만, 그 뒤에서 데이터베이스를 읽고 쓰므로 부수 효과는 그대로 있습니다. 의존성을 인수로 받는 것은 숨은 입력과 부수 효과를 없애는 방법이 아니라, 바꿔 끼울 수 있게 하고 어디서 바깥과 주고받는지 드러내는 방법입니다. 순수한 계산에는 isOverdue나 calculate처럼 이미 읽어 온 값만 넘깁니다.

정리

  • 순수 함수는 결과가 인수만으로 정해지고, 값을 돌려주는 것 말고는 바깥에 흔적을 남기지 않습니다. 그래서 호출식을 결과값으로 바꿔 읽을 수 있습니다(참조 투명성).
  • static 카운터 같은 숨은 상태를 쓰는 함수는 같은 인수에도 결과가 달라지고, score(30) + score(30)과 2 * score(30)처럼 같아 보이는 식이 서로 다른 값을 냅니다.
  • 현재 시각은 Clock으로, 난수는 RandomGenerator나 이미 뽑은 인덱스로 받으면 고정된 시계와 시드로 결과를 다시 확인할 수 있습니다.
  • 저장소 읽기와 쓰기는 바깥 코드에 모으고, 가운데의 계산은 값만 받아 값만 돌려주게 합니다.
  • 순수하다고 규칙이 옳은 것은 아닙니다. 넘침이나 정책이 바뀌는 지점은 따로 확인합니다.

연습 문제

1. 순수 함수 가려내기

다음 세 메서드가 각각 순수 함수인지 판단하고, 아니라면 두 조건 가운데 무엇을 어기는지 적어 보세요. 세 메서드에 필요한 java.util과 java.time의 import는 생략했습니다.

static int total(List<Integer> values) {
    int sum = 0;
    for (int value : values) {
        sum += value;
    }
    return sum;
}

static boolean isWeekendToday() {
    DayOfWeek day = LocalDate.now().getDayOfWeek();
    return day == DayOfWeek.SATURDAY || day == DayOfWeek.SUNDAY;
}

static int smallest(List<Integer> values) {
    Collections.sort(values);
    return values.get(0);
}
해설 보기

total은 순수 함수입니다. sum을 여러 번 바꾸지만 지역 변수이므로 바깥에서는 보이지 않고, 결과는 values의 내용만으로 정해집니다.

isWeekendToday는 첫째 조건을 어깁니다. 인수가 하나도 없는데도 결과가 실행한 날짜라는 숨은 입력에 따라 달라집니다. 날짜를 인수로 받는 isWeekend(LocalDate date)로 판단 규칙을 떼어 내고, 오늘 날짜는 바깥 코드가 LocalDate.now(clock)으로 구해 넘기면 됩니다.

smallest는 둘째 조건을 어깁니다. Collections.sort는 호출한 쪽이 넘긴 목록 자체를 정렬하므로, 30, 10, 20을 담은 ArrayList를 넘기면 10을 돌려받는 것과 함께 그 목록이 [10, 20, 30]으로 바뀌어 버립니다. List.of(3, 1, 2)처럼 바꿀 수 없는 목록을 넘기면 UnsupportedOperationException이 납니다. 목록을 건드리지 않는 Collections.min(values)를 쓰면 같은 답을 부수 효과 없이 얻습니다.

2. Clock으로 오늘의 목표 달성 여부 계산하기

Clock에서 오늘 날짜를 얻는 바깥 코드와, 날짜·목표 조회 수·공개된 게시글의 누적 조회 수를 받아 결과를 만드는 계산 규칙을 분리하세요. 고정된 Clock으로 2026년 7월 14일의 결과를 재현합니다.

목표 조회 수는 60회로 두고, 누적 조회 수가 45회인 경우와 75회인 경우를 각각 계산합니다. 두 결과의 날짜, 남은 조회 수, 달성 여부를 먼저 적어 보세요. 이어서 누적 조회 수가 정확히 60회일 때의 결과도 예상한 뒤 해설과 비교합니다. 입력은 음수가 아니고, 뺄셈 결과가 int 범위를 넘지 않는다고 가정합니다.

해설 보기
exercise/DailyGoalStatusSolution.java
import java.time.Clock;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneOffset;

public final class DailyGoalStatusSolution {
    private record GoalStatus(LocalDate date, int remainingViewCount, boolean achieved) {}

    static GoalStatus calculate(LocalDate date, int targetViewCount, int publishedViewCount) {
        int remaining = Math.max(0, targetViewCount - publishedViewCount);
        return new GoalStatus(date, remaining, remaining == 0);
    }

    static GoalStatus today(int targetViewCount, int publishedViewCount, Clock clock) {
        return calculate(LocalDate.now(clock), targetViewCount, publishedViewCount);
    }

    public static void main(String[] args) {
        Clock fixed = Clock.fixed(Instant.parse("2026-07-14T09:00:00Z"), ZoneOffset.UTC);
        System.out.println(today(60, 45, fixed));
        System.out.println(today(60, 75, fixed));
        System.out.println(today(60, 60, fixed));
    }
}
실행 결과
GoalStatus[date=2026-07-14, remainingViewCount=15, achieved=false]
GoalStatus[date=2026-07-14, remainingViewCount=0, achieved=true]
GoalStatus[date=2026-07-14, remainingViewCount=0, achieved=true]

고정된 시계가 2026년 7월 14일 9시(UTC)를 가리키므로 세 결과의 날짜는 모두 2026-07-14입니다. 45회는 목표까지 15회가 남아 achieved=false입니다. 75회는 60 - 75가 음수이지만 Math.max(0, ...)가 0으로 바꾸므로 남은 조회 수가 0이고 달성한 것으로 나옵니다. 정확히 60회일 때도 Math.max(0, 60 - 60)이 0이므로 달성으로 판단합니다.

역할을 나누어 보면 시계를 읽는 곳은 today 한 곳뿐입니다. calculate는 이미 정해진 날짜와 두 수만 받아 계산하므로 시계 없이도 확인할 수 있습니다. 다만 이 계산은 음수 입력이나 뺄셈의 넘침을 검사하지 않으므로, 실제 서비스에서 그런 입력이 들어올 수 있다면 앞의 PostMetricInput처럼 입력을 검사해야 합니다.

다음 글

불변 값과 컬렉션 스냅샷에서는 순수 함수가 주고받는 값 자체를 바뀌지 않게 만드는 방법을 다룹니다. 이 글의 calculate가 받은 목록이 계산 도중에 바뀌지 않아야 했던 것처럼, 값이 바뀌지 않으면 여러 곳에 안심하고 넘길 수 있습니다.