순수 함수와 효과 경계
참조 투명성과 부수 효과를 실행으로 구분하고 Clock, RandomGenerator, 저장소 I/O를 Study Log 계산 밖으로 밀어냅니다.
순수 함수는 같은 입력에 같은 출력을 반환하고 외부에서 관찰 가능한 상태를 변경하지 않습니다. 호출식을 결과값으로 치환해도 프로그램 의미가 바뀌지 않는 성질을 참조 투명성이라고 합니다. 반대로 현재 시간, 난수, 파일·데이터베이스 조회, 로그 출력, 가변 전역 상태는 호출 외부 세계와 상호작용하는 효과입니다. 효과를 없앨 수는 없지만 계산의 핵심과 효과의 경계를 분리할 수 있습니다.
호출 횟수에 따른 점수 변화
아래 함수는 이름과 인수만 보면 세션 점수를 계산하는 것 같지만 static 카운터를 읽고 변경합니다.
같은 세션을 두 번 평가하면 다른 결과가 나와 출력은 wrong-same=false입니다.
public final class ImpureStreakBonusBug {
private static int calls;
private static int score(int minutes) {
calls++;
return minutes * 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 자체도 불변이므로 함수 안에서 외부 상태를 찾을 이유가 없습니다.
public final class PureStudyScoreRule {
private record SessionInput(int minutes, boolean completed, int streakDays) {
private SessionInput {
if (minutes < 0 || streakDays < 0) {
throw new IllegalArgumentException("negative input");
}
}
}
static int score(SessionInput input) {
int completion = input.completed() ? 20 : 0;
int streak = Math.min(input.streakDays(), 7) * 3;
return input.minutes() * 2 + completion + streak;
}
public static void main(String[] args) {
SessionInput input = new SessionInput(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일, 미완료 상태를 따로 확인합니다.
Clock과 시간 의존성
LocalDate.now()를 계산 깊숙한 곳에서 호출하면 자정 경계, 시간대, 실행 시점에 따라 결과가 달라집니다.
애플리케이션 서비스가 Clock에서 오늘 날짜를 얻어 순수 규칙에 넘기면 운영 환경에서는 시스템 시계, 예제에서는 고정된 시계를 사용할 수 있습니다.
import java.time.Clock;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;
public final class StudyDeadlineWithClock {
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를 입력으로 받고, 난수 생성기 호출은 효과 경계에 둡니다.
시드가 있는 생성기로 예제를 재현할 수 있습니다.
import java.util.List;
import java.util.random.RandomGenerator;
import java.util.random.RandomGeneratorFactory;
public final class SeededTopicSelection {
static String select(List<String> topics, int index) {
if (topics.isEmpty()) {
throw new IllegalArgumentException("topics");
}
return topics.get(Math.floorMod(index, topics.size()));
}
static String recommend(List<String> topics, RandomGenerator random) {
return select(topics, random.nextInt(topics.size()));
}
public static void main(String[] args) {
List<String> topics = List.of("stream", "optional", "record");
RandomGenerator first = RandomGeneratorFactory.of("L64X128MixRandom").create(42L);
RandomGenerator second = RandomGeneratorFactory.of("L64X128MixRandom").create(42L);
System.out.println("first=" + recommend(topics, first));
System.out.println("replayed=" + recommend(topics, second));
}
}보안 토큰에는 일반적인 시드 기반 의사 난수 생성기를 쓰지 않고 SecureRandom과 해당 보안 요구 사항을 적용합니다.
예제의 재현성과 운영 환경의 예측 불가능성은 목적이 다릅니다.
읽기·계산·쓰기를 담당하는 I/O 셸
저장소에서 세션을 읽고 보고서를 저장하는 일은 부수 효과입니다. 도메인 함수가 저장소를 직접 부르지 않게 하면 조회 실패, 재시도, 트랜잭션을 애플리케이션 셸에서 관리하고 계산은 값만 다룹니다.
import java.util.List;
public final class FunctionalCoreImperativeShell {
private record Session(int minutes, boolean completed) {}
private record Report(int completedCount, int totalMinutes) {}
private interface SessionRepository {
List<Session> findByUser(long userId);
void saveReport(long userId, Report report);
}
static Report calculate(List<Session> sessions) {
int count = (int) sessions.stream().filter(Session::completed).count();
int minutes = sessions.stream().filter(Session::completed).mapToInt(Session::minutes).sum();
return new Report(count, minutes);
}
static Report refresh(long userId, SessionRepository repository) {
List<Session> sessions = repository.findByUser(userId);
Report report = calculate(sessions);
repository.saveReport(userId, report);
return report;
}
public static void main(String[] args) {
SessionRepository memory =
new SessionRepository() {
@Override
public List<Session> findByUser(long userId) {
return List.of(
new Session(30, true),
new Session(40, false),
new Session(20, true));
}
@Override
public void saveReport(long userId, Report report) {
System.out.println("saved user=" + userId + ", report=" + report);
}
};
refresh(7, memory);
}
}calculate는 순수한 계산 핵심이고 refresh는 부수 효과를 담당하는 셸입니다.
저장이 실패하면 계산을 다시 해도 안전하지만 저장 연산의 멱등성은 저장소 규칙에서 정해야 합니다.
부수 효과를 구분으로 밀어낼 판단 기준
- 시간, 난수, 환경 변수는 값 또는 제공자로 주입합니다.
- 파일, 네트워크, 데이터베이스는 포트 인터페이스 뒤의 애플리케이션 경계에서 호출합니다.
- 로그와 지표는 계산 결과를 바꾸지 않는 관찰 계층에 둡니다.
- 순수 함수는 예외 대신 무조건 값만 반환해야 하는 것은 아닙니다. 잘못된 입력 예외도 입력에 의해 결정되면 참조 가능한 규칙입니다.
- 모든 I/O를 거대한
main하나에 몰지 말고 사용 사례 단위 셸을 만듭니다.
의존성을 인터페이스로 주입하면 그 메서드가 순수해지나요?
아닙니다. 인터페이스 뒤에서 시간이나 데이터베이스를 읽으면 여전히 부수 효과가 있습니다. 주입은 효과를 교체하고 경계를 명시하는 도구입니다. 순수한 핵심에는 이미 읽어 온 값만 전달하고, 부수 효과를 담당하는 셸의 이름과 반환 타입에서 실패 가능성을 표현합니다.
연습 문제
Clock에서 오늘 날짜를 얻는 셸과 날짜·완료한 학습 시간을 받는 순수 규칙을 분리하세요.
고정된 Clock으로 2026-07-14의 결과를 재현합니다.
해설 보기
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 remainingMinutes, boolean achieved) {}
static GoalStatus calculate(LocalDate date, int targetMinutes, int completedMinutes) {
int remaining = Math.max(0, targetMinutes - completedMinutes);
return new GoalStatus(date, remaining, remaining == 0);
}
static GoalStatus today(int targetMinutes, int completedMinutes, Clock clock) {
return calculate(LocalDate.now(clock), targetMinutes, completedMinutes);
}
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인 것입니다.