Code/Test

[단위 테스트] 6-2. 단위 테스트 스타일: 함수형 아키텍처

noahkim_ 2025. 1. 29. 14:55

블라디미르 코리코프 님의 "단위 테스트" 책을 정리한 포스팅입니다.


1. 함수형 아키텍처 이해

함수형 프로그래밍

구분 설명 예시 / 비고
정의 수학적 함수를 사용한 프로그래밍 방식 숨은 입출력이 없음
핵심 특성 - 메서드 시그니처에 모든 입출력이 명시됨
- 동일 입력 → 동일 출력 (호출 횟수 상관 ❌)
- 상태 변화
순수 함수
참조 투명성 어떤 표현식이 항상 같은 값으로 대체될 수 있는 성질
테스트 용이 (리팩토링 내성 강함)

 

예시) 순수 함수

더보기
// 순수 함수 - 함수형 프로그래밍의 핵심
public int add(int a, int b) {
    return a + b;
}

 

숨은 입출력 

  • 메서드의 시그니처에는 명시되지 않지만, 외부 상태에 영향을 주거나 영향을 받는 동작들.
유형 예시 설명 및 문제점
부작용 (출력) file.write()
this.count++
외부나 내부 상태 변경.
- 메서드가 불변이 아님에도 불구하고 시그니처에 드러나지 않음.
예외 (출력) throw new RuntimeException()
시그니처에 예외가 없지만 실행 흐름 변경.
- 클라이언트 코드가 어떻게 반응해야 하는지 명시되지 않음.
외부 상태 참조 (입력) System.getenv()
DateTime.Now
StaticConfig.get()DB.read()
외부에서 값을 읽어옴.
- 테스트에서 입력을 제어하기 어려워짐.
- 동일한 입력으로도 결과가 불안정해질 수 있음.

 

예시) 부작용

더보기
// 숨은 출력 (부작용)
public void logResult(int result) {
    System.out.println("결과: " + result); // 콘솔 출력은 숨은 출력
}

 

함수형 아키텍처

  • 비즈니스 로직과 부작용을 일으키는 코드를 분리한 아키텍처
  • 부작용을 처리하는 코드를 로직의 끝으로 미룸
구성 요소 설명 예시 / 비고
함수형 코어 (Core) - 순수 함수로만 구성
- 입력 → 결정만 수행
- 비즈니스 로직 담당
- 테스트 용이
- 참조 투명성 유지
가변 셸 (Shell) - 부작용 처리 담당
- 입력 수집 / 출력 실행
- 외부 시스템과의 통신
- 외부 API, 파일 시스템, DB
- 통합 테스트 대상으로 분리

 

전략 효과
부작용을 줄이고, 순수 함수의 비율을 높임 테스트 코드량 감소
로직 재사용성 향상
부작용은 시스템 경계에서만 수행
시스템의 핵심은 안정적으로 리팩토링 가능
Core는 출력 기반 테스트 가능 Shell은 소수의 통합 테스트로 커버 가능

 

장점
항목 설명
테스트 용이성
Core는 단순한 출력 기반 테스트로 커버 가능
유지보수성 향상
부작용이 고립되어 있으므로, 기능 변경이 안전함
안정적 리팩토링
순수 함수 기반이므로 구조 변경 시 테스트로 신뢰 확보 용이

 

단점
단점 설명 해결 전략
숨은 입력 Shell에서 Core로 넘기는 입력 외에도 도중에 외부 정보가 필요할 수 있음
- Shell에서 모든 입력을 수집
- CanExecute / Execute 패턴 사용
불필요한 외부 호출  모든 파일/DB를 읽은 후 결정 → 쓰는 패턴으로 I/O 증가 가능
- 캐싱, 메모리 최적화 필요
- 고정된 입력은 메모리에서 유지
초기 구현 복잡성 코어/셸의 분리로 클래스나 함수 수 증가
- 초기는 복잡하지만, 장기적으로 확장성과 유지보수성 개선

 

예시

더보기

함수형 코어

