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란
메서드가 자기 클래스의 필드/메서드보다 다른 클래스의 필드/메서드를 더 많이 참조하는 코드 냄새. 해당 메서드가 현재 클래스에 속해 있을 이유가 없다는 신호. "이 메서드는 저쪽 클래스에 있어야 하는 거 아닌가?"
이건 생각보다 자주 볼 수 있다. 특히 "유틸리티 클래스"나 "헬퍼 클래스"에서 많이 발생함. 남의 데이터를 이리저리 조합해서 뭔가를 만들어내는 메서드가 떡하니 엉뚱한 클래스에 앉아 있는 거.
이런 코드
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의 원인이 됨)
고친 코드
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 필드, 내부 자료구조, 구현 방식)에 직접 접근하거나 깊이 의존하는 코드 냄새. 캡슐화 원칙 위반. 한 쪽의 내부 변경이 다른 쪽을 즉시 깨뜨린다.
인간관계로 치면 "경계선이 없는 관계"다. 상대방의 일기장을 읽고, 지갑을 뒤지고, 비밀번호를 공유하는 관계. 단기적으로는 효율적일 수 있어도, 한 쪽이 변하면 관계 전체가 깨진다.
이런 코드
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,
};
}
}
OrderProcessor와 OrderAnalytics가 Order의 내부를 완전히 들여다보고 있다. _items 배열의 구조, _appliedCoupons가 Set이라는 것, _version 필드의 존재까지 전부 알고 있음. Order의 내부 구현을 조금만 바꿔도 두 클래스가 동시에 깨진다.
뭐가 문제냐면
- 변경 파급:
Order._items의 타입을Map으로 바꾸면?OrderProcessor와OrderAnalytics둘 다 깨짐 - 캡슐화 사망:
Order의 불변식(invariant)을 외부에서 마음대로 깨뜨릴 수 있다 (예:_items.splice로 직접 삭제) - 테스트 취약: 테스트가 내부 구현에 의존해서, 리팩토링하면 테스트도 전부 깨짐
- 이해도 폭발:
Order를 이해하려면OrderProcessor와OrderAnalytics가Order를 어떻게 쓰는지도 다 알아야 함
고친 코드
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();
}
}
이제 OrderProcessor는 Order의 내부 구현을 전혀 모른다. items가 배열인지 Map인지, appliedCoupons가 Set인지 Array인지 알 필요가 없다. Order가 제공하는 public 메서드만 호출하면 됨. Order의 내부 구현을 자유롭게 바꿀 수 있게 되었다.
3. Middle Man (중개인)
이게 뭔데
클래스의 메서드 대부분이 다른 객체에 단순 위임(delegation)만 하고, 자체적인 로직이나 가치를 추가하지 않는 구조다. 택배 중개인이 포장도 안 하고, 분류도 안 하고, 그냥 "여기요~" 하면서 넘기기만 하는 것과 같다.
Middle Man이란
클래스의 메서드 대부분이 내부 객체의 동일한 메서드를 호출하는 것 외에 아무런 추가 로직이 없는 코드 냄새. "이 클래스가 없어도 되지 않나?"라는 의문이 드는 순간 Middle Man이다.
이건 "캡슐화를 위한 캡슐화" 또는 "추상화를 위한 추상화"에서 주로 발생한다. 레이어를 나누는 게 좋다는 말을 듣고, 무조건 한 겹 더 감싸는 거. 근데 감싸 놓고 하는 일이 아무것도 없으면 그건 그냥 노이즈다.
이런 코드
// 이 클래스... 존재 의미가 있는가?
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. 이 클래스를 지우고 UserController가 UserRepository를 직접 쓰면 아무 문제가 없다.
뭐가 문제냐면
- 코드 노이즈: 아무런 가치를 추가하지 않는 클래스가 코드베이스에 존재하며 인지 부하를 늘림
- 간접 참조 비용: 코드를 읽을 때 "UserManager.getUser는 뭘 하지?" → "아, 그냥 Repository 호출이네" 라는 불필요한 추적 발생
- 유지보수 부담: Repository에 메서드를 추가할 때마다 Manager에도 동일한 메서드를 추가해야 함 (Shotgun Surgery)
- 테스트 무의미: Middle Man의 테스트는 "위임이 제대로 되는지" 확인하는 게 전부. 테스트 시간 낭비
고친 코드
가장 간단한 해결책: Middle Man을 제거하고 직접 사용한다.
// 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.
실제로 가치를 추가하는 서비스 레이어는 이런 모습이다:
// 이건 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 Envy | Inappropriate Intimacy | Middle Man | |
|---|---|---|---|
| 핵심 문제 | 메서드가 엉뚱한 클래스에 있음 | 클래스가 서로의 내부를 너무 잘 앎 | 클래스가 자기 역할 없이 전달만 함 |
| 커플링 방향 | A가 B의 데이터에 과의존 | A ↔ B 양방향 과의존 | A → B 무의미한 단방향 |
| 해결 방법 | 메서드를 올바른 클래스로 이동 | public API로 캡슐화 | 중개인 제거, 직접 사용 |
| 리팩토링 기법 | Move Method | Hide Delegate, Extract Interface | Remove Middle Man |
| 감지 신호 | "이 메서드 여기 왜 있지?" | "_필드를 외부에서 직접 접근" | "이 클래스 없어도 되지 않나?" |
세 가지 냄새의 교훈
Feature Envy → 메서드를 올바른 클래스로 이동하라. 데이터와 그 데이터를 사용하는 행위는 함께 있어야 한다.
Inappropriate Intimacy → 캡슐화를 존중하라. 다른 클래스의 내부 구현이 아니라 public API에만 의존하라.
Middle Man → 가치를 추가하지 않는 레이어를 제거하라. "나중에 필요할 수도 있으니까"는 YAGNI 위반이다.
실무에서 감지하는 법
이 세 가지 냄새를 코드 리뷰에서 잡는 간단한 질문 세트가 있다.
Feature Envy 감지 질문:
코드 리뷰에서 메서드를 볼 때 이렇게 물어보자: "이 메서드에서 this의 필드를 몇 번 쓰고, 매개변수 객체의 필드를 몇 번 쓰는가?" 매개변수 쪽이 압도적으로 많으면 Feature Envy다. 해당 메서드는 매개변수의 클래스로 이동해야 한다.
Inappropriate Intimacy 감지 질문:
코드에서 obj._privateField나 obj.internalMap.get() 같은 패턴이 보이면 즉시 의심하자. TypeScript에서 private 키워드 없이 _ 접두사 관행으로만 private을 표시하는 경우 특히 많이 발생한다. # private 필드나 private 키워드를 적극 활용하고, 필요한 행위는 메서드로 노출하자.
Middle Man 감지 질문:
클래스의 메서드를 쭉 훑어보면서 "이 메서드가 자체 로직 없이 다른 객체에 위임만 하는가?"를 체크하자. 위임만 하는 메서드가 절반 이상이면 Middle Man이다. 그 레이어가 정말 필요한지 냉정하게 판단해야 한다.
결국 세 냄새 모두 "이 코드가 여기 있어야 할 이유가 있는가?"라는 하나의 질문으로 귀결된다. 이 질문을 코드를 읽을 때마다 던지는 습관이 있으면, 자연스럽게 더 나은 구조를 만들게 된다.
← 이전 글: 변경 전파 쌍 | 다음 글: 상속 지옥 →