Object Oriented Programmer's Productivity 를 읽고 씁니다. 글에서 주장하는 바를 간략히 요약하자면 이렇습니다:

  1. OOP 하면 동적 디스패치(dynamic dispatch, 대충 쉽게 말해서 다형성)가 가장 먼저 떠오르는데,
  2. 동적 디스패치는 코드를 읽기 어렵게 만들기 때문에 위험하고, OOP는 이를 장려하기 때문에 결국 문제가 된다.
  3. OOP의 장점은 다형성에 있다기 보다는 캡슐화에 있는데, 그 이유는 구조적 프로그래밍에 비해 전역 변수를 덜 쓰도록 장려하기 때문이다.

1)번은 동의하는 바이고(OOP란 조건문을 줄이는 것 참고), 2,3번에는 동의하지 않습니다. 하나씩 따져보면


1. "동적 디스패치는 코드를 읽기 어렵게 만든다"는 주장에 대해

이 문제는 정적인 프로그램(즉 소스 코드)과 동적인 프로세스(dynamic process) 사이의 간극이 넓어지면 프로그램을 분석하기가 어려워진다는 문제의 한 가지 사례인데요(혹시 부연 설명이 필요하시면 Goto 문과 AOP, 그리고 Subtext 참고), 간단히 말해서 코드를 읽다 말고 실제로 무엇이 실행되는지 파볼(drill-down) 필요가 생기지만 않는다면 문제될 것이 없습니다. 그러니깐, 설계를 잘 하면 된다는 말입니다.

이 맥락에서 올바른 설계란... 대충 중요한 것을 꼽자면 첫째, 일반화(generalization)가 올바르게 되어 있고(즉, LSP 혹은 contract - design by contract에서 말하는 - 를 잘 지키고 있고), 둘째, 클래스 및 인스턴스의 이름이 적절히 지어져 있으며(intention revealing), 3) 해당 코드의 주변부와 추상화의 수준(level of abstraction)에 일관성이 있는 것을 말합니다.

이런 상황이라면 특정 오퍼레이션(operation)에 대한 구현 코드(method)의 내용이 무엇인지 궁금해할 일이 없습니다.

사실 동적 디스패치가 코드 읽기를 어렵게 만든다는 식의 문제 제기라면 글쓴이가 좋아하는 Haskell(함수형 언어의 일종입니다)도 문제가 되는데요, 왜냐하면 higher-order programming이라는 것 자체가 함수를 인자로 넘기거나 함수를 반환값으로 받아서 쓰는 것이고 이렇게 되면 결국 늘상 일어나는 일이 동적 디스패치거든요.


2. "OOP의 장점은 다형성에 있다기 보다는 캡슐화에 있는데, 그 이유는 구조적 프로그래밍에 비해 전역 변수를 덜 쓰도록 장려하기 때문이다"는 주장에 대해

다형성은 문제이고 캡슐화가 진정한 장점이라는 얘긴데 그럴거면 OOP라는 것이 있을 이유가 없고 그냥 모듈화 프로그래밍(modular programming)이라는 말이면 충분하겠죠.

캡슐화라는 것은 구조적 프로그래밍이나 객체지향 프로그래밍과 직교적(orthogonal)인 개념으로 보는 것이 자연스럽습니다. 대부분의 구조적 언어는 모듈화 프로그래밍을 지원하고 있고, 객체지향 언어 또한 마찬가지라서 프로그래머가 어떻게 잘 쓰느냐에 따라 캡슐화가 잘 될 수도 있고 아닐 수도 있습니다.

이를테면 코드와 데이터를 묶으려면 구조체에 함수 포인터 넣어두고, 각 함수는 첫번째 인자로 자신이 속한 구조체를 받으면(python의 self와 유사) 되는 것이죠. 구조체가 없거나 함수 포인터가 없는 언어(구조적 언어의 요건은 모두 갖추었으나 함수 포인터가 없는 언어로는... 이를테면 QBasic이나 QuickBasic이 그렇습니다)라면 prefix나 postfix로 그룹핑을 하는 관습을 만들면 그만입니다(실제로 널리 쓰이던 관습입니다. namespace 개념이 없거나 약했거든요).

뭐 구현 가능성에 대해서는 그렇다치고, 구조적 프로그래밍에 비해 객체지향 프로그래밍이 캡슐화를 좀 더 강조하고 있다는 주장도 있는데 이 또한 별 설득력이 없습니다(시스템을 모듈로 나누는 기준에 대하여 참고. 추측컨데 저자가 구조적 프로그래밍을 충분히 경험해보지 못한 것이 아닌가 싶습니다).

저자는 캡슐화의 단적인 예로 전역 변수(global variable) 문제를 들고 있는데 이에 대해서는 의존의 자기유사성을 참고하시면 좋겠습니다. 전역 변수 문제라는 것은 사실 스케일의 차이만 있을 뿐 일반적인 의존성 문제의 하나이고, 객체지향 프로그래밍을 하건 구조적 프로그래밍을 하건, 전역 변수가 있건 없건 항상 존재하는 문제이고 신경 써야 하는 문제입니다.

게다가 자바처럼 전역 변수라는 개념이 아예 없는 언어에서도 전역 변수 문제는 여전히 존재할 수 있고(전역 변수가 없지만 전역 변수 문제는 존재한다는 표현이 좀 이상하지만 뭐 맞는 말입니다) 실제로 많은 개발자들이 이 문제로 허덕이고 있습니다. 이에 대해서는 모드 없는 소스코드 중 "모드와 숨은 변수들" 섹션 참고하시기 바랍니다.

OOP 덕에 전역 변수가 많이 줄었다는 것은 아쉽게도 착각입니다. 일례로 Singleton Pattern을 보세요. 이건 사용이 장려되고 있는 (디자인 패턴이라는 탈을 쓰고 나타난) 전역 변수 아닌가요?


3. 결론 및 부연

첫째, 동적 디스패치가 일어나는 부분이 읽기 어렵다면 그건 설계/코딩을 잘못했기 때문입니다. 아무리 좋은 도구라도 쓰는 사람이 잘 쓰지 못하면 문제가 생기는거죠.

