오브젝트 독서회 13회차 - 14장 일관성 있는 협력

이번 회차에는 @vincent @freebear 님께서 참여했습니다.

이번 회차에서는 서로의 인터페이스 사용 경험에 대해서 많은 이야기를 했습니다.


P 470

객체는 협력을 위해 존재한다. 협력은 객체가 존재하는 이유와 문맥을 제공한다. 잘 설계된 애플리케이션은 이해하기 쉽고, 수정이 용이하며, 재사용 가능한 협력의 모임이다. 객체지향 설계의 목표는 적절한 책임을 수행하는 객체들의 협력을 기반으로 결합도가 낮고 재사용 가능한 코드 구조를 창조하는 것이다.

@freebear

객체지향 패러다임의 장점은 설계를 재사용할 수 있다는 것이다. 하지만 재사용은 공짜로 얻어지지 않는다. 재사용을 위해서는 객체들의 협력 방식을 일관성 있게 만들어야 한다. 일관성은 설계에 드는 비용을 감소시킨다.

@vincent

객체들의 협력이 전체적으로 일관성 있는 유사한 패턴을 따른다면 시스템을 이해하고 확장하기 위해 요구되는 정신적인 부담을 줄일 수 있다.

@freebear

P478

통화 기간에 대한 정보를 가장 잘 알고 있는 객체는 Call이다. 하지만 Call은 통화 기간은 잘 알지 몰라도 기간 자체를 처리하는 방법에 대해서는 전문가가 아니다. 기간을 처리하는 방법에 대한 전문가는 바로 DateTimeInterval이다. 따라서 통화 기간을 일자 단위로 나누는 책임은 DateTimeInterval에게 할당하고 Call이 DateTimeInterval에게 분할을 요청하도록 협력을 설계하는 것이 적절할 것이다.

@vincent

P485

비일관성은 두 가지 상황에서 발목을 잡는다. 하나는 새로운 구현을 추가해야 하는 상황이고, 또 다른 하나는 기존의 구현을 이해해야 하는 상황이다. 그리고 이 장애물이 문제인 이유는 개발자로서 우리가 수행하는 대부분의 활동이 코드를 추가하고 이해하는 일과 깊숙히 연관돼 있기 때문이다.

@vincent @freebear

P486

결론은 유사한 기능을 서로 다른 방식으로 구현해서는 안 된다는 것이다.

@vincent

유사한 기능은 유사한 방식으로 구현해야 한다.

@freebear

P487

기존의 설계가 어떤 가이드도 제공하지 않기 때문에 새로운 기본 정책을 구현해야 하는 상황에서 또 다른 개발자는 또 다른 방식으로 기본 정책을 구현할 가능성이 높다.

@vincent

P488

일관성 있는 설계를 만드는 데 가장 훌륭한 조언은 다양한 설계 경험을 익히라는 것이다. 풍부한 설계 경험을 가진 사람은 어떤 변경이 중요한지, 그리고 그 변경을 어떻게 다뤄야 하는지에 대한 통찰력을 가지게 된다. 따라서 설계 경험이 풍부하면 풍부할수록 어떤 위치에서 일관성을 보장해야 하고 일관성을 제공하기 위해 어떤 방법을 사용해야 하는지를 직관적으로 결정할 수 있다. 하지만 이런 설계 경험을 단기간에 쌓아 올리는 것은 생각보다 어려운 일이다.

@vincent @freebear

일관성 있는 설계를 위한 두 번째 조언은 널리 알려진 디자인 패턴을 학습하고 변경이라는 문맥 안에서 디자인 패턴을 적용해 보라는 것이다.

@freebear

다음 장에서 설명하겠지만 디자인 패턴은 특정한 변경에 대해 일관성 있는 설계를 만들 수 있는 경험 법칙을 모아놓은 일종의 설계 템플릿이다. 디자인 패턴을 학습하면 빠른 시간 안에 전문가의 경험을 흡수할 수 있다.

@vincent

##P489

바뀌는 부분을 따로 뽑아서 캡슐화한다. 그렇게 하면 나중에 바뀌지 않는 부분에는 영향을 미치지 않은 채로 그 부분만 고치거나 확장할 수 있다.

