本文へ移動
cccskills
無料GitHub で公開

segregate-functional-core

I/O + 계산 혼재 또는 Mock stub + 계산 혼재 메서드를 Functional Core / Imperative Shell(빵속빵, Impure-Pure-Impure Sandwich)로 분리. "순수 함수 분리", "I/O와 계산 나눠", "mock 없이 테스트하게", "functional core", "/segregate-functional-core" 요청 시 사용. 단, 값 반환과 부수효과 분리(CQS)만이면 /separate-query-modifier가 적합. /segregate-functional-core [commit-ref]로 호출.

インストール方法を見る

含まれるファイル(2)

  • SKILL.md14.9 KB
  • references/example-ddd-aggregate.md4.7 KB

SKILL.md(原文)

インストールする前に、エージェントに与えられる指示の中身を確認できます。

Segregate Functional Core Skill

I/O 호출이나 Mock collaborator 호출과 순수 계산이 혼재된 메서드를 Impure-Pure-Impure Sandwich(빵속빵) 구조로 분리. Functional Core(순수 로직)와 Imperative Shell(I/O)이 나뉘어 테스트 용이성과 가독성 향상.

GOAL

  • 성공 = I/O/Mock과 계산이 혼재된 메서드가 Functional Core와 Imperative Shell로 분리되어 커밋 완료됨
  • T1(I/O + 계산 혼재) 또는 T2(Mock stub + 계산 혼재) smell 식별
  • Functional Core는 순수 함수로 추출 (입력만 받고 결정/instruction 반환)
  • Imperative Shell은 I/O만 담당 (read → pure → write 순서)
  • Functional Core 테스트는 mock 없이 값 기반으로 작성
  • 모든 기존 테스트 통과

CONSTRAINTS

  • 계열: System-wide — 후보 제시 후 승인 받고 적용 (../../references/refactoring-procedure.md §0·§3-B)

Hard Rules

  • 동작 변경 금지 — 구조 개선만 수행
  • 테스트 수정 금지 — 기존 테스트는 그대로 통과해야 함 (Functional Core 테스트는 추가)
  • 사용자 확인 없이 리팩토링 금지 — 모든 후보는 사용자 승인 후 실행
  • 커밋 단위 — 1파일 x 1기법 = 1커밋 (Shell/Core/새 테스트는 논리적으로 함께 커밋)
  • git add -A 금지 — 변경된 파일만 명시적으로 추가

Functional Core / Imperative Shell 원칙

  • Functional Core: 순수 함수. I/O 호출 없음. mutation 없음. 같은 입력 → 같은 출력
  • Imperative Shell: I/O만 담당 (DB, 파일, 네트워크, 시스템 시간). Functional Core에 데이터를 제공하고, 리턴된 instruction/결정을 실행
  • 구조: Impure(read) → Pure(decide) → Impure(write) — 샌드위치의 빵-속-빵
  • DDD Trilemma: 도메인 모델을 완전한 불변으로 만드는 비용이 지나치게 크면 Mutable Shell 수용. 순수성·완전성·성능 중 둘만 동시 만족 가능

적용 패턴

Segregate Functional Core 리팩토링 단계:

  1. 필요 데이터 식별 — Functional Core가 판단에 필요한 모든 입력을 목록화
  2. 데이터 사전 로드 — Imperative Shell이 함수 시작 전 필요한 데이터를 모두 읽어옴
  3. Functional Core 추출 — 파라미터로 데이터를 받고, 판단 결과 또는 Instruction 객체를 반환하는 순수 static 함수로 추출
  4. Imperative Shell 수렴 — 호출자를 read → pure → write 순서로 재배치
  5. Functional Core 테스트 추가 — Mock 없이 값 기반 테스트로 작성

Before/After 예시 (감사 로그 서비스)

출처: Vladimir Khorikov 『단위 테스트』 + Mark Seemann "Impureim Sandwich"

// Before: I/O + 계산 혼재 (T1) + Mock-heavy test (T2)
public class AuditService {
    private final FilePersister filePersister;  // mock 대상
    private final int maxEntries;

