Search

[6장] 객체와 자료구조 곱씹어보기

tl:dr 분별있는 프로그래머는 모든것이 객체라는 생각이 미신임을 잘 안다. 때로는 단순한 자료구조와 절차적인 코드가 가장 적합한 상황도 있다.

읽고나서 견해

챕터의 초반부터 getter/setter는 private 변수의 의미를 무색하게 만드는 자기모순적인 패턴이라고 비판한다. 변수를 숨기면서 동시에 노출하는 이 어중간한 접근이 객체도 자료구도조 아닌 마치 최악의 하이브리드를 만드는 모순을 보여주었다.
예를 들어, 다음과 같은 코드를 보자:
class Rectangle { private width: number; private height: number; getWidth() { return this.width; } setWidth(width: number) { this.width = width; } getHeight() { return this.height; } setHeight(height: number) { this.height = height; } } // 사용하는 쪽 const rect = new Rectangle(); rect.setWidth(10); rect.setHeight(20); const area = rect.getWidth() * rect.getHeight();
TypeScript
복사
이 클래스는 겉으로는 객체처럼 보이지만, 실제로는 단순히 데이터를 담는 자료구조에 불과하다. private으로 변수를 숨겼지만, getter/setter를 통해 외부에서 마음대로 접근할 수 있기 때문이다. 이는 다음 코드와 본질적으로 다르지 않다:
type Rectangle = { width: number; height: number; } const rect = { width: 10, height: 20 }; const area = rect.width * rect.height;
TypeScript
복사
오히려 후자가 더 솔직하다.
저자는 여기서 더 나아가 핵심을 지적한다. "변수 사이에 함수라는 계층을 넣는다고 구현이 저절로 감춰지지는 않는다." getter/setter라는 형식적인 메서드를 추가한다고 해서 캡슐화가 이루어지는 것이 아니라는 것이다. 진짜 필요한 것은 추상화다.
구체적인 예를 보자:
// 나쁨: 구현이 그대로 노출됨 interface Vehicle { getFuelTankCapacityInGallons(): number; getGallonsOfGasoline(): number; } // 사용하는 쪽에서 계산해야 함 const percentRemaining = (vehicle.getGallonsOfGasoline() / vehicle.getFuelTankCapacityInGallons()) * 100;
TypeScript
복사
이 인터페이스는 내부 구현(갤런 단위 연료통)을 그대로 드러낸다. 함수로 감쌌지만, 사용자는 여전히 "이 차가 갤런 단위로 연료를 저장한다"는 구현 세부사항을 알아야 한다.
// 좋음: 추상화된 인터페이스 interface Vehicle { getPercentFuelRemaining(): number; } // 깔끔하게 사용 const percentRemaining = vehicle.getPercentFuelRemaining();
TypeScript
복사
이제 사용자는 내부적으로 갤런을 쓰든 리터를 쓰든 알 필요가 없다. 단지 "남은 연료의 비율"이라는 추상적인 개념만 이해하면 된다. 내부 구현이 바뀌어도 인터페이스는 그대로 유지된다.
저자가 말하는 "진정한 의미의 클래스"는 바로 이것이다. 형식적인 getter/setter가 아니라, 사용자가 구현을 모른 채 자료의 핵심을 조작할 수 있게 하는 추상 인터페이스이다.

절차적 vs 객체지향의 트레이드오프

객체지향 프로그래머가 절차적 코드로 만들어진 도형 클래스를 본다면 코웃음을 칠지도 모른다. 하지만 저자는 흥미로운 반전을 제시한다.

절차적 도형의 장점

