개요
요즘 책 '객체지향의 사실과 오해'를 읽고 있다. 해당 책에서는 객체지향에 대해 사람들이 갖고 있는 맹목적인 클래스에 대한 오해를 깨부수고 있다. 객체지향에 대한 소프트웨어 설계 철학을 풀어내는 내용이 많아서, 나 같은 초보 개발자에게 도움이 되는 내용이 많은 것 같다.
그런데 이 책 '객체지향의 사실과 오해'를 읽으면서 문득 궁금한 점이 생겼다.
훌륭한 객체란 구현을 모른 채 인터페이스만 알면 쉽게 상호작용할 수 있는 객체를 의미한다. 이것은 객체를 섥할 때 객체 외부에 노출되는 인터페이스와 객체 내부에 숨겨지는 구현을 명확히 분리해서 고려해야 한다는 것을 의미한다.
...
인간의 두뇌가 한 번에 생각할 수 있는 양에는 한계가 있으므로 변경이라는 강력한 적과의 전쟁에서 승리하기 위해 인간이 취할 수 있는 마지막 생존 전략은 변경해도 무방한 안전 지대와 변경했을 경우 외부에 영향을 미치는 위험 지대를 구분하는 것이다. 여기서 안전 지대가 객체의 내부인 구현이고 위험 지대가 객체의 외부인 공용 인터페이스다.
P. 169 인터페이스와 구현의 분리 원칙
변경에 최대한 안전하게 설계하기 위해, 해당 책에서는 인터페이스와 구현을 분리하라는 이야기를 하고 있다.
여기서 인터페이스란 Java나 C#의 interface 개념을 말하기보다는, 객체가 외부로부터 메시지를 받을 수 있는 통로를 의미한다. 예를 들어, 사람이라는 객체가 '걷는다'는 메시지를 받으면 Walk() 메서드를 호출하여 실제로 걸어가는 것이다.
이때 인터페이스는 '무언가 하라'는 행위를 요청하는 메시지 통로 역할만 할 뿐이다. 실제로 그 무언가를 어떻게 할지는 구현에서 결정하여야 한다. 이렇게 객체 외부와 소통하는 인터페이스와 객체 내부에서 실제 행동을 수행하는 구현을 분리하게 되면, 객체가 내부에서 뭘 하는지, 어떻게 행동을 수행하는지 객체 외부에는 노출하지 않아도 되는 장점이 있다.
(여기서는 간략하게 설명하다 보니 다소 두서가 없는데, 만약 실제 어떤 뜻인지 궁금하다면 책을 직접 읽어보길 추천한다.)
그런데 여기에서 문제는 이것이다.
실무에서는 인터페이스가 되는 함수란 '불변의 스펙'이 아니다. 예를 들어, 게임에서 "체력을 깎는다"는 메시지를 전달한다고 하자. 이때 TakeDamage라는 인터페이스 메서드를 사용해서 메시지를 전달하게 하면 될 것으로 보인다. 이때 인자로는, 처음에는 얼마의 체력을 깎을지만 생각하면 될 것 같아 보인다.
그런데 하다 보면 '누가 나에게 체력을 깎았는지'와 같은 정보도 필요하게 되었다. 그래서 스펙을 변경하려고 하니, 인터페이스를 변경하게 되면 이와 연관된 모든 내용을 변경해야 해서 정말 많은 것을 수정해야 하는 상황이 온다. 즉, 객체 내부의 변경을 외부에 알리고 싶지 않아서 캡슐화했는데, 메시지 역할을 하는 TakeDamage의 인자까지 변경해야 하는 것이다.
지금까지는 TakeDamage를 수정하는 식으로 프로그래밍했지만, 사실 그건 정답이 아니라는 생각이 든다. 충돌이 안 나게 하려면 다른 프로그래머분에게도 양해를 구해서 TakeDamage 관련 로직을 건드리지 말아달라고 해야 하고, 또 TakeDamage가 어떻게 변경되었는지 모두 설명해야 하기 때문이다. 그러다가 중간에 사고라도 나면 어떻게 해야 할지, 머리가 아파진다.
해결책? (feat. Gemini)
그래서 책을 읽다가 궁금해져서 Gemini에게 한번 질문을 던져보기로 했다. 이때 답변을 정리하면 이렇다.
실제 개발에서는 인터페이스 변경을 '매우 비싼 비용'으로 간주하고, 이를 피하거나 변경의 충격을 최소화하는 방향으로 대처합니다. 실제 현업에서는 다음과 같은 전략들을 사용해 이 문제에 대처합니다.
1. 변경을 수용하는 추가(Additive) 방식 사용
2. 리팩토링과 어댑터 패턴 활용
3. 애초에 인터페이스를 잘 설계하려는 노력
... 결론적으로 '인터페이스는 절대 변하면 안 된다'는 경직된 규칙이 아닙니다.
이 원칙의 진짜 의미는 "인터페이스의 변경은 엄청난 파급효과를 낳기 때문에, 그 비용을 인지하고 최대한 안정적으로 유지하려고 노력해야 한다"는 설계 지침에 가깝습니다.
따라서 실제 개발에서는 인터페이스가 변경될 수 있다는 현실을 인정하고, 변경이 필요할 때는 위와 같은 전략들을 통해 시스템 전체에 미치는 영향을 최소화하며 유연하게 대처하는 지혜가 필요합니다.
요약하다면 "인터페이스는 절대 변하면 안된다"는 규칙이 아니고, 최대한 '객체 외부와 내부 사이의 결합을 낮추자'는 의미에서 도입한 중간 계층이라 볼 수 있다.
인터페이스 함수를 중간에 낌으로써, 최대한 객체 내부가 바뀌어도 객체 외부에는 영향을 미치지 않도록 하자는 것이다.
1. 변경을 수용하는 추가(Additive) 방식 사용하기
보통 인터페이스가 잘못되었다고 생각하면 인터페이스를 고치게 된다. 그런데 그러지 말고, 기존 인터페이스는 그대로 두고 새로운 인터페이스를 추가하는 식으로 해결하는 방식이다.
deprecated와 같은 옵션을 사용해서 기존 함수를 최대한 사용하지 않도록 하고, 새로운 인터페이스를 사용하도록 유도하는 것이다.
Java에서는 @deprecated 어노테이션이 그 역할을 하고, C#에서는 [Obsolute] 어트리뷰트가 그 역할을 한다. 아래 두 코드는 똑같은 역할을 한다.
// Java
public interface Movable
{
/**
* @deprecated 이 메서드는 더 이상 사용되지 않으며, move(int speed)를 사용하세요.
*/
@Deprecated void move(); // 기존 코드를 위해 남겨둠
void move(int speed); // 새로운 요구사항을 위한 인터페이스 추가
}
// C#
public interface Movable
{
/**
* 이 메서드는 더 이상 사용되지 않으며, move(int speed)를 사용하세요.
*/
[Obsolute]
void move(); // 기존 코드를 위해 남겨둠
void move(int speed); // 새로운 요구사항을 위한 인터페이스 추가
}
새로운 메서드를 추가해야 하니 찜찜하긴 하지만, 적어도 기존 기능을 변경하면서 생기는 문제는 피할 수 있다는 장점은 있다.
다만 문제가 있다면 매번 해당 인터페이스를 상속받을 때 기존 move 함수와 새 move 함수를 모두 구현해야 하는 건 흠이 되겠다.
2. 리팩토링과 디자인 패턴(ex: 어댑터 패턴) 활용
이미 시스템이 복잡하게 얽혀서 인터페이스 변경이 불가하고, 파급 효과가 너무 큰 경우가 있다. 이런 경우엔 한번에 깔끔하게 해결하기가 쉽지 않다.
그 경우, 첫 번째 해결책은 리팩토링으로 조금씩 조금씩 바꿔나가는 방법이다.
그리고 두 번째 해결책은 어댑터 패턴과 같은 디자인 패턴을 사용해서, 이러한 변화에 대응할 수 있는 중간 클래스를 다시 추가하는 것이 되겠다.
어댑터 패턴은 변경된 새 인터페이스와 기존 인터페이스 사이의 불일치를 해결해주는 '어댑터 클래스'를 만들어 넣는 것을 말한다. 이렇게 하면 외부 객체는 기존 인터페이스를 그대로 사용하는 것처럼 보이지만, 내부적으로는 어댑터를 통해 구현을 바꿀 수 있게 된다고 한다.
다만 어댑터 패턴은 내가 직접 사용해 본 것이 아니라서, 이런 방법도 있구나 하는 정도로 확인해주면 좋을 거 같다.
3. 최선은 "처음부터 잘 설계하기"
사실 가장 최선은 수정될 일이 없도록 최대한 처음부터 잘 설계하는 것이다.
사람이 미래를 예지할 수는 없기 때문에 어려운 일 같아 보인다. 그렇지만 객체를 설계할 때 책에서는 이러한 것을 강조하고 있다.
- 데이터가 아니라 책임에 집중하자: 불안정한 인터페이스는 객체를 '데이터의 집합'으로 보기 때문에 생기는 일이 많다. 예를 들어, 외부에서 객체의 데이터를 직접 수정하려고 하면 당연히 둘 사이의 결합도는 커지기 마련이다.
- 따라서 객체의 행동 위주로, 객체가 수행해야 할 책임 위주로 설계해야 한다.
- '상품' 객체 외부에서 가격과 할인 정책을 읽어와서 상품 가격을 직접 계산하기보단, '상품' 객체에게 "가격을 계산해줘"라는 메시지를 날리도록 설계하자. 이때, 가격과 할인 정책 같은 상태를 통해 상품 가격을 계산하는 건 '상품' 객체가 책임지고 스스로 처리하도록 한다.
- 협력 관계를 먼저 설계하자: 객체를 설계할 땐 객체 간의 협력 속에서 어떤 역할을 하게 될지를 바탕으로 설계해야 한다.
- 객체 간의 협력은 '메시지'를 통해 이뤄진다. 이 메시지란 보통 함수 호출이나 메서드 호출 등으로 실체화할 수 있다.
- 객체 사이에 "어떤 메시지"를 주고 받아야 하고, 이를 위해 어떤 객체가 필요한지를 고민해야 한다.
- 상품을 결제할 땐 우선 "가격을 계산해줘"라는 메시지를 통해 가격을 알아야 할 거고, 그걸 통해 "상품을 결제해줘"라는 메시지를 보내서 실제로 지갑이나 카드에서 돈을 차감해야 한다. 이렇게 어떤 정보가 필요한지, 어떤 행동이 필요한지 '메시지'부터 설계한 다음, 이러한 정보나 행동을 수행할 객체를 찾아나가야 한다.
- 인터페이스는 최대한 추상적으로 설계하라: '어떻게(How)' 행동을 수행할 것인지를 통해 인터페이스를 정의하면 방법이 바뀔 때마다 계속해서 인터페이스가 바뀌게 된다. 따라서 '무엇(What)'을 할 것인지 객체의 책임을 정의하는 것이 필요하다.
-
- 예를 들어, '손으로 숫가락을 들어 수저를 회전시킨 후 팔을 접어 입에 수저를 넣는다'고 어떻게 행동을 수행할지 정의하게 되면 굉장히 복잡해진다.
- 차라리 '밥을 먹는다'는 추상화된 책임을 정의하도록 하자. 그러면 방법이 바뀌어도 인터페이스는 바뀌지 않기 때문에 좋은인터페이스를 만들 수 있다.
정확히는 인터페이스에 대한 이야기에서는 '인터페이스 추상화', '최소 인터페이스', '인터페이스와 구현의 분리' 같은 이야기를 하고 있다. 다만 결국 이를 관통하는 이야기는 책의 핵심 메시지인 위 세 가지 내용이기 때문에, 이를 좀 정리해 보았다.
사실 이러한 설계 원칙은 설계 철학에 좀 더 가까운 내용인 것 같다. 그렇기 때문에 완벽한 정답은 존재하지 않는다. 그럼에도 조금이라도 야근을 줄이고 문제 없이 코드를 짜고 싶은 게 개발자의 속마음이기 때문에, 앞으로는 책을 따라서 위 원칙을 지키면서 개발해보려 한다.
공부하면서 작성한 글이므로, 혹시나 잘못된 점이 있다면 과감하게 지적해주면 감사하겠다.
'Architects, Design Patterns' 카테고리의 다른 글
ECS(Entity Component System)란 (DOD와 ECS, ECS와 메모리 구조) (0) | 2025.03.18 |
---|---|
유니티 C# 싱글턴 패턴 + Lazy를 이용한 버전 (7) | 2020.08.30 |
댓글