둘째, 캡슐화라는 것은 그걸 ADT(Abstract Data Type)라고 표현했건, 모듈이라고 표현했건 간에 객체지향프로그래밍과 무관하게 예전부터 있어 왔던 개념과 별 다른 점이 없습니다. 애초에 모든 (쓸모있는) 프로그래밍 언어는 조합(combination)과 추상화(abstraction) 요소를 갖추고 있는데(Structure and Interpretation of Computer Programs), 객체지향언어도 예외는 아닌 것이죠.

저작자 표시
신고

IF문 안쓰기 캠페인

얼마 전에 OOP란 조건문(if)을 줄이는 것이라는 글을 썼는데요, 그 이후로 몇몇 분들이 직/간접적으로(Google Reader의 공유 기능 혹은 이메일) 재미있는 사이트를 하나 알려주셨습니다. 바로 Anti-IF Compaign 입니다. IF를 쓰지 말고 서브타입 다형성을 쓰자는 캠페인입니다. 일부 페이지에 적힌 날짜를 보니 올해 4월 쯤 만들어진 것 같습니다.

캠페인 설명(what is the anti-if compaign) 부분을 대충 요약하자면: IF-반대 캠페인의 목적은 객체지향 패러다임에 대한 의식을 고양하기 위한 것이며, 이를 통해 더 좋은 설계(flexibility, comprehensibility, testability, ability to evolve)를 할 수 있도록 하기 위함이라고 하는군요.

IF를 객체지향적 코드로 대체하는 예시도 제공하고 있는데 뭐 그냥 전형적인 replace-conditional-with-polymorphism 사례입니다.

캠페인에 참여하고 싶은 사람은 사이트에 서명을 하고 본인의 홈페이지에 배너를 걸어달라고 하는데(Kent Beck 횽아가 1등으로 서명을 했군요 ㅋ), 글쎄요... 취지는 좋으나 이름을 잘못지은 캠페인의 또다른 사례(비슷한 사례로는 개발자 좀 살려주세요 캠페인이 있죠. 이건 그래도 취지에 심하게 공감하기 때문에 배너를 달긴 달았습니다)를 보는 것 같아서 거시기 합니다.

저작자 표시
신고

[본문 읽기]

저작자 표시
신고

OOP에 대해서, 아는 사람은 다 알만한 이야기 및 이 글에서 인용하고 있는 大山님의 글 객체지향 프로그래밍에 대한 오해와 진실 1편, 2편을 읽고 씁니다(3년 전 글입니다). 딱히 관련 글이라기 보다는 읽고 떠오르는 생각들 몇 가지 + 제가 평소에 가지고 있던 생각을 늘어 놓으려고 합니다.

OOP를 바라보는 (유일하게 올바른 관점이 아닌 다양한 관점 중) 한 가지 관점에 대해 쓰고자 합니다. 하나의 대상을 한 측면에서만 보는 것 보다는 여러 측면에서 보면 좀 더 그 대상에 대해서 잘 알 수 있을 것이라고 생각하기 때문입니다. 제가 제시하고자 하는 관점은 다음과 같습니다:

“OOP는 무엇을 제거 혹은 대체하고자 하는가?”

부연하자면 1) 레퍼런스는 포인터에서 산술연산을 제거하였고, 2) 구조적 프로그래밍은 GOTO 류의 흐름제어 방식을 제거하였는데, 3) OOP는 무엇을 제거/대체하는가에 대해 쓰고자 합니다.

 

포인터 - 산술연산 = 레퍼런스

예를 들어 자바나 C#의 레퍼런스(reference) 개념을 살펴보죠. 레퍼런스는 보안성, 편의성, GC 효율성 등을 향상시키기 위해 기존의 포인터 개념으로부터 산술연산(arithmatic operations)을 제거하였습니다. (결과적으로 보안성, 편의성, GC 효율성 등이 향상 되었는지 아닌지에 대한 판단은 각자 알아서)

포인터는 전지전능한 주소지칭 수단인데, 사실은 전지전능한 주소지칭 수단이 없어도 모든 프로그램을 작성할 수 있다는 것을 알게 된 것이죠(성능 이슈 등을 제외한다면).

 

스파게티 - GOTO = 구조적프로그래밍

또 다른 예로, 구조적 프로그래밍은 프로그램의 흐름을 제어하는 방법 중 goto를 제거(다익스트라 식으로 표현하자면 의미있는 프로그래머 독립 좌표계 – programmer independent coordinates - 를 훼손하는 장치들을 제거)하였습니다. 구조적 프로그래밍에서는 다음 세 가지 수단만으로 모든 프로그램의 흐름 제어를 구현해냅니다:

  • 순서(sequence) – 쉽게 말해서 코드가 위에서 아래로 실행되는 것
  • 선택(selection) – 쉽게 말해서 if와 switch
  • 반복(iteration) – 쉽게 말해서 while(혹은 do, until, for 등)

조건문(if)과 결합된 goto 문은 전지전능한 흐름제어 수단인데, 사실은 전지전능한 흐름제어 장치가 없어도 모든 프로그램을 작성할 수 있다는 것을 알게 된 것이죠(성능 이슈 등을 제외한다면).


구조적프로그래밍 - IF = OOP

드디어 본론인데요, OOP는 뭘 제거(혹은 대체)하였을까요? 혹자는 OOP가 구조적 프로그래밍을 대체한다고 말하는데 1/3 쯤 맞는 표현이라고 생각합니다. OOP는 구조적 프로그래밍의 세 가지 흐름 제어 수단(sequence, selection, iteration) 중에서 선택(selection) – 즉 if 문 – 을 제거합니다.

구조적 프로그래밍에 대한 설명에서 “조건문(if)과 결합된 goto 문은 전지전능한 흐름제어 수단”이라고 표현하였는데 이 중 goto는 제거되었으나 if는 살아남은 것이 구조적 프로그래밍이라면 if 까지도 제거하고자 하는 흐름을 OOP라고 보는 관점이 있을 수 있다는 것이죠.