@freebear

P490

절차지향 프로그램에서 변경을 처리하는 전통적인 방법은 이처럼 조건문의 분기를 추가하거나 개별 분기 로직을 수정하는 것이다.
객체지향은 조금 다른 접근방법을 취한다. 객체지향에서 변경을 다루는 전통적인 방법은 조건 로직을 객체 사이의 이동으로 바꾸는 것이다.

@vincent

P492

객체지향적인 코드는 조건을 판단하지 않는다. 단지 다음 객체로 이동할 뿐이다.

@freebear

지금까지 살펴본 것처럼 조건 로직을 객체 사이의 이동으로 대체하기 위해서는 커다란 클래스를 더 작은 클래스들로 분리해야 한다. 그렇다면 클래스르 분리하기 위해 어떤 기준을 따르는 것이 좋을까? 가장 중요한 기준은 변경의 이유와 주기다. 클래스는 명확히 단 하나의 이유에 의해서만 변경돼야 하고 클래스 안의 모든 코드는 함께 변경돼야 한다. 간단하게 말해서 단일 책임 원칙을 따르도록 클래스를 분리해야 한다는 것이다.

@vincent @freebear

P493

변하는 개념을 변하지 않는 개념으로부터 분리하라.

@vincent

변하는 개념을 캡슐화하라.

@vincent

P495

GOF의 조언에 따르면 캡슐화란 단순히 데이터를 감추는 것이 아니다. 소프트웨어 안에서 변할 수 있는 모든 '개념’을 감추는 것이다. 개념이라는 말이 다소 추상적으로 들린다면 간단히 다음처럼 생각하라.

캡슐화란 변하는 어떤 것이든 감추는 것이다[Bain08, Shalloway01].

@vincent

캡슐화의 가장 대표적인 예는 객체의 퍼블릭 인터페이스와 구현을 분리하는 것이다. 객체를 구현한 개발자는 필요할 때 객체의 내부 구현을 수정하기를 원한다. 객체와 협력하는 클라이언트의 개발자는 객체의 인터페이스가 변하지 않기를 원한다. 따라서 자주 변경되는 내부 구현을 안정적인 퍼블릭 인터페이스 뒤로 숨겨야 한다.

@freebear

P496

캡슐화란 단지 데이터 은닉을 의미하는 것이 아니다. 코드 수정으로 인한 파급효과를 제어할 수 있는 모든 기법이 캡슐화의 일종이다.

@freebear

변경을 캡슐화할 수 있는 다양한 방법이 존재하지만 협력을 일관성 있게 만들기 위해 가장 일반적으로 사용하는 방법은 서브타입 캡슐화와 객체 캡슐화를 조합하는 것이다. 그림 14.13에서 알 수 있는 것처럼 서브타입 캡슐화는 인터페이스 상속을 사용하고, 객체 캡슐화는 합성을 사용한다.

@vincent

P508

기본 정책을 추가하기 위해 규칙을 지키는 것보다 어기는 것이 더 어렵다는 점에 주목하라. 일관성 있는 협력은 개발자에게 확장 포인트를 강제하기 때문에 정해진 구조를 우회하기 어렵게 만든다.

@vincent

P509

유사한 기능에 대해 유사한 협력 패턴을 적용하는 것은 객체지향 시스템에서 개념적 무결성(Conceptual Integrity)[Brooks95]을 유지할 수 있는 가장 효과적인 방법이다.

@vincent

시스템이 일관성 있는 몇 개의 협력 패턴으로 구성된다면 시스템을 이해하고, 수정하고, 확장하는 데 필요한 노력과 시간을 아낄 수 있다. 따라서 협력을 설계하고 있다면 항상 기존의 협력 패턴을 따를 수는 없는지 고민하라. 그것이 시스템의 개념적 무결성을 지키는 최선의 방법일 것이다.

@freebear

P510

