9.coupling-smells

커플링 냄새 3종: 부러움, 밀착, 중개인

클래스 간 관계가 이상할 때 나는 냄새들


이번에는 Martin Fowler가 정의한 코드 냄새(code smell) 중에서 클래스 간 커플링과 관련된 세 가지를 묶어서 본다. Feature Envy(기능 선망), Inappropriate Intimacy(부적절한 친밀), Middle Man(중개인). 이름이 하나같이 인간관계 상담 같은데, 실제로 클래스 간 관계의 문제를 꽤 정확하게 비유한 거다.

Feature Envy는 남의 데이터를 자기 것보다 더 많이 들여다보는 메서드. Inappropriate Intimacy는 두 클래스가 서로의 속살을 너무 잘 아는 관계. Middle Man은 자기 역할 없이 남의 말을 전달만 하는 중개인. 세 가지 모두 "이 메서드/클래스가 여기 있어야 하는가?"라는 질문으로 귀결된다.

1. Feature Envy (기능 선망)

이게 뭔데

메서드가 자신이 속한 클래스의 데이터보다 다른 클래스의 데이터를 더 많이 사용하는 현상이다. 직역하면 "기능 부러움"인데, "남의 떡이 더 커 보인다"를 코드로 옮긴 거라고 보면 됨.

Feature Envy란

메서드가 자기 클래스의 필드/메서드보다 다른 클래스의 필드/메서드를 더 많이 참조하는 코드 냄새. 해당 메서드가 현재 클래스에 속해 있을 이유가 없다는 신호. "이 메서드는 저쪽 클래스에 있어야 하는 거 아닌가?"

이건 생각보다 자주 볼 수 있다. 특히 "유틸리티 클래스"나 "헬퍼 클래스"에서 많이 발생함. 남의 데이터를 이리저리 조합해서 뭔가를 만들어내는 메서드가 떡하니 엉뚱한 클래스에 앉아 있는 거.

이런 코드

typescript
class ReportGenerator {
  // 이 메서드를 보자. employee의 데이터만 잔뜩 쓰고 있다.
  calculateAnnualBonus(employee: Employee): number {
    const baseSalary = employee.getSalary();
    const yearsWorked = employee.getYearsOfService();
    const department = employee.getDepartment();
    const performanceRating = employee.getPerformanceRating();
    const isManager = employee.getRole() === "manager";

    // 보너스 계산 로직 — 전부 employee 데이터 기반
    let bonusRate = 0;

    if (yearsWorked >= 10) bonusRate += 0.15;
    else if (yearsWorked >= 5) bonusRate += 0.10;
    else bonusRate += 0.05;

    if (performanceRating >= 4.5) bonusRate += 0.20;
    else if (performanceRating >= 3.5) bonusRate += 0.10;

    if (department === "engineering") bonusRate += 0.05;
    if (isManager) bonusRate += 0.10;

    return Math.round(baseSalary * bonusRate);
  }

  // 이것도 마찬가지. employee 데이터로 가득하다.
  generateEmployeeSummary(employee: Employee): string {
    const name = employee.getName();
    const dept = employee.getDepartment();
    const salary = employee.getSalary();
    const hireDate = employee.getHireDate();
    const rating = employee.getPerformanceRating();

    return [
      `이름: ${name}`,
      `부서: ${dept}`,
      `연봉: ${salary.toLocaleString()}원`,
      `입사일: ${hireDate.toLocaleDateString()}`,
      `평가: ${"★".repeat(Math.round(rating))}`,
    ].join("\n");
  }
}

calculateAnnualBonus를 보자. this(ReportGenerator)의 필드는 하나도 안 쓰고, employee의 데이터만 6개를 끌어다 쓴다. 이 메서드가 ReportGenerator에 있어야 할 이유가 뭔가? 없음. 이건 Employee 클래스에 있어야 하는 메서드다.

뭐가 문제냐면
  • 응집도 저하: ReportGenerator는 리포트 생성이 본업인데, 보너스 계산이라는 관련 없는 책임을 들고 있다
  • 중복 위험: 다른 클래스에서도 보너스 계산이 필요하면 비슷한 로직을 또 짜게 됨
  • 캡슐화 약화: Employee의 내부 데이터를 외부에서 getter로 낱낱이 꺼내야 하는 구조
  • 변경 전파: Employee의 보너스 정책이 바뀌면 ReportGenerator를 수정해야 한다 (Shotgun Surgery의 원인이 됨)