public class LoyaltyService {
    // 순수 함수: 비즈니스 로직
    public int calculatePoints(int amountSpent) {
        return amountSpent / 10;
    }
}

 

가변 셸

public class LoyaltyApp {
    private final LoyaltyService service = new LoyaltyService();

    public void processPurchase(String customerId, int amountSpent) {
        // 순수 로직 호출
        int points = service.calculatePoints(amountSpent);

        // 부작용: DB 업데이트
        updatePointsInDatabase(customerId, points);
    }

    private void updatePointsInDatabase(String customerId, int points) {
        // DB 저장은 부작용
        System.out.println("DB 저장: " + customerId + " -> " + points + " 포인트");
        // 실제로는 JDBC나 JPA 사용
    }
}

 

단점) 숨은 입력

더보기

public boolean canAccess(String userId) {
    // 숨은 입력: 데이터베이스에서 접근레벨을 내부적으로 조회함
    int level = accessLevelRepository.getAccessLevel(userId);
    return level > 5;
}

 

public boolean canAccess(int accessLevel) {
    // 모든 입력을 인자로 받음 (숨은 입력 제거)
    return accessLevel > 5;
}

// 호출부
int accessLevel = accessLevelRepository.getAccessLevel(userId);
boolean result = canAccess(accessLevel);

 

단점) 외부 의존성 호출 증가

더보기

public void processDirectory(Path dir) throws IOException {
    Files.list(dir)
         .filter(file -> file.toString().endsWith(".log"))
         .forEach(file -> {
             // 매 파일마다 외부 API 호출
             String analysis = externalApi.analyzeLog(file);
             System.out.println("분석 결과: " + analysis);
         });
}

 

✅ 외부 호출 최소화 (캐싱 활용)

public void processDirectory(Path dir) throws IOException {
    List<Path> logFiles = Files.list(dir)
                                .filter(file -> file.toString().endsWith(".log"))
                                .toList();

    String combinedResult = externalApi.analyzeLogs(logFiles); // 한번만 호출
    System.out.println("분석 결과: " + combinedResult);
}

 

함수형 아키텍처 vs 육각형 아키텍처

구분 함수형 아키텍처 육각형 아키텍처
공통점
외부 계층과의 격리
단방향 의존성 흐름 유지
외부 계층과의 격리
단방향 의존성 흐름 유지
도메인 계층은 상위 계층에 의존하지 않음 도메인 계층은 상위 계층에 의존하지 않음
불변 코어는 가변 셸에 의존하지 않음
도메인 코어는 인프라스트럭처나 외부 시스템에 의존하지 않음
부작용 처리 모든 부작용은 가변 셸로 완전히 밀어냄
(코어는 철저히 순수)
도메인 내부에서 일부 부작용 허용
(상태 변경, 객체 생성 등)
코어의 성격 순수 함수로 구성된 비즈니스 로직만 포함 상태 변화와 비즈니스 로직이 함께 존재할 수 있음
입출력 처리 입력과 출력을 코어 바깥에서 처리 입출력은 포트와 어댑터를 통해 도메인과 연결됨

 

2. 함수형 아키텍처와 출력 기반 테스트로의 전환

단계

  1. 프로세스 외부 의존성에서 목(Mock)으로 변경
    • 직접 파일 접근 대신 인터페이스로 추상화
  2. 목(Mock)에서 함수형 아키텍처로 전환
    • 의존성과 부작용을 외부로 완전히 밀어냄
  3. 상태/통신 기반 테스트에서 출력 기반 테스트로 전환
    • 함수형 코어는 순수함수처럼 동작, 테스트는 I/O 없이 검증 가능

 

감사 시스템

  • 모든 방문자를 추적하는 감사 시스템
  • 텍스트 파일 기반의 저장소 사용
    • 가장 최근 파일의 마지막 줄에 방문자 이름과 방문 시간을 추가
    • 파일당 최대 항목 수에 도달하면 인덱스를 증가시켜 새 파일을 작성함

 

초기 버전
구성 설명
핵심 클래스
AuditManager
- 파일 시스템에 직접 접근
문제점
(단위 테스트 요건 불만족)
빠른 수행 ❌
독립적인 수행
- 공유 의존성 존재 (파일 시스템)
- 다른 테스트와 별도로 처리

 