오해의 소지가 있으니 잠깐 부연하자면, 모든 if를 제거해야 진정한 OOP라는 류의 주장은 아닙니다. 이에 대해서는 글 뒷부분에서 좀 더 이야기하도록 하겠습니다(진정한 OOP 따위 대체 뭔가요, 먹는거임? 우걱우걱).

그럼 조건문을 무엇으로 대체하고 있나요? 다형성(polymorphism)이죠. 그냥 다형성이라고 하면 너무 광범위하죠. 이를테면 파라메터 다형성(parametric/parameterized polymorphism)은 OOP 보다는 Generic Programming과 관련이 있습니다. OOP의 핵심이라고 하는 다형성은 특히나 서브타입 다형성(subtype polymorphism)을 말합니다. 이후에는 그냥 다형성이라고만 쓰겠습니다.

그럼 다형성이 조건문을 어떻게 대체하나요? 조건문이란 애초에 선택(selection)을 위한 것인데, 여기에서 선택이란 특정 변수의 값에 따라 이후에 실행할 코드가 달라지도록 하는 것을 말합니다. if 문에서는 조건절의 boolean 값에 따라 선택이 수행되는 것이고, 다형성에서는 메시지(message)에 담긴 파라메터(메시지를 받는 대상 개체도 파라메터로 칩시다. 파이선 self 마냥)에 따라 선택이 수행됩니다.


4. 다형성과 의존성 역전(DI – Depedency Inversion)

if를 다형성으로 대체하는 것이 정말 OOP를 특징지을만큼 중요한가요? 네, 전 그렇다고 생각합니다. 이쯤에서 밥 삼촌(Uncle Bob)을 인용해주어 (올바른) 권위에의 호소를 한 번 시도하도록 하겠습니다:

프로그램이 어떤 언어로 작성되었는지는 중요치 않습니다. 만약 의존성이 역전되어 있다면 이는 객체지향적 설계인 것입니다(It doesn't matter what language a program is written in. If its dependencies are inverted, it has an OO design).

--Robert Cecil Martin, Agile Software Development

if 얘기 하다말고 갑자기 왜 의존성 역전(DI) 떡밥이 나왔을까요? 그 전에, 애초에 의존성 역전이라고 할 때 이 역전이란 뭘 뒤집었다는 뜻일까요? David Parnas 큰형님이 지금으로부터 무려 37년 전에 쓴 논문에 답이 나옵니다:

첫째, 하위 계층의 서비스를 활용함으로써 시스템의 일부가 이득을 취했다(간결해졌다). 둘째 상위 계층을 들어내더라도 여전히 유용하고 사용 가능한 산출물(하위 계층 서비스들)이 남는다. (…중략…) 만약 하위 계층의 모듈이 상위 계층의 모듈을 사용하게끔 설계를 했다면 올바른 위계를 얻어낼 수 없었을 것이고 시스템의 일부만 들어내기가 훨씬 더 어려웠을 것이며, 계층이라는 말 자체가 의미를 잃게 될 것이다(First, parts of the system are benefited (simplified) because they use the services of lower levels. Second, we are able to cut off the upper levels and still have a usable and useful product. (..omitted...) If we had designed a system in which the "low level" modules made some use of the "high level" modules, we would not have the hierarchy, we would find it much harder to remove portions of the system, and "level" would not have much meaning in system).

--On the Criteria to be used in Decomposing Systems into Modules

시스템을 모듈화할 때 계층 간의 위계를 명확히 나누고 상위 계층이 하위 계층을 사용(즉 의존)하도록 만들어야 한다는 주장이며 이는 구조적 프로그래밍 혹은 모듈화 프로그래밍의 핵심 설계 원칙 중 하나입니다. 여기에서 상위 계층이란 좀 더 추상적인(abstract) 계층을 말하고 하위 계층이란 좀 더 구체적인(concrete) 계층을 말합니다. 그림으로 그려보면 아래와 같습니다:


모든 화살표가 위에서 아래로 내려가는 모습을 담고 있습니다. 의존성 역전이란 바로 이러한 의존관계를 뒤집는다는 의미로, 의존성 역전이 일어난 설계에서는 화살표 일부가 아래에서 위로 올라갑니다. UML로 치자면 속이 빈 삼각형 화살표(generalization 혹은 realization)로 표현합니다:


의존성 역전이 가능한 이유는 하위 계층이 상위 계층에 의존하면서도 순환 참조(circular dependency)가 일어나지 않게 만들어서 이를 통해 원래 David Parnas가 강조하던 목적(시스템의 일부를 쉽게 들어낼 수 있도록 하는 것)을 여전히 달성할 수 있게 되기 때문입니다.

순환 참조는 어떻게 끊나요? 즉, 위 그림의 속이 빈 삼각형 화살표는 언어에서 어떻게 표현될까요? 상속으로 표현합니다. 자바로 치자면 extends 혹은 implements가 되겠죠. 대표적 사례는? Observer Pattern. (다시 보니 위 그림은 별로 예쁘지 않군요. 대충 예쁜 의존성 역전의 사례를 상상해주세요)

잠깐 딴소리: 아마도 GoF 패턴책이 번역된 후로, 다른 커뮤니티는 모르겠고, 자바 개발자들 사이에서 extends는 꼬지고 implements는 좋은거라는 미신이 널리 퍼졌죠. 좀 과장하자면 구현 상속은 delegation(and/or composition)으로 대체하고 오직 타입 상속이 쵝오, 뭐 이런 얘기인데 “extends == 구현 상속”, “implements == 타입 상속”이라는 잘못된 공식이 오해의 원인이 아니었을까 추측해봅니다. 자바의 경우 “extends == 구현 상속+타입 상속”이라고 해야 맞습니다. 즉, implements이건 extends이건 타입 상속은 일어나는거죠. 단 extends를 했을 때는 구현 상속이 따라오게 되어 있는데 이 때의 문제를 잘 인지하고 해결할 수 있으면 그냥 쓰면 되는 것이죠.

이를테면 blackbox reuse vs. whitebox reuse 문제 같은 것이 있는데, 애초에 public/published 구분 잘 하고, protected 따위 안쓰고 메서드는 몽땅 public, 필드는 몽땅 private 같은 몇 가지 원칙들만 잘 지키면 문제의 소지가 크게 줄어듭니다.

다시 본론으로 돌아와서 지금까지의 얘기를 정리해보자면 이렇습니다:

  1. OOP는 구조적 프로그래밍의 selection(if/switch)을 서브타입 다형성으로 대체합니다.
  2. if를 서브타입 다형성으로 대체하면 하위 계층 뿐 아니라 상위 계층 까지도 더 잘 모듈화할 수 있습니다.
  3. 이러한 생각을 표현하는 말이 의존성 역전(DI) 입니다.

 

5. OOP의 나머지 개념들은 뭔가?

그럼 캡슐화(encapulation), 정보은닉(information hiding)은 안중요한가요? 중요합니다. 다만 OOP와 별 관련 없이 원래 있던 개념들입니다.

 

6. if는 다 잡아 없애야 진정한 OOP인가?

if 뿐만 아니라 switch도 다 없애야 진정한 OOP라능. 농담입니다. --;

두 가지를 이야기할 수 있겠습니다. 첫째, 진정한 OOP 아무 짝에도 쓸모 없습니다. 그런거 추구하지 마세요. 둘째, 상위 계층의 모듈화를 저해하는 if만 제거하세요. 다른 말로 바꿔서 표현하자면, 도메인 로직을 서술하기 위한 if는 남겨두세요.

 

7. OOP에 대하여 지금 서술한 관점이 킹왕짱인가?

저는 그렇다고 생각합니다. 하지만 “제일 좋은 관점”이랑 “완전한 관점”은 다른 것이죠. 제일 좋은 관점이라고 믿고 있기는 하지만 이 관점만으로 밀기엔 불충분하고 생각도 덜 다듬어진 것 같습니다. 따라서 여러 관점을 머리 속에 담아두고 그때그때 가져다 쓰는 식으로 생각하고 있습니다.


신고

예전부터 고민하던 문제인데 자꾸 미루게 되어서 일단 질러놓고 봅니다. 연재 형식으로 쓰려고 하며, 예상 독자는 게임 기획자나 웹 기획자 혹은 기획에 관심있는 개발자 입니다. 주제는 아래와 같으며 순서나 일정은 미리 정하지 않았습니다.

  • 복잡한 논리의 흐름을 몇 가지 기본적인 단위로 분해하는 방법
  • 거대한 개념을 작은 단위로 나누는 다양한 방법
  • 구체화되지 않은(혹은 의도적으로 구체화하지 않은) 매우 추상적인 개념을 효과적으로 다루는 방법
  • 서로 다른 여러 개념 사이의 공통점을 찾아내는 방법
  • 생각을 정리하기 위한 정보 시각화 방법
  • 효과적인 의사소통을 위한 정보 시각화 방법
  • 특정한 기획 요소의 변화가 다른 기획 요소에 미치는 영향을 추적하고 관리하는 방법

거의 모든 주제가 “논리적이고 체계적인 사고”에 대한 것입니다. 따라서 독자께서 이런 생각을 하실 수도 있습니다:

기획은 논리만으로 되는 것이 아니다. 기획자는 사용자의 이성적 측면 뿐 아니라 감성적 측면도 고려해야 한다.

이러한 의문에 대해서는 두 가지 생각을 가지고 있습니다.

첫째, 기획의 모든 면을 다루는 것은 이 연재의 목적이 아닙니다. 그러기에는 제 지식이나 경험이 턱없이 부족합니다.

둘째, 감성과 논리는 대치되는 개념이 아닙니다. 논리라고 하면 보통은 ‘이성’에 대한 것으로 한정지어 생각하는 경향이 있는데 감성이나 도덕에도 논리가 있습니다. 감성적 측면을 다루는 능력이 진정 중요하다고 생각한다면 인간의 감성을 지배하는 논리를 파악하고 이를 체계적으로 다루는 기술을 연마해야 한다고 생각합니다. 이 중 감성의 논리를 파악하기 위해 필요한 내용(심리학, 사회학, 생물학, 미학 등)은 아마도 이번 연재의 범위에 포함되지 않을 것이고, 파악된 논리를 체계적으로 분석하고 활용하기 위해 필요한 내용을 주로 다룬다고 보면 될 것 같습니다.

이런 종류의 글을 쓰기에 더 적합할 것 같은 분들이 주위에 좀 계셔서 부담스럽지만 스스로 공부도 할겸 제가 먼저 깝쭉 나대보려고 합니다. ㅋ

앞으로 연재가 진행되는 동안 댓글로 의견/제안/질문 등을 남겨주시면 큰 도움이 되겠습니다. :-)

신고
JSSpec 관련 메일을 일주일에 몇 통 정도 받습니다. 가장 많이 받는 질문 중 하나는(국내외를 막론하고) 바로
JSSpec에서 setInterval()이나 setTimeout()이 포함된 로직을 테스트하려면 어떻게 하나요?
입니다. 메일로 짧게 답하기가 좀 힘들기도 하고, 성의가 부족해보이기도 하고 그래서... 이 주제로 짧은 글을 하나 쓰려고 합니다.


1. 초간단 요구사항 및 초기구현

화면에 숫자 10 이 나타나고, 이게 0.1초1 씩 감소하다가 0 이 되면 멈추는 그런 애플리케이션을 만든다고 칩시다. 다음은 counter.html 입니다 (doctype 생략):
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="ko">
<head>
<meta http-equiv="Content-Type" content="text/html;charset=UTF-8" />
<title>Counter</title>
<script type="text/javascript" src="counter.js"></script>
<script type="text/javascript">// <![CDATA[
    window.onload = main;
// ]]></script>
</head>
<body>
    <div id="counter">10</div>
</body>
</html>
다음은 counter.js 입니다:
var counter = 0;
var handle = null;

function main() { startCountdown(); }

function startCountdown() {
    counter = 10;
    handle = setInterval(function() { countdown(); }, 100);
}