고친 코드

typescript
class Employee {
  private name: string;
  private salary: number;
  private yearsOfService: number;
  private department: string;
  private performanceRating: number;
  private role: string;
  private hireDate: Date;

  // 보너스 계산은 Employee 자신이 한다
  calculateAnnualBonus(): number {
    let bonusRate = 0;

    if (this.yearsOfService >= 10) bonusRate += 0.15;
    else if (this.yearsOfService >= 5) bonusRate += 0.10;
    else bonusRate += 0.05;

    if (this.performanceRating >= 4.5) bonusRate += 0.20;
    else if (this.performanceRating >= 3.5) bonusRate += 0.10;

    if (this.department === "engineering") bonusRate += 0.05;
    if (this.role === "manager") bonusRate += 0.10;

    return Math.round(this.salary * bonusRate);
  }

  // 자기 데이터를 요약하는 것도 자기가 한다
  toSummaryString(): string {
    return [
      `이름: ${this.name}`,
      `부서: ${this.department}`,
      `연봉: ${this.salary.toLocaleString()}원`,
      `입사일: ${this.hireDate.toLocaleDateString()}`,
      `평가: ${"★".repeat(Math.round(this.performanceRating))}`,
    ].join("\n");
  }
}

// ReportGenerator는 리포트 생성에만 집중
class ReportGenerator {
  generateBonusReport(employees: Employee[]): string {
    const lines = employees.map(emp =>
      `${emp.toSummaryString()}\n보너스: ${emp.calculateAnnualBonus().toLocaleString()}원`
    );
    return lines.join("\n\n---\n\n");
  }
}

이제 calculateAnnualBonus()는 Employee 안에 있다. 자기 데이터를 자기가 처리하니까 getter를 6개씩 호출할 필요도 없고, 로직이 어디에 있는지 찾아 헤맬 필요도 없다. ReportGenerator는 리포트 포맷에만 집중함.


2. Inappropriate Intimacy (부적절한 친밀)

이게 뭔데

두 클래스가 서로의 내부 구현에 과도하게 의존하는 상태다. public API가 아니라 내부 데이터 구조, private 필드, 구현 세부사항을 직접 참조한다. 캡슐화가 완전히 무너진 관계.

Inappropriate Intimacy란

두 클래스가 서로의 내부 구현 세부사항(private 필드, 내부 자료구조, 구현 방식)에 직접 접근하거나 깊이 의존하는 코드 냄새. 캡슐화 원칙 위반. 한 쪽의 내부 변경이 다른 쪽을 즉시 깨뜨린다.

인간관계로 치면 "경계선이 없는 관계"다. 상대방의 일기장을 읽고, 지갑을 뒤지고, 비밀번호를 공유하는 관계. 단기적으로는 효율적일 수 있어도, 한 쪽이 변하면 관계 전체가 깨진다.

이런 코드

typescript
class Order {
  // 이것들이 다 외부에 노출되어 있다
  _items: Array<{ productId: string; price: number; qty: number }> = [];
  _status: string = "pending";
  _discountAmount: number = 0;
  _appliedCoupons: Set<string> = new Set();
  _internalNotes: string[] = [];
  _version: number = 0;
}

class OrderProcessor {
  processOrder(order: Order): void {
    // 내부 배열을 직접 순회하면서 조작
    for (let i = 0; i < order._items.length; i++) {
      const item = order._items[i];
      if (item.price <= 0) {
        order._items.splice(i, 1); // 내부 배열을 직접 수정!
        i--;
      }
    }

    // 내부 상태를 직접 변경
    order._status = "processing";
    order._version++;

    // 내부 자료구조(Set)를 직접 사용
    if (order._appliedCoupons.has("VIP2024")) {
      order._discountAmount = order._items.reduce((sum, i) => sum + i.price * i.qty, 0) * 0.2;
    }

    // 내부 메모를 직접 추가
    order._internalNotes.push(`Processed at ${new Date().toISOString()}`);
  }

  isHighValueOrder(order: Order): boolean {
    // 내부 구현(배열 구조, 필드명)을 그대로 알고 있어야 함
    const total = order._items.reduce((sum, item) => sum + item.price * item.qty, 0);
    return total - order._discountAmount > 100000;
  }
}