개념적으로는 불필요한 FixedFeeCondition 클래스를 추가하고 findTimeIntervals 메서드의 반환 타입이 List임에도 항상 단 하나의 DateTimeInterval 인스턴스를 반환한다는 사실이 마음에 조금 걸리지만 개념적 무결성을 무너뜨리는 것보단 약간의 부조화를 수용하는 편이 더 낫다.

@vincent

P511

처음에는 일관성을 유지하는 것처럼 보이던 협력 패턴이 시간이 흐르면서 새로운 요구사항이 추가되는 과정에서 일관성의 벽에 조금씩 금이 가는 경우를 자주 보게 된다. 협력을 설계하는 초기 단계에서 모든 요구사항을 미리 예상할 수 없기 때문에 이것은 잘못이 아니며 꽤나 자연스러운 현상이다. 오히려 새로운 요구사항을 수용할 수 있는 협력 패턴을 향해 설계를 진화시킬 수 있는 좋은 신호로 받아들여야 한다.
협력은 고정된 것이 아니다. 만약 현재의 협력 패턴이 변경의 무게를 지탱하기 어렵다면 변경을 수용할 수 있는 협력 패턴을 향해 과감하게 리팩터링하라. 요구사항의 변경에 따라 협력 역시 지속적으로 개선해야 한다. 중요한 것은 현재의 설계에 맹목적으로 일관성을 맞추는 것이 아니라 달라지는 변경의 방향에 맞춰 지속적으로 코드르 개선하려는 의지다.

@vincent @freebear

P512

따라서 훌륭한 설계자가 되는 첫걸음은 변경의 방향을 파악할 수 있는 날카로운 감각을 기르는 것이다. 그리고 이 변경에 탄력적으로 대응할 수 있는 다양한 캡슐화 방법과 설계 방법을 익히는 것 역시 중요하다.

@vincent

6 Likes

"합성"이라는 단어는 무의미한 논쟁을 많이 불러 일으키곤 합니다.

UML 에서는 합성(Composition)을 연관(Association)의 하위 카테고리로 분류하고 있죠. 전체 없이는 존재할 수 없는 부분 관계를 가리킵니다. 이 합성과 대비되는 집약(Aggregation)이 따로 있죠.

문제는 이 연관이 과거에는 "합성"으로 불렸는데, "상속"에 대비되는 개념으로 사용했습니다.

어떤 이는 UML기준으로 "합성"을 언급하고 있는데, 다른 이가 뜬금없이 이 단어를 과거의 개념으로 받아 들이는 경우, 불꽃 튀는 썰전이 폭발하는 경우가 많죠. (스택오버플로우 같은 곳에서요)

느낌 상, 후자의 부류는 주로 C++ 유저가 많은 것 같았습니다.

2 Likes

항상 잘 보고 있습니다.
감사합니다.

2 Likes

오브젝트 책은 제가 이해하고 느낀 바, 6장부터 인터페이스에 대해 끊임없이 강조하고 있습니다.

언젠가 오브젝트에서 책임에 대해 의존하라 라는 말을 했던 것이 기억이 납니다.

요즘 저에게 이 책임에 대해 의존하라는 뜻은, 인터페이스로 접근해라 라는 것으로 이해되고 있습니다.

실제 코드에서 인터페이스를 사용하는 것이죠.

if문이나 for문 같은 실제 로직을 수행하는 부분에서 class type에 직접적인 접근을 하는 것이 아니라 인터페이스로 클래스를 접근하라는 것입니다.

클래스로 접근하는 것과 인터페이스로 접근하는 것에는 큰 차이가 있습니다.

바로 관점 차이인데요,

객체지향 프로그래밍이 어색한 사람은 우선 클래스를 정의합니다. 그리고 클래스를 if, for, while, switch 등등에서 타입으로 접근해서 사용합니다. 뭐 이런거죠.

public class ABC
{
     public int A { get; set; }
     public int B { get; set; }
     public int C { get; set; }
}

...

var abc = new ABC();
abc.A = 1;
abc.B = 2;

굉장히 일반적인 접근입니다. 그렇게 유지보수하다가 필요한 데이터가 생기면

public class ABC
{
     public int A { get; set; }
     public int B { get; set; }
     public int C { get; set; }
     public double D { get; set; } // 새로 추가된 property
}