function countdown() {
    counter--;
    var element = document.getElementById('counter');
    element.innerHTML = counter;
   
    if(counter === 0) clearInterval(handle);
}
간단한 코드이므로 설명은 생략합니다. 스팩을 먼저 만들고 구현을 하면(즉, BDD 혹은 TDD를 하면) 더 쉽겠지만, 실제 상황과 유사하게 하기 위해서 스팩 없이 구현을 먼저 했습니다. Michael Feather의 명저서 "Working Effectively with Legacy Code(레거시 코드 활용 전략이라는 제목으로 번역되었습니다)"에 의하면, 위 코드는 바로 레거시 코드 입니다.
To me, legacy code is simply code without tests. ... Code without tests is bad code. It doesn't matter how well written it is; it doesn't matter how pretty or object-oriented or well-encapsulated it is. With tests, we can change the behavior of our code quickly and verifiably. Without them, we really don't know if our code is getting better or worse. --pxvi

저는 테스트 케이스가 없는 코드를 레거시 코드로 봅니다. ... 테스트 없는 코드는 나쁜 코드입니다. 얼마나 잘 짰는지는 중요치 않아요. 아무리 예뻐도, 아무리 객체지향적이어도, 아무리 캡슐화가 잘 되어 있어도 소용 없습니다. 테스트가 있으면 빠르고 검증가능한 방식으로 코드의 행위를 수정할 수 있습니다. 테스트가 없으면 코드가 좋아지고 있는지 나빠지고 있는지 알 방법이 없지요.
이제, 이 레거시 코드를 좋은 코드로 고쳐봅시다.


2. 안도감을 느낄 수 있을만큼의 스팩

너무 많지도 않고 너무 적지도 않은 딱 적절한 양의 스팩을 달고 싶습니다. 다른 말로 하자면 "안도감을 느끼며 개발을 할 수 있을만큼의 스팩"입니다.

뭐 정의 자체가 애매하다보니 사람마다 그 기준이 다를 수 있겠습니다만, 저는 아래 두 가지 로직을 커버할 수 있는 정도면 안도감이 느껴질 것 같습니다:
  • countdown()이 호출되면 div#counter 의 값이 1 씩 감소하는가
  • countdown()0.1초 간격으로 호출되는가

이제 하나씩 공략해 봅시다.


3. countdown()이 호출되면 div#counter 의 값이 1 씩 감소하는가

다음은 스팩입니다. body 태그 안쪽만 옮겼습니다.
<div id="counter">10</div>

<script type="text/javascript">// <![CDATA[
describe('Counter', {
    'countdown()이 호출되면 카운터가 1 감소되어야 한다': function() {
        var element = document.getElementById('counter');
        counter = 5;
        countdown();
        value_of(element.innerHTML).should_be('4');
    }
});
// ]]></script>
일단, 전역변수인 counter5로 강제로 설정하고, countdown() 함수를 호출합니다. 그 결과로 div#counterinnerHTML4로 변했는지를 확인합니다.

스팩을 짜놓고 보니까 좀 지저분합니다. 전역 변수(counter)를  하나 제거하면 좀 나아질 것 같군요.
describe('Counter', {
    'countdown()이 호출되면 카운터가 1 감소되어야 한다': function() {
        var element = document.getElementById('counter');
        element.innerHTML = '5';
        countdown();
        value_of(element.innerHTML).should_be('4');
    }
});
위 스팩이 통과하려면 counter.js를 다음과 같이 수정해서 전역 변수 counter를 모두 제거해야 합니다:
var handle = null;

function main() { startCountdown(); }

function startCountdown() {
    handle = setInterval(function() { countdown(); }, 100);
}

function countdown() {
    var element = document.getElementById('counter');
    var counter = Number(element.innerHTML) - 1;
    element.innerHTML = counter;
   
    if(counter === 0) clearInterval(handle);
}
한편, div#counter 엘리먼트도 어디서든 접근할 수 있다는 점에서 전역 변수라고 볼 수 있겠습니다. countdown() 함수가 이 엘리먼트에 의존하고 있기 때문에 아래의 HTML이 테스트 코드와 프로덕션 코드 두 곳에 중복으로 나타나죠:
<div id="counter">10</div>
스팩을 아래와 같이 수정하면 좋겠습니다:
describe('Counter', {
    'countdown()이 호출되면 카운터가 1 감소되어야 한다': function() {
        var element = {innerHTML: '5'};
        countdown(element);
        value_of(element.innerHTML).should_be('4');
    }
});
countdown() 함수가 element를 인자로 받도록 수정하고, 가짜 엘리먼트를 하나 만들어서 넘겨주었습니다. 이 방식은 countdown() 함수가 element의 innerHTML 속성을 이용할 것이라는 가정을 하고 있다는 점에서 좋지 않습니다만(테스트가 구현 방식에 종속됨), 기존 방식(전역 변수에 의존)보다는 좋다고 판단하였습니다. "best way"는 아니지만 "better way"인거죠.

위 스팩이 통과하려면 counter.js는 아래와 같이 바뀌어야 합니다:
function startCountdown() {
    handle = setInterval(function() {
        var element = document.getElementById('counter');
        countdown(element);
    }, 100);
}

function countdown(element) {
    var counter = Number(element.innerHTML) - 1;
    element.innerHTML = counter;
    if(counter === 0) clearInterval(handle);
}
이 정도로 하고 다음 스팩을 추가하겠습니다.


4. countdown()이 0.1초 간격으로 호출되는가

"countdown()이 0.1초 간격으로 호출되는가"라는 질문은 달리 표현하자면, "0.1초가 지나면 countdown()이 호출되는가"입니다. 이제 드디어 본론에 해당하는 내용이 나왔습니다.

대체 "0.1초가 지나면"이라는 것을 어떻게 테스트하면 좋을까요? JSSpec에 아래와 같은 가상의 기능이 추가되어서 0.1초를 "기다릴 수 있으면" 될까요?
    'countdown()이 0.1초 간격으로 호출되어야 한다': function() {
        // 여기에서 뭔가를 수행하고

        wait_for(100); // 0.1초(100msec)를 멈춰서 기다린 후

        // 여기에서 결과를 확인한다
    }