class OrderAnalytics {
  getOrderMetrics(order: Order): object {
    // 또 다른 클래스도 Order의 내부를 속속들이 알고 있다
    return {
      itemCount: order._items.length,
      uniqueProducts: new Set(order._items.map(i => i.productId)).size,
      hasDiscount: order._discountAmount > 0,
      couponCount: order._appliedCoupons.size,
      noteCount: order._internalNotes.length,
      version: order._version,
    };
  }
}

OrderProcessorOrderAnalyticsOrder의 내부를 완전히 들여다보고 있다. _items 배열의 구조, _appliedCoupons가 Set이라는 것, _version 필드의 존재까지 전부 알고 있음. Order의 내부 구현을 조금만 바꿔도 두 클래스가 동시에 깨진다.

뭐가 문제냐면
  • 변경 파급: Order._items의 타입을 Map으로 바꾸면? OrderProcessorOrderAnalytics 둘 다 깨짐
  • 캡슐화 사망: Order의 불변식(invariant)을 외부에서 마음대로 깨뜨릴 수 있다 (예: _items.splice로 직접 삭제)
  • 테스트 취약: 테스트가 내부 구현에 의존해서, 리팩토링하면 테스트도 전부 깨짐
  • 이해도 폭발: Order를 이해하려면 OrderProcessorOrderAnalyticsOrder를 어떻게 쓰는지도 다 알아야 함

고친 코드

typescript
class Order {
  private items: Map<string, OrderItem> = new Map();
  private status: OrderStatus = "pending";
  private discountAmount: number = 0;
  private appliedCoupons: Set<string> = new Set();
  private notes: string[] = [];
  private version: number = 0;

  // public API로 필요한 행위만 노출
  addItem(productId: string, price: number, quantity: number): void {
    if (price <= 0) throw new Error("가격은 양수여야 합니다");
    this.items.set(productId, { productId, price, qty: quantity });
  }

  removeInvalidItems(): number {
    let removed = 0;
    for (const [id, item] of this.items) {
      if (item.price <= 0) { this.items.delete(id); removed++; }
    }
    return removed;
  }

  applyDiscount(couponCode: string, rate: number): void {
    if (this.appliedCoupons.has(couponCode)) return;
    this.appliedCoupons.add(couponCode);
    this.discountAmount += this.subtotal * rate;
  }

  markAsProcessing(): void {
    this.status = "processing";
    this.version++;
    this.notes.push(`Processed at ${new Date().toISOString()}`);
  }

  get subtotal(): number {
    let total = 0;
    for (const item of this.items.values()) total += item.price * item.qty;
    return total;
  }

  get totalAfterDiscount(): number {
    return this.subtotal - this.discountAmount;
  }

  get isHighValue(): boolean {
    return this.totalAfterDiscount > 100000;
  }

  get metrics(): OrderMetrics {
    return {
      itemCount: this.items.size,
      uniqueProducts: this.items.size,
      hasDiscount: this.discountAmount > 0,
      couponCount: this.appliedCoupons.size,
    };
  }
}

// 외부 클래스는 public API만 사용
class OrderProcessor {
  processOrder(order: Order): void {
    order.removeInvalidItems();
    order.markAsProcessing();
  }
}

이제 OrderProcessorOrder의 내부 구현을 전혀 모른다. items가 배열인지 Map인지, appliedCoupons가 Set인지 Array인지 알 필요가 없다. Order가 제공하는 public 메서드만 호출하면 됨. Order의 내부 구현을 자유롭게 바꿀 수 있게 되었다.


3. Middle Man (중개인)

이게 뭔데

클래스의 메서드 대부분이 다른 객체에 단순 위임(delegation)만 하고, 자체적인 로직이나 가치를 추가하지 않는 구조다. 택배 중개인이 포장도 안 하고, 분류도 안 하고, 그냥 "여기요~" 하면서 넘기기만 하는 것과 같다.

Middle Man이란

클래스의 메서드 대부분이 내부 객체의 동일한 메서드를 호출하는 것 외에 아무런 추가 로직이 없는 코드 냄새. "이 클래스가 없어도 되지 않나?"라는 의문이 드는 순간 Middle Man이다.