    public AuditService(FilePersister filePersister, int maxEntries) {
        this.filePersister = filePersister;
        this.maxEntries = maxEntries;
    }

    // T1: 읽기(I/O) + 판단(계산) + 쓰기(I/O) 혼재
    public void addRecord(String name, LocalDateTime timestamp) {
        List<Path> files = filePersister.listAuditFiles();            // I/O
        Path current = files.isEmpty() ? null : files.get(files.size() - 1);

        int count = current == null
                ? 0
                : filePersister.readLines(current).size();            // I/O

        String entry = name + ";" + timestamp.toString();

        if (current == null || count >= maxEntries) {                 // 계산
            Path newFile = Paths.get("audit_" + (files.size() + 1) + ".txt");
            filePersister.writeLines(newFile, List.of(entry));        // I/O
        } else {
            filePersister.appendLine(current, entry);                 // I/O
        }
    }
}

// T2: 테스트에서 mock stub이 다수 필요
class AuditServiceTest {
    @Mock FilePersister persister;

    @Test void addRecord_new_file_when_max_entries_reached() {
        when(persister.listAuditFiles())
            .thenReturn(List.of(Paths.get("audit_1.txt")));            // stub 1
        when(persister.readLines(any()))
            .thenReturn(List.of("a;...", "b;...", "c;..."));           // stub 2
        doNothing().when(persister).writeLines(any(), any());          // stub 3
        // ...

        new AuditService(persister, 3).addRecord("d", NOW);

        verify(persister).writeLines(any(), any());                    // verify 1
    }
}
// After: Functional Core / Imperative Shell 분리

// Functional Core: 순수 static 함수. 파일 I/O 없음, mutation 없음.
public final class AuditDecider {
    private AuditDecider() {}

    public static AuditInstruction decide(
            List<Path> existingFiles,
            int currentFileLineCount,
            int maxEntries,
            String name,
            LocalDateTime timestamp) {

        String entry = name + ";" + timestamp.toString();
        boolean needNewFile = existingFiles.isEmpty()
                || currentFileLineCount >= maxEntries;

        if (needNewFile) {
            Path newFile = Paths.get(
                "audit_" + (existingFiles.size() + 1) + ".txt");
            return AuditInstruction.createFile(newFile, entry);
        }
        Path current = existingFiles.get(existingFiles.size() - 1);
        return AuditInstruction.appendTo(current, entry);
    }
}

// Imperative Shell: I/O만. 순수 함수를 호출하고 instruction 실행.
public class AuditService {
    private final FilePersister filePersister;
    private final int maxEntries;

    public void addRecord(String name, LocalDateTime timestamp) {
        // 빵 (Impure: read)
        List<Path> files = filePersister.listAuditFiles();
        int count = files.isEmpty()
                ? 0
                : filePersister.readLines(files.get(files.size() - 1)).size();

        // 속 (Pure: decide)
        AuditInstruction instruction =
                AuditDecider.decide(files, count, maxEntries, name, timestamp);

        // 빵 (Impure: write)
        instruction.execute(filePersister);
    }
}

// Functional Core 테스트는 mock 불필요 — 값 기반
class AuditDeciderTest {
    @Test void create_new_file_when_max_reached() {
        AuditInstruction actual = AuditDecider.decide(
                List.of(Paths.get("audit_1.txt")), 3, 3, "d", NOW);

        assertThat(actual).isEqualTo(
                AuditInstruction.createFile(
                    Paths.get("audit_2.txt"), "d;" + NOW));
    }

    @Test void append_when_below_limit() {
        AuditInstruction actual = AuditDecider.decide(
                List.of(Paths.get("audit_1.txt")), 1, 3, "b", NOW);

        assertThat(actual).isEqualTo(
                AuditInstruction.appendTo(Paths.get("audit_1.txt"), "b;" + NOW));
    }
}

AuditInstruction 값 타입 정의 (Java 17+: sealed interface + record)