위와 같은 방식이 기술적으로 가능한지 여부를 떠나서, 이는 좋은 단위 테스트라고 볼 수 없습니다. 만약 요구사항이 0.1초가 아니라 한 시간이었다면, 위 테스트를 수행하는데 한 시간이 걸린다는 얘기인데, 이렇게 되면 원하는 때에 피드백(즉, 테스트 성공 여부에 대한 확인)을 받을 수가 없게 됩니다.

요약하자면, "0.1초가 지난 후 어떻게 되는가"를 테스트하기 위해 실제로 0.1초를 기다려야 하는 방식은 적절치 않습니다.

좀 더 적절한 방법은 0.1초를 가짜로 흘려보내는 것입니다. 자바스크립트의 Date 클래스를 다음과 같이 확장하고, 애플리케이션의 모든 코드에서 new Date() 대신에 Date.get()을 쓰도록 수정할 것을 권장합니다:
Date._preset = -1;
Date.preset = function(msec) {Date._preset = msec;};
Date.pass = function(msec) {
   Date._preset = Date._preset == -1 ? msec : Date._preset + msec;
};
Date.get = function() {
   return Date._preset == -1 ? new Date() : new Date(Date._preset);
};
preset() 함수는 시간을 임의로 설정하여 멈추어놓기 위해 사용됩니다. pass() 함수는 시간을 가상으로 흘려보냅니다. get() 함수는 현재 시간을 얻어옵니다. get() 함수가 반환하는 값은 preset() 혹은 pass()가 사용되지 않았다면 실제 시스템 시간이지만, preset()이나 pass()가 사용되었다면 가짜 시간입니다. 위 코드는 실제 프로덕션에서도 사용될 코드이므로 스팩이 아닌 counter.js에 추가합니다. 이제 아래 스팩을 추가해봅시다:
    '0.1초가 지나면 countdown()이 호출되어야 한다': function() {
        // countdown 바꿔치기
        var backup = countdown;
        var called = false;
        countdown = function() { called = true; };

        try {
            Date.preset(0); // 시간 고정
            updateIfTimePassed();
            value_of(called).should_be(false);
           
            Date.pass(100); // 0.1초 흘리기
            updateIfTimePassed();
            value_of(called).should_be(true);
        } finally {
            // countdown 복원
            countdown = backup;
        }
    }
좀 길군요. 하나씩 설명을 하겠습니다.

첫째, updateIfTimePassed() 함수가 추가되었습니다. 이 함수가 하는 일은 자신이 마지막으로 호출된 후로 0.1초 이상이 흘렀으면 countdown() 함수를 호출해주는 것입니다.

둘째, 이번에 작성한 스팩은 0.1초 간격으로 countdown() 함수가 호출되는가를 확인하기 위한 것이지 countdown() 함수가 제대로 작동되는가를 확인하는 것이 아닙니다. 다른 말로 하자면 countdown() 함수가 제대로 작동하건 안하건 0.1초 간격으로 호출만 된다면 이 스팩이 깨지지 않아야 합니다. 따라서, countdown() 함수를 가짜로 대체하고 다시 복구하는 코드(변수 backup이 사용되는 부분들)가 들어 있습니다.

셋째, 조금 전에 Date 클래스에 추가한 메서드들을 사용하여 시간을 고정시킨 후 가짜로 흘려보내고 있습니다. 이렇게 하면 이 테스트는 실제로 0.1초를 보내지 않고 순식간에 수행됩니다.

이번에는 이에 따른 프로덕션 코드의 수정입니다:
var updatedAt = null;

function startCountdown() {
    handle = setInterval(updateIfTimePassed, 10);
}

function updateIfTimePassed() {
    if(!updatedAt) updatedAt = Date.get();
   
    var now = Date.get();
    var timePassed = now - updatedAt >= 100;
    if(timePassed) {
        var element = document.getElementById('counter');
        countdown(element);
        updatedAt = now;
    }
}
변화되지 않은 부분들 - main(), countdown() - 은 생략하였습니다. 변화된 부분은 다음과 같습니다.

첫째, startCountdown 내부의 setInterval이 updateIfTimePassed를 0.1초 간격이 아닌 0.01초 간격으로 호출하고 있습니다. 0.01초는 대략 "시스템에 무리를 주지 않는 한도 내에서 최대한 빈번한 간격"입니다. 이렇게 수정하자 이 메서드는 말 그대로 카운트다운을 시작하는 일만 하게 되었고, 카운트다운의 간격 등에 대해서는 아무런 간섭도 하지 않게 되었습니다.

둘째, updateIfTimePassed() 함수가 새로 만들어졌습니다. 이 함수는 자신이 마지막으로 호출된 이후로 0.1초 이상이 지나면 countdown() 함수를 호출하도록 되어 있습니다.

셋째, 이 과정에서 전역 변수 updatedAt이 추가되었습니다. 좋지 않지만 잠시 참고 가보도록 하겠습니다.

Date 클래스를 확장하는 방식에 대해서 조금 더 부연설명이 필요하신 분은 유닛테스트에서 시각과 시간이라는 글을 참고해주세요. 자바를 기준으로 설명하고 있지만 결국 같은 방식입니다.


5. 리팩토링

원하는 스팩을 두 개 만들었으니 이제 좀 마음놓고 리팩토링을 해야 합니다. 마음에 안드는 부분들이 많습니다.

첫째, 0.1초 간격으로 무언가를 호출하는 로직(updateIfTimePassed 함수)이 카운트다운을 수행하는 로직(countdown 함수)과 엉켜있습니다. 게다가 countdown 함수에 필요한 인자를 넘겨주기 위해서 DOM에도 의존하고 있습니다.

