A Philosophy of Software Design — John Ousterhout 7
APoSD Ch.10 — 에러를 정의에서 제거하라 (Define Errors Out of Existence)
핵심 요약
예외 처리는 소프트웨어 시스템에서 복잡도의 최악의 원천 중 하나다. 예외를 줄이는 최선의 방법은 예외를 처리해야 하는 장소의 수를 줄이는 것이다.
4가지 전략:
Define errors out of existence — API를 예외가 발생하지 않도록 설계
Mask exceptions — 저수준에서 예외를 처리해 상위 레벨이 알 필요 없게 함 (complexity를 아래로)
Exception aggregation — 여러 예외를 단일 핸들러로 처리
Crash application — 특정 에러는 처리할 가치가 없음
핵심 원칙:
예외는 인터페이스의 일부다. 간단하게 유지하라.
불필요한 예외를 정의하지 마라 (over-defensive coding 금지).
예외를 호출자에게 떠넘기는 것은 문제를 회피하는 것이다.
예외 처리 코드는 정상 케이스 코드보다 작성하기 어렵고, 거의 실행되지 않아 버그 위험이 높다.
바이브 코딩 판단 포인트
AI가 과도한 예외 처리 코드를 생성하는 이유
AI는 방어적 코딩(defensive coding)을 과도하게 적용하는 경향이 있다:
모든 가능한 에러 케이스를 예상하고 처리 코드를 생성
각 메서드마다 try-catch 블록 추가
커스텀 예외를 남발 (UserNotFoundException, InvalidPasswordException, ...)
null 체크를 도처에 삽입
validation 로직을 중복으로 여러 레이어에 배치
이는 코드를 읽기 어렵게 만들고, 정상 흐름(normal flow)을 가리며, 실제로 처리가 필요한 예외를 찾기 어렵게 한다.
판단해야 할 코드 패턴
🚨 Red Flag (AI가 자주 생성하는 나쁜 패턴)
// 1. 메서드마다 try-catch 중첩
public void processOrder(Long orderId) {
try {
Order order = orderRepository.findById(orderId)
.orElseThrow(() -> new OrderNotFoundException(orderId));
try {
User user = userRepository.findById(order.getUserId())
.orElseThrow(() -> new UserNotFoundException(order.getUserId()));
try {
paymentService.charge(user, order.getAmount());
} catch (PaymentFailedException e) {
log.error("Payment failed", e);
throw new OrderProcessingException("Payment failed", e);
}
} catch (UserNotFoundException e) {
log.error("User not found", e);
throw new OrderProcessingException("User not found", e);
}
} catch (OrderNotFoundException e) {
log.error("Order not found", e);
throw new OrderProcessingException("Order not found", e);
}
}
// 2. 커스텀 예외 남발
public class UserNotFoundException extends RuntimeException { }
public class OrderNotFoundException extends RuntimeException { }
public class InvalidOrderStateException extends RuntimeException { }
public class PaymentFailedException extends RuntimeException { }
public class InsufficientBalanceException extends RuntimeException { }
// ... 20개 더
// 3. 불필요한 null 체크 중복
public void updateUser(UserDto dto) {
if (dto == null) {
throw new IllegalArgumentException("DTO cannot be null");
}
if (dto.getId() == null) {
throw new IllegalArgumentException("ID cannot be null");
}
if (dto.getName() == null || dto.getName().isEmpty()) {
throw new IllegalArgumentException("Name cannot be null or empty");
}
// ... 10개 더
User user = userRepository.findById(dto.getId())
.orElseThrow(() -> new UserNotFoundException(dto.getId()));
// 위에서 이미 null 체크했는데 또...
}
✅ Good (간결하고 판단 가능한 패턴)
// 1. Exception aggregation with @ControllerAdvice
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(EntityNotFoundException.class)
public ResponseEntity<ErrorResponse> handleNotFound(EntityNotFoundException e) {
return ResponseEntity.status(NOT_FOUND)
.body(new ErrorResponse(e.getMessage()));
}
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<ErrorResponse> handleBadRequest(IllegalArgumentException e) {
return ResponseEntity.badRequest()
.body(new ErrorResponse(e.getMessage()));
}
}
// 2. Define errors out of existence with Optional
public void processOrder(Long orderId) {
Order order = orderRepository.findById(orderId)
.orElseThrow(() -> new EntityNotFoundException("Order not found: " + orderId));
User user = userRepository.findById(order.getUserId())
.orElseThrow(() -> new EntityNotFoundException("User not found: " + order.getUserId()));
paymentService.charge(user, order.getAmount());
// PaymentService 내부에서 예외를 처리하고 실패 시 명확한 예외를 던짐
}
// 3. Sane defaults (에러 정의 제거)
public class Configuration {
private int maxRetries = 3; // null이 아닌 기본값
private Duration timeout = Duration.ofSeconds(30); // null이 아닌 기본값
// null 체크 불필요 - 항상 유효한 값 보장
public int getMaxRetries() {
return maxRetries;
}
}
// 4. Mask exceptions (복잡도를 아래로)
public class CacheService {
public Optional<User> getUserFromCache(Long userId) {
try {
return Optional.ofNullable(redisTemplate.opsForValue().get("user:" + userId));
} catch (RedisConnectionException e) {
log.warn("Redis unavailable, returning empty", e);
return Optional.empty(); // 상위 레벨은 캐시 실패를 몰라도 됨
}
}
}
판단 질문
AI가 생성한 예외 처리 코드를 볼 때 스스로에게 물어라:
이 예외를 호출자가 정말 처리할 수 있는가?
NO → 예외를 aggregation하거나 crash 시켜라
YES → 정말? 로그만 찍고 rethrow하는 건 아닌가?
이 커스텀 예외가 정말 필요한가?
표준 예외(IllegalArgumentException, IllegalStateException)로 충분하지 않은가?
예외 메시지로 구분 가능하지 않은가?
이 null 체크가 여러 곳에서 중복되고 있는가?
API 설계를 바꿔서 null이 들어올 수 없게 만들 수 있는가?
Optional, @NonNull, validation 프레임워크로 대체 가능한가?
try-catch 블록이 정상 로직을 가리고 있는가?
정상 케이스가 한눈에 보이는가?
예외 처리가 비즈니스 로직보다 더 많은 줄을 차지하는가?
이 예외 처리 코드가 실제로 테스트되었는가?
예외 케이스는 거의 발생하지 않아 버그가 숨어 있기 쉽다
테스트 없이 "안전해 보이는" 코드는 위험하다
Bad vs Good 예시
예시 1: 사용자 조회 및 업데이트
❌ Bad — AI가 생성한 과도한 예외 처리
@Service
public class UserService {
public void updateUserEmail(Long userId, String newEmail) {
// 불필요한 null 체크 중복
if (userId == null) {
throw new IllegalArgumentException("User ID cannot be null");
}
if (newEmail == null || newEmail.trim().isEmpty()) {
throw new IllegalArgumentException("Email cannot be null or empty");
}
if (!newEmail.contains("@")) {
throw new InvalidEmailFormatException("Invalid email format");
}
// 불필요한 try-catch
User user;
try {
user = userRepository.findById(userId)
.orElseThrow(() -> new UserNotFoundException("User not found: " + userId));
} catch (UserNotFoundException e) {
log.error("Failed to find user", e);
throw new UserServiceException("Cannot update email - user not found", e);
}
// 또 다른 불필요한 try-catch
try {
user.setEmail(newEmail);
userRepository.save(user);
} catch (DataAccessException e) {
log.error("Failed to save user", e);
throw new UserServiceException("Failed to update email", e);
}
// 이메일 전송 실패를 과도하게 처리
try {
emailService.sendEmailChangeNotification(user);
} catch (EmailSendException e) {
log.error("Failed to send notification", e);
// 이메일 전송 실패는 치명적이지 않은데도 rollback 시도
throw new UserServiceException("Email updated but notification failed", e);
}
}
}
// 커스텀 예외 남발
public class UserNotFoundException extends RuntimeException { }
public class InvalidEmailFormatException extends RuntimeException { }
public class UserServiceException extends RuntimeException { }
public class EmailSendException extends RuntimeException { }
✅ Good — 간결하고 명확한 처리
@Service
public class UserService {
public void updateUserEmail(Long userId, String newEmail) {
// validation은 Bean Validation으로 이미 처리됨
User user = userRepository.findById(userId)
.orElseThrow(() -> new EntityNotFoundException("User not found: " + userId));
user.setEmail(newEmail);
userRepository.save(user); // 실패 시 Spring이 DataAccessException 던짐
// 이메일 전송 실패는 mask - 상위 레벨이 알 필요 없음
emailService.sendEmailChangeNotification(user); // 내부에서 예외 처리
}
}
@RestControllerAdvice
public class GlobalExceptionHandler {
// Exception aggregation - 모든 EntityNotFoundException을 한 곳에서 처리
@ExceptionHandler(EntityNotFoundException.class)
public ResponseEntity<ErrorResponse> handleNotFound(EntityNotFoundException e) {
return ResponseEntity.status(NOT_FOUND).body(new ErrorResponse(e.getMessage()));
}
}
차이점:
10개 커스텀 예외 → 1개 표준 예외 (EntityNotFoundException)
4개 try-catch 블록 → 0개
20줄 null 체크 → Bean Validation으로 이동
비즈니스 로직이 명확하게 보임
예시 2: 결제 처리
❌ Bad — 과도한 방어적 코딩
@Service
public class PaymentService {
public PaymentResult processPayment(PaymentRequest request) {
// 중첩된 validation 지옥
if (request == null) {
throw new InvalidPaymentRequestException("Request cannot be null");
}
if (request.getAmount() == null) {
throw new InvalidAmountException("Amount cannot be null");
}
if (request.getAmount().compareTo(BigDecimal.ZERO) <= 0) {
throw new InvalidAmountException("Amount must be positive");
}
if (request.getUserId() == null) {
throw new InvalidUserException("User ID cannot be null");
}
// 불필요한 중첩 try-catch
try {
User user = userRepository.findById(request.getUserId())
.orElseThrow(() -> new UserNotFoundException(request.getUserId()));
try {
if (user.getBalance().compareTo(request.getAmount()) < 0) {
throw new InsufficientBalanceException("Insufficient balance");
}
try {
PaymentGateway gateway = paymentGatewayFactory.create(request.getPaymentMethod());
try {
PaymentResult result = gateway.charge(request.getAmount());
user.setBalance(user.getBalance().subtract(request.getAmount()));
userRepository.save(user);
return result;
} catch (PaymentGatewayException e) {
log.error("Payment gateway failed", e);
throw new PaymentProcessingException("Gateway error", e);
}
} catch (UnsupportedPaymentMethodException e) {
log.error("Unsupported payment method", e);
throw new PaymentProcessingException("Unsupported method", e);
}
} catch (InsufficientBalanceException e) {
log.error("Insufficient balance", e);
throw new PaymentProcessingException("Insufficient balance", e);
}
} catch (UserNotFoundException e) {
log.error("User not found", e);
throw new PaymentProcessingException("User not found", e);
}
}
}
// 8개의 커스텀 예외
public class InvalidPaymentRequestException extends RuntimeException { }
public class InvalidAmountException extends RuntimeException { }
public class InvalidUserException extends RuntimeException { }
public class InsufficientBalanceException extends RuntimeException { }
public class PaymentGatewayException extends RuntimeException { }
public class UnsupportedPaymentMethodException extends RuntimeException { }
public class PaymentProcessingException extends RuntimeException { }
public class UserNotFoundException extends RuntimeException { }
✅ Good — Define errors out + Exception aggregation
@Service
public class PaymentService {
@Transactional
public PaymentResult processPayment(@Valid PaymentRequest request) {
// validation은 @Valid로 처리 - Define errors out
User user = userRepository.findById(request.getUserId())
.orElseThrow(() -> new EntityNotFoundException("User not found"));
// 비즈니스 규칙 검증은 명확하게
if (user.getBalance().compareTo(request.getAmount()) < 0) {
throw new IllegalStateException("Insufficient balance");
}
// 결제 게이트웨이 호출 - 실패 시 명확한 예외
PaymentGateway gateway = paymentGatewayFactory.create(request.getPaymentMethod());
PaymentResult result = gateway.charge(request.getAmount()); // throws PaymentException
user.deductBalance(request.getAmount());
userRepository.save(user);
return result;
}
}
// Exception aggregation
@RestControllerAdvice
public class PaymentExceptionHandler {
@ExceptionHandler(PaymentException.class)
public ResponseEntity<ErrorResponse> handlePaymentError(PaymentException e) {
return ResponseEntity.status(PAYMENT_REQUIRED)
.body(new ErrorResponse(e.getMessage()));
}
@ExceptionHandler(IllegalStateException.class)
public ResponseEntity<ErrorResponse> handleInvalidState(IllegalStateException e) {
return ResponseEntity.badRequest()
.body(new ErrorResponse(e.getMessage()));
}
}
// PaymentGateway 내부에서 Mask exceptions
public class StripePaymentGateway implements PaymentGateway {
@Override
public PaymentResult charge(BigDecimal amount) {
try {
// Stripe API 호출
return stripeClient.charge(amount);
} catch (StripeNetworkException e) {
log.warn("Stripe network error, retrying", e);
return retryCharge(amount); // 내부에서 재시도 - 상위는 몰라도 됨
} catch (StripeApiException e) {
// 복구 불가능한 에러만 상위로 전파
throw new PaymentException("Payment failed: " + e.getMessage(), e);
}
}
}
차이점:
8개 커스텀 예외 → 2개 (PaymentException, 표준 예외 활용)
5단 중첩 try-catch → 0개
15줄 validation → @Valid로 2줄
네트워크 에러 재시도 로직은 masking (상위 레벨은 몰라도 됨)
정상 흐름이 명확하게 보임
예시 3: 파일 업로드
❌ Bad — 모든 예외를 호출자에게 떠넘김
@Service
public class FileUploadService {
public String uploadFile(MultipartFile file) throws IOException,
FileValidationException, StorageException, VirusScanException {
if (file == null || file.isEmpty()) {
throw new FileValidationException("File is empty");
}
if (file.getSize() > MAX_FILE_SIZE) {
throw new FileValidationException("File too large");
}
String contentType = file.getContentType();
if (!ALLOWED_TYPES.contains(contentType)) {
throw new FileValidationException("Invalid file type");
}
// 바이러스 스캔 실패를 호출자가 처리하게 함
if (!virusScanner.scan(file)) {
throw new VirusScanException("Virus detected");
}
// 스토리지 저장 실패를 호출자가 처리하게 함
try {
return s3Client.upload(file);
} catch (AmazonS3Exception e) {
throw new StorageException("S3 upload failed", e);
}
}
}
// Controller가 모든 예외를 처리해야 함
@PostMapping("/upload")
public ResponseEntity<?> upload(@RequestParam("file") MultipartFile file) {
try {
String url = fileUploadService.uploadFile(file);
return ResponseEntity.ok(url);
} catch (FileValidationException e) {
return ResponseEntity.badRequest().body(e.getMessage());
} catch (VirusScanException e) {
return ResponseEntity.status(FORBIDDEN).body(e.getMessage());
} catch (StorageException e) {
return ResponseEntity.status(INTERNAL_SERVER_ERROR).body(e.getMessage());
} catch (IOException e) {
return ResponseEntity.status(INTERNAL_SERVER_ERROR).body("IO error");
}
}
✅ Good — Mask exceptions + Define errors out
@Service
public class FileUploadService {
public FileUploadResult uploadFile(@Valid FileUploadRequest request) {
MultipartFile file = request.getFile();
// validation은 @Valid + custom validator로 처리
// null, empty, size, type 체크는 선언적으로
// 바이러스 스캔 실패는 내부에서 처리 (Mask exception)
if (!virusScanner.isSafe(file)) {
throw new IllegalArgumentException("File is not safe");
}
// S3 업로드 실패는 재시도 후 실패 시에만 예외 (Mask exception)
String url = storageService.upload(file); // 내부에서 retry 처리
return new FileUploadResult(url, file.getOriginalFilename());
}
}
// StorageService 내부에서 복잡도 흡수
@Service
public class S3StorageService implements StorageService {
@Override
public String upload(MultipartFile file) {
try {
return uploadWithRetry(file, 3);
} catch (AmazonS3Exception e) {
log.error("S3 upload failed after retries", e);
throw new StorageException("Failed to upload file", e);
}
}
private String uploadWithRetry(MultipartFile file, int maxRetries) {
// 네트워크 에러는 재시도 - 상위 레벨은 몰라도 됨
}
}
// Controller는 간결
@PostMapping("/upload")
public ResponseEntity<FileUploadResult> upload(@Valid FileUploadRequest request) {
return ResponseEntity.ok(fileUploadService.uploadFile(request));
}
// Exception aggregation
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(StorageException.class)
public ResponseEntity<ErrorResponse> handleStorageError(StorageException e) {
return ResponseEntity.status(INTERNAL_SERVER_ERROR)
.body(new ErrorResponse("File upload failed"));
}
}
차이점:
Controller에서 4개 catch 블록 → 0개
Service throws 절에 4개 예외 → 0개 (unchecked exception만)
네트워크 재시도 로직은 StorageService 내부로 (Mask exception)
Validation은 선언적으로 처리 (Define errors out)
한 줄 판단 기준
AI가 생성한 예외 처리 코드를 볼 때:
"이 예외를 호출자가 다르게 처리할 수 있는가?"
NO → 예외를 제거하거나 aggregation하라. 로그만 찍고 rethrow하는 것은 noise다.
YES → 정말? 그 처리가 여러 곳에 중복되지 않는가? 중복된다면 더 아래 레벨로 내려라.
추가 기준:
"try-catch 블록이 정상 로직보다 더 많은가?" → Bad code
"커스텀 예외가 5개 이상인가?" → Over-engineering
"이 예외 처리 코드가 테스트되었는가?" → 테스트 없으면 위험
"이 API를 null 불가능하게 만들 수 있는가?" → Define errors out
댓글
댓글이 없습니다.