이건 "캡슐화를 위한 캡슐화" 또는 "추상화를 위한 추상화"에서 주로 발생한다. 레이어를 나누는 게 좋다는 말을 듣고, 무조건 한 겹 더 감싸는 거. 근데 감싸 놓고 하는 일이 아무것도 없으면 그건 그냥 노이즈다.

이런 코드

typescript
// 이 클래스... 존재 의미가 있는가?
class UserManager {
  private userRepository: UserRepository;

  constructor(userRepository: UserRepository) {
    this.userRepository = userRepository;
  }

  async getUser(id: string): Promise<User | null> {
    return this.userRepository.getUser(id);  // 그냥 전달
  }

  async createUser(data: CreateUserData): Promise<User> {
    return this.userRepository.createUser(data);  // 그냥 전달
  }

  async updateUser(id: string, data: UpdateUserData): Promise<User> {
    return this.userRepository.updateUser(id, data);  // 그냥 전달
  }

  async deleteUser(id: string): Promise<void> {
    return this.userRepository.deleteUser(id);  // 그냥 전달
  }

  async listUsers(page: number, size: number): Promise<User[]> {
    return this.userRepository.listUsers(page, size);  // 그냥 전달
  }

  async getUserByEmail(email: string): Promise<User | null> {
    return this.userRepository.getUserByEmail(email);  // 그냥 전달
  }

  async countUsers(): Promise<number> {
    return this.userRepository.countUsers();  // 그냥 전달
  }
}

// 사용하는 쪽
class UserController {
  constructor(private userManager: UserManager) {}

  async handleGetUser(req: Request, res: Response) {
    const user = await this.userManager.getUser(req.params.id);
    // ...
  }
}

UserManager의 메서드 7개를 보자. 전부 this.userRepository의 같은 이름 메서드를 호출하는 것 외에 하는 일이 없다. 로깅도 없고, 검증도 없고, 캐싱도 없고, 변환도 없다. 순수한 pass-through. 이 클래스를 지우고 UserControllerUserRepository를 직접 쓰면 아무 문제가 없다.

뭐가 문제냐면
  • 코드 노이즈: 아무런 가치를 추가하지 않는 클래스가 코드베이스에 존재하며 인지 부하를 늘림
  • 간접 참조 비용: 코드를 읽을 때 "UserManager.getUser는 뭘 하지?" → "아, 그냥 Repository 호출이네" 라는 불필요한 추적 발생
  • 유지보수 부담: Repository에 메서드를 추가할 때마다 Manager에도 동일한 메서드를 추가해야 함 (Shotgun Surgery)
  • 테스트 무의미: Middle Man의 테스트는 "위임이 제대로 되는지" 확인하는 게 전부. 테스트 시간 낭비

고친 코드

가장 간단한 해결책: Middle Man을 제거하고 직접 사용한다.

typescript
// UserManager를 삭제하고, 컨트롤러가 Repository를 직접 사용
class UserController {
  constructor(private userRepository: UserRepository) {}

  async handleGetUser(req: Request, res: Response) {
    const user = await this.userRepository.getUser(req.params.id);
    if (!user) return res.status(404).json({ error: "사용자를 찾을 수 없습니다" });
    return res.json(user);
  }

  async handleCreateUser(req: Request, res: Response) {
    const user = await this.userRepository.createUser(req.body);
    return res.status(201).json(user);
  }
}

"잠깐, 나중에 비즈니스 로직이 추가되면 어떡해?" 좋은 질문이다. 그때 서비스 레이어를 추가하면 된다. 지금 존재하지 않는 로직을 위해 미리 빈 레이어를 만드는 것이 바로 Middle Man이다.

Middle Man이 괜찮은 경우

모든 위임이 나쁜 건 아니다. 이런 경우에는 위임 레이어가 정당하다:

  • Facade 패턴: 복잡한 하위 시스템에 대한 단순한 인터페이스를 제공할 때
  • Decorator 패턴: 로깅, 캐싱, 인증 등 횡단 관심사를 추가할 때
  • Adapter 패턴: 인터페이스 불일치를 해결할 때

핵심은 "이 레이어가 가치를 추가하는가?"다. 가치가 있으면 정당한 추상화, 가치가 없으면 Middle Man.

