블라디미르 코리코프 님의 "단위 테스트" 책을 정리한 포스팅입니다.
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. 함수형 아키텍처와 출력 기반 테스트로의 전환
단계
- 프로세스 외부 의존성에서 목(Mock)으로 변경
- 직접 파일 접근 대신 인터페이스로 추상화
- 목(Mock)에서 함수형 아키텍처로 전환
- 의존성과 부작용을 외부로 완전히 밀어냄
- 상태/통신 기반 테스트에서 출력 기반 테스트로 전환
- 함수형 코어는 순수함수처럼 동작, 테스트는 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());
}
}
- 출력 기반 테스트로 변환하기
- 유지보수성과 가독성을 높임
'Code > Test' 카테고리의 다른 글
[단위 테스트] 7-2. 가치 있는 단위 테스트를 위한 리팩터링: 감사 시스템 (0) | 2025.02.28 |
---|---|
[단위 테스트] 7-1. 가치 있는 단위 테스트를 위한 리팩터링: 코드 유형 (0) | 2025.02.27 |
[단위 테스트] 6-1. 단위 테스트 스타일: 단위 테스트 스타일 (0) | 2025.01.25 |
[단위 테스트] 5-2. 목과 테스트 취약성: 통신 (1) | 2025.01.24 |
[단위 테스트] 5-1. 목과 테스트 취약성: 목 (0) | 2025.01.24 |