이렇게 아무 생각없이 추가합니다. 그냥 하다보니까 필요해서 추가한 것입니다.
그러면 이제 이 객체를 사용하는 곳에서는 새로 추가한 것을 사용해야만 합니다.

var abc = new ABC();
abc.A = 1;
abc.B = 2;
abc.D= 3.0d;

물론 필수 Property가 아니라면 사용하지 않아도 되겠지만 사용하지 않는 프로퍼티를 추가한 것은 더 문제겠지요.

이런 식으로 우리는 개발을 합니다.

그러다가 클래스가 너무 방대해지면 나름의 기준에 맞춰서 클래스를 나누게(새로 만들게) 됩니다.

여기까지는 문제가 없습니다. 당연하죠. 유지보수할 일이 생긴다는 것은 비즈니스가 활성화되어있다는 것이고, 사업이 어떤 방향으로든 움직이고 있다는 것입니다. 고객의 편리에 맞춰, 회사의 유지보수에 맞춰서 클래스가 추가되는 것은 너무 당연한 일입니다. 그래서 문제가 없습니다.

하지만 특이점이 온 다음부터는 그러지 못합니다. 그러면서 클래스는 점점 많아져만 가고, 클래스를 사용하는 곳들에서는 클래스끼리 복잡한 의존성을 갖게 되는 시점을 개발자, 혹은 개발팀이 인지한 상황입니다. 여기서 이제 리펙토링이 들어가게 됩니다. 그런데 이 때 클래스만 재설계해서는 안된다는 것입니다.

클래스가 많아지면 클래스끼리도 사용할 때 공통적으로 사용하는 부분이 보이며, 코드도 공통적으로 사용되는 부분이 보입니다. 디자인 패턴이라고 까지는 못해도, 비즈니스를 운영하다보니 하나의 이 비즈니스에 대한 코드를 사용하는 패턴이 생긴 것입니다.


공통적인 부분이 발생하면 이것은 인터페이스를 통해 추상화가 가능합니다. 반대로 말하면 공통적으로 사용하는 부분이 발생하지 않으면, 인터페이스의 도입은 빛을 보지 못합니다.


이 공통적인 부분을 발생했을 때 인터페이스에 익숙하지 않은 우리(과거의 나)는 그저 추상클래스, 또는 부모클래스를 만들어서 비즈니스 로직을 중복 소스코드만을 제거하기 위해 사용했다는 것입니다. 그래놓고 리펙토링이 끝났다. 추상화를 다했다. 라고 결론을 내는 것인데, 이것이 어디가 문제나면,

객체를 사용하는 곳에서는 여전히 앤드포인트 클래스를 사용한다는 점이 문제입니다. 그래서 공통로직을 부모클래스, 추상클래스로 옮긴 정도는 오브젝트를 본 사람은 추상화를 했다라고 할 수는 없는 것입니다. 예를 들어

public abstract class ParentABC
{
     public int A { get; set; }
     public int B { get; set; }

     public abstract void Method123();
}

public class ABC1 : ParentABC
{
     public int C { get; set; }

     public override void Method123()
     {
         A = 1; // 부모 클래스의 프로퍼티 사용
         B = 3; // 부모 클래스의 프로퍼티 사용
     }
}

public class ABC2 : ParentABC
{
     public double D { get; set; }

     public override void Method123()
     {
         A = 500; // 부모 클래스의 프로퍼티 사용
         B = 20000; // 부모 클래스의 프로퍼티 사용
     }
}

...

ParentABC abc1 = new ABC1();
ParentABC abc2 = new ABC2();

abc1.Method123();
abc2.Method123();

이런 코드는 추상화를 사용하는 의미가 적다는 것입니다.

앞선 독서회에서도 말했지만 객체지향에서는 생성과 사용의 책임이 분리되는 일명 생산과 컨트롤이 중요한데, 코드 페이지 하나에서 생산과 사용을 모두 하고 있기 때문입니다.

ParentABC abc1 = new ABC1(); // 생산
ParentABC abc2 = new ABC2(); // 생산
abc1.Method123(); // 사용
abc2.Method123(); // 사용