실제로 가치를 추가하는 서비스 레이어는 이런 모습이다:

typescript
// 이건 Middle Man이 아니다 — 실제 비즈니스 로직이 있다
class UserService {
  constructor(
    private userRepo: UserRepository,
    private emailService: EmailService,
    private cache: CacheService,
    private logger: Logger,
  ) {}

  async createUser(data: CreateUserData): Promise<User> {
    // 검증 — 가치 추가
    if (await this.userRepo.getUserByEmail(data.email)) {
      throw new ConflictError("이미 존재하는 이메일입니다");
    }

    // 생성
    const user = await this.userRepo.createUser(data);

    // 부수 효과 관리 — 가치 추가
    await this.emailService.sendWelcome(user.email);
    this.cache.invalidate(`users:list`);
    this.logger.info(`User created: ${user.id}`);

    return user;
  }

  async getUser(id: string): Promise<User> {
    // 캐싱 — 가치 추가
    const cached = await this.cache.get<User>(`user:${id}`);
    if (cached) return cached;

    const user = await this.userRepo.getUser(id);
    if (!user) throw new NotFoundError("사용자를 찾을 수 없습니다");

    await this.cache.set(`user:${id}`, user, 300);
    return user;
  }
}

UserService는 Middle Man이 아니다. 검증, 캐싱, 이벤트 발행, 에러 처리라는 실제 가치를 추가하고 있다. 위임도 하지만 위임만 하는 게 아닌 거.


세 냄새의 공통점과 차이

Feature EnvyInappropriate IntimacyMiddle Man
핵심 문제메서드가 엉뚱한 클래스에 있음클래스가 서로의 내부를 너무 잘 앎클래스가 자기 역할 없이 전달만 함
커플링 방향A가 B의 데이터에 과의존A ↔ B 양방향 과의존A → B 무의미한 단방향
해결 방법메서드를 올바른 클래스로 이동public API로 캡슐화중개인 제거, 직접 사용
리팩토링 기법Move MethodHide Delegate, Extract InterfaceRemove Middle Man
감지 신호"이 메서드 여기 왜 있지?""_필드를 외부에서 직접 접근""이 클래스 없어도 되지 않나?"
세 가지 냄새의 교훈

Feature Envy → 메서드를 올바른 클래스로 이동하라. 데이터와 그 데이터를 사용하는 행위는 함께 있어야 한다.

Inappropriate Intimacy → 캡슐화를 존중하라. 다른 클래스의 내부 구현이 아니라 public API에만 의존하라.

Middle Man → 가치를 추가하지 않는 레이어를 제거하라. "나중에 필요할 수도 있으니까"는 YAGNI 위반이다.

실무에서 감지하는 법

이 세 가지 냄새를 코드 리뷰에서 잡는 간단한 질문 세트가 있다.

Feature Envy 감지 질문:

코드 리뷰에서 메서드를 볼 때 이렇게 물어보자: "이 메서드에서 this의 필드를 몇 번 쓰고, 매개변수 객체의 필드를 몇 번 쓰는가?" 매개변수 쪽이 압도적으로 많으면 Feature Envy다. 해당 메서드는 매개변수의 클래스로 이동해야 한다.

Inappropriate Intimacy 감지 질문:

코드에서 obj._privateFieldobj.internalMap.get() 같은 패턴이 보이면 즉시 의심하자. TypeScript에서 private 키워드 없이 _ 접두사 관행으로만 private을 표시하는 경우 특히 많이 발생한다. # private 필드나 private 키워드를 적극 활용하고, 필요한 행위는 메서드로 노출하자.

Middle Man 감지 질문:

클래스의 메서드를 쭉 훑어보면서 "이 메서드가 자체 로직 없이 다른 객체에 위임만 하는가?"를 체크하자. 위임만 하는 메서드가 절반 이상이면 Middle Man이다. 그 레이어가 정말 필요한지 냉정하게 판단해야 한다.

결국 세 냄새 모두 "이 코드가 여기 있어야 할 이유가 있는가?"라는 하나의 질문으로 귀결된다. 이 질문을 코드를 읽을 때마다 던지는 습관이 있으면, 자연스럽게 더 나은 구조를 만들게 된다.


이전 글: 변경 전파 쌍 | 다음 글: 상속 지옥