public sealed interface AuditInstruction
        permits AuditInstruction.CreateFile, AuditInstruction.AppendTo {

    void execute(FilePersister persister);

    record CreateFile(Path file, String entry) implements AuditInstruction {
        @Override public void execute(FilePersister persister) {
            persister.writeLines(file, List.of(entry));
        }
    }

    record AppendTo(Path file, String entry) implements AuditInstruction {
        @Override public void execute(FilePersister persister) {
            persister.appendLine(file, entry);
        }
    }

    static AuditInstruction createFile(Path file, String entry) {
        return new CreateFile(file, entry);
    }

    static AuditInstruction appendTo(Path file, String entry) {
        return new AppendTo(file, entry);
    }
}

sealed + record를 선택한 이유

  • permits로 variant 집합이 닫혀 있어 Shell에서 switch pattern matching이 exhaustiveness 검증됨 → 새 Instruction 추가 시 컴파일러가 누락을 탐지함
  • record는 equals/hashCode/toString 자동 생성 → Functional Core 테스트에서 assertThat(...).isEqualTo(...) 바로 사용

Java 8+ 호환이 필요한 경우: AuditInstruction을 abstract class 또는 interface로 두고 CreateFile/AppendTo를 일반 class + factory method로 구현. 기능은 동일하지만 pattern matching 이점은 포기.

핵심 변화:

  • addRecord의 제어 흐름이 read → decide → write 3단계로 직선화됨
  • 판단 로직은 AuditDecider.decide로 이동, I/O 의존성 0
  • Mock 없이 값 기반 테스트로 복수의 엣지 케이스를 저비용으로 커버
  • Imperative Shell 테스트는 통합 테스트 1~2개로 축소 가능

DDD Aggregate 맥락 — 예시 2는 references

위 감사 로그 예제는 stateless 한 계산이었다. 대상이 aggregate mutation을 포함하면 (order.applyDiscount(...) 후 save) DDD Trilemma가 개입한다 — 순수성·완전성·성능 중 둘만 동시에 만족할 수 있다.

이때의 처방은 Functional Core가 aggregate를 읽기 전용으로만 참조해 "무엇을 할지" (Decision)를 반환하고, mutation은 Shell이 aggregate 메서드로 위임하는 것이다. 완전한 pure는 아니지만 판단 로직은 mock 없이 테스트할 수 있다. JPA/Hibernate 환경에서 완전 불변 aggregate는 dirty checking과 충돌하므로 순수성 일부를 포기하는 쪽이 맞다.

주문 할인 적용 전체 Before/After와 Trilemma 판단 근거는 이 스킬 디렉터리의 references/example-ddd-aggregate.md를 Read로 읽는다.

적용 기준 (Trigger Smells)

다음 둘 중 하나 이상 감지 시 이 리팩토링을 제안한다.

T1: I/O + 계산 혼재

프로덕션 메서드 내부에 모두 존재:

  • I/O 호출 2회 이상 — Repository.*, *Client.*, Jdbc*, FileReader/Writer, HttpClient, Files.*, 외부 시스템 SDK 호출
  • 분기/루프를 포함한 판단 로직 — if/for/while/switch/복수 조건식

T2: Mock stub + 계산 혼재 (테스트 관점)

대응되는 테스트에서:

  • Mock stub 3개 이상 — when(...).thenReturn(...), given(...).willReturn(...), doReturn/doNothing/doThrow
  • 같은 프로덕션 메서드 안에 순수 계산 로직(분기/루프) 존재

적용하지 말아야 하는 경우

  • 단순 passthrough — I/O 1회 + 결과 반환만 (분리할 가치 없음)
  • 성능상 사전 로드 불가 — 대량 데이터를 Functional Core 앞에서 모두 읽는 비용이 감당 불가
  • 도메인 모델 불변 비용 과다 — DDD Trilemma에서 성능·완전성 대신 순수성을 포기해야 하는 경우
  • 원자적 연산 — read-modify-write가 원자성을 요구 (CAS, 분산 락 등)

OUTPUT FORMAT

실행 절차

공통 골격(대상 파일 수집 → 후보 제시(계열별 승인 규칙) → 적용 → 테스트 → 커밋/되돌리기, 브랜치·PR이 필요한 조건)은 이 스킬 디렉터리 기준 ../../references/refactoring-procedure.md가 정본이다. 아래는 이 기법에 고유한 부분만 규정한다.

