A Philosophy of Software Design — John Ousterhout 3
APoSD Ch.5 — 정보 은닉과 정보 누출 (Information Hiding and Leakage)
핵심 요약
정보 은닉 (Information Hiding)은 깊은 모듈을 만드는 핵심 기법이다. 구현 세부사항을 인터페이스에 노출하지 않음으로써 복잡도를 줄인다.
정보 은닉의 두 가지 이점:
단순한 인터페이스로 인지 부하 감소
시스템 진화 용이 — 설계 변경이 한 모듈에만 영향
정보 누출 (Information Leakage)은 같은 지식이 여러 곳에서 사용될 때 발생한다:
설계 결정이 여러 모듈에 반영됨
변경 시 여러 모듈을 동시에 수정해야 하는 의존성 생성
인터페이스에 드러나지 않아도 누출될 수 있음 (예: 두 함수가 같은 파일 포맷 기대)
시간적 분해 (Temporal Decomposition)의 함정:
시스템 구조가 작업 실행 순서에 대응하도록 설계하는 것
실행 순서가 아닌 각 작업에 필요한 지식을 기준으로 모듈 설계해야 함
바이브 코딩 판단 포인트
AI가 코드를 생성할 때 자주 만드는 정보 누출 패턴:
1. 레이어 간 DTO 중복
AI는 Controller/Service/Repository 각각에 거의 동일한 구조의 DTO를 생성하는 경향이 있다. 이는 같은 데이터 구조 지식이 여러 레이어에 누출된 것이다.
판단 신호:
UserRequest, UserServiceDto, UserRepositoryDto가 필드가 거의 같음
한 필드를 추가하면 3개 클래스를 모두 수정해야 함
2. 파일 포맷/데이터 구조의 암묵적 공유
두 개 이상의 클래스가 같은 파일 포맷, JSON 구조, 데이터베이스 스키마를 "알고 있는" 경우.
판단 신호:
여러 서비스가 같은 JSON 키를 하드코딩
CSV 파일의 컬럼 순서를 여러 곳에서 가정
같은 Map<String, Object> 구조를 여러 곳에서 파싱
3. 시간 순서에 따른 메서드 분해
AI는 "주문 처리" 요구사항을 받으면 validateOrder(), saveOrder(), sendNotification() 순서대로 메서드를 나누는 경향이 있다. 이는 시간적 분해의 전형이다.
판단 신호:
메서드 이름이 동사의 시간 순서 (validate → save → send → update)
각 메서드가 이전 단계의 결과를 매개변수로 받음
한 트랜잭션의 단계들이 여러 public 메서드로 분리됨
4. Private이면 은닉된 것이라는 착각
AI가 private 키워드를 붙였다고 정보 은닉이 달성된 것은 아니다. 클래스 내부에서 여러 메서드가 같은 구조를 중복 사용하면 여전히 누출이다.
판단 신호:
Private 메서드들이 같은 상수, 포맷, 규칙을 중복 사용
클래스 내부에 숨겨진 지식이 여러 메서드에 산재
판단 질문
AI 생성 코드를 리뷰할 때 이 질문들을 던져라:
"이 데이터 구조/포맷/규칙이 몇 군데서 사용되는가?"
2곳 이상이면 정보 누출 의심
변경 시 몇 개 파일을 열어야 하는가?
"이 클래스들이 작고 밀접하게 연결되어 있는가?"
그렇다면 하나로 합치는 게 나을 수 있음
분리가 복잡도를 낮추는가, 높이는가?
"이 메서드 분해가 시간 순서를 따르는가, 지식 단위를 따르는가?"
step1(), step2() 식의 이름은 시간적 분해의 증거
"이 메서드가 무엇을 알고 있는가"가 분리 기준이어야 함
"공통 케이스가 가장 단순한가?"
AI는 모든 예외 케이스를 인터페이스에 노출하는 경향이 있음
80%의 사용 사례가 간단해야 함
"외부에 필요한 정보를 숨기고 있지는 않은가?"
과도한 은닉도 문제 (getter 없이 모든 걸 내부에서 처리)
필요한 정보는 노출해야 함
Bad vs Good 예시
예시 1: 레이어 간 DTO 중복 (정보 누출)
❌ Bad: AI가 흔히 생성하는 코드
// Controller DTO
@Data
public class OrderRequest {
private String productId;
private Integer quantity;
private String userId;
}
// Service DTO
@Data
public class OrderServiceDto {
private String productId;
private Integer quantity;
private String userId;
private LocalDateTime createdAt; // 추가 필드
}
// Repository Entity
@Entity
public class OrderEntity {
@Id
private Long id;
private String productId; // 같은 구조 반복
private Integer quantity; // 같은 구조 반복
private String userId; // 같은 구조 반복
private LocalDateTime createdAt;
}
// Service 레이어
public class OrderService {
public void createOrder(OrderServiceDto dto) {
// OrderServiceDto → OrderEntity 변환 로직
OrderEntity entity = new OrderEntity();
entity.setProductId(dto.getProductId());
entity.setQuantity(dto.getQuantity());
entity.setUserId(dto.getUserId());
// ...
}
}
문제점:
productId, quantity, userId 구조가 3곳에 누출
필드 타입 변경 시 3개 클래스 + 변환 로직 모두 수정
변환 로직이 데이터 구조를 또 한 번 알아야 함
✅ Good: 정보 은닉 적용
// Domain 모델 (핵심 지식의 단일 소스)
@Entity
public class Order {
@Id
private Long id;
private String productId;
private Integer quantity;
private String userId;
private LocalDateTime createdAt;
// Factory method - 생성 로직 캡슐화
public static Order create(String productId, Integer quantity, String userId) {
Order order = new Order();
order.productId = productId;
order.quantity = quantity;
order.userId = userId;
order.createdAt = LocalDateTime.now();
return order;
}
// 도메인 행동 캡슐화
public void updateQuantity(Integer newQuantity) {
if (newQuantity <= 0) throw new IllegalArgumentException();
this.quantity = newQuantity;
}
}
// Controller는 Domain을 직접 사용 (간단한 경우)
@PostMapping("/orders")
public OrderResponse createOrder(@RequestBody CreateOrderRequest request) {
Order order = Order.create(
request.getProductId(),
request.getQuantity(),
getCurrentUserId()
);
orderService.save(order);
return OrderResponse.from(order);
}
// Request/Response DTO는 얇은 변환 레이어
@Data
public class CreateOrderRequest {
private String productId;
private Integer quantity;
// userId는 인증 컨텍스트에서 가져오므로 제외
}
@Data
public class OrderResponse {
private Long id;
private String productId;
private Integer quantity;
public static OrderResponse from(Order order) {
OrderResponse response = new OrderResponse();
response.id = order.getId();
response.productId = order.getProductId();
response.quantity = order.getQuantity();
return response;
}
}
개선점:
주문 데이터 구조는 Order 도메인 모델에만 존재
Request/Response는 API 계약만 표현 (내부 구조 독립적)
비즈니스 규칙 변경 시 Order 클래스만 수정
예시 2: 파일 포맷의 암묵적 공유 (정보 누출)
❌ Bad: CSV 구조가 여러 곳에 누출
// 파일 읽기
public class CsvOrderReader {
public List<Order> readOrders(String filePath) {
List<Order> orders = new ArrayList<>();
// CSV: productId,quantity,userId,price
try (BufferedReader br = new BufferedReader(new FileReader(filePath))) {
String line;
while ((line = br.readLine()) != null) {
String[] parts = line.split(",");
Order order = new Order();
order.setProductId(parts[0]); // 컬럼 순서 의존
order.setQuantity(Integer.parseInt(parts[1]));
order.setUserId(parts[2]);
order.setPrice(new BigDecimal(parts[3]));
orders.add(order);
}
}
return orders;
}
}
// 파일 쓰기 (다른 클래스)
public class CsvOrderWriter {
public void writeOrders(List<Order> orders, String filePath) {
try (PrintWriter pw = new PrintWriter(new FileWriter(filePath))) {
for (Order order : orders) {
// 같은 컬럼 순서를 여기서도 알아야 함
pw.println(String.format("%s,%d,%s,%s",
order.getProductId(),
order.getQuantity(),
order.getUserId(),
order.getPrice()
));
}
}
}
}
// 검증 로직 (또 다른 클래스)
public class CsvOrderValidator {
public boolean validate(String line) {
String[] parts = line.split(",");
// 4개 컬럼 기대 - 또 같은 구조를 알고 있음
return parts.length == 4
&& !parts[0].isEmpty() // productId
&& isNumeric(parts[1]); // quantity
}
}
문제점:
CSV 포맷 (컬럼 순서, 개수, 구분자)이 3개 클래스에 누출
컬럼 추가 시 3곳 모두 수정 필요
각 클래스가 같은 지식을 중복 구현
✅ Good: 포맷 지식을 한 곳에 캡슐화
// CSV 포맷 지식을 캡슐화한 전략 클래스
public class OrderCsvFormat {
private static final String DELIMITER = ",";
private static final int PRODUCT_ID_INDEX = 0;
private static final int QUANTITY_INDEX = 1;
private static final int USER_ID_INDEX = 2;
private static final int PRICE_INDEX = 3;
private static final int EXPECTED_COLUMNS = 4;
// 파싱 로직 캡슐화
public Order parseLine(String line) {
String[] parts = line.split(DELIMITER);
if (parts.length != EXPECTED_COLUMNS) {
throw new IllegalArgumentException("Invalid CSV format");
}
Order order = new Order();
order.setProductId(parts[PRODUCT_ID_INDEX]);
order.setQuantity(Integer.parseInt(parts[QUANTITY_INDEX]));
order.setUserId(parts[USER_ID_INDEX]);
order.setPrice(new BigDecimal(parts[PRICE_INDEX]));
return order;
}
// 직렬화 로직 캡슐화
public String formatLine(Order order) {
return String.join(DELIMITER,
order.getProductId(),
String.valueOf(order.getQuantity()),
order.getUserId(),
order.getPrice().toString()
);
}
// 검증 로직 캡슐화
public boolean isValidLine(String line) {
String[] parts = line.split(DELIMITER);
return parts.length == EXPECTED_COLUMNS
&& !parts[PRODUCT_ID_INDEX].isEmpty()
&& isNumeric(parts[QUANTITY_INDEX]);
}
private boolean isNumeric(String str) {
try {
Integer.parseInt(str);
return true;
} catch (NumberFormatException e) {
return false;
}
}
}
// Reader는 포맷을 몰라도 됨
public class OrderFileReader {
private final OrderCsvFormat format = new OrderCsvFormat();
public List<Order> read(String filePath) throws IOException {
List<Order> orders = new ArrayList<>();
try (BufferedReader br = new BufferedReader(new FileReader(filePath))) {
String line;
while ((line = br.readLine()) != null) {
if (format.isValidLine(line)) {
orders.add(format.parseLine(line)); // 포맷 지식은 format이 소유
}
}
}
return orders;
}
}
// Writer도 포맷을 몰라도 됨
public class OrderFileWriter {
private final OrderCsvFormat format = new OrderCsvFormat();
public void write(List<Order> orders, String filePath) throws IOException {
try (PrintWriter pw = new PrintWriter(new FileWriter(filePath))) {
for (Order order : orders) {
pw.println(format.formatLine(order)); // 포맷 지식은 format이 소유
}
}
}
}
개선점:
CSV 포맷 지식이 OrderCsvFormat 한 곳에만 존재
컬럼 순서 변경, 구분자 변경 시 한 클래스만 수정
Reader/Writer는 "무언가를 읽고 쓴다"는 책임만 가짐 (포맷은 모름)
예시 3: 시간적 분해 (Temporal Decomposition)
❌ Bad: 실행 순서에 따라 메서드 분해
@Service
public class OrderProcessService {
// Step 1: 검증
public OrderValidationResult validateOrder(OrderRequest request) {
// 재고 확인
boolean hasStock = checkStock(request.getProductId(), request.getQuantity());
// 사용자 확인
boolean isValidUser = checkUser(request.getUserId());
// 가격 확인
BigDecimal price = calculatePrice(request.getProductId(), request.getQuantity());
return new OrderValidationResult(hasStock, isValidUser, price);
}
// Step 2: 저장
public Order saveOrder(OrderRequest request, OrderValidationResult validation) {
Order order = new Order();
order.setProductId(request.getProductId());
order.setQuantity(request.getQuantity());
order.setUserId(request.getUserId());
order.setPrice(validation.getPrice()); // Step 1의 결과 의존
return orderRepository.save(order);
}
// Step 3: 재고 차감
public void deductStock(Order order) {
stockService.deduct(order.getProductId(), order.getQuantity());
}
// Step 4: 알림 발송
public void sendNotification(Order order) {
notificationService.send(order.getUserId(), "Order created: " + order.getId());
}
// Controller에서 순서대로 호출해야 함
@PostMapping("/orders")
public OrderResponse createOrder(@RequestBody OrderRequest request) {
OrderValidationResult validation = orderProcessService.validateOrder(request);
if (!validation.isValid()) {
throw new ValidationException();
}
Order order = orderProcessService.saveOrder(request, validation);
orderProcessService.deductStock(order);
orderProcessService.sendNotification(order);
return OrderResponse.from(order);
}
}
문제점:
메서드가 "언제 실행되는가"를 기준으로 분리됨
Controller가 올바른 순서를 알아야 함 (순서 지식 누출)
각 단계가 이전 단계의 결과에 의존 (강한 결합)
트랜잭션 경계가 불명확 (재고 차감 실패 시 롤백?)
✅ Good: 지식 단위로 분해
@Service
public class OrderService {
private final OrderValidator validator;
private final OrderRepository orderRepository;
private final StockManager stockManager;
private final OrderNotifier notifier;
// 하나의 트랜잭션으로 완결된 작업
@Transactional
public Order createOrder(String productId, Integer quantity, String userId) {
// 각 컴포넌트가 **무엇을 알고 있는가**로 분리됨
// OrderValidator: 주문 가능 여부 판단 지식
validator.validateOrderable(productId, quantity, userId);
// StockManager: 재고 관리 지식
stockManager.reserve(productId, quantity);
// Order: 주문 생성 규칙 지식
Order order = Order.create(productId, quantity, userId);
Order savedOrder = orderRepository.save(order);
// OrderNotifier: 알림 발송 지식 (비동기로 분리 가능)
notifier.notifyOrderCreated(savedOrder);
return savedOrder;
}
}
// 주문 검증 지식 캡슐화
@Component
public class OrderValidator {
private final ProductRepository productRepository;
private final UserRepository userRepository;
public void validateOrderable(String productId, Integer quantity, String userId) {
Product product = productRepository.findById(productId)
.orElseThrow(() -> new ProductNotFoundException());
if (!product.isAvailable()) {
throw new ProductNotAvailableException();
}
User user = userRepository.findById(userId)
.orElseThrow(() -> new UserNotFoundException());
if (!user.canOrder()) {
throw new UserNotAllowedException();
}
if (quantity <= 0 || quantity > product.getMaxOrderQuantity()) {
throw new InvalidQuantityException();
}
}
}
// 재고 관리 지식 캡슐화
@Component
public class StockManager {
private final StockRepository stockRepository;
@Transactional
public void reserve(String productId, Integer quantity) {
Stock stock = stockRepository.findByProductIdForUpdate(productId)
.orElseThrow(() -> new StockNotFoundException());
if (!stock.canReserve(quantity)) {
throw new InsufficientStockException();
}
stock.reserve(quantity);
stockRepository.save(stock);
}
}
// 알림 발송 지식 캡슐화
@Component
public class OrderNotifier {
private final NotificationService notificationService;
@Async // 비동기로 분리 가능
public void notifyOrderCreated(Order order) {
notificationService.send(
order.getUserId(),
NotificationTemplate.orderCreated(order)
);
}
}
// Controller는 단순히 호출만
@PostMapping("/orders")
public OrderResponse createOrder(@RequestBody CreateOrderRequest request) {
Order order = orderService.createOrder(
request.getProductId(),
request.getQuantity(),
getCurrentUserId()
);
return OrderResponse.from(order);
}
개선점:
각 클래스가 지식의 단위로 분리됨
OrderValidator: 주문 가능 조건 지식
StockManager: 재고 관리 규칙 지식
OrderNotifier: 알림 발송 방법 지식
Controller/Service가 실행 순서를 알 필요 없음
트랜잭션 경계가 명확 (createOrder 메서드)
각 컴포넌트를 독립적으로 테스트/변경 가능
한 줄 판단 기준
패턴 | 신호 | 액션 |
|---|---|---|
레이어 간 DTO 중복 | 같은 필드 구조가 3개 이상 클래스에 등장 | 도메인 모델로 통합, DTO는 얇게 |
포맷/구조의 암묵적 공유 | 같은 JSON 키, CSV 컬럼 순서를 여러 곳에서 하드코딩 | Format/Strategy 클래스로 캡슐화 |
시간적 분해 |
| 지식 단위로 재분해 (Validator, Manager 등) |
Private = 은닉 착각 | Private 메서드들이 같은 상수/규칙 중복 | 중복 지식을 별도 클래스로 추출 |
작고 밀접한 클래스들 | A 변경 시 항상 B, C도 함께 변경됨 | 하나의 클래스로 병합 고려 |
과도한 은닉 | 외부에서 필요한 정보를 얻을 방법이 없음 | 필요한 정보는 인터페이스로 노출 |
최종 판단 질문:
"이 설계 결정이 바뀌면 몇 개 파일을 열어야 하는가?"
답이 2개 이상이면 정보 누출이다.
댓글
댓글이 없습니다.