// 도형들 (자료구조) class Square { topLeft: Point; side: number; } class Circle { center: Point; radius: number; } // 로직은 한 곳에 class Geometry { area(shape: Object): number { if (shape instanceof Square) { return shape.side * shape.side; } else if (shape instanceof Circle) { return Math.PI * shape.radius ** 2; } } }
TypeScript
복사
여기서 둘레 길이를 구하는 함수를 추가한다면?
class Geometry { area(shape: Object): number { /* 기존 코드 */ } // 새 함수 추가 - 도형 클래스들은 변경 없음! perimeter(shape: Object): number { if (shape instanceof Square) { return 4 * shape.side; } else if (shape instanceof Circle) { return 2 * Math.PI * shape.radius; } } }
TypeScript
복사
Square와 Circle 클래스는 전혀 수정할 필요가 없다. 반대로 새 도형을 추가하려면? Geometry 클래스의 모든 함수(area, perimeter 등)를 고쳐야 한다.
객체지향 도형의 장점
interface Shape { area(): number; } class Square implements Shape { area(): number { return this.side ** 2; } } class Circle implements Shape { area(): number { return Math.PI * this.radius ** 2; } }
TypeScript
복사
새 도형 추가는 쉽다:
// 기존 코드 수정 없이 새 타입 추가! class Triangle implements Shape { area(): number { return this.base * this.height / 2; } }
TypeScript
복사
하지만 새 메서드를 추가하려면?
interface Shape { area(): number; perimeter(): number; // 추가 } // 모든 클래스를 수정해야 함 class Square implements Shape { area(): number { /* 기존 */ } perimeter(): number { return 4 * this.side; } // 추가 } class Circle implements Shape { area(): number { /* 기존 */ } perimeter(): number { return 2 * Math.PI * this.radius; } // 추가 } class Triangle implements Shape { area(): number { /* 기존 */ } perimeter(): number { /* ... */ } // 추가 }
TypeScript
복사

정반대의 트레이드오프

절차적: 새 함수 추가 쉬움 새 타입 추가 어려움
객체지향: 새 타입 추가 쉬움 새 함수 추가 어려움
저자는 이것이 상호 보완적이라고 말한다. 상황에 따라 적절한 방식을 선택해야 한다는 것이다. 객체지향이 무조건 우월한 것이 아니다.

디미터 법칙: 낯선 사람과 대화하지 마라

객체와 자료구조를 구분하는 또 다른 관점이 디미터 법칙(Law of Demeter)이다. 간단히 말하면 "친구하고만 대화하라"는 원칙으로, 한 객체가 다른 객체의 내부 구조를 탐색하며 뒤져서는 안 된다는 의미다.
다음과 같은 코드를 흔히 볼 수 있다:
// 기차 충돌 (train wreck) const outputDir = ctxt.getOptions().getScratchDir().getAbsolutePath();
TypeScript
복사
이 코드는 여러 객체를 거쳐 최종 값에 도달한다. 마치 기차가 충돌하는 것처럼 보인다고 해서 "기차 충돌"이라 부른다.
이것이 왜 문제일까? ctxt 객체가 Options를 반환하고, Options가 ScratchDir를 반환하고, ScratchDir가 경로를 반환한다는 내부 구조를 모두 알아야 한다. 만약 중간에 구조가 바뀌면? 이 코드를 사용하는 모든 곳을 수정해야 한다.

자료구조라면 괜찮다

하지만 여기서 중요한 구분이 나온다. 만약 이들이 자료구조라면?
type Options = { scratchDir: ScratchDir; } type ScratchDir = { absolutePath: string; } // 자료구조는 내부가 노출되어 있으므로 당연히 접근 가능 const outputDir = ctxt.options.scratchDir.absolutePath;
TypeScript
복사
자료구조는 내부를 공개하는 것이 목적이므로 이런 접근이 자연스럽다. 디미터 법칙은 객체에 적용되는 규칙이지, 자료구조에는 해당하지 않는다.

진짜 문제: 하이브리드

문제는 위 코드가 객체인지 자료구조인지 불분명하다는 것이다:
// 이게 객체야? 자료구조야? const outputDir = ctxt.getOptions().getScratchDir().getAbsolutePath();
TypeScript
복사
getter를 사용하니 객체처럼 보이지만, 실제로는 내부 구조를 탐색하고 있으니 자료구조처럼 사용된다. 이것이 바로 앞서 말한 "최악의 하이브리드"다.

객체라면: 묻지 말고 시켜라

만약 이들이 진짜 객체라면, 내부 구조를 몰라야 한다:
// 나쁨: 내부를 탐색하며 묻는다 const outputDir = ctxt.getOptions().getScratchDir().getAbsolutePath(); const filePath = outputDir + "/" + fileName; writeFile(filePath, data); // 좋음: 의도를 말하고 시킨다 ctxt.createScratchFileStream(fileName);
TypeScript
복사
후자가 훨씬 낫다. ctxt에게 "임시 파일 스트림을 만들어줘"라고 의도만 전달하면, 내부적으로 어떻게 처리하든 알 필요가 없다. 이것이 진정한 객체 지향이다.
이 챕터를 통해 적어도 "왜 이렇게 하는가"는 설명할 수 있게 됐다. 예를들어 API 응답을 DTO로 만드는 이유, 비즈니스 로직을 클래스로 만드는 이유, getter/setter를 지양하는 이유. 단순히 "좋은 코드"라서가 아니라 명확한 의도를 가지고 선택할 수 있다.
무엇보다, 레거시 코드를 보고 "이게 왜 이상하지?"라는 막연한 느낌이 아니라 "이건 객체와 자료구조가 섞인 하이브리드라서 문제다"고 구체적으로 지적할 수 있게 된 것 같다.