안동민 개발노트

본문 시작

순수 함수와 효과 경계

참조 투명성과 부수 효과를 실행으로 구분하고 Clock, RandomGenerator, 저장소 I/O를 게시판 통계 계산 밖으로 밀어냅니다.

순수 함수는 같은 입력에 같은 출력을 반환하고 외부에서 관찰 가능한 상태를 변경하지 않습니다.

호출식을 결과값으로 치환해도 프로그램 의미가 바뀌지 않는 성질을 참조 투명성이라고 합니다.

반대로 현재 시간, 난수, 파일·데이터베이스 조회, 로그 출력, 가변 전역 상태는 호출 외부 세계와 상호작용하는 효과입니다.

효과를 없앨 수는 없지만 계산의 핵심과 효과의 경계를 분리할 수 있습니다.


호출 횟수에 따른 점수 변화

아래 함수는 이름과 인수만 보면 게시글 점수를 계산하는 것 같지만 static 카운터를 읽고 변경합니다.

원문을 순서대로 계산하면 첫 점수는 61, 두 번째는 62이므로 wrong-same 비교는 false가 됩니다.

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));
    }
}

원칙은 필요한 최근 공개 게시글 수나 정책 버전을 명시적인 입력으로 받는 것입니다.

호출 횟수 측정은 계산 밖의 데코레이터나 모니터링 계층에서 수행합니다.

순수 핵심은 순서와 스레드에 독립적으로 확인할 수 있습니다.


순수 결정 함수

게시글 지표 점수가 조회 수, 공개 여부, 최근 공개 게시글 수에 의해 결정된다면 세 값을 입력으로 받습니다.

입력 record 자체도 불변이므로 함수 안에서 외부 상태를 찾을 이유가 없습니다.

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));
    }
}

이 함수는 별도 단위 검사 없이도 예제 main()에서 대표 사례를 실행할 수 있고, 병렬 컬렉션 계산에도 안전합니다.

순수 함수라는 사실이 도메인 규칙의 정확성을 보장하지는 않습니다. 조회 0회, 최근 공개 게시글 7·8개, 미공개 상태와 함께 큰 조회 수의 int 오버플로도 검토합니다.


Clock과 시간 의존성

LocalDate.now()를 계산 깊숙한 곳에서 호출하면 자정 경계, 시간대, 실행 시점에 따라 결과가 달라집니다.

애플리케이션 서비스가 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));
    }
}

시간대는 단순한 구현 세부 사항이 아니라 오늘 날짜를 정하는 업무 입력입니다.

사용자 지역을 기준으로 할지 서버 지역을 기준으로 할지 정하고, 해당 ZoneId를 가진 Clock을 구성합니다.


난수 생성과 정책 판단의 분리

추천 분류를 난수로 고를 때 선택 함수는 index를 입력으로 받고, 난수 생성기 호출은 효과 경계에 둡니다. 선택 중에는 입력 목록의 내용도 유지돼야 합니다.

같은 알고리즘·시드·호출 순서를 사용하면 난수 선택을 재현할 수 있습니다.

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

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 = RandomGeneratorFactory.of("L64X128MixRandom").create(42L);
        RandomGenerator second = RandomGeneratorFactory.of("L64X128MixRandom").create(42L);
        System.out.println("first=" + recommend(categories, first));
        System.out.println("replayed=" + recommend(categories, second));
    }
}

빈 목록으로 recommend를 부르면 select에 들어가기 전에 nextInt(0)이 예외를 던집니다. 원문의 main은 비어 있지 않은 목록만 사용합니다.

보안 토큰에는 일반적인 시드 기반 의사 난수 생성기를 쓰지 않고 SecureRandom과 해당 보안 요구 사항을 적용합니다.

예제의 재현성과 운영 환경의 예측 불가능성은 목적이 다릅니다.


읽기·계산·쓰기를 담당하는 I/O 셸

저장소에서 게시글을 읽고 보고서를 저장하는 일은 부수 효과입니다.

도메인 함수가 저장소를 직접 부르지 않게 하면 조회 실패, 재시도, 트랜잭션을 애플리케이션 셸에서 관리하고 계산은 값만 다룹니다.

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);
    }
}
refresh가 읽기·계산·쓰기를 순서대로 연결한다

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

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

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

calculate는 전달받은 목록을 두 번 순회하며 복사하거나 잠그지 않습니다. 계산 중 목록이 바뀌지 않는 입력 계약이 필요합니다.

같은 입력의 계산 재시도와 refresh 전체 재시도는 다릅니다. 다시 조회하면 원본이 달라질 수 있고, 저장 연산의 멱등성은 저장소 규칙에서 정해야 합니다.


부수 효과를 경계로 밀어낼 판단 기준

  • 시간, 난수, 환경 변수는 값 또는 제공자로 주입합니다.
  • 파일, 네트워크, 데이터베이스는 포트 인터페이스 뒤의 애플리케이션 경계에서 호출합니다.
  • 로그와 지표는 계산 결과를 바꾸지 않는 관찰 계층에 둡니다.
  • 순수 함수는 예외 대신 무조건 값만 반환해야 하는 것은 아닙니다. 잘못된 입력 예외도 입력에 의해 결정되면 참조 가능한 규칙입니다.
  • 모든 I/O를 거대한 main 하나에 몰지 말고 사용 사례 단위 셸을 만듭니다.
의존성을 인터페이스로 주입하면 그 메서드가 순수해지나요?

아닙니다.

인터페이스 뒤에서 시간이나 데이터베이스를 읽으면 여전히 부수 효과가 있습니다.

주입은 효과를 교체하고 경계를 명시하는 도구입니다.

순수한 핵심에는 이미 읽어 온 값만 전달하고, 부수 효과를 담당하는 셸의 이름과 반환 타입에서 실패 가능성을 표현합니다.


연습 문제

Clock에서 오늘 날짜를 얻는 셸과 날짜·목표 조회 수·공개된 게시글의 누적 조회 수를 받는 계산 규칙을 분리하세요.

고정된 Clock으로 2026-07-14의 결과를 재현합니다.

해설 보기
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 targetCount, int publishedCount) {
        int remaining = Math.max(0, targetCount - publishedCount);
        return new GoalStatus(date, remaining, remaining == 0);
    }

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

    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));
    }
}

원문 입력의 계산 결과는 첫째 남은 조회 수 15·achieved=false, 둘째 남은 조회 수 0·achieved=true이며, 고정 시계의 날짜는 두 경우 모두 2026-07-14입니다. 이 계산은 목표와 누적 조회 수의 음수 입력이나 뺄셈 오버플로를 검사하지 않습니다.