둘째, 전역 변수가 두 개(handle, updatedAt) 있습니다. 그리고 DOM 이 숨은 전역 변수 역할을 하고 있기도 합니다. 이게 무슨 말이냐하면, 전역 변수라는게 나쁜 이유는 어디에서든 접근할 수 있기 때문에, 코드의 여러 지점이 이 전역 변수를 중심으로 엮인다는 점입니다. 그렇게 본다면 div#counter 라는 엘리먼트가 여기저기에서 쓰이면서 의존성을 만들어내고 있다는 점에서 숨은 전역  변수입니다. 예전에 박응주님이 자바의 ThreadLocal이나 WebWork의 ActionContext 등도 전역 변수이다라고 말씀하신 것과 같은 맥락입니다.

(저는 예전에 위 두 가지 문제를 해결하기 위해 Timer 혹은 Scheduler라는 클래스를 만들었었는데 아주 만족스러웠습니다.)

셋째, 스팩 자체도 깔끔하지가 않습니다. 가짜 객체(element, countdown)를 만들어서 이런저런 의존성을 끊어주어야만 테스트간 격리(test isolation)가 이루어진다는 것은 결국 좋지 못한 설계로 인해 생긴 문제에 다름 아닙니다. 위에서 언급한 두 가지 문제를 해결하면 스팩도 더 깔끔해지겠죠. 원래 테스트하기 쉬운 코드일수록 설계가 좋은 코드라는 말이 있습니다. 그 반대도 성립합니다. 설계가 좋은 코드일수록 테스트하기가 쉽기도 합니다. 역시나 가장 좋은 방법은 테스트(혹은 스팩)를 먼저 만들고나서 설계를 하는 것(즉, TDD 혹은 BDD를 하는 것)입니다.

이제 퇴근을 해야겠으니 이런 부분들은 미해결로 남겨놓겠습니다. ^^;


PS - 요약 및 일반화된 결론

setInterval 을 테스트하는 문제는 사실 더 큰 문제의 구체적 사례일 뿐입니다. 문제를 더 일반화하면 이렇게 됩니다:
제어하기 힘들거나 비용이 많이드는 외부 시스템(시스템 타이머, 디스크, 네트워크, 외부 라이브러리, 프레임워크의 노출된 인터페이스 등등)과 엮인 코드를 어떻게 테스트할 것인가.
이 문제에 대한 일반해는 InsertTestableLayer를 참고하시기 바랍니다.

그런데, TestableLayer라는 것을 넣으면 테스트가 가능해진다는 점 말고 대체 뭐가 좋아지는걸까요. 위에서 설계가 좋아진다고 쓰기는 했는데 구체적으로 뭐가 어떻게 좋아진다는 것일까요. 뭐 여러가지 주절주절 설명할 수 있겠지만... 자바스크립트의 경우는 이렇습니다:

비즈니스 로직(0.1초 간격으로 1씩 카운트다운)에 해당하는 코드를 전혀 수정하지 않고 다른 호스트 환경(host environment - Rhino, jslibs, ASP, JScript.NET, Silverlight, Flash/Flex 등)에서 그대로 사용할 수 있게 됩니다. 이것도 또한 반대로 얘기할 수 있는데, 코드를 전혀 수정하지 않고 다른 호스트 환경에서 사용할 수 있게 된다면 제어하기 힘든 외부 시스템에 대한 의존이 제거되었다고 볼 수 있습니다.

신고
아래 글들에서 구구단 얘기좀 쓰다보니 갑자기 불현듯 생각이 나서 하나 더 씁니다. ㅎㅎ

객체지향적 구구단이라거나 객체지향적 Hello World에 대한 이야기를 간혹 보게 되는데 과연, 객체지향적으로 잘 설계된 구구단이란게 뭘까요?

아니, 객체지향이고 뭐고를 떠나서, 잘 설계된 구구단이란 뭘까요?

Uncle Bob이 "Agile Software Development"라는 책에 쓴 구절을 인용하는게 좋겠습니다:
(발번역입니다 --; ) LSP에서 중요한 결론을 한 가지 얻을 수 있다: 모델만 따로 보아서는 결코 적절성을 논할 수 없다. 해당 모델을 사용하는 코드의 입장에서 보았을 때에만 적절성을 이야기할 수 있는 것이다.

(중략)

특정 설계가 적절한가 그렇지 않은가에 대해서 그 설계만 놓고서는 알 수 없다. 해당 설계를 사용하는 측으로부터 도출되는 타당한 가정들에 기반하여 평가해야 한다. (단위 테스트가 이러한 가정들을 명시적으로 선언하는 역할을 한다. 이는 TDD의 또 다른 장점이다)

The Liskov Substitution Principle leads us to a very important conclusion: A model, viewed in isolation, cannot be meaningfully validated. The validity of a model can only be expressed in terms of its clients.

(...omitted...)

When considering whether a particular design is appropriate or not, one cannot simply view the solution in isolation. One must view it in terms of the reasonable assumptions made by the users of that design(Often you will find that those reasonable assumptions are asserted in the Unit Tests written for the base class. Yet another good reason to practice Test Driven Development).

--p116

그럼 다시. 구구단을 최대한 잘 설계하라고 할 때 어떠한 답을 내놔야 할까요? 저라면 이렇게 짜겠습니다:
public class Gugu {
    public static void main(String args[]) {
        for (int i = 2; i <= 9; i++)
            for (int j = 1; j <= 9; j++)
                System.out.println( i + " * " + j + " = " + (i * j) );
    }
}

스스로 평가를 해보자면:
  • 상속성: 모듈이 하나라 상속 필요 없음
  • 캡슐화: 모듈이 하나라 캡슐화 필요 없음
  • 은닉: 모듈가 하나라 숨길 필요 없음

많은 사람들이 제일 중요한 덕목으로 쳐주는 재활용성(ㄷㄷㄷ)의 측면에서 보자면:
  • 재활용성: 어디에 "다시 쓰일지(re-use)" 예측할 수 있는 정보가 없으므로, 가장 단순하게 작성하였음. 코드가 짧고 명확하니 최소한의 수정비용으로 최대한 다양한 곳에 활용될 수 있음.

이렇다고 생각합니다.

신고

I'm a boy. You're a girl.

