A Philosophy of Software Design — John Ousterhout 5
APoSD Ch.7 — 다른 레이어, 다른 추상화 (Different Layer, Different Abstraction)
핵심 요약
잘 설계된 시스템의 각 레이어는 서로 다른 추상화(different abstraction)를 제공한다. 인접한 레이어가 비슷한 추상화를 가지면, 이는 클래스 분해에 문제가 있다는 신호다.
Pass-through Method (통과 메서드):
다른 메서드를 호출하는 것 외에는 거의 아무것도 하지 않는 메서드
시그니처가 유사하거나 동일함
클래스를 얕게(shallow) 만들고, 인터페이스 복잡도만 증가시키며, 클래스 간 의존성을 생성
AI가 Spring Boot의 Controller→Service→Repository 레이어를 만들 때 가장 흔하게 생성하는 패턴
Pass-through Variable (통과 변수):
긴 메서드 체인을 통해 전달되는 변수
모든 중간 메서드가 이를 인지해야 하므로 복잡도 증가
핵심 원칙: 레이어가 단순히 데이터를 전달하기만 한다면, 그 레이어는 존재할 이유가 없다.
바이브 코딩 판단 포인트
1. Service 레이어가 단순 위임만 하는가?
AI는 "레이어드 아키텍처"를 맹목적으로 따라 Service 레이어를 생성한다. 하지만 Service가 Repository를 그대로 호출하기만 한다면 불필요한 레이어다.
판단 기준:
Service 메서드가 Repository 메서드와 1:1 매핑되는가?
Service 메서드 안에 비즈니스 로직이 없고 단순 호출만 있는가?
Service가 제공하는 "추상화"가 Repository와 다른가?
2. 여러 레이어를 거치는 DTO 변환
AI는 종종 Controller→Service→Repository 각 레이어마다 DTO를 변환하는 코드를 생성한다. 이는 pass-through의 변형이다.
판단 기준:
DTO 변환이 각 레이어의 책임과 일치하는가?
단순히 필드를 복사하기만 하는 변환이 여러 레이어에 흩어져 있는가?
3. Pass-through Variable이 3개 이상의 메서드를 거치는가?
AI는 "확장성"을 위해 모든 설정을 파라미터로 전달하려 한다. 하지만 3개 이상의 메서드를 거쳐 전달되는 변수는 context object나 shared object로 개선해야 한다.
판단 질문
Service 레이어 판단
이 Service 메서드는 Repository 호출 외에 무엇을 하는가?
트랜잭션 처리? → 필요
여러 Repository 조합? → 필요
비즈니스 규칙 검증? → 필요
단순 위임? → 삭제 고려
Service를 제거하면 Controller가 복잡해지는가?
아니오 → Service 불필요
예 → Service 유지
Service의 추상화가 Repository와 얼마나 다른가?
메서드명만 다름 → 불필요
개념 수준이 다름 (예: saveUser vs registerNewMember) → 필요
Decorator/Wrapper 판단
이 Wrapper 클래스가 제공하는 기능을 원본 클래스에 직접 추가할 수 없는가?
추가 가능 → Wrapper 불필요
원본 수정 불가 (외부 라이브러리) → Wrapper 필요
Decorator가 shallow한가? (인터페이스는 넓지만 기능은 적은가)
예 → 재검토 필요
아니오 (의미 있는 기능 추가) → 유지
Pass-through Variable 판단
이 변수가 몇 개의 메서드를 거쳐 전달되는가?
1~2개 → 허용
3개 이상 → context object나 shared state로 리팩토링 고려
중간 메서드들이 이 변수를 실제로 사용하는가?
사용함 → 허용
단순 전달만 → 리팩토링 필요
Bad vs Good 예시
예시 1: Pass-through Service (가장 흔한 AI 실수)
❌ Bad: Service가 단순 위임만 함
// AI가 생성한 코드
@RestController
@RequestMapping("/api/users")
public class UserController {
private final UserService userService;
@GetMapping("/{id}")
public UserResponse getUser(@PathVariable Long id) {
return userService.getUser(id);
}
@PostMapping
public UserResponse createUser(@RequestBody UserRequest request) {
return userService.createUser(request);
}
}
@Service
public class UserService {
private final UserRepository userRepository;
// 이 메서드는 존재 이유가 없다 - Repository를 그대로 호출
public UserResponse getUser(Long id) {
User user = userRepository.findById(id)
.orElseThrow(() -> new UserNotFoundException(id));
return UserResponse.from(user);
}
// 이 메서드도 단순 위임
public UserResponse createUser(UserRequest request) {
User user = User.from(request);
User saved = userRepository.save(user);
return UserResponse.from(saved);
}
}
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
}
문제점:
Service가 Repository와 1:1 매핑
Service의 추상화가 Repository와 동일 (둘 다 CRUD)
Service가 shallow: 인터페이스만 추가하고 기능은 없음
레이어를 거칠 때마다 클래스만 늘어나고 복잡도는 증가
✅ Good: Service를 제거하거나 의미 있는 로직 추가
옵션 1: Service 제거 (단순 CRUD라면)
@RestController
@RequestMapping("/api/users")
public class UserController {
private final UserRepository userRepository;
@GetMapping("/{id}")
public UserResponse getUser(@PathVariable Long id) {
User user = userRepository.findById(id)
.orElseThrow(() -> new UserNotFoundException(id));
return UserResponse.from(user);
}
@PostMapping
public UserResponse createUser(@RequestBody UserRequest request) {
User user = User.from(request);
User saved = userRepository.save(user);
return UserResponse.from(saved);
}
}
옵션 2: Service에 비즈니스 로직 추가 (복잡한 로직이라면)
@Service
public class UserService {
private final UserRepository userRepository;
private final EmailService emailService;
private final PointService pointService;
// 이제 Service의 추상화가 다르다: "회원가입" vs "저장"
@Transactional
public UserResponse registerNewMember(UserRequest request) {
// 1. 중복 검증 (비즈니스 규칙)
if (userRepository.existsByEmail(request.getEmail())) {
throw new DuplicateEmailException(request.getEmail());
}
// 2. 여러 Repository 조합
User user = User.from(request);
User saved = userRepository.save(user);
pointService.grantWelcomePoints(saved.getId(), 1000);
// 3. 외부 서비스 호출
emailService.sendWelcomeEmail(saved.getEmail());
return UserResponse.from(saved);
}
}
왜 개선되었나:
옵션 1: 불필요한 레이어 제거, 복잡도 감소
옵션 2: Service의 추상화가 Repository와 달라짐 (registerNewMember vs save)
옵션 2: Service가 여러 의존성을 조합하고 트랜잭션을 관리
예시 2: Pass-through Method with Multiple Layers
❌ Bad: 3개 레이어 모두 단순 위임
// AI가 "레이어드 아키텍처"를 맹신하고 생성한 코드
@RestController
public class ProductController {
private final ProductFacade productFacade;
@GetMapping("/products/{id}")
public ProductDto getProduct(@PathVariable Long id) {
return productFacade.getProduct(id);
}
}
@Component
public class ProductFacade {
private final ProductService productService;
// 이 레이어는 왜 존재하는가?
public ProductDto getProduct(Long id) {
return productService.getProduct(id);
}
}
@Service
public class ProductService {
private final ProductRepository productRepository;
// 이것도 단순 위임
public ProductDto getProduct(Long id) {
Product product = productRepository.findById(id)
.orElseThrow(() -> new ProductNotFoundException(id));
return ProductDto.from(product);
}
}
문제점:
Facade 레이어가 완전히 불필요
Service도 단순 조회만 하므로 불필요
3개 레이어를 거치지만 추가되는 기능 없음
✅ Good: 의미 있는 레이어만 유지
@RestController
public class ProductController {
private final ProductRepository productRepository;
@GetMapping("/products/{id}")
public ProductDto getProduct(@PathVariable Long id) {
Product product = productRepository.findById(id)
.orElseThrow(() -> new ProductNotFoundException(id));
return ProductDto.from(product);
}
}
// Service는 복잡한 비즈니스 로직이 있을 때만 추가
@Service
public class ProductService {
private final ProductRepository productRepository;
private final InventoryService inventoryService;
private final PricingService pricingService;
// 이건 의미 있는 Service: 여러 도메인을 조합
@Transactional
public ProductDetailDto getProductWithDetails(Long id) {
Product product = productRepository.findById(id)
.orElseThrow(() -> new ProductNotFoundException(id));
int stock = inventoryService.getAvailableStock(id);
PriceInfo priceInfo = pricingService.calculatePrice(id);
return ProductDetailDto.of(product, stock, priceInfo);
}
}
예시 3: Pass-through Variable
❌ Bad: userId가 5개 메서드를 거쳐 전달됨
// AI가 "모든 것을 파라미터로"라는 원칙을 과도하게 적용
public class OrderService {
public void processOrder(Long orderId, Long userId) {
validateOrder(orderId, userId);
}
private void validateOrder(Long orderId, Long userId) {
checkInventory(orderId, userId);
}
private void checkInventory(Long orderId, Long userId) {
reserveStock(orderId, userId);
}
private void reserveStock(Long orderId, Long userId) {
logReservation(orderId, userId);
}
private void logReservation(Long orderId, Long userId) {
// userId는 여기서만 실제로 사용됨
log.info("User {} reserved stock for order {}", userId, orderId);
}
}
문제점:
userId가 4개의 중간 메서드를 거쳐 전달됨
중간 메서드들은 userId를 사용하지 않음
메서드 시그니처가 불필요하게 복잡해짐
✅ Good: Context Object 사용
public class OrderService {
private final OrderContext context;
public void processOrder(Long orderId, Long userId) {
context.setUserId(userId);
validateOrder(orderId);
}
private void validateOrder(Long orderId) {
checkInventory(orderId);
}
private void checkInventory(Long orderId) {
reserveStock(orderId);
}
private void reserveStock(Long orderId) {
logReservation(orderId);
}
private void logReservation(Long orderId) {
// context에서 userId를 가져옴
log.info("User {} reserved stock for order {}",
context.getUserId(), orderId);
}
}
// ThreadLocal을 사용한 Context
@Component
@Scope("request")
public class OrderContext {
private Long userId;
public void setUserId(Long userId) {
this.userId = userId;
}
public Long getUserId() {
return userId;
}
}
또는 SecurityContext 활용 (Spring Security 환경):
public class OrderService {
public void processOrder(Long orderId) {
// userId는 SecurityContext에서 가져옴
validateOrder(orderId);
}
private void logReservation(Long orderId) {
Long userId = getCurrentUserId();
log.info("User {} reserved stock for order {}", userId, orderId);
}
private Long getCurrentUserId() {
return ((UserDetails) SecurityContextHolder.getContext()
.getAuthentication().getPrincipal()).getUserId();
}
}
예시 4: Interface Duplication (언제 OK인가?)
✅ Good: Dispatcher Pattern (중복 OK)
// 여러 구현체를 선택하는 dispatcher는 인터페이스 중복이 허용됨
@Service
public class PaymentService {
private final List<PaymentProcessor> processors;
// 이 메서드는 pass-through처럼 보이지만 의미가 있음
public PaymentResult process(PaymentRequest request) {
PaymentProcessor processor = findProcessor(request.getPaymentType());
return processor.process(request);
}
private PaymentProcessor findProcessor(PaymentType type) {
return processors.stream()
.filter(p -> p.supports(type))
.findFirst()
.orElseThrow(() -> new UnsupportedPaymentTypeException(type));
}
}
// 각 구현체
@Component
public class CreditCardProcessor implements PaymentProcessor {
public PaymentResult process(PaymentRequest request) {
// 신용카드 결제 로직
}
}
@Component
public class KakaoPayProcessor implements PaymentProcessor {
public PaymentResult process(PaymentRequest request) {
// 카카오페이 결제 로직
}
}
왜 OK인가:
Dispatcher 메서드는 "어떤 구현체를 사용할지 결정"이라는 책임이 있음
각 구현체는 다른 동작을 제공
인터페이스는 같지만 추상화 수준이 다름 (전략 선택 vs 구체적 실행)
한 줄 판단 기준
패턴 | 판단 기준 | 액션 |
|---|---|---|
Service가 Repository 1:1 매핑 | "이 Service를 제거하면 Controller가 복잡해지나?" | NO → Service 삭제 |
여러 레이어를 거치는 단순 위임 | "각 레이어의 추상화가 다른가?" | NO → 중간 레이어 삭제 |
Pass-through Variable | "3개 이상 메서드를 거치는가?" | YES → Context Object로 |
Decorator/Wrapper | "원본 클래스에 직접 추가 가능한가?" | YES → Wrapper 삭제 |
인터페이스 중복 | "각 메서드가 다른 구현을 제공하나?" | NO → 중복 제거 |
핵심 질문: "이 레이어가 사라지면 무엇을 잃는가?"
아무것도 잃지 않으면 → 삭제
복잡도만 줄어들면 → 삭제
의미 있는 추상화를 잃으면 → 유지
댓글
댓글이 없습니다.
