Clean Code · Ch.06

객체와 자료 구조

자료 추상화

구현을 숨기고 추상적인 인터페이스로 자료를 표현하라.

❌ 구체적인 Point 클래스
public class Point {
  public double x;
  public double y;
}
✅ 추상적인 Point 인터페이스
public interface IPoint {
  double getX();
  double getY();
  void setCartesian(double x, double y);
  double getR();
  double getTheta();
  void setPolar(double r, double theta);
}
특성 설명
구현 캡슐화 사용자는 내부가 직각좌표인지 극좌표인지 모름
변경 유연성 추후에 PolarPoint, SphericalPoint로 교체 가능
추상적 의미 "좌표"라는 의미로 다룸, 수학적 개념과 맞음
SRP/OCP 만족 구현은 바꿀 수 있으나 인터페이스는 안정적 유지
C# 구현 예시 CartesianPoint
public class CartesianPoint : IPoint
{
    private double _x, _y;
    public double GetX() => _x;
    public double GetY() => _y;
    public void SetCartesian(double x, double y) { _x = x; _y = y; }
    public double GetR() => Math.Sqrt(_x * _x + _y * _y);
    public double GetTheta() => Math.Atan2(_y, _x);
    public void SetPolar(double r, double theta)
    {
        _x = r * Math.Cos(theta);
        _y = r * Math.Sin(theta);
    }
}
핵심 — 자료를 세세하게 공개하기보다 추상적인 개념으로 표현하라. 인터페이스나 조회/설정 함수만으로는 추상화가 이루어지지 않는다.

전략 패턴과 자료 추상화

런타임에 알고리즘(또는 내부 구조)을 바꾸고 싶을 때 사용하는 패턴.

ICoordinateStrategy 인터페이스
public interface ICoordinateStrategy
{
    double GetX();
    double GetY();
    void SetCartesian(double x, double y);
    void SetPolar(double r, double theta);
}
직각 좌표 전략
public class CartesianCoordinate : ICoordinateStrategy
{
    private double _x, _y;
    public double GetX() => _x;
    public double GetY() => _y;
    public void SetCartesian(double x, double y) { _x = x; _y = y; }
    public void SetPolar(double r, double theta)
    {
        _x = r * Math.Cos(theta);
        _y = r * Math.Sin(theta);
    }
}
극 좌표 전략
public class PolarCoordinate : ICoordinateStrategy
{
    private double _r, _theta;
    public double GetX() => _r * Math.Cos(_theta);
    public double GetY() => _r * Math.Sin(_theta);
    public void SetCartesian(double x, double y)
    {
        _r = Math.Sqrt(x * x + y * y);
        _theta = Math.Atan2(y, x);
    }
    public void SetPolar(double r, double theta) { _r = r; _theta = theta; }
}
사용하는 쪽 — 컨텍스트
public class Point
{
    private ICoordinateStrategy _coordinate;
    public Point(ICoordinateStrategy coordinate) { _coordinate = coordinate; }
    public double X => _coordinate.GetX();
    public double Y => _coordinate.GetY();
    public void SetCartesian(double x, double y) => _coordinate.SetCartesian(x, y);
    public void SetPolar(double r, double theta) => _coordinate.SetPolar(r, theta);
}
🔌

내부가 Cartesian인지 Polar인지 모른 채 사용 가능 — 자료 추상화 완벽 달성.

🔄

런타임에 전략만 바꿔 끼우면 됨 → 유연성, 테스트성 향상

디자인 트레이드오프: OOP vs 절차적

어느 쪽이 옳은가가 아니라, 언제 무엇을 쓸지의 문제다.

자료 구조: 데이터는 공개, 함수는 외부에서 절차적 방식
public class Square { public Point TopLeft; public double Side; }
public class Rectangle { public Point TopLeft; public double Height; public double Width; }
public class Circle { public Point Center; public double Radius; }
public class Geometry
{
    public double Area(object shape)
    {
        return shape switch
        {
            Square s   => s.Side * s.Side,
            Rectangle r => r.Height * r.Width,
            Circle c   => Math.PI * c.Radius * c.Radius,
            _           => throw new ArgumentException("Unknown shape")
        };
    }
}
⚠️

자료구조 특성 — 기존 자료 구조를 변경하지 않으면서 새 함수를 추가하기 쉽다. 반면, 새로운 자료 구조를 추가하려면 모든 함수를 고쳐야 한다.


✅ 추상 인터페이스 객체 지향 방식
public interface IShape { double Area(); }

public class Square : IShape
{
    private double _side;
    public Square(Point topLeft, double side) { _side = side; }
    public double Area() => _side * _side;
}
public class Circle : IShape
{
    private double _radius;
    public Circle(Point center, double radius) { _radius = radius; }
    public double Area() => Math.PI * _radius * _radius;
}

객체 특성 — 기존 함수를 변경하지 않으면서 새 클래스를 추가하기 쉽다. 반면, 새로운 함수(동작)를 추가하려면 모든 클래스를 고쳐야 한다.