Smell 후보 식별 (공통 절차 2단계)

대상 파일에서 T1/T2 smell을 탐지:

T1 탐지:

  • 한 메서드 내 I/O 호출 카운트 (Repository/Client/Jdbc/FileReader/HttpClient/Files 등) ≥ 2
  • 동일 메서드에 if/for/while/switch 존재

T2 탐지:

  • 대응되는 테스트 파일 경로 추정 (src/test/.../ClassNameTest.java)
  • 해당 테스트 클래스에서 when/given/do(Return|Nothing|Throw) 호출 수 ≥ 3
  • 대상 프로덕션 메서드에 분기/루프 존재

이미 순수 함수이거나 I/O만 있는 단순 passthrough는 제외.

후보 제시 예시 (공통 절차 3단계)

후보를 하나씩 제시하고 사용자 확인:

## 리팩토링 후보 1: Segregate Functional Core

**파일**: AuditService.java
**대상**: addRecord() 메서드

**감지된 smell**:
- T1: I/O 호출 3회 (listAuditFiles, readLines, writeLines/appendLine) + if 분기 1회
- T2: AuditServiceTest에서 mock stub 4개 필요

**현재 코드**:
[대상 메서드 전체]

**제안 변경**:
1. Functional Core 신규 클래스 `AuditDecider` (static) 생성
2. `decide(existingFiles, currentFileLineCount, maxEntries, name, timestamp)` 순수 함수
3. `AuditInstruction` 값 타입 도입 — "무엇을 쓸지" 표현 (createFile / appendTo)
4. `AuditService.addRecord`를 [read → decide → write] 3단계로 재배치
5. `AuditDeciderTest` 추가 — mock 없는 값 기반 테스트 (엣지 케이스 다수)

**적용할까요?** (yes / no / 수정 요청)

성능상 모든 데이터 사전 로드가 부담되면 적용하지 말 것.
도메인 모델 불변 비용이 크면 Mutable Shell로 타협 가능.
  • 사용자가 yes → 실행 목록에 추가
  • 사용자가 no → 스킵
  • 사용자가 수정 요청 → 요청 반영 후 재제시

모든 후보 확인 후 최종 실행 목록을 보여주고 진행 여부 확인.

Functional Core / Imperative Shell 분리 실행 (공통 절차 4단계)

확정된 리팩토링을 하나씩 수행:

  1. Functional Core 클래스/함수 추출 (static, 파라미터만으로 동작)
  2. 필요 시 Instruction/Decision Value Object 생성 (record 또는 sealed 타입)
  3. Imperative Shell을 read → pure → write 순서로 재배치
  4. Functional Core 테스트 추가 (mock 없이 값 기반)
  5. 기존 테스트 실행 (gradle test 또는 mvn test)
  6. 통과 확인
  7. 변경된 파일만 명시적으로 git add (Core, Shell, 새 테스트)

커밋 메시지 형식:

refactor: segregate functional core from [원본클래스명].[메서드명]

결과 보고

사용자에게 보고:

  • 적용된 Functional Core 분리 목록 (대상 메서드, 생성된 Core 클래스, Instruction 타입)

FAILURE CONDITIONS

공통 실패 조건(계열별 승인 규칙 위반, 테스트 실패 방치, 테스트 수정, 커밋 단위, git add -A, heredoc 한글 메시지)은 ../../references/refactoring-procedure.md에 있다. 아래는 이 기법에 고유한 것만.

  • Functional Core 내부에 I/O 호출 잔존 (완전한 pure가 아님)
  • Functional Core 내부에서 mutation 발생 (입력 컬렉션 수정 등)
  • Imperative Shell이 여전히 판단 로직 포함 (read → write 사이에 분기 존재)
  • read → pure → write 순서가 어긋남 (중간에 I/O 삽입됨)
  • Functional Core 테스트에 mock 사용 (값 기반이어야 함)
  • DDD Trilemma 무시하고 성능 감당 불가능한 사전 로드 강행
  • 원자성이 필요한 read-modify-write를 분리하여 race condition 유발