결국 이렇게 사용하고 있다면 ABC1, ABC2 라는 타입이 직접적으로 의존한 소스코드가 되었으므로, ABC1과 ABC2가 바뀌면 이 코드페이지는 바뀌어야 합니다.

이 부분이 오브젝트에서 말하는 대로 추상화가 되려면

생산과 컨트롤 추상클래스 예시

전역으로_사용하는_클래스.cs

public static List<ParentABC> Parent { get; set; } = new();

생성코드페이지.cs

var abc1 = new ABC1();
var abc2 = new ABC2();
전역으로_사용하는_클래스.Parent.Add(abc1);
전역으로_사용하는_클래스.Parent.Add(abc2);

사용코드페이지.cs

foreach(ParentABC item in 전역으로_사용하는_클래스.Parent)
{
   item.Method123();
}

예시가 좀 좋지 않지만 이런 식으로 사용하면 적어도 [전역으로_사용하는_클래스.cs] 와 [사용코드페이지.cs] 는 유지보수하지 않아도 됩니다.

그럼 여기서 문제가 발생하는데요.

“저는 추상화를 하긴 했는데, List에 추가된 것 객체중에서 for문으로 실행할 때 ‘조건에 따라서’ 어떤 건 실행하고 어떤 건 실행하고 싶지 않아요.”

의 경우입니다.

이 때 추상클래스’만’ 사용했다면 C#의 구조상 class는 수직적인 상속계층을 따르므로, 1개씩만 상속을 받아서 구현할 수 있는 class는 인터페이스 분리 원칙(ISP)를 이용한 한번에 여러 개를 함께 구현할 수 있는 interface보다 다형성 측면에서 불리한 것입니다. 따라서 추상클래스와 인터페이스는 함께 사용되거나, 그만큼 상속 레이어를 나눌 '현재 상태’가 아니라면 인터페이스만 사용할 수 있습니다. 여기서 말하는 현재 상태란, 팀 또는 개인의 추상화 이해 수준 또는 비즈니스의 성장단계입니다.

인터페이스를 써서 필터링한다면 아래와 같이 바꿀 수 있을 것 같습니다.

생산과 컨트롤 인터페이스 예시

정의코드파일.cs

public interface IBase
{
     int A { get; set; }
     int B { get; set; }
     int C { get; set; }
}

public interface IA : IBase
{
     void 하하호호();
}

public interface IB : IBase
{
     void 하하호호11111();
}

public interface IC : IBase
{
     void 하하호호111122();
}

public interface ID : IBase
{
     void 하하호호zxcvzxcvzxcv();
}

public interface IE : IBase
{
     void 하하호호werskdjn();
}

public class ABC1 : IA, IB
{
     public int A { get; set; }
     public int B { get; set; }
     public int C { get; set; }
     public void 하하호호() => Console.WriteLine("하"); // interface IA
     public void 하하호호11111() => Console.WriteLine("하호"); // interface IB
}

public class ABC2 : IA, IC
{
     public int A { get; set; }
     public int B { get; set; }
     public int C { get; set; }
     public void 하하호호() => Console.WriteLine("하22222"); // interface IA, ABC1과 IA를 다르게 구현
     public void 하하호호111122() => Console.WriteLine("피카츄라이츄"); // interface IC
}

public class ABC3 : IB, IE
{
     public int A { get; set; }
     public int B { get; set; }
     public int C { get; set; }
     public void 하하호호11111() => Console.WriteLine("파이리꼬부기"); // interface IB, ABC1과 IB를 다르게 구현
     public void 하하호호werskdjn() => Console.WriteLine("버터플야도란"); // interface IE
}

public class ABC4 : ID
{
     public int A { get; set; }
     public int B { get; set; }
     public int C { get; set; }
     public void 하하호호zxcvzxcvzxcv() => Console.WriteLine("피죤투또가스"); // interface ID
}

