A Philosophy of Software Design — John Ousterhout 5
APoSD Ch.8 — 복잡도를 아래로 끌어내려라 (Pull Complexity Downwards)
핵심 요약
모듈은 구현이 단순한 것보다 인터페이스가 단순한 것이 더 중요하다.
모듈 개발자는 사용자의 삶을 최대한 쉽게 만들어야 한다 (자신이 더 고생하더라도)
쉬운 문제만 해결하고 어려운 문제를 다른 사람에게 떠넘기면 복잡도가 증폭된다
한 곳에서 복잡도를 처리하면, 여러 곳에서 반복적으로 처리할 필요가 없다
설정 파라미터의 함정:
클래스 내부에서 결정하는 대신 파라미터로 빼는 것은 복잡도를 사용자에게 떠넘기는 것
"사용자가 나보다 더 적절한 값을 결정할 수 있는가?"를 물어야 한다
정말 설정이 필요하다면, 합리적인 기본값을 제공하라
복잡도를 끌어내려야 하는 경우:
클래스의 기존 기능과 밀접하게 관련되어 있을 때
애플리케이션 전체에서 많은 단순화를 가져올 때
클래스의 인터페이스를 단순화할 때
바이브 코딩 판단 포인트
AI가 생성한 코드에서 복잡도가 위로 올라가는 신호:
🚨 Red Flags (복잡도를 위로 떠넘김)
과도한 설정 파라미터
메서드에 5개 이상의 파라미터
대부분의 경우 같은 값을 전달하는 파라미터
내부에서 충분히 결정할 수 있는 값을 외부에서 받음
호출자에게 전가된 책임
같은 전처리 로직이 여러 호출자에게 반복됨
"이 값은 null일 수 있으니 호출 전에 체크하세요" 주석
호출자가 순서를 지켜야 하는 메서드들
설정 클래스의 남용
XxxConfig, XxxProperties가 과도하게 많음
대부분 값이 비어있거나 기본값인 설정 객체
단순한 동작을 위해 복잡한 빌더 패턴 강요
✅ Green Flags (복잡도를 아래로 끌어내림)
스마트한 기본값
80%의 경우에 적합한 기본값 제공
필요할 때만 오버라이드 가능
내부에서 처리하는 엣지 케이스
null 체크, 빈 컬렉션 처리 등을 내부에서 처리
호출자는 정상 케이스만 신경 쓰면 됨
간결한 인터페이스
메서드 파라미터 3개 이하
직관적인 네이밍으로 추가 문서 불필요
판단 질문
AI가 제시한 코드를 보고 다음을 물어라:
"이 파라미터/설정이 정말 필요한가?"
내부에서 결정할 수 있는가?
사용자가 적절한 값을 알 수 있는가?
대부분의 경우 같은 값을 쓰지 않는가?
"이 예외 처리를 여기서 해야 하는가?"
모든 호출자가 같은 처리를 반복하는가?
모듈 내부에서 처리하면 인터페이스가 단순해지는가?
"이 책임이 여기에 있는 게 맞는가?"
이 기능이 클래스의 기존 목적과 관련있는가?
한 곳에서 처리하면 여러 곳이 단순해지는가?
"사용자가 이 API를 직관적으로 쓸 수 있는가?"
문서를 보지 않아도 쓸 수 있는가?
실수하기 쉬운 부분은 없는가?
Bad vs Good 예시
예시 1: 페이징 처리 서비스
❌ Bad: 복잡도를 호출자에게 떠넘김
// AI가 생성한 과도하게 설정 가능한 서비스
@Service
public class ProductService {
public Page<Product> getProducts(
int pageNumber, // 호출자가 관리
int pageSize, // 호출자가 관리
String sortField, // 호출자가 관리
String sortDirection, // 호출자가 관리
boolean includeDeleted, // 호출자가 관리
boolean fetchEagerly // 호출자가 관리
) {
// pageNumber가 음수면? pageSize가 0이면?
// sortField가 존재하지 않는 필드면?
// 모든 검증을 호출자가 해야 함
Sort sort = Sort.by(
"ASC".equals(sortDirection) ? Sort.Direction.ASC : Sort.Direction.DESC,
sortField
);
Pageable pageable = PageRequest.of(pageNumber, pageSize, sort);
if (includeDeleted) {
return fetchEagerly
? productRepository.findAllWithDetails(pageable)
: productRepository.findAll(pageable);
} else {
return fetchEagerly
? productRepository.findActiveWithDetails(pageable)
: productRepository.findActive(pageable);
}
}
}
// 호출자 코드 - 매번 이 모든 것을 지정해야 함
@RestController
public class ProductController {
@GetMapping("/products")
public Page<Product> list(@RequestParam(defaultValue = "0") int page) {
// 호출자가 모든 복잡도를 떠안음
return productService.getProducts(
page,
20, // 매번 반복
"createdAt", // 매번 반복
"DESC", // 매번 반복
false, // 매번 반복
true // 매번 반복
);
}
@GetMapping("/admin/products")
public Page<Product> adminList(@RequestParam(defaultValue = "0") int page) {
// 같은 코드를 또 작성
return productService.getProducts(
page,
20, // 중복
"createdAt", // 중복
"DESC", // 중복
true, // 유일한 차이
true // 중복
);
}
}
✅ Good: 복잡도를 서비스 안으로 끌어내림
// 스마트한 기본값을 가진 서비스
@Service
public class ProductService {
private static final int DEFAULT_PAGE_SIZE = 20;
private static final Sort DEFAULT_SORT = Sort.by(Sort.Direction.DESC, "createdAt");
// 간단한 케이스를 위한 간결한 인터페이스
public Page<Product> getProducts(int pageNumber) {
return getProducts(pageNumber, false);
}
// 삭제된 항목 포함 여부만 선택 가능 (진짜 필요한 설정만)
public Page<Product> getProducts(int pageNumber, boolean includeDeleted) {
// 검증과 기본값 처리를 내부에서 수행
int safePage = Math.max(0, pageNumber);
Pageable pageable = PageRequest.of(safePage, DEFAULT_PAGE_SIZE, DEFAULT_SORT);
// 복잡한 로직을 내부에서 처리
if (includeDeleted) {
return productRepository.findAllWithDetails(pageable);
} else {
return productRepository.findActiveWithDetails(pageable);
}
}
// 고급 사용자를 위한 오버로드 (선택적)
public Page<Product> getProducts(Pageable pageable, boolean includeDeleted) {
// 커스텀 페이징이 정말 필요한 경우에만 사용
return includeDeleted
? productRepository.findAllWithDetails(pageable)
: productRepository.findActiveWithDetails(pageable);
}
}
// 호출자 코드 - 단순함
@RestController
public class ProductController {
@GetMapping("/products")
public Page<Product> list(@RequestParam(defaultValue = "0") int page) {
return productService.getProducts(page); // 한 줄
}
@GetMapping("/admin/products")
public Page<Product> adminList(@RequestParam(defaultValue = "0") int page) {
return productService.getProducts(page, true); // 의도가 명확
}
}
예시 2: 파일 업로드 처리
❌ Bad: 검증 책임을 호출자에게 떠넘김
// AI가 생성한 "유연한" 파일 업로드 서비스
@Service
public class FileUploadService {
public String uploadFile(
MultipartFile file,
String targetDirectory, // 호출자가 경로 관리
long maxFileSize, // 호출자가 제한 결정
Set<String> allowedTypes, // 호출자가 타입 결정
boolean generateThumbnail, // 호출자가 썸네일 여부 결정
int thumbnailWidth, // 호출자가 크기 결정
int thumbnailHeight // 호출자가 크기 결정
) throws IOException {
// 주석: "호출 전에 파일이 null이 아니고, 크기와 타입을 체크하세요"
// 내부에서는 아무것도 검증하지 않음
String filename = UUID.randomUUID() + "_" + file.getOriginalFilename();
Path targetPath = Paths.get(targetDirectory, filename);
Files.copy(file.getInputStream(), targetPath);
if (generateThumbnail) {
// 썸네일 생성 로직
}
return targetPath.toString();
}
}
// 호출자 - 모든 것을 체크하고 설정해야 함
@RestController
public class ProductController {
@PostMapping("/products/{id}/image")
public ResponseEntity<String> uploadImage(
@PathVariable Long id,
@RequestParam("file") MultipartFile file) {
// 호출자가 모든 검증을 반복
if (file == null || file.isEmpty()) {
return ResponseEntity.badRequest().build();
}
if (file.getSize() > 5 * 1024 * 1024) { // 5MB
return ResponseEntity.status(413).build();
}
String contentType = file.getContentType();
if (contentType == null ||
(!contentType.equals("image/jpeg") &&
!contentType.equals("image/png"))) {
return ResponseEntity.badRequest().build();
}
try {
String path = fileUploadService.uploadFile(
file,
"/var/www/uploads/products", // 매번 반복
5 * 1024 * 1024, // 매번 반복
Set.of("image/jpeg", "image/png"), // 매번 반복
true, // 매번 반복
300, // 매번 반복
300 // 매번 반복
);
return ResponseEntity.ok(path);
} catch (IOException e) {
return ResponseEntity.status(500).build();
}
}
}
✅ Good: 검증과 설정을 서비스 안으로 끌어내림
// 도메인별 특화된 업로드 서비스
@Service
public class ProductImageUploadService {
@Value("${app.upload.product-images.directory:/var/www/uploads/products}")
private String uploadDirectory;
@Value("${app.upload.product-images.max-size:5242880}") // 5MB
private long maxFileSize;
private static final Set<String> ALLOWED_TYPES =
Set.of("image/jpeg", "image/png", "image/webp");
private static final int THUMBNAIL_SIZE = 300;
/**
* 상품 이미지를 업로드합니다.
* 자동으로 검증, 썸네일 생성, 경로 관리를 수행합니다.
*
* @throws InvalidFileException 파일이 유효하지 않은 경우
* @throws FileTooLargeException 파일 크기가 제한을 초과한 경우
*/
public String uploadProductImage(MultipartFile file) {
// 모든 검증을 내부에서 수행
validateFile(file);
try {
String filename = generateFilename(file);
Path targetPath = Paths.get(uploadDirectory, filename);
// 디렉토리 생성도 내부에서 처리
Files.createDirectories(targetPath.getParent());
Files.copy(file.getInputStream(), targetPath);
// 항상 썸네일 생성 (상품 이미지는 항상 필요)
generateThumbnail(targetPath);
return filename; // 절대 경로가 아닌 상대 경로 반환
} catch (IOException e) {
throw new FileUploadException("Failed to upload product image", e);
}
}
private void validateFile(MultipartFile file) {
if (file == null || file.isEmpty()) {
throw new InvalidFileException("File is required");
}
if (file.getSize() > maxFileSize) {
throw new FileTooLargeException(
String.format("File size %d exceeds limit %d",
file.getSize(), maxFileSize)
);
}
String contentType = file.getContentType();
if (contentType == null || !ALLOWED_TYPES.contains(contentType)) {
throw new InvalidFileException(
"File type must be one of: " + ALLOWED_TYPES
);
}
}
private String generateFilename(MultipartFile file) {
String extension = getExtension(file.getOriginalFilename());
return UUID.randomUUID() + extension;
}
private String getExtension(String filename) {
if (filename == null) return ".jpg";
int dotIndex = filename.lastIndexOf('.');
return dotIndex > 0 ? filename.substring(dotIndex) : ".jpg";
}
private void generateThumbnail(Path imagePath) {
// 썸네일 생성 로직 (내부에서 자동 처리)
}
}
// 호출자 - 극도로 단순함
@RestController
public class ProductController {
@PostMapping("/products/{id}/image")
public ResponseEntity<String> uploadImage(
@PathVariable Long id,
@RequestParam("file") MultipartFile file) {
// 한 줄로 끝. 모든 복잡도는 서비스가 처리
String filename = productImageUploadService.uploadProductImage(file);
return ResponseEntity.ok(filename);
}
}
// 예외 처리도 통합된 곳에서
@RestControllerAdvice
public class FileUploadExceptionHandler {
@ExceptionHandler(InvalidFileException.class)
public ResponseEntity<ErrorResponse> handleInvalidFile(InvalidFileException e) {
return ResponseEntity.badRequest()
.body(new ErrorResponse("INVALID_FILE", e.getMessage()));
}
@ExceptionHandler(FileTooLargeException.class)
public ResponseEntity<ErrorResponse> handleFileTooLarge(FileTooLargeException e) {
return ResponseEntity.status(413)
.body(new ErrorResponse("FILE_TOO_LARGE", e.getMessage()));
}
}
예시 3: 리트라이 로직
❌ Bad: 모든 호출자가 리트라이 구현
@Service
public class ExternalApiClient {
// "네트워크 오류 시 재시도하세요" 라는 주석만 있음
public OrderResponse createOrder(OrderRequest request) {
return restTemplate.postForObject(
apiUrl + "/orders",
request,
OrderResponse.class
);
}
}
// 모든 호출자가 리트라이 로직을 직접 구현
@Service
public class OrderService {
public Order placeOrder(OrderRequest request) {
int maxRetries = 3;
int retryCount = 0;
Exception lastException = null;
while (retryCount < maxRetries) {
try {
OrderResponse response = externalApiClient.createOrder(request);
return convertToOrder(response);
} catch (RestClientException e) {
lastException = e;
retryCount++;
if (retryCount < maxRetries) {
try {
Thread.sleep(1000 * retryCount); // exponential backoff
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
throw new RuntimeException(ie);
}
}
}
}
throw new OrderPlacementException("Failed after " + maxRetries + " retries",
lastException);
}
}
✅ Good: 리트라이를 클라이언트 안으로 끌어내림
@Service
public class ExternalApiClient {
private static final int MAX_RETRIES = 3;
private static final long INITIAL_BACKOFF_MS = 1000;
/**
* 주문을 생성합니다. 네트워크 오류 시 자동으로 재시도합니다.
*/
public OrderResponse createOrder(OrderRequest request) {
return executeWithRetry(() ->
restTemplate.postForObject(
apiUrl + "/orders",
request,
OrderResponse.class
)
);
}
private <T> T executeWithRetry(Supplier<T> operation) {
Exception lastException = null;
for (int attempt = 0; attempt < MAX_RETRIES; attempt++) {
try {
return operation.get();
} catch (RestClientException e) {
lastException = e;
if (attempt < MAX_RETRIES - 1) {
sleep(INITIAL_BACKOFF_MS * (attempt + 1));
} else {
break; // 마지막 시도 후에는 더 이상 재시도하지 않음
}
}
}
throw new ExternalApiException(
"API call failed after " + MAX_RETRIES + " attempts",
lastException
);
}
private void sleep(long millis) {
try {
Thread.sleep(millis);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException("Retry interrupted", e);
}
}
}
// 호출자는 리트라이를 신경쓰지 않음
@Service
public class OrderService {
public Order placeOrder(OrderRequest request) {
// 한 줄로 끝. 리트라이는 자동으로 처리됨
OrderResponse response = externalApiClient.createOrder(request);
return convertToOrder(response);
}
}
한 줄 판단 기준
AI 코드를 보고 "이거 호출하려면 뭘 준비해야 하지?"가 머릿속에 떠오르면 Bad.
파라미터가 3개를 넘어가면 의심하라
같은 값을 여러 곳에서 전달하면 끌어내려라
"사용 전에 X를 체크하세요" 주석이 보이면 끌어내려라
호출자 코드가 서비스 코드보다 길면 잘못된 것
Good 코드는 "그냥 쓰면 된다"는 느낌을 준다.
댓글
댓글이 없습니다.