항목 절차적 (자료 중심) 객체 지향 (다형성)
새 함수 추가 ✅ 쉬움 — Geometry에만 추가 ❌ 어려움 — 모든 클래스 수정
새 타입 추가 ❌ 어려움 — 모든 함수 수정 ✅ 쉬움 — 새 클래스만 추가
적합한 경우 함수가 자주 추가되는 경우 타입이 자주 추가되는 경우
선택 기준 — 새로운 자료 타입(클래스)이 필요하면 OOP, 새로운 함수(동작)가 필요하면 절차적 방식이 유리하다.

Visitor Pattern

객체 구조를 변경하지 않고 새로운 연산(동작)을 추가하는 패턴.

IShape + IShapeVisitor
public interface IShape { double Accept(IShapeVisitor visitor); }

public interface IShapeVisitor
{
    double Visit(Square square);
    double Visit(Rectangle rectangle);
    double Visit(Circle circle);
}
도형 클래스들
public class Square : IShape
{
    public double Side { get; }
    public Square(double side) => Side = side;
    public double Accept(IShapeVisitor visitor) => visitor.Visit(this);
}
public class Circle : IShape
{
    public double Radius { get; }
    public Circle(double radius) => Radius = radius;
    public double Accept(IShapeVisitor visitor) => visitor.Visit(this);
}
AreaVisitor
public class AreaVisitor : IShapeVisitor
{
    public double Visit(Square s) => s.Side * s.Side;
    public double Visit(Rectangle r) => r.Width * r.Height;
    public double Visit(Circle c) => Math.PI * c.Radius * c.Radius;
}
PerimeterVisitor
public class PerimeterVisitor : IShapeVisitor
{
    public double Visit(Square s) => 4 * s.Side;
    public double Visit(Rectangle r) => 2 * (r.Width + r.Height);
    public double Visit(Circle c) => 2 * Math.PI * c.Radius;
}
사용 예시
var shapes = new List<IShape> { new Square(5), new Rectangle(4, 6), new Circle(3) };
var areaVisitor = new AreaVisitor();
foreach (var shape in shapes)
    Console.WriteLine($"Area: {shape.Accept(areaVisitor)}");
항목 설명
새 도형 추가 Visitor에 Visit(NewShape) 추가 필요 → 약점
새 동작 추가 Visitor 클래스 하나 추가로 끝 (기존 도형 손대지 않음) ✅
OCP 만족 동작(방문자)은 열려 있고, 도형 구조는 닫혀 있음
Visitor의 장점 — 새로운 연산이 자주 추가되고 클래스 구조는 안정적일 때 가장 유용하다. OOP의 새 함수 추가 약점을 보완한다.

디미터 법칙 (Law of Demeter)

모듈은 자신이 조작하는 객체의 속사정을 몰라야 한다. 낯선 사람은 경계하고 친구랑만 놀아라.

허용 범위 — 클래스 C의 메서드 f는 다음만 호출할 수 있다: (1) 클래스 C 자신, (2) f가 생성한 객체, (3) f의 인수로 넘어온 객체, (4) C의 인스턴스 변수에 저장된 객체. 허용된 메서드가 반환하는 객체의 메서드는 호출하면 안 된다.
❌ 기차 충돌 Train Wreck
readonly String outputDir = ctxt.getOptions().getScratchDir().getAbsolutePath();
⚠️

여러 객차가 한 줄로 이어진 기차처럼 보인다. ctxt의 내부 구조를 속속들이 알아야 코드가 작동한다.

✅ 분리
Options opts = ctxt.getOptions();
File scratchDir = opts.getScratchDir();
readonly String outputDir = scratchDir.getAbsolutePath();
차라리 자료 구조라면...
final String outputDir = ctxt.options.scratchDir.absolutePath;
💡

이것이 자료 구조라면 내부 구조를 직접 노출하는 것이 자연스럽다. 디미터 법칙은 객체에 적용되는 법칙이다.

낯선 사람 경계 친구와만 소통 기차 충돌 방지 결합도 감소

DTO와 잡종 객체

데이터 전달용 객체(DTO)에 비즈니스 로직을 넣으면 잡종 객체가 된다.

Active Record 스타일 — 적절한 사용
public class User
{
    public int Id { get; set; }
    public string Email { get; set; }
    public string PasswordHash { get; set; }

    public void Save() { Database.Save(this); }
    public static User? Find(int id) { return Database.FindById<User>(id); }
}
❌ 잡종 객체 — 비즈니스 로직 추가 시
public class User
{
    public int Id { get; set; }
    public string Email { get; set; }

    public void Save() => Database.Save(this);

    // ❌ 문제: 비즈니스 로직이 섞임
    public bool CanLogin(DateTime now)
    {
        return Email.Contains("@") && now.Hour < 22;
    }
}
🚨

잡종 객체의 문제 — 비즈니스 로직과 DB 저장 책임이 섞여 있다. SRP를 위반하고, 테스트하기 어렵고, 두 가지 이유로 변경된다.

해결 — 비즈니스 로직은 별도 Service/DomainObject로 분리하고, DTO는 순수한 자료 전달 역할만 담당하게 하라.