코드

더보기
public class AuditManager {
    private final int maxEntriesPerFile;
    private final String directoryName;
    private final DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss");

    public AuditManager(int maxEntriesPerFile, String directoryName) {
        this.maxEntriesPerFile = maxEntriesPerFile;
        this.directoryName = directoryName;
    }

    public void addRecord(String visitorName, LocalDateTime timeOfVisit) throws IOException {
        File dir = new File(directoryName);
        if (!dir.exists()) {
            dir.mkdirs();
        }

        File[] files = dir.listFiles((d, name) -> name.startsWith("audit_") && name.endsWith(".txt"));
        List<File> sorted = sortByIndex(files != null ? Arrays.asList(files) : new ArrayList<>());

        String newRecord = visitorName + ";" + timeOfVisit.format(formatter);

        if (sorted.isEmpty()) {
            Path newFile = Paths.get(directoryName, "audit_1.txt");
            Files.writeString(newFile, newRecord);
            return;
        }

        File currentFile = sorted.get(sorted.size() - 1);
        List<String> lines = Files.readAllLines(currentFile.toPath());

        if (lines.size() < maxEntriesPerFile) {
            lines.add(newRecord);
            Files.write(currentFile.toPath(), lines);
        } else {
            int currentIndex = getIndex(currentFile.getName());
            String newFileName = "audit_" + (currentIndex + 1) + ".txt";
            Path newFile = Paths.get(directoryName, newFileName);
            Files.writeString(newFile, newRecord);
        }
    }

    private List<File> sortByIndex(List<File> files) {
        return files.stream()
                .sorted(Comparator.comparingInt(f -> getIndex(f.getName())))
                .collect(Collectors.toList());
    }

    private int getIndex(String filename) {
        try {
            String number = filename.replace("audit_", "").replace(".txt", "");
            return Integer.parseInt(number);
        } catch (NumberFormatException e) {
            return -1;
        }
    }
}
  • 작업 디렉토리에서 전체 파일 목록을 검색함
  • 인덱스별로 정렬
  • 아직 감사 파일이 없으면 단일 레코드로 첫 번째 파일 생성
  • 감사 파일이 있으면 최신 파일을 가져와서 파일의 항목 수가 한계에 도달했는지에 따라 새 레코드를 추가하거나 새 파일 생성

 

목 기반 버전
구성 설명
핵심 변경
파일 시스템 접근을 IFileSystem 인터페이스로 추상화
장점
테스트 시 모킹 가능
빠르고 안정적인 테스트 가능 (파일 I/O 제거)
테스트 예시
fileSystemMock.Verify(...)로 부작용 검증

 

코드

더보기
public interface FileSystem {
    List<String> getFiles(String directoryName) throws IOException;
    List<String> readAllLines(String filePath) throws IOException;
    void writeAllText(String filePath, String content) throws IOException;
}
public class LocalFileSystem implements FileSystem {
    @Override
    public List<String> getFiles(String directoryName) throws IOException {
        File dir = new File(directoryName);
        if (!dir.exists()) return Collections.emptyList();
        return Arrays.stream(Objects.requireNonNull(dir.listFiles((d, name) -> name.startsWith("audit_") && name.endsWith(".txt"))))
                     .map(File::getPath)
                     .collect(Collectors.toList());
    }

    @Override
    public List<String> readAllLines(String filePath) throws IOException {
        return Files.readAllLines(Paths.get(filePath));
    }

    @Override
    public void writeAllText(String filePath, String content) throws IOException {
        Files.writeString(Paths.get(filePath), content);
    }
}
public class AuditManager {
    private final int maxEntriesPerFile;
    private final String directoryName;
    private final FileSystem fileSystem;
    private final DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss");

    public AuditManager(int maxEntriesPerFile, String directoryName, FileSystem fileSystem) {
        this.maxEntriesPerFile = maxEntriesPerFile;
        this.directoryName = directoryName;
        this.fileSystem = fileSystem;
    }