나쁜 코딩 - 약한 결합을 위해 추상클래스를 선언했지만...

이 글... 은근히 좋은 화두가 되는군요. ^^

위 글에 인용된 한 학생의 코드는 여러가지 객체지향 설계의 원칙들 혹은 그 이전에 구조적 프로그래밍의 원칙들을 어기고 있습니다(사실은 이 둘이 별로 다르지도 않지만).

과제가 뭐였길래 저런 결과가 나왔을까요? 아마도, 계산 로직은 동일하고 출력만 다르게 하는 것이 요구사항이었던 것 같습니다. 그리고... "상속과 추상 클래스를 사용할 것" 같은 제약이 있었을 것 같기도 하고요.

이렇게 추측한 요구사항을 가지고 대충 고쳐보자면 전 아마 이런식으로 했을 것 같아요:
abstract class Computer {
  void print() {
        for (int i = 2; i <= 9; i++)
            for (int j = 1; j <= 9; j++)
                System.out.print( i + " * " + j + " = " + (i * j) + this.getSeparator());

        System.out.println(this.getName());
  }
  abstract String getName();
   abstract String getSeparator();
}
class Linux {
   public String getName() {return "Linux";}
   public String getSeparator() {return "\t";}
}
class Window {
   public String getName() {return "Window";}
   public String getSeparator() {return "\n";}
}

// main은 생략.

그런데 말이죠, 위 코드는 과연 얼마나 현실적인가요?

전 이런 코드를 보면 "I'm a boy. You're a girl." 같은 영어 문장이 생각납니다. 올바른 문장이기는 하지만 평생 실생활에서 쓸 일이 없고, 대체 어떤 상황에서 저런 대사가 나오는지 짐작조차 할 수 없는 그런 문장 말이죠.

과제의 조건이 좀 더 그럴듯했다면, 즉 억지로 상속과 추상 클래스를 사용하는 것이 아니라 실제로 상속과 추상 클래스를 썼을 때 깔끔하게 풀릴만한 조건이 과제로 제시되었다면 어땠을까요? 좀 더 나은 코드가 나오지 않았을까 싶어요. 코드 리뷰를 하면서 잘못된 부분을 지적해줄 때도 좀 더 쉽게 납득할 수 있지 않았을까요?

(비현실적인 예제가 무조건 나쁜 것은 아니라고 생각해요. 예를 들어 이런 종류의 과제는 참말 훌륭하다고 생각합니다.)

신고

구구단을 UML로..

나쁜 코딩 - 약한 결합을 위해 추상클래스를 선언했지만...를 읽다가 흥미가 동하여 끄적여봅니다.

참고로 UML을 학습하시는 분들이 계신데, 위에 작성한 클래스들을 UML로 작성해보면 정말 약한 결합을 실현한 것처럼 보입니다. 제가 UML을 별로 선호하지 않는 이유인데요. 책에 나와 있는 그림이랑 똑같은데 뭐가 문제라는 겁니까라고 얼굴이 벌게져서 항의하는 후배 앞에서 정말 뭐라 할 말을 잃었습니다. 여전히 후배랑 화해를 못하고 있어요... 나쁜 UML (엉뚱한데 탓한다) UML을 신봉하는 사람에게 코드를 좀 보란 말이야 라고 얘기해도 자꾸 UML 다이어그램만 보더라구요.

저도 UML을 딱히 좋아하는 것은 아니지만, 어느 정도 유용한 측면이 분명 있다고 생각합니다. 사실, 이 경우엔 UML 자체가 문제라기 보다 UML을 제대로 못 그린 것이 문제인거죠.

위에 작성한 클래스라는 것은 복잡한 것 빼고 간단히 요약하면 이런 코드입니다:
abstract class Computer {
   static int arr[][];
   abstract void print();
}

class Windows extends Computer {
   void print() {
       // depends on "arr"
   }
}

class Linux extends Computer {
   void print() {
      // depends on "arr"
  }
}

이 코드를 UML로 나타내라고 하면 보통은 이렇게 하겠죠:

사용자 삽입 이미지

따라서, 위 다이어그램을 보면 마치 약한 결합(generalization)인 것으로 보입니다. 하지만, 좋은 모델이라는 것은 "적절한 요소를 드러내야"하는데, 이 맥락에서 "적절한 요소"란 Windows 클래스와 Linux 클래스가 "arr"에 의존하고 있다는 점입니다. 한편, arr은 엄밀히 쓰자면 Computer.arr 이죠. 따라서 아래와 같은 선이 추가되어야 합니다:

사용자 삽입 이미지

이제 다이어그램만 봐도 잘못된 설계가 눈에 보이게 됩니다.

많은 사람들이 다양한 측면에서 UML을 비판합니다만(시각화가 적절치 못하다, Executable UML은 비용이 너무 비싸다 등등), 그렇다고 해서 아주 못 쓸 물건은 아니라는 얘기가 하고 싶었어요.

관련글:

신고

Circular Dependency

사용자 삽입 이미지
[다이어그램1] 이건희와 이재용 '법' 걱정 없이 훨훨 날아가려나


Circular Dependency란?
Occurrences of circular dependencies should normally be limited, since they make the resulting object-models unclear and unstructured, and are hence considered a bad programming habit. Circular dependencies will also prevent some very primitive automatic garbage collectors (those that use reference counting) from deallocating objects.

순환적 의존관계는 가능하면 제약하는 것이 좋다. 왜냐하면 객체 모델을 불명확하고 비구조적으로 만들기 때문이다. 따라서 순환적 의존관계는 나쁜 프로그래밍 습관으로 간주된다. 순환적 의존관계는 (레퍼런스 카운팅 기법을 사용하는) 원시적인 자동 쓰레기 수집기의 작동을 방해하여 객체가 메모리에서 해제되는 것을 막기도 한다.

(위키피디아 중에서)

여기서 퀴즈! [다이어그램1]에서 Circular Dependency를 제거하기 위해 끊어야 하는 association은?

에.. 답은 뻔하지만 현실은 다이어그램과 달라요~
신고
< Newer     Older >

티스토리 툴바