public class ABC5 :IA, IC
{
     public int A { get; set; }
     public int B { get; set; }
     public int C { get; set; }
     public void 하하호호() => Console.WriteLine("찾아라비밀의열쇠"); // interface IA, ABC1과 IA를 다르게 구현, ABC2와 IA를 다르게 구현
     public void 하하호호111122() => Console.WriteLine("미로같이얽힌모험들"); // interface IC, ABC2과 IC를 다르게 구현
}

전역코드파일.cs

public static List<IBase> 유치한컬렉션 { get; set; } = new();

생산코드파일.cs

전역코드파일.유치한컬렉션.Add(new ABC1());
전역코드파일.유치한컬렉션.Add(new ABC2());
전역코드파일.유치한컬렉션.Add(new ABC1());
전역코드파일.유치한컬렉션.Add(new ABC3());
전역코드파일.유치한컬렉션.Add(new ABC1());
전역코드파일.유치한컬렉션.Add(new ABC4());
전역코드파일.유치한컬렉션.Add(new ABC2());
전역코드파일.유치한컬렉션.Add(new ABC5());
전역코드파일.유치한컬렉션.Add(new ABC3());
전역코드파일.유치한컬렉션.Add(new ABC4());
전역코드파일.유치한컬렉션.Add(new ABC1());

사용코드파일.cs

foreach (IBase item in 전역코드파일.유치한컬렉션)
{
    if (item is IA a) { // a로 뭐할지 적음 }
    if (item is IB b) { // b로 뭐할지 적음 }
    if (item is IC c) { // c로 뭐할지 적음 }
    if (item is ID d) { // d로 뭐할지 적음 }
    if (item is IE e) { // e로 뭐할지 적음 }
}

좀 예시를 보시기 힘들게 만들었지만 만드는 사람도 재미있어야 해서 이렇게 만들었습니다.
만들다보니까 단축키를 잘못눌러서 그냥 글이 올라가버렸네요.

이렇게 할 경우 [사용코드파일.cs] 는 수정하지 않습니다.
유지보수가 발생해서 클래스가 새로 필요해질 경우, 수정할 것은

  1. [정의코드파일.cs] 에 새로운 클래스를 정의하는 것
  2. [생산코드파일.cs]에 새로 정의한 클래스를 추가하는 것

이게 전부입니다.

오늘 독서회 문장 중에서

는 위와 같은 뜻이라고 생각합니다.

변하지 않는 컨트롤 파트인 [사용코드파일.cs] 가 생겼고, 변하는 생산 파트인 [정의코드파일.cs] 과 [생산코드파일.cs] 가 생긴 것입니다.

여기서 만약 '책임’자체가 추가되어 인터페이스가 추가될 경우에만 [사용코드파일.cs]를 수정하게 됩니다.

따라서 의존성이 분리된 객체지향적인 유지보수를 할 수 있게되는 것입니다.

결론1

절차지향 개발에서 if-else 문으로, 또는 switch-case 문의 경우에도 조건문이 계속 추가되는 방식이 아니라 객체지향에서는 ISP를 활용한 인터페이스를 늘려가는 것으로 조건문을 대체할 수 있게 되는 것입니다.

우리는 객체지향 언어를 사용하고, 이런 제약조건에 대해서 의존(사용)하는 코드를 만들면 제약조건이 있다는 가정으로 출발하기 때문에 사용하는 곳에서 그것을 구분할 필요가 없습니다. 이미 클래스의 정의 단계에서 구분이 인터페이스를 통해 다 되어있기 때문입니다.

그렇기 때문에 나타나는 현상은

결론2

class 자체는 도메인, 비즈니스와 유사한 구조를 나타내지만, interface같은 추상화는 '코드가 쓰기 편한 방식’을 취하며, 인터페이스의 이름만으로는 도메인과 비즈니스를 유추할 수 없다.

가 되는 것 같습니다. 왜냐하면 인터페이스는 책임이라서 도메인을 나타내지 못하거든요. 그냥 쓰기 좋은대로 가상에서 만들어질 뿐이기 때문입니다. 물론 추상클래스까지 붙힌다면 도메인까지 표현이 가능해질 것입니다.


오랜만에 글이 길었네요. 뿌듯합니다.

봐주셔서 감사합니다~

3 Likes

감사합니다…!