    public void addRecord(String visitorName, LocalDateTime timeOfVisit) throws IOException {
        List<String> filePaths = fileSystem.getFiles(directoryName);
        List<FileEntry> sorted = sortByIndex(filePaths);

        String newRecord = visitorName + ";" + timeOfVisit.format(formatter);

        if (sorted.isEmpty()) {
            String newFile = Paths.get(directoryName, "audit_1.txt").toString();
            fileSystem.writeAllText(newFile, newRecord);
            return;
        }

        FileEntry last = sorted.get(sorted.size() - 1);
        List<String> lines = fileSystem.readAllLines(last.path);

        if (lines.size() < maxEntriesPerFile) {
            lines.add(newRecord);
            fileSystem.writeAllText(last.path, String.join("\r\n", lines));
        } else {
            int newIndex = last.index + 1;
            String newName = "audit_" + newIndex + ".txt";
            String newFile = Paths.get(directoryName, newName).toString();
            fileSystem.writeAllText(newFile, newRecord);
        }
    }

    private List<FileEntry> sortByIndex(List<String> filePaths) {
        return filePaths.stream()
                .map(path -> new FileEntry(getIndexFromFilename(path), path))
                .sorted(Comparator.comparingInt(entry -> entry.index))
                .toList();
    }

    private int getIndexFromFilename(String path) {
        String name = Paths.get(path).getFileName().toString();
        try {
            return Integer.parseInt(name.replace("audit_", "").replace(".txt", ""));
        } catch (NumberFormatException e) {
            return -1;
        }
    }

    private static class FileEntry {
        int index;
        String path;

        FileEntry(int index, String path) {
            this.index = index;
            this.path = path;
        }
    }
}

 

테스트

더보기
class AuditManagerTest {

    @Test
    void aNewFileIsCreatedWhenTheCurrentFileOverflows() throws IOException {
        // Arrange
        FileSystem fileSystemMock = mock(FileSystem.class);
        
        when(fileSystemMock.getFiles("audits")).thenReturn(List.of(
                "audits/audit_1.txt",
                "audits/audit_2.txt"
        ));
        
        when(fileSystemMock.readAllLines("audits/audit_2.txt")).thenReturn(List.of(
                "Peter; 2019-04-06T16:30:00",
                "Jane; 2019-04-06T16:30:00",
                "Jack; 2019-04-06T16:30:00"
        ));
        
        AuditManager sut = new AuditManager(3, "audits", fileSystemMock);

        // Act
        sut.addRecord("Alice", LocalDateTime.parse("2019-04-06T16:30:00"));

        // Assert
        verify(fileSystemMock).writeAllText(
                eq("audits/audit_3.txt"),
                eq("Alice; 2019-04-06T16:30:00")
        );
    }
}

 

  • 파일 시스템과의 통신이 애플리케이션의 부작용을 일으키는 동작이기 때문에, 이를 인터페이스로 추상화하여 테스트에서 실제 파일 시스템과의 상호작용을 숨기고 모킹을 사용하여 부작용 없이 로직만 테스트할 수 있게 합니다.
  • 이렇게 하면 애플리케이션의 테스트가 외부 시스템에 의존하지 않게 되어 테스트가 더 빠르고 독립적으로 실행될 수 있습니다.

 

 

함수형 아키텍처 기반 리팩터링
구성 설명
함수형 코어 AuditManager는 오직 "무엇을 해야 하는지"만 결정함 (입출력 직접 수행 ❌)
불변 데이터
FileContent[], FileUpdate 등
가변 셸
Persister
- 실제 파일 시스템과의 상호작용 담당
서비스 계층
ApplicationService
- 코어와 셸을 연결
테스트 용이성
AuditManager
- 출력 기반 테스트
- 순수함수처럼 테스트 가능 

 

코드

더보기

함수형 코어

public class AuditManager {
    private final int maxEntriesPerFile;

    public AuditManager(int maxEntriesPerFile) {
        this.maxEntriesPerFile = maxEntriesPerFile;
    }