レビュー

まだレビューはありません。使ってみた感想をお寄せください。

同じリポジトリのスキル

概要と使いどころ

동일한 결과를 내는 여러 조건문(OR 나열·중첩 AND)을 하나로 통합하고 의미 있는 boolean 메서드로 추출. "조건문 합쳐", "같은 결과 반환하는 if 정리", "중첩 if 평탄화", "/consolidate-conditional" 요청 시 사용. 단, 여러 메서드에 흩어진 동일 조건을 호출자 쪽으로 올리는 것은 /lift-up-conditional, 복잡한 조건식·분기를 메서드로 쪼개는 것은 /decompose-conditional이 적합. /consolidate-conditional [commit-ref]로 호출.

日本語の概要は準備中です。原文の説明を表示しています。

msbaek/msbaek-claude-plugins82026年10月1日 更新

기능의 external behavior를 Cucumber 인수 테스트(주 검증층)로 구축 — .feature 실행으로 문서↔코드 드리프트를 구조적으로 차단, Four Layer(Steps→Protocol Driver→SUT), 태그 기반 가역 제외, 기존 JUnit 인수 테스트 이관. "인수 테스트 도입", "Gherkin을 실행 가능하게", "cucumber 셋업" 요청 시 사용. /cucumber-acceptance로 호출.

日本語の概要は準備中です。原文の説明を表示しています。

msbaek/msbaek-claude-plugins82026年10月1日 更新

복잡한 if/then/else의 조건식과 각 분기를 의미 있는 메서드로 추출하여 가독성 향상. "조건문 분해", "if 가독성", "복잡한 조건식에 이름 붙여", "/decompose-conditional" 요청 시 사용. 단, 같은 결과를 내는 조건문들을 하나로 합치는 것은 /consolidate-conditional, 타입별 분기를 클래스로 바꾸는 것은 /replace-conditional-with-poly가 적합. /decompose-conditional [commit-ref]로 호출.

日本語の概要は準備中です。原文の説明を表示しています。

msbaek/msbaek-claude-plugins82026年10月1日 更新

Primitive Obsession 제거 — 검증·연산이 따라다니는 primitive 필드(금액+통화, 이메일 문자열 등)를 도메인 개념을 담은 Value Object로 치환. "값 객체 도입", "primitive obsession", "Money 클래스로", "/discover-value-object" 요청 시 사용. 단, 함께 전달되는 파라미터 묶음을 객체로 바꾸는 것은 /introduce-parameter-object, 컬렉션을 감싸는 것은 /first-class-collection이 적합. /discover-value-object [commit-ref]로 호출.

日本語の概要は準備中です。原文の説明を表示しています。

msbaek/msbaek-claude-plugins82026年10月1日 更新

컬렉션 getter가 내부 List/Set을 직접 노출하는 것을 방지 — unmodifiable 반환 + add/remove 메서드 제공. "컬렉션 캡슐화", "getter가 List 그대로 노출", "unmodifiable로", "/encapsulate-collection" 요청 시 사용. 단, 컬렉션과 관련 로직을 전용 클래스로 뽑는 것은 /first-class-collection이 적합. /encapsulate-collection [commit-ref]로 호출.

日本語の概要は準備中です。原文の説明を表示しています。

msbaek/msbaek-claude-plugins82026年10月1日 更新

암묵적 의존성(전역 변수·클래스 필드·싱글턴 접근)을 명시적 파라미터로 전환하여 메서드 투명성 향상. "숨은 의존성 드러내", "필드 대신 파라미터로", "전역 참조 제거", "/explicit-parameters" 요청 시 사용. 단, 파라미터가 많아져 묶어야 하면 /introduce-parameter-object, I/O와 계산 분리는 /segregate-functional-core가 적합. /explicit-parameters [commit-ref]로 호출.

日本語の概要は準備中です。原文の説明を表示しています。

msbaek/msbaek-claude-plugins82026年10月1日 更新

msbaek のスキルをすべて見る

このスキルの問題を報告する