    public FileUpdate addRecord(FileContent[] files, String visitorName, Date timeOfVisit) {
        List<Pair<Integer, String>> sorted = sortByIndex(files);

        String newRecord = visitorName + ";" + timeOfVisit;

        if (sorted.isEmpty()) {
            return new FileUpdate("audit_1.txt", newRecord);
        }

        Pair<Integer, String> lastFile = sorted.get(sorted.size() - 1);
        String currentFilePath = lastFile.getRight();
        List<String> lines = readAllLines(currentFilePath);

        if (lines.size() < maxEntriesPerFile) {
            lines.add(newRecord);
            String newContent = String.join("\r\n", lines);
            return new FileUpdate(lastFile.getRight(), newContent);
        } else {
            int newIndex = lastFile.getLeft() + 1;
            String newFile = "audit_" + newIndex + ".txt";
            return new FileUpdate(newFile, newRecord);
        }
    }

    private List<Pair<Integer, String>> sortByIndex(FileContent[] files) {
        List<Pair<Integer, String>> sorted = new ArrayList<>();
        for (FileContent file : files) {
            int index = Integer.parseInt(file.getFileName().split("_")[1].split("\\.")[0]);
            sorted.add(new Pair<>(index, file.getFileName()));
        }
        sorted.sort(Comparator.comparing(Pair::getLeft));
        return sorted;
    }

    private List<String> readAllLines(String filePath) {
        try {
            return Files.readAllLines(Paths.get(filePath));
        } catch (IOException e) {
            e.printStackTrace();
            return new ArrayList<>();
        }
    }
}

 

  • 부작용을 클래스 외부로 완전히 이동하기
  • AuditManager는 파일에 수행할 작업을 둘러싼 결정만 책임지게 됨

 

가변 셸

public class Persister {
    public FileContent[] readDirectory(String directoryName) {
        File dir = new File(directoryName);
        File[] files = dir.listFiles();
        if (files == null) {
            return new FileContent[0];
        }

        return Arrays.stream(files)
                .filter(file -> file.isFile())
                .map(file -> new FileContent(file.getName(), readAllLines(file)))
                .toArray(FileContent[]::new);
    }

    private String[] readAllLines(File file) {
        try {
            return Files.readAllLines(file.toPath()).toArray(new String[0]);
        } catch (IOException e) {
            e.printStackTrace();
            return new String[0];
        }
    }

    public void applyUpdate(String directoryName, FileUpdate update) {
        Path filePath = Paths.get(directoryName, update.getFileName());
        try {
            Files.write(filePath, update.getNewContent().getBytes());
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

 

애플리케이션 서비스

public class ApplicationService {
    private final String directoryName;
    private final AuditManager auditManager;
    private final Persister persister;

    public ApplicationService(String directoryName, int maxEntriesPerFile) {
        this.directoryName = directoryName;
        this.auditManager = new AuditManager(maxEntriesPerFile);
        this.persister = new Persister();
    }

    public void addRecord(String visitorName, Date timeOfVisit) {
        FileContent[] files = persister.readDirectory(directoryName);
        FileUpdate update = auditManager.addRecord(files, visitorName, timeOfVisit);
        persister.applyUpdate(directoryName, update);
    }
}

 

 

테스트

더보기
public class AuditManagerTest {

    @Test
    public void testNewFileIsCreatedWhenCurrentFileOverflows() {
        AuditManager auditManager = new AuditManager(3);
        
        FileContent[] files = new FileContent[]{
            new FileContent("audit_1.txt", new String[]{}),
            new FileContent("audit_2.txt", new String[]{
                "Peter; 2019-04-06T16:30:00",
                "Jane; 2019-04-06T16:30:00",
                "Jack; 2019-04-06T16:30:00"
            }),
        };
        
        FileUpdate update = auditManager.addRecord(files, "Alice", new Date("2019/04/06 18:00:00"));

        assertEquals("audit_3.txt", update.getFileName());
        assertEquals("Alice;2019-04-06T18:00:00", update.getNewContent());
    }
}
  • 출력 기반 테스트로 변환하기
  • 유지보수성과 가독성을 높임