객체는 처리의 추상화다. 스레드는 일정의 추상화다.
제임스 O. 코플리엔(James O. Coplien) - Advanced C++ Programming Styles and Idioms의 저자, 힐사이드 그룹의 창립 멤버
동시성과 깔끔한 코드는 양립하기 아주 어려운 영역이다.
단순히 하나의 스레드만 실행하는 코드를 작성하는 것은 쉽다.
다중 스레드 코드라도 겉으로 멀쩡하게 보이도록 작성하는 것도 쉽다.
다만 스레드 깊숙한 곳에 문제가 있는 경우엔 시스템이 부하를 직접적으로 받기전까진 알아채기 어렵다.
본 포스팅에서는 이러한 문제점을 감안하도서라도 여러 스레드를 동시에 작동시키는 이유를 살펴보고,
최대한 깨끗한 코드를 작성하는 방법, 마지막으로 동시성의 문제점과 테스트 방법에 대해서도 알아본다.
동시성은 결합을 없애기 위해 무엇(what) 과 언제(when) 를 분리하는 전략이다.
스레드가 하나인 프로그램은 이 무엇 과 언제 가 서로 밀접하기에 호출 스택만 보아도 프로그램의 상태가 바로 드러난다.
단일 스레드 프로그램의 경우 브레이크포인트 하나로 시스템 상태를 파악하고 디버깅하는 것도 가능하다.
이때 무엇 과 언제 를 분리하면 애플리케이션의 구조와 효율이 매우 높아진다.
구조적인 관점에서 프로그램은 거대한 루프 하나가 아닌 작은 협력 프로그램 여러 개의 조합이기때문에,
분리할 수록 시스템을 이해하기도 쉽고 문제를 분리하는 것도 쉬워진다.
하지만 구조적 개선만을 위해 동시성을 채택하는 것은 아니다.
어떤 시스템 A는 응답 시간과 작업 처리량 개선이라는 요구사항으로 인해 직접적인 동시성 구현이 불가피하며,
어떤 시스템 B는 한 번에 한 명의 사용자만을 처리할 수 있는 상황에서 사용자가 늘어나는 경우의 응답속도를 개선하기 위해서도 동시성을 구현해야 한다.
마지막으로 어떤 시스템 C는 대량의 정보를 한 번에 분석하기에, 응답 속도가 너무 느린 경우 대량의 정보를 여러 컴퓨팅 장치에서 동시에 처리하도록 동시성을 구현해야할 수 도 있다.
미신과 오해
위에서 상술했듯 반드시 동시성이 필요한 상황은 존재한다.
동시성은 개념 자체도 어렵고, 구현하는 것도 어렵기에 아래와 같은 미신과 오해가 존재한다.
동시성을 구현하기가 어려운 이유는 무엇일까?
아래 예제를 통해 이해해보자.
1 | public class X { |
아래 상황을 가정해보자.
클래스 X
의 객체를 생성한 뒤 lastIdUsed
를 42로 초기화했다.
이때 두 개의 스레드 A, B가 해당 객체에 접근하여 getNextId()
를 호출한다.
결과는 아래 3개 중 하나로 나올 것이다.
lastIdUsed
는 44로 저장된다.lastIdUsed
는 44로 저장된다.lastIdUsed
는 43로 저장된다.두 개의 스레드가 같은 변수를 동시에 참조하면 세번째 결과 처럼 의도돠는 다른 값을 반환한다.
이러한 결과는 두 개의 스레드가 자바 코드 한 줄을 거쳐가는 경로가 수없이 많기 때문이다.
참고 가능한 경로 수를 계산하려면 자바 컴파일러가 생성한 바이트 코드를 살펴보아야 한다.
간단하게 두 스레드가 getNextId()
메서드를 호출할 수 있는 잠재적인 경로는 최대 12,870개에 달한다.
이때 lastIdUsed
의 타입을 int에서 long으로 변경하면 조합 가능한 경로의 수는 2,704,156개로 증가한다.
물론 대다수의 경로는 올바른 결과를 내놓지만, 문제는 잘못된 결과를 반환하는 일부 경로이다.
즉, 코드에 따라 경우의 수가 동적으로 증감하는 만큼 동시성을 대응하는 것은 어렵다고 볼 수 있다.
이번엔 동시성 코드가 일으키는 문제로부터 시스템을 방어하는 원칙과 기술에 대해서 알아보자.
동시성과 단일 책임 원칙
단일 책임 원칙은 메서드/클래스/컴포넌트를 변경할 이유가 하나여야 한다는 원칙이다.
동시성은 복잡성 하나만으로도 따로 분리해야할 이유가 충분하다.
즉 동시성 관련 코드는 다른 코드와 명확히 분리되어야 한다.
하지만 불행히도 동시성과 관련이 없는 코드에서 동시성을 바로 구현하는 사례가 너무 흔하다.
따라서 동시성을 구현할 때에는 아래와 같은 부분을 고려해야한다.
결론 동시성 코드는 다른 코드와 명백하게 분리해야 한다.
따름 정리 : 자료 범위를 제한하라
위의 예제에서 확인할 수 있듯이,
객체 하나를 공유한 후 동일 필드를 수정하던 두 개의 스레드가 서로 간섭하는 경우 의도와는 다른 결과를 반환할 수 있다.
이러한 문제를 해결하는 방안으로 공유 객체를 사용하는 코드 내에서 임계 영역을 synchronized
키워드로 보호하는 것이 권장된다.
결국 중요한 것은 이 임계 영역을 최소화하는 것이다.
만약 공유 자료를 수정하는 위치가 많아진다면 아래와 같은 문제들을 맞닥뜨리게 된다.
결론 자료를 캡슐화하고, 공유 자료를 최대한 줄여야 한다.
따름 정리 : 자료 사본을 사용하라
공유 자료를 줄이는 가장 좋은 방법은 처음부터 공유하지 않는 것이다.
경우에 따라 객체를 복사해서 읽기 전용으로 사용하는 방법이 가능하다.
또 다른 경우에는 각 스레드가 접근하려는 객체의 사본을 만들어 사용한 후, 다른 스레드가 해당 사본에서 결과를 가져오는 것도 가능하다.
위와 같이 어떠한 방법이든 공유 객체를 회피한다는 것은 코드가 문제를 일으킬 가능성도 낮아지는 것을 기대할 수 있다.
만약 객체를 복사하는 비용 혹은 복사 과정에서 발생하는 부하가 우려되는 경우, 실측 후 판단하는 것이 좋다.
다만 사본으로 동기화를 회피하는 것이 객체 내부의 잠금을 통해 절약한 비용을 상쇄할 가능성이 크다.
따름 정리 : 스레드는 가능한 독립적으로 구현하라
스레드를 독립적인 세상에 홀로 존재하도록 구현하는 것이 좋다.
즉 애초에 다른 스레드와 자료를 공유하지않도록 하는 것이다.
좀 더 구체적으로는 각 스레드는 클라이언트 요청을 처리하며 비공유 영역에서 자료를 가져와, 로컬 변수에 저장하도록 하여 동기화의 여지를 차단해버리는 것이다.
결론 독자적인 스레드로, 가능하면 다른 프로세서에서 구동해도 괜찮도록 자료를 독립적인 단위로 분할하라.
자바5부터는 동시성 측면에서 좀 더 개선되었다고 볼 수 있다.
이제 스레드 코드를 구현한다면 아래와 같은 사항을 고려해야 한다.
스레드 환경에 안전한 컬렉션
java.util.concurrent
패키지에서 제공되는 클래스는 다중 스레드 환경에서도 안전함이 보장되며 성능도 뛰어나다.
실제로 ConcurrentHashMap
은 거의 모든 상황에서 HashMap
보다 빠르다.
좀 더 복잡한 동시성 문제를 해결하기 위해 사용할 수 있는 컬렉션은 아래와 같다.
| 구분 | 명세 |
| ReentrantLock | 한 메서드에서 잠그고 다른 메서드에서 푸는 Lock이다 |
| Semaphore | 공유된 자원의 데이터 또는 임계 영역 등에 여러 스레드가 접근하는 것을 막아주는 개수가 있는 Lock이다 |
| CountDownLatch | 지정한 수만큼 이벤트가 발생하고 나서야 대기중인 스레드를 모두 해제해주는 Lock 이다 |
결론 언어가 제공하는 클래스를 검토하라.
자바에서는java.util.concurrent
,java.util.concurrent.atomic
,java.util.concurrent.locks
가 해당된다.
다중 스레드 애플리케이션을 분류하는 방식을 여러 가지이다.
먼저 용어 정의부터 진행하자
이제 다중 스레드 프로그래밍에서 사용하는 실행 모델을 몇 가지 살펴보자.
생산자-소비자(Producer-Consumer)
하나 이상의 생산자 스레드가 정보를 생성해 버퍼나 대기열에 넣는다.
동시에 하나 이상의 소비자 스레드가 버퍼나 대기열에서 정보를 가져와 사용한다.
이때 생산자와 소비자 스레드가 사용하는 대기열을 한정된 자원이며, 빈 공간이 있어야 정보를 채울 수 있으므로 빈 공간이 생길때까지 기다린다.
반대로 소비는 대기열에 정보가 있어야 가져온다.
따라서 생산자와 소비자 스레드는 서로에게 시그널을 보내야 하며,
둘 다 진행할 수 있는 상황에서 서로의 시그널을 기다리는 상황이 발생할 여지가 있다.
읽기-쓰기(Readers-Writers)
읽기 스레드는 주된 정보의 출처로 공유 자원을 사용하고 있고, 쓰기 스레그가 이 공유 자원을 간헐적으로 갱신한다고 가정하자.
이때 처리율이 문제의 핵심이 된다.
처리율을 강조하면 기아 현상이 생기거나, 오래된 정보가 쌓이게 된다.
이때 쓰기 스레드에 갱신을 허용하면 처리율에 영향을 미치므로, 서로가 서로에게 영향을 줄 수 밖에 없다.
따라서 읽기 쓰레드의 요구와 쓰기 스레드의 요구를 적절히 만족히여 타협할 수 있는 지점을 파악해야 한다.
동기화하는 메서드 사이에 의존성이 존재하면 동시성 코드에 찾아내기 어려운 버그가 생길 수 있다.
이를 해소하기 위해 자바는 개별 메서드를 보호하는 synchronized
키워드를 제공해주고 있다.
하지만 공유 클래스 하나에 동기화된 메서드가 여럿이라면 구현이 올바르게 된건지 다시 확인해볼 필요가 있다.
결론 공유 객체 하나에는 메서드 하나만 사용하라.
물론 공유 객체 하나에 여러 메서드가 필요한 상황도 발생할 수 있다.
그럴 땐 아래의 세 가지 방법을 고려해보도록 하자.
상술 했듯 자바에서는 synchronized
키워드를 사용해 락을 설정한다.
같은 락으로 감싼 모든 코드 영역은 한 번에 하나의 스레드만 실행가능하지만, 이 락은 스레드를 지연시키고 부하를 가중시킨다.
그러므로 synchronized
키워드를 남발하는 코드는 지양해야 한다.
결론 동기화하는 부분을 최대한 작게 만들어라.
영구적으로 돌아가는 시스템을 구현하는 방법과, 잠시 돌다가 깔끔하게 종료되는 시스템을 구현하는 방법은 다르다.
이중 후자에 해당하는 코드를 올바르게 구현하기 어려운데, 데드락이 가장 흔하게 발생할 수 있기 때문이다.
예를 들어, 부모 스레드가 자식 스레드를 여러 개 만든 후 모두가 끝나기를 기다렸다가 자원을 해제하고 종료하는 시스템이 있다고 가정하자.
부모 스레드는 영원히 기다리고, 시스템은 영원히 종료되지 않는다.
사용자가 직접 종료하도록 지시했다하더라도, 부모 스레드가 자식 스레드에 보낸 시그널이 차단되어있을 수 있다.
결론 종료 코드를 개발 초기부터 고민하고 동작하게 초기부터 구현하라.
코드가 올바르다고 증명하기란 현실적으로 불가능에 가까운 어려운 일이다.
테스트가 정확성을 보장해주지도 않는다.
다만 충분한 테스트를 통해 위험을 최소화하는 것은 가치있는 일이다.
같은 코드와 같은 자원을 사용하는 스레드가 둘 이상인 경우 많은 위험이 발생하므로 고려해야할 것이 많다.
결론 문제를 노출하는 테스트 케이스를 작성하고, 프로그램 설정과 시스템 설정과 부하를 변경해가며 수행해본다.
테스트가 실패하면 원인을 추적해야 한다. 다시 시도했을 때 통과한다고 하더라도 바로 넘어가서는 안된다.
고려사항을 명세한 아래 지침을 따라보도록 하자.
말이 안 되는 실패는 잠정적인 스레드 문제로 취급하라
다중 스레드 코드는 때때로 말이 안되는 오류를 발생 시킨다.
대다수의 개발자는 스레드가 다른 코드와 교류하는 방식을 직관적으로 이해하지 못하기에, 스레드 코드내 버그는 매우 간헐적으로 한 번 씩 드러날 수 있다.
이때 실패는 매우 재현하기 어려우므로 단순한 일회성 문제로 치부하기 쉽다.
결론 시스템 실패를 일회성이라 치부하지 마라.
다중 스레드를 고려하지 않는 순차 코드부터 제대로 돌게 만들자
당연한 말이다.
스레드 외부 환경부터 제대로 동작함을 보장하는 것이 필요하다.
일반적으로는 스레드가 호출하는 POJO를 만들어, 스레드 환경 밖에서 테스트를 수행하는 것이다.
스레드보다는 POJO에 넣는 코드가 많을수록 좋다.
결론 스레드 환경 밖에서 생기는 버그와 스레드 환경에서 생기는 버그를 동시에 디버깅하지 마라. 먼저 스레드 환경 밖에서 코드를 올바르게 동작시켜라.
다중 스레드를 쓰는 코드 부분을 다양한 환경에 쉽게 끼워 넣을 수 있게 스레드 코드를 구현하라
다중 스레드를 쓰는 코드를 다양한 설정으로 실행하기 쉽게 구현하는 것이 필요하다.
예를 들어 아래와 같다.
결론 제목 그대로다. 다양한 설정에서 실행할 목적으로 다른 환경에 쉽게 끼워넣을 수 있게 코드를 구현하라.
다중 스레드를 쓰는 코드 부분을 상황에 맞게 조율할 수 있게 작성하라
적절한 스레드 개수를 파악하는 데는 많은 시행 착오가 필요하다.
따라서 처음부터 다양한 설정으로 프로그램의 성능 측정 방법을 강구하는 것이 좋다.
예를 들어 아래와 같은 방식을 고민해보는 것이다.
프로세스 수보다 많은 스레드를 돌려보라
시스템이 스레드를 스와핑하는 경우에도 문제는 발생한다.
스와핑을 강제로 발생시키려면 프로세서 수보다 많은 스레드를 동작시킨다.
스와핑이 잦을수록 임계 영역내의 빼먹은 코드나 데드락을 발견하기 쉬워진다.
다른 플랫폼에서 돌려보라
운영체제마다 스레드를 처리하는 정책이 다를 수 있다.
따라서 다중 스레드 코드는 다양한 운영체제 위에서 제대로 동작하는지 확인이 필요하다.
결론 처음부터 그리고 자주 모든 목표 플랫폼에서 코드를 돌려라.
코드에 보조 코드를 넣어 돌려라. 강제로 실패를 일으키게 해보라
스레드 코드의 오류는 간단한 테스트로 드러나지 안흔ㄴ다.
스레드 버그가 산발적이고 재현이 어려운 이유는 코드가 실행되는 수천 가지 경로 중에 아주 소수만 실패하기때문이다.
이 오류를 자주 발생하게 만드려면 보조 코드를 추가해서 코드가 실행되는 순서를 바꿔주는 기법을 적용할 수 있다.
코드에 보조 코드를 추가하는 방법은 두 가지다.
각기 자세히 알아보자.
직접 구현하기
코드에다 직접 wait()
, sleep()
, yield()
, priority()
함수를 추가한다.
까다로운 코드를 테스트할때 적합하다.
아래는 예시이다.
1 | public synchronized String nextUrlOrNull() { |
위 예제처럼 yield()
함수의 추가만으로 실행되는 경로가 바뀌게 되어 오류가 발생할 가능성을 열어준다.
다만 이 방법에는 아래와 같은 문제들이 있다.
자동화
보조 코드를 자동으로 추가하려면 AOF, CGLIB, ASM 등과 같은 도구를 사용해야 한다.
아래 예제를 보자.
1 | public class ThreadJigglePoint { |
아래 코드에서 다양한 위치에 ThreadJigglePoint.jiggle()
메서드 호출을 추가한다.
1 | public synchronized String nextUrlOrNull) { |
ThreadJigglePoint.jiggle()
메서드 호출은 무작위로 sleep()
이나 yield()
를 호출한다.
때로는 아무런 동작도 하지 않는다.
]]>결론 흔들기 기법을 사용해 오류를 찾아내라.
창발성(創發性)
명사 남이 모르거나 하지 아니한 것을 처음으로 또는 새롭게 밝혀내거나 이루어 내는 성질.
네 가지 규칙만 준수하면 우수한 설계가 나온다면?
네 가지 규칙만 준수하면 코드 구조와 설계를 파악하기 쉬워진다면?
네 가지 규칙만 준수하면 단일 책임 원칙과 의존 관계 역전 원칙을 적용하기 쉬워진다면?
네 가지 규칙만 준수하면 우수한 설계의 창발성이 촉진된다면?
켄트 백은 아래 네 가지 규칙을 준수한다면 설계를 단순한 것이라고 말한다.
이 규칙은 중요도 순으로 나열된 것이다.
각 규칙을 자세히 알아보자.
무엇보다도 설계는 의도한 대로 돌아가는 시스템을 내놓아야 한다.
문서상으로는 완벽한 시스템을 설계했더라도, 시스템이 의도한 대로 돌아가는지 검증할 방법이 없다면 문서화에 쏟은 리소스는 인정받기 힘든 비용이다.
테스트를 철저히 거쳐 모든 테스트 케이스를 항상 통과하는 시스템은 “테스트가 가능한 시스템”이다.
테스트가 불가능한 시스템은 검증이 불가능하므로 절대 출시해서는 안된다.
다행히도, 테스트가 가능한 시스템을 만들려고 노력할수록, 크기가 작고 하나의 목적만 수행하는 클래스가 나오므로 설계 품질이 저절로 높아진다.
즉 단일 책임 원칙을 준수하는 클래스가 테스트하기 더욱 쉽다.
테스트 케이스가 많을수록 개발자는 테스트가 쉽게 코드를 작성하므로, 철저한 테스트가 가능한 시스템을 만드는 것이 곧 더 나은 설계를 획득하는 방법이다.
이 단순한 규칙을 따르면 시스템은 낮은 결합도와 높은 응집력이라는 객체 지향 방법론이 지향하는 목표를 저절로 달성한다.
규칙 1에 따라 테스트 케이스를 모두 작성했다면 코드와 클래스를 정리할 시간이다.
새로 추가하는 코드가 설계 품질을 낮춘다면 이를 먼저 정리한 후, 리그레션 테스트를 통해 기존 기능의 정상 동작을 보장하는 방식으로 접근하면
리팩토링 도중 시스템이 깨질 걱정을 할 필요가 없다.
리팩토링 단계에서는 소프트웨어 설계 품질을 높이는 기법이라면 무엇이든 적용해도 괜찮다.
결합도를 낮추고, 응집도를 높이고, 더 나은 이름을 선택하는 등 다양한 기법을 동원하고, 남은 규칙 3개를 순차적으로 적용한다.
우수한 설계에서 중복은 커다란 적이다.
중복은 진정한 의미는 추가 작업, 추가 위험, 불필요한 복잡도이기 때문이다.
중복을 여러 가지 형태로 표출된다.
똑같은 코드는 당연히 중복이고, 비슷한 코드는 더 비슷하게 고쳐서 리팩토링을 용이하게 해야한다.
또 다른 형태로 구현의 중복이 있다.
예를 들어 집합을 의미하는 클래스에 아래 메서드가 있다고 가정하자.
1 | int size() { |
size()
는 현재 집합 내 원소의 개수를, isEmpty()
는 집합이 비어있는지를 판단해서 반환한다.
코드의 중복없이 아래와 같이 구현할 수도 있다.
1 | int size() { |
깔끔한 시스템을 만들려면 단 몇 줄의 중복이라도 제거하겠다는 의지가 필요하다.
이번엔 좀 더 긴 예제를 살펴보자.
1 | public void scaleToOneDimension(float desiredDimension, float imageDimension) { |
scaleToOneDimension()
메서드와 rotate()
메서드를 살펴보면 일부 코드가 동일하다는 것을 알 수 있다.
중복을 별도로 분리하여 아래와 같이 정리할 수 있다.
1 | public void scaleToOneDimension(float desiredDimension, float imageDimension) { |
중복을 제거하고보니 클래스가 단일 책임 원칙을 위반한다.
이를 해소하기위해 새로 만든 replaceImage()
를 다른 클래스로 옮기는 것도 좋다.
이러한 소규모 재사용은 시스템의 복잡도를 극적으로 줄여주며, 소규모 재사용을 제대로 익혀야 대규모 재사용이 가능하다.
아래는 고차원 중복을 제거하기 위해 자주 사용하는 템플릿 메서드 패턴(Template Method Pattern)의 예제이다.
1 | public class VacationPolicy { |
최소 법정 일수를 계산하는 코드가 직원 유형에 따라 달라지는 것만 제외하면 두 메서드는 거의 동일하다.
여기에 템플릿 메서드 패턴을 적용해 눈에 들어오는 중복을 제거한다.
1 | abstract public class VacationPolicy { |
슈퍼 클래스인 VacationPolicy
의 서브 클래스인 USVacationPolicy
클래스와 EUVacationPolicy
클래스는 중복되지 않는 정보만 제공해서 accrueVacation()
메서드의 달라지는 부분만 보완한다.
대다수의 개발자들은 엉망인 코드를 접해본 경험이 있을 것이다.
물론 스스로 엉망인 코드를 작성한 경험 또한 있을 것이다.
개발자가 자신이 이해하는 코드를 작성하는 것은 쉽지만, 나중에 해당 코드를 유지보수할 사람이 똑같은 이해도를 가질 가능성은 희박하다.
소프트웨어 프로젝트 비용 중 대다수는 장기적인 유지보수에 쓰인다.
코드를 변경하면서 오류를 발생할 가능성을 줄이려면 유지보수할 개발자가 시스템을 제대로 이해할 수 있도록 개발자의 의도를 분명히 표현하는 코드를 작성해야 한다.
첫 번째, 먼저 좋은 이름을 선택하고, 이름과 기능이 딴판인 클래스나 함수는 배제한다.
두 번째, 함수와 클래스의 크기를 가능한 줄여서 작명도, 구현도, 이해하기도 쉽게 만든다.
세 번째, 표준 명칭을 사용한다.
예를 들어 특정 디자인 패턴은 이용해 구현된다면 해당 클래스 이름에 디자인 패턴의 이름을 넣어준다.
네 번재, 단위 테스트 케이슬르 꼼꼼하게 작성한다.
테스트 케이스는 예제로 보여주는 또 다른 문서 이므로, 기능의 이해를 도와준다.
방법을 나열해봤지만 표현력을 높이는 가장 중요한 방법은 결국 노력이다.
나중에 코드를 읽을 사람을 고려해 조금이라도 쉽게 만드려는 고민을 하는 습관을 들이자.
그리고 명심하자.
나중에 코드를 읽게 될 사람이 미래의 나 자신일 수도 있다.
중복 제거, 의도 표현, 단일 책임 준수라는 기본적인 개념도 극단으로 치닫게 되면 득보다 실이 많아지는 경우가 있다.
클래스와 메서드의 크기를 줄이는 것에 집착해 조그마한 클래스와 메서드를 수없이 만들게 될 수도 있다.
그래서 이 규칙은 무조건 크기를 줄이라는 게 아닌, 가능한 범주에서 최소로 줄이라고 쓰는 것이다.
목표는 함수와 클래스 크기를 작게 유지하면서 동시에 시스템 크기도 작게 유지하는 데 있다.
다만 이 규칙은 4개의 규칙 중 가장 우선순위가 낮다는 것을 염두에 두어야 한다.
다시 말해 클래스와 함수를 줄이는 작업도 중요하지만, 테스트 케이스를 만들고 중복을 제거하고 의도를 표현하는 것이 더욱 중요하다.
]]>복잡성은 죽음이다. 개발자에게서 생기를 앗아가며, 제품을 계획하고 제작하고 테스트하기 어렵게 만든다.
레이 오지(Ray Ozzie) - 마이크로소프트 CTO / CSA
깨끗한 코드를 구현하면 낮은 추상화 수준에서 관심사를 분리하기 쉬워진다.
본 포스팅에서는 높은 추상화 수준인 시스템 수준에서도 깨끗함을 유지하는 방법에 대해서 알아보자.
우선 제작(construction) 과 사용(use) 는 아주 다르다는 전제를 깔고 시작해야 한다.
소프트웨어 시스템은 애플리케이션 객체를 제작하고 의존성을 서로 연결하는 준비 과정,
준비 과정 이후에 이어지는 런타임 로직을 분리할 수 있어야 한다.
제일 처음 풀어야할 것은 시작 단계의 관심사(concern) 이다.
관심사의 분리는 가장 오래되고 가장 중요한 설계 기법 중 하나이다.
불행하게도 대다수의 애플리케이션은 관심사를 분리하지 않기에, 준비 과정인 코드를 주먹구구식으로 구현하거나 런타임 로직과 마구 뒤섞어놓는다.
아래는 전형적인 예시이다.
1 | public Service getService() { |
이러나 기법을 지연 초기화(Lazy Initialization) 혹은 계산 지연(Lazy Evaluation) 이라고 부른다.
이 기법은 실제로 필요할 때까지 객체를 생성하지않으므로 불필요한 부하가 걸리는 것을 피할 수 있고,
애플리케이션의 시작 시간이 그만큼 빨라지며, Service
객체가 Null로 반환되는 경우도 존재하지않는다.
하지만 getService()
메서드는 MyServiceImpl
과 생성자의 파라미터에 의존하고 있는 문제가 있다.
런타임 로직에서 MyServiceImpl
객체를 전혀 사용하지않더라도 이 의존성을 해소하지않으면 컴파일을 할 수 없다.
만약 MyServiceImpl
객체가 무거운 편이라면 단위 테스트에 많은 시간이 걸리므로 이를 대체할 수 있는 테스트 전용 객체도 제공해야 한다.
추가로 런타임 로직에 객체 생성을 섞어놓은 탓에 Service
객체가 null인 경우와 null이 아닌 경우를 모두 테스트해야 한다.
이 메서든믄 null인 경우의 책임과 null이 아닌 경우의 책임으로 쪼개지므로 단일 책임 원칙도 미세하게 위반한다.
위 예제의 주석처럼 MyServiceImpl
객체가 모든 상황에 적합한 객체인지 모르므로, 어떤 맥락에서 어떤 객체를 사용하면 될지 혼란을 야기한다.
한 번 정도의 지연 초기화 기법은 그다지 심각한 문제를 초래하지 않지만, 개수가 늘어나는 경우 모듈화 지수를 낮추며 심각한 중복 문제를 야기한다.
체계적이고 탄탄한 시스템을 만들기 위해서는 쉽게 느껴지는 기법을 통해 모듈성을 깨서는 안된다.
주요 의존성을 해소하기 위해 전반적으로 일관적인 방식의 적용도 필요하다.
Main 분리
시스템 생성과 시스템 사용을 분리하는 한 가지 방법으로, 생성과 관련된 코드를 모두 main이나 main이 호출하는 모듈로 옮기고
나머지 시스템은 모든 객체가 생성되었고, 모든 의존성이 연결되었다고 가정하는 방법이 있다.
아래 그림을 참고하자.
main()
함수에서 시스템에 필요한 객체를 생성한 후 이를 애플리케이션이 넘긴다.
애플리케이션은 넘겨받은 객체를 “사용만” 한다.
그림을 자세히 보면 모든 화살표가 main에서 application쪽을 가리키고 있음을 볼 수 있따.
즉, application은 main에서 생성하는 과정을 전혀 알지 못한다는 뜻이다.
팩토리
때로는 객체가 생성되는 시점을 애플리케이션이 결정해야하는 경우도 있다.
이때는 추상 팩토리 패턴을 사용하는것이 좋다.
그렇다면 생성 시점은 애플리케이션이 결정하되, 생성하는 코드는 모르는 상태로 접근할 수 있다.
아래 그림을 참고하자.
main의 분리와 마찬가지로 모든 의존성이 main에서 OderProcerssing 애플리케이션 쪽을 향하고 있다.
의존성 주입
사용과 제작을 분리하는 강력한 매커니즘으로 의존성 주입(DI : Dependency Injection) 가 있다.
의존성 주입은 제어의 역전(Inversion of Control) 기법을 의존성 관리에 적용한 기법으로,
한 객체가 맡은 보조 책임을 새로운 객체에데 모두 전가한다.
새로운 객체를 넘겨받은 책임만 관리하므로 단일 책임 원칙을 지키게 된다.
의존성 관리의 맥락에서 객체는 의존성 자체를 인스턴스로 만드는 책임은 지지않기에 제어를 역전한다고 볼 수 있다.
아래 예제를 보자.
1 | MyService myService = (MyService)(jndiContext.lookup("NameOfMyService")); |
JNDI 검색은 의존성 주입을 부분적으로 구현한 것으로, 디렉토리 서버에 이름을 제공하고 그 이름에 부합하는 서비스를 반환 받는다.
호출하는 객체는 반환되는 객체가 적절한 인터페이스를 구현한다는 전제하에 반환 객체의 유형을 제어하지 않는다.
진정한 의존성 주입은 여기서 한 걸음 더 나아간다.
클래스가 의존성 해결을 시도하지않고 수동적으로 움직이다.
다만 의존성을 주입하는 방법으로 setter나 생성자 파라미터를 받을 수 있도록 제공할 뿐이다.
실제로 생성되는 객체 유형은 설정 파일에엇 지정하거나 특수 생성 모듈에서 코드로 명시한다.
참고 스프링 프레임워크는 Bean 객체를 IoC 컨테이너를 통해 제어하며, xml에 이를 정의해둔다.
그렇다면 지연 초기화를 통해 얻을 수 있는 장점은 포기해야할까?
대다수의 DI 컨테이너는 필요할때 객체를 생성해주는 방식으로 지연 초기화의 장점을 같이 제공해준다.
처음부터 올바르게 시스템을 만드는 것은 불가능에 가깝다.
다만 오늘 주어진 사용자 흐름에 맞추어 시스템을 조정하고 확장하며, 내일은 내일 주어진 사용자 흐름에 맞추면 된다.
이렇게 반복적이고 점진적인 게 애자일 방식의 핵심이며, 테스트 주도 개발 및 리팩토링을 통해 깨끗한 코드를 유지하며 시스템을 조정하고 확장하기 쉽게 만들어야 한다.
코드 수준은 알겠는데, 시스템 수준에서는 어떨까?
단순한 아키텍쳐를 복잡한 아키텍쳐로 조금씩 키우는 것은 불가능하다.
다만 소프트웨어 시스템은 수명이 짧다는 본질로 인해, 아키텍쳐의 점진적인 발전이 가능하다.
먼저 관심사를 적절히 분리하지 못한 아키텍쳐의 예제를 보자.
엔터프라이즈 자바빈즈(EJB: Enterprise JavaBeans)을 통해 작성된 BankLocal
인터페이스이다.
참고 EJB1과 EJB2 아키텍쳐는 관심사를 적절히 분리하지 못해 생긴 장벽으로, 점진적으로 유기적인 성장이 불가능한 상태였다.
1 | import java.util.Collections; |
위의 인터페이스는 클라이언트가 사용할 지역 인터페이스의 정의이다.
Bank의 주소, 은행이 소유하는 계좌 등을 표현하고 있으며, 계좌 정보는 Acount EJB
로 처리한다.
아래는 위의 인터페이스를 구현한 Bank
클래스이다.
1 | import java.util.Collections; |
예제 내에서 객체를 생성하는 팩토리인 LocalHome
인터페이스 및 기타 Bank
클래스의 탐색 메서드는 생략되었다.
비즈니스 논리는 EJB2
애플리케이션 컨테이너에 강결합된다.
클래스를 생성할 때는 컨테이너에서 파생되어야 하며, 컨테이너가 요구하는 생명 주기 메서드도 필요하다.
이렇게 비즈니스 논리다 컨테이너와 강겹할되어있기에 독자적인 단위 테스트가 어려운 문제가 생긴다.
또한 EJB2에 의존적인 코드는 프레임워크 밖에서 사용하기가 사실상 불가능하다.
이는 결국 객체 지향 프로그래밍 개념 조차 흔들리는 원인이 된다.
횡단(cross-cutting) 관심사
EJB2 아키텍처는 일부 영역에 대한 관심사를 거의 완벽하게 분리한다.
예를 들어 트랜잭션, 보안, 일부 영속적인 동작은 소스 코드가 아닌 배치 기술자에서 정의된다.
영속성과 같은 관심사는 대개 애플리케이션의 객체 경계를 넘나드는 경향이 있다.
원론적으로는 모듈화되고 캡슐화된 방식으로 영속성 방식을 구상할 수는 있으나, 현실적으로는 영속성 방식을 구현한 코드가 온갖 객체로 흩어지게 된다.
이러한 현상을 횡단 관심사 라고 한다.
물론 영속성 프레임워크도 모듈화하거나 도메인 논리도 모듈화할 수는 있다.
문제는 이 두 영역이 세밀한 단위로 겹치다는 점이다.
EJB 아키텍처가 영속성, 보안, 트랜잭션을 처리하는 방식은 사실상 관점 지향 프로그래밍(AOP : Aspect-Oriented Programming) 을 예견한 셈이다.
관점 지향 프로그래밍은 횡단 관심사에 대해 모듈성을 확보하는 일반적인 방법으로, 특정 관심사를 지원하려면 시스템에서 특정 지점들이
동작하는 방식을 일관성 있게 바꾸어야 한다고 명시하는 것이다.
자바에서 사용하는 관점 혹은 관점과 유사한 매커니즘들을 알아보자.
자바 프록시는 단순한 상황에 적합한다.
개별 객체나 클래스에서 메서드 호출을 감싸는 경우가 좋은 예시이다.
하지만 JDK에서 제공하는 동적 프록시는 인터페이스만 지원하며, 클래스 프록시를 사용하려면 CGLIB, ASM, Javassist 등과 같은 바이트 코드 처리를 위한 라이브러리가 필요하다.
참고
CGLIB : Code Generator Library의 약자로 런타임에 동적으로 자바 클래스의 프록시를 생성해준다.
ASM : 자바 바이트 코드 조직 및 분석 프레임워크
Javassist : 동적으로 자바 클래스로 변경하는 바이트 코드 라이브러리
이해를 위해 계좌 목록을 가져오는 설정하는 메서드만 살펴보자.
먼저 프록시로 감쌀 Bank
인터페이스를 작성한다.
1 | // Bank.java |
이후 비즈니스 로직을 구현하는 POJO인 BankImpl
을 작성한다.
1 | // BankImpl.java |
프록시를 구현하려면 InvocationHandler
를 구현해야 한다.
구현한 InvocationHandler
는 프록시에 호출되는 Bank
의 메서드를 구현하는데 사용한다.
BankProxyHandler
는 리플렉션을 이용해 제네릭스 메서드를 이에 상응하는 BankImpl
메서드로 매핑한다.
1 | // BankProxyHandler.java |
1 | // 다른 곳에 위치하는 코드 |
예제에서 알 수 있다시피, 자바 프록시는 코드의 양이 매우 커지는 것이 단점이다.
참고 동적 프록시가 아닌 일반 프록시는 대상 클래스 수만큼 프록시 클래스를 만들어야하며, 비슷한 코드의 중복이 발생하기 쉽다.
또한 프록시 기법은 시스템 단위로 실행 지점을 명시하는 매커니즘도 누락되어 있다.
다행히 대부분의 프록시 코드는 그 형태가 매우 유사하기에 자동화되어있는 부분이 많다.
순수 자바 관점을 구현하는 스프링 AOP, JBoss AOP 등과 같은 여러 자바 프레임워크는 내부적으로 프록시를 사용하고 있다.
참고 순수 자바란 아래에서 다룰 AspectJ를 사용하지않는다는 뜻이다.
스프링은 비즈니스 논리를 POJO로 구현한다.
POJO는 순수하게 도메인에 초점을 맞추므로, 프레임워크나 다른 도메인에도 의존하지 않는다.
이로 인해 개념적인 측면에서 테스트가 쉽고 간단하다는 장점이 있다.
이는 곧, 사용자 스토리를 올바르게 구현하고, 미래에 주어질 스토리에 맞춰 유지보수 용이성을 확보하는 데 도움을 준다.
개발자는 설정 파일이나 API를 사용해 필수적인 애플리케이션 기반 구조를 구현한다.
여기에는 영속성, 트랜잭션, 보안, 캐시, 장애조치 등과 같은 횡단 관심사도 포함되며, 대부분 스프링이나 JBoss 라이브러리의 관점을 명시한다.
이때 프레임워크는 사용자 모르게 프록시나 바이트코드 라이브러를 사용해 이를 구현한다.
이러한 선언들이 요청에 따라 주요 객체를 생성하고 서로 연결하는 등 DI 컨테이너의 구체적인 동작을 제어한다.
아래 예제는 스프링 v2.5의 설정 파일인 app.xml의 일부로, 아주 전형적인 모습을 보여준다.
1 | <beans> |
Bank
도메인 객체는 접근자 객체(DAO : Data Accessor Object) 로 프록시 되었고,
자료 접근자 객체는 JDBC 드라이브 자료 소스로 프록시되어 있다.
아래 그림을 참고하자.
클라이언트는 Bank
객체에서 getAccounts()
를 호출하는 것처럼 보이지만, 실제로는 Bank POJO
의 기본 동작을 확장한 중첩 데코레이터 객체 집합의 가장 외곽부를 통해 통신한다.
애플리케이션에서 DI 컨테이너에게에 XML 파일에 명시된 시스템 내 최상위 객체를 요청하려면 아래와 같은 코드가 필요하다.
1 | XmlBeanFactory bf = new XmlBeanFactory(new ClassPathResource("app.xml", getClass())); |
스프링에 의존적인 코드가 거의 존재하지않으므로 사실상 스프링 프레임워크에 독립적이다.
즉, EJB2 시스템이 지녔던 강결합이 해소되는 것이다.
물론 XML 파일은 장황하고 읽기 어려운 포맷은 맞다.
하지만 자동으로 생성되는 프록시나 관점 논리보다는 단순하다.
이러한 아키텍처의 매력으로 인해 스프링 프레임워크의 EJB3는 완전히 뜯어고쳐져있다.
EJB3는 XML 설정 파일과 Java 5의 어노테이션 기능을 사용해 횡단 관심사를 선언적으로 지원하는 스프링 모델을 따른다.
아래 코드는 EJB3를 기반으로 Bank
클래스를 다시 작성해본 것이다.
1 |
|
초기에 작성했던 EJB2 코드보다 훨씬 깔끔해졌다.
모든 정보가 어노테이션 내부로 갈무리되어 코드가 깔끔해진 것이고, 그만큼 코드를 테스트하거나 유지보수하기 쉬워졌다.
관심사를 관점으로 분리하는 가장 강력한 도구는 AspectJ 언어이다.
AspectJ는 언어 차원에서 관점을 모듈화 구성으로 지원하는 자바 언어의 확장이다.
스프링 AOP와 JBoss AOP가 제공하는 순수 자바 방식은 관점이 필요한 상황의 80~90% 정도를 충족해준다.
AspectJ는 나머지 10%를 채워주는 좋은 도구이지만, 새로운 도구이니만큼 사용법과 언어 문법을 익혀야할 필요성은 있다.
AspectJ의 어노테이션 폼(annotation form)은 이 부담감을 경감시켜주긴 한다.
어노테이션 폼은 순수한 자바 코드에 자바 5 어노테이션을 사용해 관점을 정의하기에 AspectJ에 대해 미숙한 상태여도 쉽게 사용할 수 있도록 해준다.
모듈을 나누고 관심사를 분리하면 지엽적인 관리와 결정이 가능해진다.
거대한 시스템 안에서는 한 사람이 모든 결정을 내리기 어렵기 때문에 가장 적합한 사람에게 책임을 맡기는 것이 좋다.
성급한 결정은 불충분한 지식으로 내린 결정이기에, 때로는 마지막 순간까지 결정을 미루는 것도 최선의 방법이 되기도 한다.
EJB2는 단지 표준이라는 이유로 많은 팀이 채택하였다.
아주 가볍고 간단한 설계로도 충분한 프로젝트가 EJB2의 도입으로 오버엔지니어링을 겪게되는 경우가 빈번했다는 뜻이다.
따라서 아주 과장되게 포장된 표준인지에 대해서 고민해볼 필요가 있다.
]]>지금까지는 코드를 깨끗하게 작성하는 방법을 위주로 알아보았다.
그리고 함수를 깨끗하게 구현하는 방법과 함수간의 관계를 맺는 방식도 알아보았다.
이제 더 높은 단계인 깨끗하게 클래스를 작성하는 방법에 대해서 알아보도록 하자.
클래스를 정의하는 표준 자바 관례에 따르면 문서에서 노출되는 순서는 아래와 같다.
즉, 추상화 단계가 순차적으로 내려감을 알 수 있다.
캡슐화
변수와 유틸리티 함수는 가능한 공개하지않는 것이 좋다.
때로는 변수나 유틸리티 함수의 접근제어자를 protected
로 선언해서 테스트 코드의 접근을 허용하는 케이스도 있다.
같은 패키지 안에서 테스트 코드가 함수를 호출하거나 변수를 사용해야 한다면 그 함수나 변수를 protected
로 선언하거나 패키지 전체로 공개한다.
하지만 무작정 접근을 허용해선 안된다.
모든 방법을 고민해보고, 최후의 방법으로만 캡슐화를 깨야한다.
클래스 작성의 첫 번째 규칙은 바로 크기이다.
단언하자면 클래스는 작아야하고, 더 작아야한다.
그럼 작다 크다를 가를 수 있는 기준이 필요해진다.
클래스는 얼마나 작아야할까?
함수처럼 물리적인 코드의 행을 카운트하면 될까?
클래스 크기의 기준은 바로 책임이다.
아래 예제로 보자.
1 | public class SuperDashboard extends JFrame implements MetaDataUser { |
SuperDashboard
클래스를 보면 어떤 생각이 드는가?
공개된 메서드만 70여개에 달한다. 이러한 클래스를 만능 클래스(God Object)라고 부르기도 한다.
참고 001. (The Essence of Object-Orientation) 1. 협력과 역할 그리고 책임
만약 SuperDashboard
클래스가 아래와 같다면 어떨까?
1 | public class SuperDashboard extends JFrame implements MetaDataUser { |
좀 더 괜찮아보이긴 한다.
하지만 메서드의 개수와 상관없이 SuperDashboard
클래스가 가지고 있는 책임이 너무 많다.
가장 먼저 신경써야하는 것은 클래스의 이름을 짓는 것이다.
클래스의 이름을 해당 클래스가 가지고 있는 책임을 기술해야 하며, 클래스의 크기를 줄이는 제일 첫 관문이다.
적당한 이름이 안 떠오른다면 해당 클래스가 많은 책임을 가지고 있는 것이다.
단일 책임 원칙
단일 책임 원칙(SRP - Single Responsibility Principle)은 클래스나 모듈을 변경해야하는 이유가 단 하나뿐이어야한다는 원칙이다.
위의 예제를 다시 가져와보자.
1 | public class SuperDashboard extends JFrame implements MetaDataUser { |
SuperDashboard
클래스는 변경해야하는 이유가 몇 가지일까?
첫 번째, SuperDashboard
클래스는 소프트웨어의 버전을 출력해주는 책임을 가지고 있기에 버전이 변경될 때마다 계속 변경해야 한다.
두 번째, SuperDashboard
클래스는 Java의 Swing 컴포넌트인 JFrame
을 구현하고 있다. 스윙 코드가 변경될 때마다 버전 번호가 달라질 수 있따.
결과적으로 SuperDashboard
클래스는 두 가지의 책임을 가지고 있다.
책임 즉, 변경할 이유를 파악하다보면 해당 코드를 추상화하기도 용이해진다.
아래와 같이 SuperDashboard
클래스에서 버전 정보를 다루는 메서드를 별도로 분리해서 책임을 분리해보자.
1 | public class Version { |
Version
이라는 독자적인 클래스를 만들었고, 다른 곳에서 재사용하기도 쉬워졌다.
위의 사례와 같이 단일 책임 원칙과 별도 분리에 대해 자세한 내용은 아래 포스팅을 참고하자.
참고
001. (The Essence of Object-Orientation) 1. 협력과 역할 그리고 책임
045. (Pragmatic Unit Testing in Kotlin with JUnit) 9. 리팩토링 - 단일 책임 원칙, 명령 질의 분리 원칙
응집도(Cohesion)
클래스는 가지고 있는 인스턴스 변수가 최소화되어야 한다.
각 클 래스 메서드는 클래스 인스턴스 변수를 하나 이상 사용해야한다.
위와 같이 메서드가 변수를 더 많이 사용할수록 메서드와 클래스의 응집도는 높다고 볼수 있다.
만약 모든 인스턴스 변수를 메서드마다 사용하는 클래스는 응집도가 가장 높을 것이다.
응집도가 높다는 것은 클래스에 속한 메서드와 변수가 서로 의존하며 논리적인 단위로 묶였다는 의미를 가지고 있다.
아래 Stack
예제를 보자.
1 | public class Stack { |
size()
메서드를 제외하고 push()
와 pop()
메서드는 모든 변수를 접근한다.
즉 응집도가 높은 클래스라고 볼 수 있다.
함수를 작게, 파라미터 개수를 적게 작성하다보면 종종 몇몇 메서드만이 사용하는 인스턴스 변수가 늘어날 수 밖에 없다.
이렇게 응집도가 떨어지는 것은 클래스를 쪼개야한다는 일종의 시그널이다.
참고
011. (Objects) 4. 설계 품질과 트레이드오프
004. (The Essence of Object-Orientation) 4. 객체지향 설계 기법의 기초
응집도를 유지하면 작은 클래스 여럿이 나온다
큰 함수를 작은 함수 여럿으로 쪼개기만 해도 클래스의 수는 증가한다.
아래 예제를 살펴보자.
1 | public class PrintPrimes { |
메인 함수 뿐인 위 코드는 전형적인 지저분한 코드이다.
들여쓰기도 심하고, 이상한 변수도 많고, 구조도 복잡하게 결합되어있다.
하나씩 추출해서 깨끗한 코드로 바꿔보자.
먼저 페이지 관련 출력을 RowColumnPagePrint
클래스로 추출한다.
RowColumnPagePrint
클래스는 숫자 목록을 주어진 행과 열에 맞추어 페이지 출력하는 책임을 가지고 있다.
출력하는 포맷이 변경되면 RowColumnPagePrint
클래스만 수정하면 된다.
1 | public class RowColumnPagePrinter { |
그 다음 소수 관련 생성 로직을 PrimeGenerator
클래스로 추출한다.
PrimeGenerator
클래스는 소수 목록을 생성하는 책임을 가지고 있다.
소수를 계산하는 알고리즘이 변경되면 PrimeGenerator
클래스만 수정하면 된다.
1 | public class PrimeGenerator { |
책임을 분리한 뒤 다시 PrintPrimes
에 적용하면 아래와 같다.
1 | public class PrimePrinter { |
결과적으로 코드의 길이가 많이 늘어났다.
하지만 이는 잘못된 게 아니다.
변수와 함수에 의미있는 이름을 적용하였고, 가독성을 확보하기 위한 들여쓰기와 형식을 맞추었기 때문이다.
대다수의 시스템은 지속적으로 변경이 발생한다.
변경은 필연적으로 시스템의 오동작을 발생할 여지가 잠재되어있기에 이 위험도를 낮추는 노력이 지속적으로 필요하다.
아래 Sql
예제는 주어진 메타 자료로 적절한 SQL 문자열을 생성하는 코드이다.
update()
기능은 아직 지원하지 않는 상태로, 추후 update()
기능을 지원하게 되면 어찌되었든 변경을 해야하는 상황이다.
1 | public class Sql { |
새로운 SQL 문을 지원하려면 반드시 Sql
클래스를 수정해야하며, 기존 SQL문을 수정할때도 이는 마찬가지이다.
변경해야할 이유가 2개인 시점부터 Sql
클래스는 단일 책임 원칙을 위반하게 되었다.
아래와 같이 수정하는 건 어떨까?
1 | abstract public class Sql { |
Sql
클래스에 있는 모든 공개 인터페이스를 Sql
클래스의 서브 클래스로 치환하였다.
valueList
와 같은 비공개 메서드는 해당하는 서브 클래스로 이전하여였다.
모든 서브 클래스가 공통적으로 사용하는 비공개 메서드는 Where
와 ColumnList
유틸리티 클래스로 이관하였다.
다소 번거로워졌지만 코드의 이해 자체는 쉬워졌고, 함수의 수정이 다른 함수를 망가트릴 위험도 없어졌다.
update()
문을 추가하더라도 기존 클래스또한 변경할 필요가 없어졌다.
결과적으로 단일 책임 원칙 뿐만 아니라 개방 폐쇄 원칙도한 준수할 수 있게 되었다.
변경으로부터 격리
요구항은 계속해서 변하기 마련이며, 이는 코드 또한 계속해서 변한다는 것을 의미한다.
객체지향 프로그래밍의 세계에는 구현 클래스와 추상 클래스가 존재하는데
구현 클래스는 상세한 구현을, 추상 클래스는 개념만 포함한다고도 인지하고 있다.
헌데 상세한 구현에 의존하는 코드는 테스트를 어렵게 만든다.
이러한 경우 변경 포인트를 별도로 분리하여 테스트용 클래스로 분리하는 것이 좋다.
]]>참고
031. (Unit Test Principles) 5. Mock과 테스트 취약성
035. (Unit Test Principles) 9. Mock 처리에 대한 모범 사례
1997년만해도 TDD : Test-Driven Development 라는 개념이 없었다.
당시 대다수의 개발자들에게 단위 테스트란 내가 작성한 프로그램이 돌아간다는 사실만 확인하는 일회성 코드에 불과했다.
대개, 클래스와 메서드를 구현하는 데 많은 시간으 할애하고 임시 코드를 급조해 프로그램을 수동으로 실행하는 방식이었다.
이후 테스트 분야는 눈부신 성장을 이뤘다.
이제 테스트 케이스를 모두 구현하고 통과한 후에는 테스트 코드와 프로덕션 코드를 같은 경로에 패키징해두는 것이 일반적이 되었다.
애자일과 TDD 덕택에 단위 테스트를 자동화하는 개발자들은 이미 많아졌으며 점점 더 늘어나고 있다.
이에 비례하여 테스트를 추가한다는 목적에 매몰되어, 제대로된 테스트 케이스를 작성해야하는 사실을 놓치는 경우도 빈번해졌다.
TDD를 실제로 실천하지않더라도 실제 코드를 작성하기전에 단위 테스트부터 작성하라는 개념을 모르는 사람은 거의 없을 것이다.
하지만 이 개념은 TDD의 극히 일부이다.
아래 세 가지 법칙을 살펴보자.
첫 번째 법칙 실패하는 단위 테스트를 작성할 때까지 실제 코드를 작성하지 않는다.
두 번째 법칙 컴파일은 실패하지 않으면서 실행이 실패하는 정도로만 단위 테스트를 작성한다.
세 번째 법칙 현재 실패하는 테스트를 통과할 정도로만 실제 코드를 작성한다.
위 세 가지 법칙을 준수했을 때는 개발과 테스트가 대략 30초의 주기로 묶인다.
테스트 코드와 프로덕션 코드가 함께 생산되고, 테스트 코드가 프로덕션 코드보다 불과 몇 초전에 작성된다.
이를 습관하하면 프로덕션 코드를 사실상 전부 테스트하는 테스트 케이스가 나온다.
하지만 프로덕션 코드와 맞먹을 정도로 방대한 테스트 코드는 심각한 관리 문제와 유지보수 비용의 증가를 야기하기도 한다.
일회용 테스트 코드를 짜오다가 자동화된 단위 테스트 슈트를 작성하기란 쉽지 않다.
명심해야할 것은 지저분한 테스트 코드를 작성하는 건 테스트를 하지 않는 것과 다를 바 없다는 것이다.
문제는 프로덕션 코드가 변경되거나 개선되었을 때, 이를 테스트 코드도 쫓아가며 변경되어야한다.
이때 지저분한 테스트 코드일수록 변경하기는 더 어려워지고, 역설적으로 프로덕션 코드를 작성하는 시간보다 테스트 코드를 작성하는 시간이 더 걸리는 경우도 발생할 수 있다.
또한 프로덕션 코드의 변경으로 테스트 코드가 실패하는 경우, 이를 다시 통과시키는 것도 점점 더 어려워진다.
결국 테스트 코드는 개발자에게 점점 증가하는 부채로 다가오게 된다.
결국 테스트 코드를 포기하게 되고 이는 시스템의 안정성이 낮아지며 결함율이 높아지는 결과를 초래한다.
높은 결함율을 가진 시스템은 결국 개발자에게 변경을 주저시키는 요인으로 작용하여 완벽한 레거시 시스템으로 굳어진다.
따라서 테스트 코드는 프로덕션 코드 못지않게 깨끗하게 작성해야 한다.
테스트는 유연성, 유지보수성, 재사용성을 제공한다
테스트 케이스의 부재는 프로덕션 코드의 유연성을 제거하는 원인이다.
반대로 프로덕션 코드의 유연성, 유지보수성, 재사용성을 제공하는 것이 단위 테스트이다.
테스트 케이스가 없다면 사실 모든 변경이 잠재적인 버그라고 간주하게 되는 셈이다.
테스트 커버리지가 높을수록 지저분한 코드라도 별다른 우려사항 없이 변경을 하거나, 아키텍쳐를 개선할 수 있다.
그러므로 프로덕션 코드를 추종하는 자동화된 단위 테스트는 아키텍쳐를 보존하는 핵심이라고 볼 수 있다.
깨끗한 테스트 코드의 중요성은 잘 알앗다.
그럼 깨끗한 테스트 코드는 어떻게 만들 수 있을까?
여기서 꼭 필요한 것이 가독성이다.
어쩌면 프로덕션 코드보다 높은 가독성을 보장해야하는 것이 테스트 코드일 수도 있다.
테스트 코드는 높은 가독성을 바탕으로하여, 최소의 표현으로 많은 것을 나타내야한다.
아래 예제를 보자.
1 | public void testGetPageHieratchyAsXml() throws Exception { |
위 예제의 가독성에 대해서 생각해보자.
먼저 addPage()
메서드와 assertSubString()
메서드를 호출하느라 중복되는 코드가 너무 많다.
그리고 자질구레한 사항이 너무 많아 표현력이 떨어지는 상태이다.
그럼 어떻게 개선할 수 있을까?
먼저 PathParser
클래스를 살펴보자.
PathParser
는 문자열을 파싱하여 pagePath
인스턴스로 변환해주는 역할을 하며, pagePath
는 crawler
가 사용하는 객체이다.
이 PathParser
는 테스트의 목적과도 관련이 없고 표현력만 저하시키는 코드이다.
두번째로 responder
객체를 생성하는 코드와 response
를 수집해 변환하는 코드 또한 표현력을 저하시키고 있다.
이를 개선해본다면 아래와 같은 코드가 될 것이다.
1 | public void testGetPageHierarchyAsXml() throws Exception { |
위와 같은 구조로 단위 테스트의 가독성을 확보하는 패턴을 BUILD-OPERATE-CHECK 패턴이라고 한다.
각 테스트는 정확히 세 부분으로 나누어진다.
첫 번째, 테스트 자료를 만든다.
두 번째, 테스트 자료를 조작한다.
세 번째, 테스트 결과를 확인한다.
모든 단위 테스트에서 위와 같은 구조를 유지하면 높은 가독성을 유지할 수 있다.
JUnit으로 테스트 코드를 작성할 때에는 assert
를 단 하나만 사용해야하나는 의견도 있다.
다소 가혹한 규칙으로 보일 수도 있겠지만 assert
가 하나인 함수는 결론이 하나이기때문에 코드를 빠르고 쉽게 이해할 수 있다.
자세한 내용은 아래 포스팅을 참고하도록 하자.
깨끗한 테스트는 아래 다섯가지 규칙을 따른다.
흔히 단위테스트의 FIRST 원칙이라고 불리는 것들이다.
자세한 내용은 아래 포스팅을 참고하도록 하자.
]]>참고 041. (Pragmatic Unit Testing in Kotlin with JUnit) 5. FIRST 원칙
거대한 시스템을 개발함에 있어, 모든 부분을 직접 개발하는 경우는 드물다.
외부 솔루션을 구매해서 도입하거나, 오픈 소스를 이용하기도 한다.
본 포스팅에서는 이러한 외부 의존성을 개발중인 시스템에 깔끔하게 통합하는 방법에 대해서 알아보자.
패키지 및 프레임워크를 통해 인터페이스를 제공하는 경우, 적용성을 최대한 넓히려 애쓰며,
인터페이스 사용자는 자신의 요구에 집중하는 인터페이스를 바란다.
이 견해차이로 인해 시스템의 경게에선 문제가 생길 소지가 많다.
하나의 예시로 java.util.Map
인터페이스를 살펴보자.
Map
은 굉장히 다양한 인터페이스를 통해 수많은 기능을 제공한다.
수많은 기능을 통해 주어지는 유용함도 좋지만, 그만큼 위험도도 증가한다.
만약 프로그램내에서 Map
을 만들어 여기저기 넘긴다고 가정하자.
이때 넘겨받는 쪽이 Map
내부의 데이터를 지우지않는다고 보장할 수 있는가?
제일 위에 clear()
메서드가 보인다.
즉 어떤 위치에서건 해당 Map
객체의 내용을 소거할 수 있는 상황이라는 것이다.
또 다른 예졔도 살펴 보자.
1 | Map sensors = new HashMap(); |
Map
객체는 특정 타입을 제한하지않으므로 어떠한 객체 타입도 추가할 수 있다.
즉 위와 같은 코드가 반복적으로 요구되며, 올바른 객체 타입을 변환할 책임을 사용자에게 강요하게 된다.
결과적으로 깨끗한 코드가 아니게 된다.
이를 해결하기위해 Map
은 아래와 같이 제네릭을 지원한다.
1 | Map<String, Sensor> sensors = new HashMap<Sensor>(); |
하지만 위 방법도 Map<String, Sensor>
가 필요하지않은 기능까지 전부 제공한다는 문제가 남아있게 된다.
또한 Map
의 인터페이스가 변할 경우 사용하는 모든 부분에 대해서 변경이 발생하게 될 것이다.
참고 컬렉션에 해당하는
Map
의 변경은 거의 없을 것이다. 다만 Java 5에서 제네릭을 지원하기위해 인터페이스가 변한 역사가 있기에 장담할 수 없는 문제이다.
아래는 좀 더 깔끔하게 Map
을 사용한 코드이다.
1 | public class Sensors { |
경계에 해당하는 인터페이스인 Map
을 Sensors
클래스 안으로 숨겨서 Map
의 변경사항을 Sensors
클래스 내부에서만 처리할 수 있게 되었다.
또한 Sensors
는 프로그램에 꼭 필요한 getById()
메서드만을 제공하여서 읽기 쉬운 코드와 함께 오용을 방지하기도 한다.
그렇다면 Map
과 같은 경계 인터페이스를 사용할 때마다 항상 캡슐화르 해야할까?
아니다. 경게 인터페이스를 시스템 내 여기저기에 넘기는 상황을 방지하는 것이 더 중요하다.
결론적으로 Map
인스턴스를 공개된 메서드의 파라미터나 반환 타입으로 사용하지않는 것이 좋다.
외부에서 가져온 패키지를 사용하고 싶다면 어디서 어떻게 시작해야할까?
일단 외부 패키지의 테스트는 우리의 책임은 아니다.
하지만 우리 스스로를 위해 사용할 부분의 코드를 테스트하는 것이 바람직하다.
외부 코드는 익히기도 어렵고, 통합하기도 어려우면 두 가지를 동시에 하는 것은 더더욱 어렵다.
따라서 접근법을 바꾸어야 한다.
우리쪽 코드를 작성해 외부 코드를 호출하는 대신, 먼저 간단한 테스트 케이스를 작성해 외부 코드를 익히는 학습 테스트를 적용해볼 수 있다.
학습 테스트는 프로그램에서 사용하려는 방식대로 외부 API를 호출한다.
테스트 환경이라는 통제된 환경에서 외부의 API를 제대로 이해하고 있는지를 검증하는 것이다.
이처럼 학습 테스트는 API를 사용하려는 목적 자체에 초점을 맞춘다.
학습 테스트 자체에 드는 비용은 없다. 오히려 필요한 지식만 확보하는 손쉬운 방법일 수도 있다.
사실 학습 테스트는 투자하는 노력보다 얻는 성과가 더 큰 공짜 그 이상의 값어치를 한다.
만약 새로운 패키지가 출시한다면 학습 테스트만 돌려도 현재 시스템에 영향을 주는 변경이 있는지 단 번에 알 수 있다.
경계와 관련해 또 다른 유형은 아는 코드와 모르는 코드를 분리하는 경계이다.
때로는 우리의 지식이 경계 너머를 알 수 있는 상태일수도,
알려고 해도 알 수 없는 경우도 종종 존재한다.
이 경우 예상되는 기능들에 대해 자체적인 인터페이스를 정의한 후 마찬가지로 예상되는 데이터들을 주입해서 일종의 Mock 객체를 만드는 것도 방법이다.
자세한 내용은 아래 포스트를 참고하도록 하자.
참고
(Working Effectively with Legacy Code) 014. Dependencies on Libraries Are Killing Me
(Working Effectively with Legacy Code) 015. My Application Is All API Calls
경게에서는 흥미로운 일이 많이 벌어진다.
대표적인 예시가 바로 경계에서 벌어지는 변경이다.
우수한 설계가 적용된 소프트웨어라면 변경에 많은 리소스를 투입할 필요는 없다.
다만 통제하지 못하는 코드를 사용할 때는 의도치않게 많은 리소스가 소모될 수도 있다.
따라서 경게에 위치하는 코드는 깔끔히 분리하고, 이에 대한 기대치를 정의하는 테스트 케이스도 작성해두어야 한다.
통제가 불가능한 외부 패키지에 의존하는 대신 이를 통제가 가능한 우리 코드에 의존하는 것이 훨씬 낫기 때문이다.
]]>오류 처리는 프로그램에 반드시 필요한 요소 중 하나이다.
사용자의 입력이 언제 실패할지, 디바이스가 언제 어떻게 될지 모르기때문이다.
다시 말해, 뭔가 잘못될 가능성은 항상 존재하기때문에 이에 대한 대응 방안이 필요하다.
상당수의 코드는 오류 처리 코드에 의해 좌우된다.
여기저기 흩어진 오류 처리 코드에 의해 실제 코드가 어떤 동작을하는지 파악하는 데 어려움이 따르기때문이다.
따라서 깨끗한 코드를 작성하기 위해서는 오류 처리도 중요한 요소 중 하나이다.
이번 포스팅에서는 깨끗한 코드를 작성하기 위해 우아하게 오류를 처리하는 몇 가지 기법에 대해서 알아보자.
예외를 지원하지 않는 프로그래밍 언어는 어떻게 예외를 처리할 수 있을까?
이 경우 오류를 의미하는 플래그를 설정하거나 호출자에게 약속된 오류 코드를 반환하는 방법이 전부였다.
아래 예제 코드를 보자.
1 | public class DeviceController { |
예외가 없으면 위의 예제처럼 호출자 코드가 계속해서 복잡해질 수 밖에 없다.
이는 함수를 호출한 즉시 오류를 확인해야하기 때문이며, 누락하기 쉬운 영역이기도 하다.
예외를 지원한다면 오류 발생시 예외를 던지는 것이 낫다.
아래는 예외를 적용한 코드이다.
1 | public class DeviceController { |
호출자의 코드가 훨씬 깔끔해졌음을 알 수 있다.
이는 디바이스를 종료하는 알고리즘과 오류를 처리하는 알고리즘을 분리했기때문이다.
이처럼 뒤섞인 개념을 있을 경우 각각 분리해서 독립적으로 살필 수 있게 처리하는 것이 권장된다.
예외를 처리할때 프로그램 내부에 범위를 작성할 수 있다.
try-catch-finally
문의 try
블록내에서 발생하는 모든 예외는 catch
블록으로 던질 수 있기 때문이다.
이때 try
블록에서 어떠한 예외가 발생하던간에 catch
블록은 프로그램의 상태를 일관성있게 유지할 수 있어야 한다.
아래 예제를 살펴보자.
이 예제는 파일이 없으면 예외를 던지는 지 알아보는 단위 테스트 코드이다.
1 |
|
위 단위 테스트에 맞춰 아래와 같이 코드를 구현한다.
1 | public List<RecordedGrip> retrieveSection(String sectionName) { |
위 코드는 예외를 던지지 않으므로 단위 테스트는 실패한다.
이제 예외를 던지도록 코드를 아래와 같이 수정해보자.
1 | public List<RecordedGrip> retrieveSection(String sectionName) { |
이제 예외를 던지므로 단위 테스트는 성공한다.
이번엔 catch
블록에서 Exception
이 아닌 FileNotFoundException
을 잡도록 리팩토링한다.
1 | public List<RecordedGrip> retrieveSection(String sectionName) { |
try-catch
문을 이용해 범위를 지정했으므로 TDD에 의거하여 강제로 예외를 일으키는 테스트 케이스를 작성한 뒤,
이 테스트를 통과하도록 코드를 작성하는 방식으로 진행하는 것이 권장된다.
그동안 JVM 생태계의 개발자들은 확인된(checked) 예외의 장단점에 대해 논쟁을 벌여왔다.
초창기엔 Checked Exception이 우아한 방식이라고 인식되었지만, 최근엔 반드시 Checked Exception이 필요하다고 보지 않는다.
기본적으로 Checked Exception은 개방-폐쇄 원칙(OCP : Open-Closed Principle) 을 위반한다.
만약 메서드에서 Checked Exception을 던졌을 때, catch
블록이 세 단계 위에 있다면 그 사이에 속한 메서드가 전부 해당 예외를 정의해야한다.
대규모 시스템의 최상위 함수가 하위의 함수들을 순차적으로 호출하는 경우, 최하위 함수가 예외를 추가할 때마다
모든 단계의 함수가 throw
를 추가해야하는 연쇄 수정이 발생하여 캡슐화를 깨버린다.
예외를 던질 때는 전후 상황을 충분히 덧붙여서 오류가 발생한 원인과 위치를 찾기 쉽도록 한다.
java의 예외는 기본적으로 콜스택을 제공하지만, 콜스택으로는 충분하지않는 경우가 많다.
따라서 오류 메시지에 실패한 연산 이름과 실패 유형 등 정보를 담아 예외와 함께 던질 수 있도록 한다.
가능하다면 catch
블록에서 발생한 오류를 로깅하도록 하는 것도 좋다.
오류를 분류하는 방법은 무수히 많다.
오류가 발생한 컴포넌트의 위치 혹은 유형으로도 분류도 가능하다.
하지만 오류를 정의할때 가장 중요한 것은 오류를 잡아내는 방법이다.
아래는 오류를 형펀없이 분류한 사례이다.
1 | ACMEPort port = new ACMEPort(12); |
외부 라이브러리를 호출하는 try-catch-finally
문으로 외부 라이브러리가 던질 수 있는 예외를 모두 잡으려고 3개의 catch
블록이 쓰였다.
대다수 상황에서 오류를 처리하는 방식은 오류를 기록하고, 프로그램을 계속 수행해도 좋은지 확인하는 방식이다.
위 예제는 예외의 종류와 상관없이 거의 동일하므로 아래와 같이 리팩토링하면 된다.
1 | public class LocalPort { |
LocalPort
클래스를 이용해 ACMEPort
클래스를 랩핑하였고, 이를 통해 하나의 예외만 받아 처리할 수 있게 되었다.
이러한 감싸기 기법을 사용하면 특정 업체가 API를 설계한 방식과 상관없이 사용하기 편한대로 API를 정의할 수 있다.
참고 (Working Effectively with Legacy Code) 015. My Application Is All API Calls
깨끗한 코드는 비즈니스 로직과 오류 처리가 잘 분리되어 있다.
외부 API를 감싸서 독자적으로 정의한 예외를 던지고 이를 처리하는 방식은 대체로 우아하지만 적합하지 않은 케이스도 존재한다.
예제를 통해 알아보자.
아래는 비용 청구 애플리케이션이 총계를 계산하는 코드이다.
1 | try { |
이 코드는 식비를 비용으로 청구했다면 청구한 식비를 총계에 더하고, 식비를 청구하지않았다면 일일 기본 식비를 총계에 더한다.
그런데 예외 자체가 논리를 따라가기 어렵게 만든다.
try
문 내부 로직을 좀 더 간결하게 할 수 있을까?
1 | MealExpenses expenses = expenseReportDAO.getMeals(employee.getID()); |
아래와 같이 리팩토링을 통해 좀 더 간결하게 고쳐야 한다.
1 | public class PerDiemMealExpenses implements MealExpenses { |
이러한 사례를 특수 사례 패턴이라고 부른다.
개발자가 가장 흔히 저지르는 오류 패턴 중 하나가 null을 반환하는 습관이다.
java를 경험해봤다면 라인마다 null 여부를 체크하는 코드가 가득한 경우를 수없이 보았을 것이다.
아래 예제를 보자.
1 | public void registerItem(Item item) { |
null을 반환하는 코드는 일거리를 늘릴뿐 아니라 책임을 호출자에게 전가하는 구조를 가지게 되며,
null 여부 체크를 하나라도 누락하는 순간 오류가 발생할 가능성이 생긴다.
위 코드에서 만약 peristentStore
가 null이라면 어떨까?
바로 NullPointerException
이 발생하게 될것이다.
null 확인 누락된 게 문제일까?
아니다. null 확인이 너무 많아서 문제이다.
반환 객체를 도입하거나, 메서드를 감싸서 예외를 던지도록 처리하는 것이 옳다.
또 다른 사례를 살펴보자.
1 | List<Employee> employees = getEmployees(); |
getEmployees()
메서드는 null을 반환할 수도 있다.
하지만 굳이 null을 반환하지않고 비어있는 list를 반환해주면 null 여부 체크없이도 코드를 간결하게 작성할 수 있을 것이다.
1 | List<Employee> employees = getEmployees(); |
위에서 살펴봤듯 메서드에서 null을 반환하는 것도 문제이지만, 파라미터로 null을 전달하는 것은 더 큰 문제이다.
정상적으로 null을 기대하는 api가 아니라면 null을 전달하는 케이스는 최대한 피하도록 해야 한다.
아래 예제를 보자.
1 | public class MetricsCalculator { |
파라미터로 null을 넘기는 순간 바로 NullPointerException
이 발생할 것이다.
이 경우 어떻게 고치는 것이 좋을까?
1 | public class MetricsCalculator { |
NullPointerException
이 발생하지않으므로 우아한 코드가 되었을까?
아니다. 결국 호출자에게 InvalidArgumentException
의 오류 처리를 강제하게 된다.
또 다른 대안으로 assert
키워드를 사용하는 방법이 있다.
1 | public class MetricsCalculator { |
만약 조건이 거짓이면 AssertionError
예외가 발생하게 된다.
다양한 방법이 있지만 어느것도 만족스럽진 않다.
결국 제일 좋은 방법은 애초에 null을 넘기지 못하도록 금지하는 것이다.
]]>객체지향 프로그래밍 언어에서 클래스가 가진 변수를 private으로 정의하는 이유는 무엇일까?
클래스 자기 자신이 아닌 다른 클래스가 해당 변수를 의존하지 않게 만들기 위해서이다.
그런데 왜 변수를 private으로 선언해놓고, getter/setter를 통해 간접적으로 외부에 노출시키는 걸까?
아래 두 개의 클래스를 보자.
1 | public class Point { |
1 | public interface Point { |
같은 Point
지만 클래스는 변수가 외부에 노출되고, 인터페이스는 완전히 감추어져 있다.
또한 인터페이스는 Point
가 어떤 자료구조를 가지고 있는지 메서드를 통해 표현하고 있다.
그럼 단순히 변수를 private으로 선언하고 함수 계층을 넣으면 구현이 감추어질까?
엄밀하게 말해 구현을 감춘다는 것은 추상화를 통해 구현을 모른채 해당 자료를 수정하고 조회할 수 있어야 한다.
이번엔 다른 예제를 살펴보자.
1 | // Vehicle-A |
1 | // Vehicle-B |
Vehicle-A는 자동차의 연료 상태를 구체적인 숫자로 알려주고 있고, Vehicle-B는 백분율로 알려주고 있다.
무슨 차이가 있을까?
Vehicle-A는 클래스가 가진 어떤 변수의 값을 그대로 반환하고 있음을 추측할 수 있고,
Vehicle-B는 내부의 변수를 활용해 계산하는건지 아예 백분율을 변수로 가지고 있는 건지 알수가 없다.
결과적으로 구현을 감추는 것이 중요한 객체지향에서는 Vehicle-B가 제일 우아하게 자료를 표현한 것임을 알 수 있다.
객체는 추상화를 방패로 데이터를 숨긴채, 해당 데이터를 활용하는 함수만을 공개한다.
반면, 자료구조는 자료를 그대로 공개하고 별다른 함수를 제공하지 않는다.
즉 객체와 자료구는 본질적으로 상반된 정의를 가지고 있는 것이다.
아래 예제를 보자.
1 | public class Square { |
상위 3개의 클래스는 각각 정사각형, 직사각형, 원을 표현하고 있고, Geometry
클래스는 파라미터로 넘어온 도형의 면적을 반환해주는 area()
함수를 가지고 있다.
만약 Geometry
에 도형의 둘레를 구하는 perfimeter()
함수를 추가한다면 어떻게 될까?
Square
, Rectangle
, Circle
클래스에 전혀 영향을 주지않고 추가할 수 있을 것이다.
하지만 반대로 새로운 도형 클래스를 추가하는 경우엔 Geometry
의 모든 함수를 고쳐야 한다.
이를 해소하기 위해 좀 더 객체지향적으로 도형을 표현해보자.
1 | public class Square implements Shape { |
모든 도형이 Shape
인터페이스를 구현하여 각각 면적에 해당하는 함수를 구현하였다.
절차지향적인 코드와 객체지향적인 코드 둘 중 어느 것이 더 좋은 코드일까?
정답은 “절대 우위는 없다” 이다.
절차지향적인 코드와 객체지향적인 코드 모두 상호보완적이기 때문이다.
그래서 결국 객체와 자료구조는 근본적으로 둘로 나뉜다.
술어로 정리하면 아래와 같다.
자료구조를 사용하는 절차지향적인 코드는 기존 자료구조를 변경하지 않으면서 새로운 함수를 추가하기 쉽다.
반면, 객체지향적인 코드는 기존 함수를 변경하지않으면서 새로운 클래스를 추가하기 쉽다.
복잡한 시스템을 구현하다보면 새로운 함수가 아닌 새로운 타입의 자료구조가 필요한 경우가 생긴다.
이때는 객체지향 기법이 좀 더 유리하고, 새로운 함수가 필요한 경우엔 절차지향 기법이 좀 더 유리하다.
상술했듯 절대 우위는 없으므로 각 상황에 맞는 최적을 잘 적용해야 한다.
디미터 법칙은 모듈은 자신이 조작하는 객체의 속사정을 몰라야한다는 법칙이다.
좀 더 명세해보자면, 객체는 조회 함수를 통해 내부 구조를 공개해선 안된다는 뜻이다.
예를 들어 아래 코드는 디미터 법칙을 위반한다고 볼 수 있는 코드이다.
1 | final String outputDir = ctxt.getOptions().getScratchDir().getAbsolutePath(); |
참고
기차 충돌 - Train Wrecks
1 | final String outputDir = ctxt.getOptions().getScratchDir().getAbsolutePath(); |
위와 같은 코드를 흔히 기차 충돌이라고 부른다.
코드의 호출 구조가 여러 객체가 한 줄로 이어진 기차처럼 보이기때문인데,
일반적으로 조잡한 코드로 취급되므로 아래와 같이 분리하는 것이 권장된다.
1 | Options opts = ctxt.getOptions(); |
위와 같이 분리하면 디미터 법칙을 준수한 것일까?
이는 ctxt
, opts
, scratchDir
이 객체인지, 자료구조인지에 따라 다르다.
객체라면 내부 구현을 숨겨야 하므로 디비터 법칙을 위배한 것이고, 자료구조라면 내부 자료를 보여주어야하므로 위배가 아니다.
아래와 같이 조회 함수가 아닌 공개된 변수를 통해서만 접근이 가능했다면 디미터 법칙을 위반하지 않았을 것이다.
1 | final String outputDir = ctxt.options.scratchDir.absolutePath; |
잡종 구조 -Hybrids
객체와 자료구조가 주는 혼란으로 인해, 반은 객체 반은 자료구조인 하이브리드 형태를 작성하게 된다.
하이브리드는 중요한 기능을 수행하는 함수와 공개된 변수나 조회, 설정 함수도 제공한다.
이러한 구조를 새로운 함수를 추가할때도, 새로운 자료구조를 추가하기도 어려운 단점의 총아와 같은 형태를 가진다.
구조체 감추기
1 | Options opts = ctxt.getOptions(); |
만약 위 코드에서 ctxt
, opts
, scratchDir
이 객체라고 가정하면 어떻게 될까?
객체는 내부 구현을 감추어야하므로 기차 충돌 형태를 띄어서는 안된다.
그럼 어떻게 변경하면 좋을까?
첫 번째 방법은 ctxt
객체에서 처음부터 outputDir
을 제공하는 것이다.
1 | // 첫 번째 방법 |
이 방법은 ctxt
객체가 공개해야하는 메서드가 너무 많아질 수 있다는 단점이 있다.
1 | // 두 번째 방법 |
두 번째 방법은 getScratchDirectoryOption()
함수를 통해 자료구조를 반환하는 것인데 이도 우아하게 느껴지진 않는다.
근본적으로 절대 경로 outputDir
이 왜 필요한지에 대해서부터 고민해야한다.
만약 아래와 같이 절대 경로를 얻는 목적이 임시 파일을 생성하고 이를 저장하기위한 경로를 확보하기 위함임을 알게되었다고 가정하자.
1 | String outFile = outputDir + "/" + className.replace('.', '/') + ".class"; |
목적을 알았으니, 이를 우아하게 해결하기 위해선 ctxt
객체에 임시 파일을 생성하도록 시키면 된다.
1 | BufferedOutputStream bos = ctxt.createScratchFileStream(classFileName); |
이제 ctxt
는 내부 구조를 드러내지않고, 특정 경로에 임시파일을 생성하기 위한 목적을 달성할 수 있게 되었다.
자료구조의 전형적인 형태는 공개된 변수만 있고 함수가 없는 클래스이다.
이러한 형태의 클래스를 자료 전달 객체(DTO : Data Transfer Object) 라고 부른다.
DTO는 굉장히 유용한 구조체로 데이터베이스와 통신하거나 소켓에서 받은 메시지의 구분을 분석할때 유용하다.
좀 더 일반적인 형태는 빈(Bean) 구조이다.
빈은 비공개 변수를 조회/설정 함수로 조작하는 일종의 가짜 캡슐화다.
아래 예제가 빈의 예시이다.
1 | public class Address { |
활성 레코드
활성 레코드는 DTO의 특수한 형태이다.
공개 변수가 있거나, 비공개 변수에 조회/설정 함수가 있는 자료구조 형태를 띄지만, 대개 save()
나 find()
와 같은 탐색 함수도 제공한다.
이 활성 레코드는 데이터베이스의 테이블이나 다른 소스에서 자료를 직접 반환한 결과이며,
활성 레코드에 특정 비즈니스 로직을 추가해서 객체로 취급하는 불행한 경우가 종종 존재한다.
이 경우 잡종 구조이기때문에 최대한 회피해야한다.
근본적인 해결책은 당연히 모든 활성 레코드를 자료구조로 취급하여 비즈니스 로직을 아예 추가하지않는 것이다.
]]>개발자라면 코드의 형식을 깔끔하게 맞추어야 한다.
코드 형식을 맞추기 위한 간단한 규칙을 정하고, 이를 자동으로 적용하는 도구를 활용하는 것이 좋다.
코드 형식은 의사소통의 일환으로 매우 중요하며, 개발자의 일차적인 의무라고 볼 수 있다.
소스 코드는 얼마나 길어야 적당할까?
세로길이부터 고민해보자.
객체지향 프로그래밍 언어의 경우 파일의 크기는 클래스의 크기와 밀접하다.
대표적인 언어인 자바 소스 파일은 크기가 어느정도일까?
위의 그림은 JUnit, FitNesse, testNG, Time and Money(tam), JDepend, Ant, Tomcat 프로젝트의 파일당 라인 길이를 보여준다.
FieNesse의 경우 평균 65줄이고, 전체 파일 중 3분의 1일 40줄에서 100줄 정도를 유지하고 있다.
JUnit, Time and Money 프로젝트도 대다수가 200줄 미만인 반면,
Tomcat과 Ant는 절반이 200줄을 넘어서고 수천줄에 달하는 파일도 존재한다.
그럼 위 그래프가 의미하는 것은 무엇일까?
대부분 200줄 정도인 파일로도 거대한 시스템을 구축하는 데 지장이 없다는 ㄷ쓰이다.
이는 반드시 지켜야하는 엄격한 규칙은 아니지만, 바람직한 규칙으로 삼는 것이 좋다.
소스파일도 신문 기사와 비슷하게 작성해야한다.
이름은 간단하면서도 설명이 가능하게 짓는다.
이름만 보고도 올바른 모듈을 살펴보고 있는지 아닌지를 판단할 정도로 신경써서 지어야 한다.
소스 파일 첫 부분은 고차원의 개념과 알고리즘을 설명하고 아래로 내려갈수록 의도를 명세하고,
마지막에는 가장 저차원 함수와 세부 내역이 나와야한다.
대부분의 코드는 왼쪽에서 오른쪽으로, 위에서 아래로 읽는다.
각 행은 수식이나 절을 나타내고, 일련의 행 묶음은 완결된 생각 하나를 표현한다.
이 생각 사잉네는 빈행을 분리해야 마땅하다.
아래 예제를 보자.
1 | class BoldWidget( |
만약 위 코드에서 빈 행과 개행을 제거하면 어떻게 될까?
1 | class BoldWidget(parent: ParentWidget?, text: String?): ParentWidget(parent) { |
이처럼 코드의 변경이 없더라도 코드의 가독성이 많이 상실되었음을 알 수 있다.
개행은 그만큼 중요하다.
개행이 개념을 분리한다면 세로 밀집도는 연광성과 관련이 있다.
서로 밀접한 코드일 수록 세로로 가까운 곳에 위치해야 한다.
아래 예제를 보자.
1 | class ReporterConfig { |
위 코드는 의미없는 주석으로 두 변수를 떨어뜨려놓았다.
차라리 아래처럼 주석을 제거하는 것이 더욱 가독성이 좋다.
1 | class ReporterConfig { |
함수 연관 관계와 동작 방식을 파악하려고 이 함수에서 저 함수를 오가는 것만큼 지치는 경험도 없다.
따라서 서로 밀접한 개념을 세로로 가까이 두어야 한다.
변수 선언
변수는 사용하는 위치에 최대한 가까이 선언한다.
1 |
|
루프를 제어하는 경우 루프 문 내에 선언한다.
1 | fun countTestCases(): Int { |
아주 긴 함수의 경우 블록의 상단이나 루프 직전에 변수를 선언하는 사례도 있다.
1 | // ... |
인스턴스 변수
인스턴스 변수는 일반적인 변수와 달리 클래스의 제일 처음에 선언한다.
잘 설계한 클래스의 경우, 많은 메서드에서 해당 인스턴스 변수를 사용하기 때문이다.
참고 C++의 경우 모든 인스턴스 변수를 클래스의 마지막에 선언하는 가위 규칙(scissors rule) 을 적용한다.
아래 예제를 보자.
1 | class TestSuite : Test { |
위처럼 코드를 살펴보다가 중간에 갑자기 인스턴스 변수가 나오면 당황할 수 밖에 없다.
코드의 이해도를 올릴 수 있는 알맞은 위치에 인스턴스 변수를 위치시키도록 하자.
종속 함수
한 함수가 다른 함수를 호출한다면 두 함수를 세로로 가까이 배치한다.
이러한 배치는 자연스럽게 위에서 아래로 코드를 읽을 수 있게 해준다.
아래가 적절한 예제 코드이다.
1 | public class WikiPageResponder implements SecureResponder { |
위 코드를 보고 getPageNameOrDefault()
함수 안에서 FrontPage
상수를 사용하는 방법도 있지만,
이 경우 상수가 저차원 함수에 묻히는 문제가 생긴다.
상수를 꼭 알아야하는 함수에서 실제로 사용하는 함수로 상수를 넘겨주는 것이 더욱 바람직하다.
개념적 유사성
어떤 코드는 서로 끌어당긴다.
이는 해당 코드의 개념적인 친화도가 높기 때문이다.
친화도가 높은 요인은 여러가지가 존재한다.
한 삼수가 다른 함수를 호출해서 생기는 직접적인 종속성이 그 예시이다.
그 외에는 비슷한 동작을 수행하는 함수이다.
아래 예제를 보자.
1 | public class Assert { |
위 함수들은 개념적인 친화도가 매우 높다.
작명 방식은 물론 기본 기능도 유사하고 간단하다.
서로가 서로를 호출하는 종속 관계가 없더라도 가까이 배치할만한 함수라고 볼 수 있다.
세로는 살펴보았으니 이제 가로를 볼 차례이다.
한 행은 가로로 얼마나 길어야할까?
이번에도 세로를 조사한 프로젝트를 기준으로 살펴보자.
20자에서 60자 사이의 행이 40%에 달하며, 10자 미만 30%이다.
80자 이후부터는 그 숫자가 금격하게 감소하고 있음을 알 수 있다.
예전에는 스크롤이 필요없을 정도로 길이를 짧게했으나, 최근엔 모니터들의 크기도 크기때문에 절대적인 규칙은 아니다.
일반적으로는 120자 정도의 행 길이를 규칙으로 삼는다.
가로 공백과 밀집도
가로는 개행이 아닌 공백을 사용해 개념의 밀접함과 느슨함을 표현한다.
아래 예제를 보자.
1 | private void measureLine(String line) { |
할당 연산자를 강조하기 위해 연산자의 앞 뒤로 공백을 주었다.
할당문은 공백을 통해 왼쪽 요소와 오른쪽 요소가 분명히 구분된다.
반면 함수 이름과 이어지는 괄호 사이에는 공백이 없다.
이는 함수와 인수가 서로 밀접하기 때문인다.
연산자의 우선순위를 강조하기 위해서도 공백을 사용한다.
아래 예제를 보자.
1 | public class Quadratic { |
공백의 유무로 우선순위를 표현하여 가독성을 확보하였다.
다만 코드 형식을 맞춰주는 대부분의 도구는 연산자의 우선순위를 고려하지않으므로 균일한 공백을 부여한느 경우가 많다.
가로 정렬
특정 구조를 강조하기 위해 아래와 같이 가로 정렬을 하는 경우가 있다.
1 | public class FitNesseExpediter implements ResponseSender { |
최근인 위와 같은 정렬이 쓰이지않는다.
이는 엉뚱한 부분을 강조하고, 코드의 진짜 의도를 가릴 여지가 있기때문이다.
만약 정렬이 필요할 정도로 목록이 길다면, 문제는 목록의 길이이지, 정렬의 부족이 아니다.
만약 아래 코드처럼 선언부가 길다면 클래스를 쪼개는 것이 좋다.
1 | public class FitNesseExpediter implements ResponseSender { |
들여쓰기
소스 파일은 윤곽도와 계층이 비슷하다.
이때 들여쓰기를 사용해 범위로 이루어진 계층을 표현한다.
아래 들여쓰기가 적용된 코드 예쩨를 보자.
1 | public class FitNesseServer implements SocketServer { |
위 코드처럼, 들여쓰기한 파일은 그 구조가 한 눈에 들어온다.
개발자는 이 들여쓰기 체계에 크게 위존한다.
들여쓰기가 없다면 우리는 아래의 코드를 이해하고 작성해야할지도 모른다.
1 | public class FitNesseServer implements SocketServer { private FitNesseContext |
들여쓰기 무시하기
때로는 간단한 if
문이나 while
문 등에서는 의도적으로 들여쓰기를 제거하기도 한다.
이 경우에도 이왕이면 들여쓰기를 넣는 것이 좋다.
아래 예제를 보자.
1 | public class CommentWidget extends TextWidget { |
위 코드에 들여쓰기를 적용하면 아래와 같다.
1 | public class CommentWidget extends TextWidget { |
간단한 코드라도 들여쓰기를 적용한 것이 가독성 측면에서 훨씬 우월함을 알 수 있다.
가짜 범위
때로는 비어있는 while
문이나 for
문을 만날 때도 있다.
최대한 피하는 것이 좋은 코드이지만, 불가피한 경우 들여쓰기를 적용하고 괄호로 감싼다.
아래 예제를 참조하자.
1 | while (dis.read(buf, 0, readBufferSize) != -1) |
개발자라면 각자 선호하는 규칙이 있다.
하지만 어떤 팀에 속한다면 최우선으로 지켜야하는 것은 개인이 아닌 팀의 규칙이다.
마틴이 사용하는 규칙은 아주 간단하다.
코드 자체가 구현 표준 문서가 되도록 작성한다.
아래 예제 코드를 참고하자.
1 | public class CodeAnalyzer implements JavaFileAnalysis { |
나쁜 코드에 주석을 달지 마라. 새로 짜라
브라이언 윌슨 커니핸(Brian Wilson Kernighan) - The C Programming Language의 저자인 K&R 중 한 명
P.J. 플라우거(Phillip James Plauger) - The Elements of Programming Style의 저자
잘 작성된 주석은 어떤 정보보다 유용하지만, 근거없는 주석은 오히려 코드를 이해하기 어렵게 만든다.
제일 좋은 것은 주석이 필요없을 정도로 의도가 선명한 코드를 작성하는 것이다.
하지만 프로그래밍 언어 자체의 표현력이 부족한 경우, 이를 만회하기 위해 주석은 필요하다.
즉, 주석은 필요악이다.
코드는 게속해서 여기 저기 나뉘고 갈라지면서 변화하고 진화한다.
이때 모든 주석이 각 코드를 추종해서 쫓아가지 못하는 경우가 너무 흔한다.
아래 예제를 보자.
1 | var request: MockRequest? = null |
위 코드를 보았을 때, HTTP_DATE_REGEXP
와 주석 사이에 다른 인스턴스 변수를 추가했을 가능성이 높다.
이처럼 주석은 엄격하게 관리해야하기때문에 애초에 주석이 필요없는 방향으로 코드를 작성하는 것이 최상책이라고 볼 수 있다.
코드에 주석을 추가하는 일반적인 이유는 코드의 품질이 나쁘기 때문이다.
표현력이 풍부하고 깔끔하며 주석이 거의 없는 코드가, 복잡하고 어순선하며 주석이 많이 달린 코드보다 훨씬 좋다.
물론 코드만으로 의도를 설명하기 어려운 경우도 존재한다.
그렇다고 이를 그대로 둘 수는 없다.
아래 두 개의 예제를 살펴보자.
1 | // 직원에게 복지 혜택을 받을 자격이 있는지 검사한다. |
1 | if (employee.isEligibleForFullBenefits()) |
많은 경우 아래 예제처럼 주석으로 표현하고자 하는 내용을 함수로 만들어서 표현해도 충분하다.
그렇다면 모든 주석은 쓸모가 없는 것일까?
주석이 필요한 경우의 예시를 알아보자.
때로는 회사내 사규에 의해 법적인 이유로 특정 주석을 넣도록 되어있는 경우도 있다.
예를 들어, 각 소스 파일의 최상단에 아래와 같은 저작권 정보 및 소유권 정보를 명시하는 경우이다.
1 | // Copyright (C) 2003,2004,2005 by Object Mentor, Inc. All rights reserved. |
때로는 기본적인 정보를 주석으로 제공하는 경우도 있다.
아래 주석은 추상 메서드가 반환할 값을 설명한다.
1 | // 테스트 중인 Responder 인스턴스를 반환한다. |
물론 위의 주석이 유용하다고 하더라도, 아래와같이 함수 이름에 정보는 담는 것이 더 좋다.
1 | abstract fun responderBeingTested(): Responder |
상대적으로 한 눈에 알아보기 힘든 정규식은 주석을 남기는 것도 좋다.
1 | // kk:mm:ss EEE, MMM dd, yyyy 형식이다. |
다만 이 경우에도, 시각과 날짜를 변환하는 클래스를 만들어 코드를 옮겨주면 더 좋을 것이다.
떄때로 주석은 구현의 이해를 도와주는 것을 넘어, 해당 코드를 작성하게 된 의도까지 설명한다.
아래 예제는 두 객체를 비교할 때, 다른 어떤 객체보다 자기 객체에 높은 우선 순위를 주는 코드이다.
1 | fun compareTo(o: Any): Int { |
또 다른 의도를 드러내는 예제를 살펴보자.
1 |
|
문제 해결 방식이 납득되지 않더라도, 주석을 통해 이렇게 작성한 의도를 분명히 드러낸 것을 알 수 있다.
또 다른 주석의 사용 방법으로 의미를 명료하게 밝히는 경우가 있다.
특정 인수나 반환값이 표준 라이브러리 등의 외부 의존성이라 변경이 불가한 경우, 주석을 통해 의도를 명확히 밝히는 것이다.
1 |
|
다만 주석은 검증하기가 매우 어렵기때문에, 위와 같은 용도로 쓰다가 잘못 작성했을 때 실수를 알아차리기가 어렵다.
때로는 다른 개발자에게 경고를 목적으로 주석을 작성하는 케이스도 있다.
1 | // 여유 시간이 충분하지 않다면 실행하지 마십시오. |
이 경우 @Ignore
어노테이션을 이용해 테스트에서 제외하는 방법도 사용할 수 있다.
_
접두사를 가진 것도 예쩐 규칙이므로 아래와 같이 개선할 수 있다.
1 |
|
위의 예시가 다소 억지스러울 수 있으니 다른 예제를 살펴보자.
1 |
|
위 예제가 개발자에게 경고로 쓰이는 주석의 적절한 예시라고 볼 수 있을 것이다.
경고뿐만 아니라 해야할 일도 주석으로 남겨둘 수 있다. 바로 TODO
주석이다.
아래 예제를 보자.
1 | // TODO-MdM 현재 필요하지 않다. |
TODO
주석은 개발자가 필요하다 여기지만 당장 구현하기 어려운 업무를 표현하기위해 작성한다.
다만, 이또한 불필요한 주석임은 맞다.
다행히 최근 IDE들은 TODO
주석만 모아서 보여주는 기능을 제공하므로 주기적으로 점검 후 소거하는 방향이 바람직하다.
코드가 표현하기 어려운 중요성을 강조하기 위한 주석도 있다.
1 | val listItemContent = match.group(3).trim() |
여태까지 좋은 주석들의 예시를 살펴보았다.
이제 반대로 나쁜 주석들의 예시를 살펴보자.
안타깝게도 대부분의 주석이 나쁜 주석의 범주에 속한다.
일반적으로 대다수의 주석은 허술한 코드를 지탱하거나, 엉성한 코드를 변경하거나, 미숙한 결정을 합리화하는 등의 개발자의 독백에서 벗어나지 못하기 때문이다.
따라서 주석을 작성하기로 했다면 충분한 시간을 들여 최대한 좋은 주석을 작성하도록 해야 한다.
아래 예제는 제대로된 주석이 아니어서 그저 개발자의 독백으로 남은 주석의 예시이다.
1 | fun loadProperties() { |
위 코드의 주석은 무슨 의미가 있을까?
IOException
이 발생하는 경우, 해당 위치에 속성 파일이 없다는 뜻이다.
그럼 정말로 모든 기본값을 메모리에 적재한 것이 맞는건가?
이 추측이 맞는지 검증하기 위해 다른 코드들을 추가 검증해야하는 것이 불가피하다.
즉, 위 주석을 쓸모없는 나쁜 주석이다.
같은 이야기를 중복하는 주석
코드를 보면 이해할 수 있는 내용을 굳이 주석으로 작성하여 의미를 중복시킨 경우도 있다.
1 | // this.closed가 true일 때 반환되는 유틸리티 메서드다. |
위와 같은 주석은 코드보다 더 많은 정보를 제공하지도 않고, 코드의 의도를 설명하지도 않는 중복일뿐이다.
오해할 여지가 있는 주석
의도만 좋은 주석이 존재하는 경우도 있다.
위의 예제를 다시 살펴보자.
1 | // this.closed가 true일 때 반환되는 유틸리티 메서드다. |
이 코드는 closed
가 true일 경우에만 무언가가 반환된다.
아니면 타임아웃을 기다렸다가 true가 아니어야 예외를 던진다.
타임아웃만 명시된 주석으로 인해 오히려 혼동을 야기한다.
의무적으로 다는 주석
모든 함수에 javadocs나 모든 변수에 주석을 달아야한다는 규칙은 나쁜 규칙이다.
오히려 코드를 복잡하게 만들고, 무질서를 초래하기때문이다.
아래 예제 코드를 보자.
1 | /** |
위 예제는 모든 함수에 javadocs를 넣으라는 규칙이 낳은 코드이다.
오히려 코드를 헷갈리게하고, 잘못된 정보를 제공할 여지만 만든다.
있으나 마나 한 주석
너무 당연한 사실을 언급하며, 새로운 정보를 제공하지 않는 주석도 있다.
1 | // 기본 생성자 |
이러한 주석은 상술한 중복에도 해당된다.
함수나 변수로 표현할 수 있따면 주석을 달지 마라
아래 코드를 살펴보자.
1 | // 전역 목록 smodule에 속하는 모듈이 우리가 속한 하위 시스템에 의존하는가? |
위 예제의 주석을 없애고 다시 코드로 표현해보자.
1 | val modulDependees: ArrayList = smodule.getDependSubsystems() |
위와 같아 코드를 통한 표현력을 늘리면 주석이 필요없어진다.
주석으로 처리한 코드
주석으로 처리해둔 코드만큼 나쁜 관행도 드물다.
아래와 같은 방식의 코드는 작성하지않도록 해야 한다.
1 | val response = InputStreamResponse() |
굳이 주석으로 코드를 가려놓는 경우엔, 이를 발견하는 다른 사람이 소거하기가 꺼려지는 부작용도 발생한다.
전역 정보
주석을 달아야하는 경우은 근처에 있는 코드에 대해서만 작성해야 한다.
일부 코드에 주석을 추가하면서 시스템의 전반적인 정보를 기술할 필요는 없다.
아래 코드를 보자.
1 | /** |
일단 코드와 주석의 의미가 중복되었음을 알 수 있다.
그 외에도 주석은 기본 포트정보를 적어놓았지만, 정작 함수는 포트 번호에 대한 검증 기능 자체가 없어서 기본 포트값을 통제하지 못한다.
즉 이 주석은 setFitnessePoirt()
함수가 아닌 시스템 어딘가에 있는 기본 포트를 설정하는 기능을 설명하는 주석이 되어버린다.
모호한 관계
주석과 주석이 설명하는 코드는 둘 사이의 관계가 명백해야 한다.
충분한 시간을 들여 주석을 달았다면, 이 주석과 코들르 통해 이해를 증진시켜야함을 명심하자.
아래 예제를 보자.
1 | /** |
위 코드에서 필터 바이트는 도대체 무엇일까?
width
에 더한 1일까? 아니면 height
에 더한 3일까? 혹은 둘 다 일까?
결국 의도가 명확하지않은 모호한 주석이 되어버린다.
]]>어떤 프로그램이든 가장 작은 단위는 함수이다.
먼저 아래 코드를 살펴보자.
1 |
|
위 함수는 코드의 길이도 길고, 중복된 코드와 문자열도 난립하고 모호한 타입과 Api가 존재한다.
이 코드를 리팩토링해서 아래 형태의 함수를 만들었다고 가정한다.
1 |
|
위와 아래의 코드 중 어떤 코드가 이해하기가 더 쉬울까?
당연히 아래에 있는 코드이다.
어째서 이해가 쉬워졌을까?
함수를 작성하는 규칙들을 통해 하나씩 익혀보도록 하자.
함수를 만드는 첫 번째 규칙은 작게 만드는 것 이고, 두 번째 규칙은 더 작게 만드는 것 이다.
그렇다면 얼마나 작게 작성야 할까?
위의 예시로 살펴본 코드보다는 짧아야 한다.
더욱 줄여보면 아래와 같이 줄일 수 있다.
1 |
|
결론적으로 if
, else
, while
등에 주어지는 조건문은 한 줄로 끝나는 것이 좋다.
이 말은 중첩 구조가 생길 정도로 함수를 크게 작성하지않아야 한다는 뜻이다.
함수는 한 가지를 해야 한다. 그 한 가지를 잘해야 한다. 그 한 가지만을 해야 한다.
함수가 하나의 동작만을 표현하고 수행해야한다는 이야기는 꽤나 많이 들어보았을 것이다.
어떻게 동작을 구분하고 함수를 추출해야할까?
특정 함수의 내용을 추상화하였을 때, 해당 수준이 하나인 단계만 수행하는 경우 하나의 작업만 한다고 간주할 수 있다.
다른 방법으로는
함수 내의 특정 코드를 다른 의미를 가진 함수로 뽑아낼 수 있는지 검증해보는 것이다.
함수가 확실하게 한 가지 작업만 하려면 함수 내의 모든 문장의 추상화 수준이 동일해야한다.
위의 예시 코드에서 getHtml()
함수는 추상화 수준이 아주 높고,
1 | val pagePathName: String = PathParser.render(pagePath) |
위의 코드는 중간 수준의 추상화 수준을 가진다.
물론 append("\n")
과 같은 코드는 추상화 수준이 아주 낮다.
위 예시처럼 한 함수 내에 추상화 수준이 섞이면 코드를 이해하기 어려워진다.
이는 특정 표현이 어떠한 근본적인 개념인지 세부사항을 명세한 것인지 구분하기 어렵기 때문이다.
우리는 코드를 어떤 순서로 읽을까?
기본으로 위에서 아래로 읽을 것이다.
이때 이 코드를 마치 이야기하듯이, 책을 보듯이 읽혀야 한다.
하나의 함수 다음에는 추상화 수준이 한 단계 낮은 함수를 배치하여, 읽을 수록 추상화의 수준이 하나씩 내려가게끔 작성해야 한다.
참고 저자인 로버트 C. 마틴은 이를 내려가기 규칙 이라고 부른다.
when
절은 작게 만들 기 어려울까?
분기가 2개인 when
도 길다고 볼 수 있으며, 분기 자체의 특성 때문에 한 가지 작업만 한다고 보장하는 것도 힘들다.
아래 예제를 보자.
1 |
|
위 예제는 직원의 유형에 따라 다른 값을 반환해주는 코드이다.
이때 직원의 유형이 추가되면 계속해서 함수도 변경해야하므로 개방 폐쇄 원칙을 위배하며,
또한 한 가지의 작업만을 수행하지도 않으므로 단일 책임 원칙을 위배한다.
이를 해결하려면 분기 자체를 아래와 같이 추상 클래스로 이관해야 한다.
1 | abstract class Employee { |
이후 추가되는 직원의 유형은 다형성을 기반으로 파생 클래스에서 처리할 수 있게 된다.
좋은 이름이 주는 가치는 아무리 강조해도 지나치지않ㄴ다.
코드를 읽으면서 짐작했던 기능을 각 루틴이 그대로 수행한다면 이는 클린 코드라고 볼 수 있끼 때문이다.
특히 함수가 작고 단순할수록 서술적인 이름을 고르기도 쉬워진다.
길고 서술적인 이름이 짧고 어려운 이름보다, 길고 서술적인 주석보다 좋다.
함수가 가질 가장 이상적인 인수의 개수는 당연히 0개이다.
그 다음은 1개, 그 다음은 2개이다.
즉, 인수는 적을수록 좋다.
참고 4개 이상의 인수를 가지는 경우는 최대한 회피하는 것이 좋다.
많이 쓰는 단항 형식
함수를 인수 1개를 넘기는 이유는 크게 두 가지로 나뉜다.
하나는 인수에 질문을 던지는 경우, 또 하나는 인수를 뭔가로 변환해 결과를 반환하는 경우이다.
위 두 경우가 아리라면 단항 함수는 회피하는 것이 좋다.
플래그 인수
특정 플래그값을 인수를 넘기는 것은 매우 끔찍한 일이다.
이는 함수 내부적으로 분기를 강제하며, 여러 가지 일을 하게끔 만들기 때문이다.
이항 함수
인수가 2개인 함수는 당연히 1개인 함수보다 이해하기 어렵다.
불가피한 상황도 있겠지만, 이항 함수부터는 인수의 개수에 비례해새서 위험이 생긴다는 사실을 인지하고 코드를 작성해야한다.
참고 물론 관념적으로 x,y값을 가지는 좌표계 등은 예외이다.
사이드 이펙트없는 코드를 짜는 것은 개발자의 지상과제이다.
함수에서 한 가지 작업을 하겠다고 정의한 후, 몰래 다른 짓을 하면 어떻게 될까?
많은 경우 시간적인 결합이나 순서 종속성을 초래하여 프로그램의 유연성을 저해하게 된다.
아래 예제 코드를 보자.
1 | class UserValidator { |
위 클래스는 표준 함수를 통해 userName
과 password
를 검증한다.
얼핏보면 괜찮아보이나, Session.initialize()
이 사이드 이펙트를 발생시킨다.
호출시에 함수를 호출하는 사용자를 인증하면서 기존의 세션 정보를 소거시킬 수 있기때문이다.
이러한 코드가 시간적인 결합을 초래한다.
여기서의 시간적인 결합이란 checkPassword()
함수를 특정 상황에서만 호출해야 정상 동작이 보장된다는 것을 뜻한다.
따라서 아래와 같이 이름을 교체하는 것이 더욱 좋다.
1 | class UserValidator { |
참고 사실 개선한 코드도 한 가지 작업만을 해야한다는 규칙을 위배한다.
함수는 뭔가를 수행하거나 뭔가에 답하거나 둘 중 하나만 해야한다.
좀 더 명세해보면, 객체의 상태를 변경하거나 객체의 정보를 반환하거나 둘 중 하나의 작업만 수행해야한다는 것이다.
아래 코드를 살펴보자.
1 | fun set(attribute: String, value: string): Boolean |
위 함수는 이름이 attribute
인 속성을 찾아 value
로 설정한 후, 설정이 성공하면 true
실패하면 false
를 반환하는 코드이다.
여기까지만 봐서는 문제가 없다.
문제는 외부에서 호출하는 경우에 발생한다.
1 | if (set("username", "unclebob")) { |
위의 코드를 마주쳤을 때, username
을 unclebob
으로 바꾸는 것일까 아니면 unclebob
인지 검증하는 것인지 헷갈린다.
외부에서 혼란을 초래하는 함수이므로 잘못 작성되었다고 볼 수 있는 것이다.
따라서 아래와 같이 명령과 조회를 분리하는 것이 좋다.
1 | if (attributeExist("username")) { |
명령 함수에서 오류 코드를 반환하는 코드는 바로 위에서 언급한 명령과 조회의 분리 규칙을 살짝 위반한다.
아래와 같이 명령을 표현식으로 사용하기 쉬운 탓이다.
1 | if (deletePage(page) == E_OK) |
위와 같이 오류 코드를 반환하면, 아래와 같이 코드의 개수에 맞추어 전부 처리해줘야하는 문제점이 생긴다.
1 | if (deletePage(page) == E_OK) { |
따라서 오류 코드보다는 예외를 이용해 코드를 깔끔하게 정리하는 것이 좋다.
1 | try { |
다만, try-catch
블록 또한 권장되는 방식은 아니다.
코드의 블록 구조에 혼란을 일으시켜, 정상 동작과 오류 처리 동작을 뒤섞기 때문이다.
따라서 아래와 같이 별도 함수로 분리하는 것이 좋다.
1 |
|
코드의 중복은 항상 문제가 된다.
코드의 길이가 늘어날 뿐 아니라 알고리즘이 변하면 모든 중복 위치에 코드를 수정해야하며,
하나라도 빠뜨리면 이는 장애로 이어지기 때문이다.
많은 원칙과 기법이 이 중복을 제거하기위한 목적으로 작성되었을 정도이다.
결론적으로 소프트웨어를 개발하는 행위는 글을 작성하는 행위와 비슷한다.
함수를 짤때도 마찬가이다.
처음에는 길고 복잡하며, 들여쓰기 단계고, 중복도, 반복문도 많을 것이다.
따라서 코드를 다듬고, 별도의 함수로 추출하고, 이름을 명확하게 작성하고, 중복을 제거하는 작업을 습관화해야한다.
때로는 전체 클래스를 쪼개기도 해야한다.
]]>참고 당연하겠지만 모든 개선 과정에서 단위 테스트를 통과하는 것이 좋다.
Android와 iOS가 공통으로 사용하는 shared
모듈에는 어떤 로직들이 작성되어야 할까?
제일 먼저 떠올릴 수 있는 것은 데이터 레이어 영역이다.
애플리케이션의 비즈니스 로직에서 처리하기 위한 데이터는 어딘가에 존재하기 마련이고, 이는 동일한 URI로 표현되고 있을 가능성이 매우 높기 떄문이다.
예를 들어 원격에 있는 데이터를 API 호출을 통해 받아오거나, 로컬내 데이터베이스에 있는 데이터를 조회해서 꺼내오는 작업은
플랫폼에 상관없이 공통적으로 일어나는 동작이라고 볼 수 있기 때문이다.
KMM에서는 API 호출을 ktor 로, 내부 데이터는 SQL Delight 를 통해 구현할 수 있다.
간단하게 표로 나타내보면 아래와 같다.
구분 | Android | iOS | KMM |
---|---|---|---|
Http Api 호출 | OkHttp / Retrofit | URLSession / Alamofire | Ktor |
DB SQL | SQLite / Room | SQLite / CoreData | SQL Delight |
즉 KMM에서는 Ktor와 SQL Delight을 이용해 kotlin으로 데이터 획득을 위한 코드를 작성하는 공통 라이브러리가 이미 지원되고 있기때문에
양쪽 플랫폼에 대해 하나의 코드로 커버할 수 있다.
KMP를 살펴보면서 한 가지 의문이 들었다.
shared
모듈에 data / damain / presenter를 전부 다 배치하는 것이 옳은 걸까?
아니면
옳진 안더라도 어느정도는 유용할까?
유용하다면 얼만큼 유용할까?
솔직히 확신을 얻기가 힘들었다.
개인적으로 shared
모듈에 허용할 수 있는 범주는 data / domain / 의존적이지않은 플랫폼 api 까지라는 생각이 계속 들었기 댸문이다.
만약 ViewModel
을 shared
에 배치한다면 Android 까지는 당연히 잘 동작하겠지만 iOS에서의 suspend
나 flow
를 어떻게 접근할까?
단순히 생산성이 좋다라는 측면 하나만 두고 공통 로직으로 다 작성하는 것이 맞을까?
KMP가 익숙치않아서이기도 하겠지만, 오히려 생산성이 더 안좋아지는 느낌이 들었다.
즉, 공통화가 가능한 것과 공통화가 권장되는 영역의 경계가 모호해서 딱 부러지는 맛이 없다.
억지로 하나의 공통코드로 커버하는 방법도 생각해보니 추상화를 위한 추상화가 될 것 같고,
추상화의 수준이 높아지면 결국 이해도는 떨어지는 것이 아닌가?
결국 최대한 유연하고 느슨한 인터페이스로 생각을 정리하긴 했다.
다만 단일 방안을 찾아낸 사례들을 듣고 있어서, 언젠간 딱 부러지지않을까? 하는 기대를 가지고 있다.
소프트웨어에서 이름은 어디에나 쓰이고 있다.
변수를 선언할때도, 함수를 선언할때도, 클래스를 작성할때도 이름을 붙여준다.
위의 설문조사 결과처럼 실제로 개발자들은 네이밍을 정하는 데 많은 고민을 하게 된다.
본 포스팅에서는 이름을 잘 짓는 간단한 규칙을 소개한다.
의도를 알 수 있는 이름을 지어라.
말은 쉽다.
문제는 의도가 분명한 이름을 짓는 것이 어렵다는 것일 뿐이다.
변수, 함수, 클래스 등은 특히 더 어렵다.
변수, 함수, 클래스의 존재 이유, 수행 기능, 사용 방법을 명확히 표현할 수 있어야 하며, 별도의 주석이 존재한다면 잘못된 작명으로 볼 수 있다.
1 | va d: Int // 경과 시간 (단위: 날짜) |
위 예시를 보자. 변수명 d
는 아무런 의미도 가지고 있지않다.
즉, 경과 시간이나 날짜를 표현한다는 의도를 느낄 수 없는 것이다.
따라서 아래와 같이 좀 더 명확하게 변수명을 작성해야 한다.
1 | var elapsedTimeInDays: Int |
이처럼 의도가 드러나는 이름을 가지면 코드의 이해와 변경을 쉬워진다.
이해는 알겠는데 변경은 무엇일까?
아래 예제를 보자.
1 | fun getThem(): List<IntArray> { |
위 함수는 도대체 어떤 동작을 하는 것일까?
쓰인 변수도 몇 개 없고, 로직도 단순하다.
단순한 코드니까 좋은 걸까?
아니다. 이 코드의 문제는 단순성이 아니라 함축성이다.
즉 이 코드가 의미하는 맥락이 명시적으로 드러나지않고 있다.
좀 더 명세해보자면 위의 코드는 개발자에게 아래 정보를 기존에 알고 있도록 강요하다.
theList
에 무엇이 들어있는지 알고 있다.theList
의 0번 인덱스 값이 왜 중요한지 알고 있다.getThem()
함수가 반환하는 list1
을 어떻게 사용할지 알고 있다.예제의 스니펫만 보았을 때 위 4가지 정보를 유추할 수 있는가?
이제 이 스니펫에 적당한 이름을 부여해보도록 하자.
1 | fun getFlaggedCells(): List<IntArray> { |
코드의 단순성을 그대로 둔채로, 변수와 함수, 그리고 상수들에 대해 이름을 부여했다.
이 함수가 지뢰찾기 게임을 만드는 데 쓰인다고 가정해보면 맥락을 파악할 수 있다.
gameBoard
는 현재 지뢰찾기 게임에 노출된 보드의 상태일 것이고, cell
은 해당 보드의 한 칸을, 0이라는 인덱스는 해당 칸의 상태를 의미하는 STATUS_VALUE
로
상수 4는 FLAGGED
를 의미하며, 결과적으로 이 함수는 flagged 상태인 칸들을 종합해서 반환하는 함수임을 유추할 수 있게 되었다.
리팩토링을 통해 좀 더 개선해보면 아래와 같이 표현할 수 있겠다.
1 | fun getFlaggedCells(): List<Cell> { |
결론적으로 같은 로직이라도 의도를 포함한 이름을 가진다면 더욱 쉬운 이해와 변경이 가능하다.
개발자는 코드에 잘못된 정보를 남겨서는 안된다. 잘못된 정보는 코드의 이해도를 저하시키기 때문이다.
대중적으로 통용되는 단어 사용 금지
hp, aix, sco 등 널리 쓰이는 의미가 있는 단어를 다른 의미를 가진 변수명으로 사용해선 안된다.
개발자에게 특수한 의미를 가진 단어 사용 금지
자료구조의 List에 더 익숙한 개발자 특성상 실제로 List가 아니라면 Group등을 사용하는 방법이 좋다.
흡사한 이름 사용 금지
서로 흡사한 이름을 사용하지않도록 주의해야 한다.
예를 들어 XYZControllerForEfficientHandlingOfStrings
라는 이름과 XYZControllerForEfficientStorageOfStrings
라는 이름은 다른 곳에서 쓰이더라도
혼란을 야기할 가능성이 크다.
소문자 L과 대문자 O
1 | var a: Int = 1 |
위 예제 코드로 설명을 대신한다.
단순히 컴파일 오류 없이 빌드 테스트를 통과하는 정도의 코드 작성은 많은 버그를 야기할 수 있다.
컴파일이 되는 것과는 별개로, 연속된 숫자를 덧붙이거나 사용되지않는 단어를 추가하는 방식을 적절하지 않다.
의미에 맞게 이름을 붙여야하는 것처럼, 이름이 달라지면 의미도 달라져야하기 때문이다.
아래 코드를 보자.
1 | fun copyChars(a1: CharArray, a2: CharArray) { |
a1
과 a2
는 아무런 의미를 제공하지않는다.
a1
을 a2
에 복사하는 맥락을 보건대, 아래와 같이 source
와 destination
을 사용하면 이해가 더욱 쉬워질 것이다.
1 | fun copyChars(source: CharArray, destination: CharArray) { |
또 다른 예시를 생각해보자.
어떤 클래스가 Product
라는 이름으로 존재한다고 가정한다.
그런데 다른 클래스로 ProductInfo
혹은 ProductData
가 같이 존재하는 경우, 각 클래스가 가진 의미가 모호해진다.
Info
나 Data
는 a
, an
, the
처럼 의미가 불분명한 불용어이기 때문이다.
또한 중복 의미하는 이름도 지양해야 한다.
Name
으로 끝낼 수 있는 것을 NameString
으로 작명하거나,
Customer
로 충분한 것을 CustomerObject
라고 작명하는 경우가 그 예시이다.
비단 클래스명만 아니라 함수명도 마찬가지이다.
1 | getActiveAccount() |
위의 함수명만 보고 정확히 원하는 반환값을 돌려주는 함수를 추측하기란 어렵기때문이다.
혼자서 개발하는 경우도 있겠지만, 대부분의 개발은 협업을 통해 진행된다.
이 협업이란 것은 필수적으로 의사소통을 동반해야하는데, 발음을 어려운 이름을 가진 경우 토론이 어려워진다.
동일한 역할을 하는 아래 클래스를 보고, 어떤 게 의사소통이 쉬울지 생각해보면 답이 나올 것이다.
1 | // BAD |
문자 하나를 사용하는 이름과 상수는 코드에서 쉽게 눈에 들어오지 않는 문제점이 있다.
예를 들어 MAX_CLASSES_PER_STUDENT
는 grep만 걸면 대부분 찾을 수 있겠지만, 숫자 7
은 온갖 영역에서 다 검색될 것이다.
알파벳 e
도 영어에서 가장 많이 쓰이는 문자이기때문에 검색하기 어렵다.
이름에 유형이나 범위까지 포함하면 의미를 이해하기 어려워진다.
따라서 불필요한 인코딩을 피하고 꼭 필요한 것만 인코딩하는 것이 좋다.
헝가리안 표기법
이름에 길이제한이 존재하던 시절 포트란은 첫 글자로 유형을 표현하였다.
접두어 | 데이터 타입 |
---|---|
b | byte, boolean |
n | int, short |
i | int, short (주로 인덱스로 사용) |
c | int, short (주로 크기로 사용) |
l | long |
f | float |
d, db | double |
ld | long double |
w | word |
dw | double word |
qw | quad word |
ch | char |
sz | NULL로 끝나는 문자열 |
str | C++ 문자열 |
arr | 배열 (문자열 제외): 다른 접두어와 조합 가능 |
p | 포인터 (16bit, 32bit): 다른 접두어와 조합 가능 |
lp | 포인터 (64bit): 다른 접두어와 조합 가능 |
psz | NULL로 끝나는 문자열을 가리키는 포인터 (16비트, 32비트) |
lpsz | NULL로 끝나는 문자열을 가리키는 포인터 (32비트[2], 64비트) |
fn | 함수 타입 |
pfn | 함수 포인터 (16bit, 32bit) |
lpfn | 함수 포인터 (64bit) |
모든 변수명 앞에 prefix로 위의 문자들이 붙는다고 생각하면 변수의 의도를 이해하는 데에 더욱 많은 노력이 필요할 것이다.
다만 이는 옛날의 불가피한 선택이었고, 현재 쓰이는 프로그래밍 언어들은 컴파일러가 타입을 감지해주고 있다.
따라서 현재 헝가리안표기법은 의미없는 인코딩이 되었다.
멤버 접두어
예전엔 m_
이라는 접두어를 변수명에 붙여서 멤버 변수임을 표기하기도 했다.
1 | public class Part { |
이제 m_
을 붙이지 않고 작성하는 것이 더욱 의도가 명확해진다.
1 | // java |
1 | // kotlin |
인터페이스 클래스와 구현 클래스
때로는 오히려 인코딩이 필요한 경우가 있다.
예를 들어 도형을 생성하는 추상 팩토리를 구현한다고 가정하자.
이 팩토리는 인터페이스 클래스(interface class) 이며 구현은 구체 클래스(concrete class) 에서 진행한다.
인터페이스 클래스와 구체 클래스의 이름은 어떻게 지어야할까?
가장 전통적인 방법으로는 인터페이스를 뜻하는 I
를 접두어로 사용하여 IShapeFactory
와 ShapeFactory
를 떠올릴 수 있다.
하지만 I
는 주의를 흐트리거나 (인터페이스라는) 과도한 정보를 표현한다.
즉, 인터페이스임을 알 필요가 없음에도 알아버리게 되는 불편함이 존재한다.
외부에서는 그저 ShapeFactory
로만 노출하고, 구체 클래스를 인코딩하는 것이 좋다고 볼 수 있다.
IShapeFactory
보다는 CShapeFactory
나 ShapeFactoryImp
이 더 낫다.
참고 위 내용은 저자인 로버트 C. 마틴의 의견으로 필자도 이에 동의한다.
코드를 읽으면서 변수명을 자신이 아는 이름으로 변경해야하는 경우가 있다면,
일반적으로 문제 영역이나 해법 영역에서 사용하지않는 이름을 사용했기 때문에 발생하는 문제이다.
루프에서 자주 쓰이는 i
,j
,k
정도는 범위가 작고 다른 이름과 충돌하지않는 선에서는 사용해도 되지만
해당 케이스 외에는 부적합한다.
개인의 기억력이 좋고, 똑똑한 것과는 상관없이 개발자로서의 미덕은 명료한 코드 이다.
어떤 변수 p
의 의미가 특정 URI에서 프로토콜과 호스트 영역을 제외한 파라미터 영역을 의미한다는 것을 “혼자” 끝까지 기억할 수 있다고 가정하자.
이러한 작명은 협업 관계에 있는 누군가에게 반복적으로 설명해주어야하며, 코드의 이해도도 저하시킨다.
즉, 별도의 설명이 필요없는 명료한 코드가 제일 좋은 코드이다.
클래스와 객체의 이름은 명사나 명사구가 적합하다.
Customer
, WikiPage
, Account
, AddressParse
등이 좋은 예시이다.
반대로 Manager
, Processor
, Data
, Info
등은 피하고 동사는 사용하지않아야 한다.
반대로 메서드의 이름은 동사나 동사구가 적합하다.
postPayment()
, deletePage()
, save()
등이 좋은 예시이다.
접근자(Accessor), 변경자(Mutator), 조건자(Predicate)는 javabean 표준에 따라 get, set, is를 붙인다.
아래는 예시 코드이다.
1 | // java |
1 | // kotlin |
만약 생성자를 중복 정의하는 경우엔 정적 팩토리 메서드를 사용한다.
예를 들어
1 | val fulcrumPoint = Complex(23.0) |
보다는
1 | val fulcrumPoint = Complex.fromRealNumber(23.0) |
이 더 좋다.
참고
정적 팩토리 메서드의 경우 생성자 사용을 제한하기 위해 생성자를private
으로 선언하는 것도 좋다.
관련 포스트 : (Effective Java 2/E) 101. Item 1 - Consider static factory methods instead of constructors
추상적인 개념 하나에 단어 하나를 선택해야 한다.
예를 들어 동일한 행위를 하는 메서드를 각 클래스마다 fetch()
, retrieve()
, get()
등으로 각기 네이밍하면 혼란을 야기할 수 있다.
비단 메서드뿐만이 아니라 클래스의 이름도 마찬가지이다.
동일한 코드 내에서 controller
, manager
, driver
등이 혼재되어있어도 혼란이 야기된다.
예를 들어 DeviceManager
와 ProtocolController
는 근본적으로 동일한 녀석이기 때문이다.
이름이 다르면 다른 클래스로 착각할 여지가 존재하기 때문이다.
따라서 이름은 독자적이고 일관적이어야 한다.
개발자라면 전산 용어, 알고리즘과 패턴의 이름, 수학 용어등에 친숙할 것이다.
가능한 경우 이처럼 솔루션 영역에서 이름을 차용하는 것이 의도를 내포하기 좋은 경우가 있다.
예를 들어 계정 정보 목록이 있다면 AccountGroup
보다는 AccountList
가 더욱 와닿을 것이다.
머신 러닝 측면에서 보면 data_generator
는 반복자가 적용된 데이터 생성기라는 것을 바로 유추할 수 있을 것이다.
즉, 모든 이름을 문제 도메인 영역에서만 가져오는 것은 현명하지 못하다.
만약 솔루션 내에 적절한 이름이 없다면 문제 영역에서 이름을 차용하는 것이 좋다.
문제 영역에서 이름을 차용하는 경우, 개발자가 해당 문제의 전문가를 통해 맥락이나 의도를 파악하는 것이 수월해지기 때문이다.
대부분의 이름은 스스로 의미를 가지고 있다.
하지만 복합적인 개념의 경우 맥락을 부여해서 이해를 해야 한다.
예를 들어 firstName
, lastName
, street
, houseNumber
, city
, state
, zipcode
라는 변수들이 있다고 가정해보자.
이 변수들이 합쳐서 표현하는 것은 어떠한 주소라는 것을 쉽게 떠올릴 수 있다.
하지만 만약 state
하나만 딸랑 있는 경우엔 주소를 쉽게 떠올릴 수 있을까?
이처럼 전체적인 맥락은 의도를 파악하는 데 매우 중요한 요소이다.
위 변수들을 추가하는 addFirstName()
, addListName()
, addState()
등의 메서드를 가진 Address
클래스를 정의하면 매우 명확해 질 것이다.
이번엔 예제를 통해 살펴보자.
1 | private void printGuessStatistics(char candidate, int count) { |
위 메서드의 최상단에 정의된 number
, verb
, pluralModifer
변수는 메서드의 끝까지 가서야 통계 결과에 쓰이는 값임을 알 수 있다.
printGuessStatistics()
메서드에 맥락을 부여하기 위해서 아래와 같이 클래스로 치환해보자.
1 | private class GuessStatisticsMessage { |
위와 같은 맥락의 부여를 통해 number
, verb
, pluralModifer
변수와 출력 결과에 대한 알고리즘이 명확해졌다.
]]>참고 이 코드는 일부러 kotlin으로 작성하지않았다. kotlin으로 하면 더 간결하고 클래스의 전환도 필요없어지긴 하겠지만
이는 java로도 충분히 간소화할 수 있으며, 위 예제는 맥락 부여의 중요성을 납득시키기 위한 의도적인 저품질 코드로 판단되기때문이다.
코드란 무엇일까?
코드란 요구사항을 상세히 표현하는 수단이며, 코드의 도움없이 요구사항을 명세하거나 추상화하는 것은 불가능하다.
기계가 실행할 수 있을 정도로 요구사항을 명세하는 이 작업이 바로 프로그래밍 이다.
앞으로 프로그래밍 언어의 추상화 수준은 더더욱 높아질 것이며, 특정 응용 분야에 적합한 언어의 수도 점점 늘어나게 될 것이다.
세상엔 좋은 코드와 나쁜 코드가 존재한다.
코드를 보고 이게 좋은지 나쁜지 판단해보고,
나쁜 코드라면 좋은 코드로 리팩토링할 수 있도록,
더 나아가 작성시점에 좋은 코드를 작성할 수 있는 방법에 대해서도 알아보도록 하자.
프로그래머라면 누구나 나쁜 코드로 경험한 고생이 있을 것이다.
그럼 누군가 일부러 나쁜 코드를 작성한 것일까?
대부분은 일정에 치여서, 급하게 대응하느라 등의 그럴싸한 명분하에 작성되었을 것이다.
작성 시점에 “아 이건 나중에 꼭 개선해야겠다” 라고 생각하고 주석을 남겨두기도 했을 것이다.
문제는 여기서의 나중 은 절대 오지 않는다.
참고 이러한 상황을 르블랑의 법칙 이라고 한다.
원문은 다음과 같다. Leblanc’s Law : Later equals Never. - Dave LeBlanc
나쁜 코드는 존재 그 자체로 개발 속도를 크게 저하시키는 원인이 되며, 코드를 수정할 때마다 전혀 예상하지 못한 곳에서 문제를 발생시킨다.
이러한 나쁜 코드가 프로젝트 내에 적치될 수록 팀의 생산성은 0을 향해 수렴하게 된다.
결론적으로 생산성을 유지하려면 프로젝트 내 코드를 깨끗하게 유지해야 하고 이 깨끗함을 명확히 정의해서 프로젝트 내에 적용해야 한다.
깨끗한 코드의 정의는 무엇일까?
유명 프로그래머들이 말하는 깨끗한 코드를 정리해보자.
코드는 우아하고 효율적이어야 한다. 의존성을 최대한 줄이고, 오류는 명백한 전략에 의거해 처리하며 성능을 최적으로 유지해야 한다.
비야네 스트롭스트룹(Bjarne Stroustrup) - C++의 창시자
깨끗한 코드는 단순하고 직접적이며 잘 쓴 문장처럼 읽힌다. 또한 설계자의 의도를 숨기지않으며, 명쾌한 추상화와 단순한 제어문으로 가득하다.
그래디 부치(Grady Booch) - UML의 개발자
깨끗한 코드는 작성자가 아닌 사람도 읽기 쉽고 고치기 쉬우며, 의미있는 이름을 가지고 있다. 또한 특정 목적을 달성하기위해 한 가지 방법만을 제공한다.
데이브 토마스(David A. Thomas) - Object Technology International의 창립자
중복을 제거하고, 모든 테스트를 통과하며, 시스템 내 모든 설계 아이디어를 표현하면서 클래스 / 메서드 / 함수 등을 최대한 줄여야 한다.
론 제프리스(Ron Jeffries) - 익스트림 프로그래밍 소프트웨어 개발 방법론의 창립자 중 한 명
깨끗한 코드는 코드를 읽으면서 짐작했던 기능을 그대로 수행하는 코드이다.
워드 커닝햄(Ward Cunningham - Wiki 개념의 창시자
그래서 도대체 깨끗한 코드는 정확히 무엇일까?
결론적으로 깨끗한 코드를 정확히 정의할 수는 없다.
각 프로덕트 혹은 개발자에 따라 기준점이나 추구하는 방향이 다를 것이기 때문이며, 방향이 다르다고해서 깨끗한 코드가 아닌 것도 아니기 때문이다.
따라서 깨끗한 코드를 작성하기 위한 규칙을 나름대로 정하고, 이를 잘 지키는 것이 더 중요하다고 할 수 있겠다.
대략적으로 정리해보자면 아래와 같겠다.
위의 정리들을 잘 지켰을 때 우리는 이 코드를 깨끗하다고 판단할 수 있을 것이다.
]]>이전 포스팅에서 생성한 프로젝트에 의존성 관리는 위한 BuildSrc
모듈을 추가해보자.
참고 프로젝트에 따라
build-logic
으로 생성하기도 한다.
루트 폴더 하위에 buildSrc
폴더와 build.gradle.kts
스크립트 파일을 추가한다.
이후
build.gradle.kts
에 아래 스크립트를 작성한다.
1 | import org.gradle.kotlin.dsl.`kotlin-dsl` |
이후 아래와 같이 소스 폴더를 생성한다.
이후 Gradle Sync를 수행하면 .gradle
폴더가 빌드를 수행하면 build
폴더가 생성됨을 확인할 수 있다.
Deps
클래스 추가buildSrc/src/main/kotlin
경로에 Deps
오브젝트 클래스 파일을 추가한 뒤 아래 코드를 작성한다.
1 | object Deps { |
androidApp
모듈 의존성 변경이제 androidApp
의 의존성을 아래와 같이 교체할 수 있다.
before
1 | // androidApp build.gradle.kts |
after
1 | // androidApp build.gradle.kts |
이때 kapt
와 kaptAndroidTest
에서 오류가 발생하니, plugins
블록에 kapt
관련 코드를 추가한다.
before
1 | // androidApp build.gradle.kts |
after
1 | // androidApp build.gradle.kts |
위에서 추가한 id("dagger.hilt.android.plugin")
스크립트 때문에 또 오류가 발생할 것이다.
프로젝트의 build.gradle.kts
에서 아래와 같이 의존성을 추가로 설정하자.
before
1 | // root build.gradle.kts |
after
1 | // root build.gradle.kts |
shared
모듈 의존성 변경다시 공용 모듈인 shared
모듈로 돌아와서 의존성을 변경하자.
before
1 | // shared build.gradle.kts |
after
1 | // shared build.gradle.kts |
이제 오류 없이 정상적으로 빌드가 되었음을 확인할 수 있다.
KMM 애플리케이션 프로젝트를 생성하고 구조를 살펴보자.
제일 처음엔 당연히 Android Studio 실행!
이전 포스팅과 동일하게 Kotlin Multiplatform App 프로젝트를 선택한다.
이번엔 iOS framework distribution 항목을 Regular framework가 아닌 CocoaPods dependency manager로 선택한다.
이후 프로젝트가 생성되면 아래와 같은 오류 메시지가 출력될 것이다.
이를 해소하려면 project 내 iosApp
모듈 내에서 pod을 설치해주어야 한다.
1 | pod install |
pod의 설치를 완료하면 프로젝트 오류는 사라진다.
앞선 포스팅과 동일하게 각 플랫폼에 맞는 프로젝트 파일이 생성되었다.
먼저 androidApp 모듈에는 MainActivity
와 MyApplicationTheme
가 생성되었음을 확인할 수 있다.
모듈의 build.gradle.kts
에는 기본적인 설정과 compose가 반영되어있다.
이제 우리의 관심사인 shared
모듈을 살펴보자.
android 설정은 물론 cocoapad에 대한 의존성으로 ios 관련된 설정들도 눈에 들어온다.
공용 모듈에서 공통적으로 요구하는 코드인 Platform
관련 코드도 볼 수 있는데, 이 중 iOS 관련 코드를 살펴보자.
iOS모듈에서 주입받아야하는 부분이 공통 패키지인 platform.*
을 통해 구현되어있음을 확인할 수 있다.
특히 getPlatform()
함수에 붙어있는 actual
키워드를 보자.
KMM에서 actual
키워드는 각 플랫폼에 종속적인 코드를 작성해야할 때 추가하는 키워드이다.
여기서는 iOS 모듈에 의존하는 코드임을 명시하는 것이다.
반대로 공통적인 코드는 위와 같이 expect
키워드를 사용하여 공통부분임을 명시한다.
당연하겠지만 간단한 모듈간 구조는 아래의 코드로 연결된다.
1 | // androidApp build.gradle.kts |
1 | # iosApp podfile |
대략 이런 구조를 기본값으로 확장이 되는 구조이다.
graph TD; shared androidApp iosApp androidApp --> shared; iosApp --> shared;]]>
모든 프로그래밍 언어 혹은 프레임워크의 출발점 Hello, World!를 출력해보자.
Android Studio를 실행한 뒤 [New Project] 를 선택해 [Kotlin Multiplatform App] 템플릿을 선택한다.
이후 적당한 프로젝트 이름을 입력한다.
KMM 관련 설정은 아래와 같이 진행하였다.
프로젝트가 생성되면 기존의 Android와 달리, 꽤나 복잡한 폴더 구조임을 확인할 수 있다.
근데 이상하다.
공통로직을 담당하는 shared 모듈과 Android 한정 로직을 담당하는 androidApp 은 있는데 iosApp 은 보이지않는다.
이는 Android Studio의 프로젝트 트리를 Android로 지정해서 그렇다.
순수히 디렉토리 구조를 다 보여주는 Project로 변경하면 전부 보인다.
최초로 프로젝트를 생성하면 Android와 iOS에는 각각 뷰를 띄우기 위한 코드가 존재하고,
공통 모듈에는 각 뷰에 띄우기 위한 메시지가 Greeting
클래스에 작성되어있다.
최초엔 플랫폼 버전 관련된 코드로 작성되어있는데 이를 아래와 같이 Hello, World!
로 변경하자.
Android Studio에서 바로 실행하면 아래와 같이 출력된다.
프로젝트 생성 후 확인했던 iosApp 폴더를 기준으로 iOS 앱을 실행할 수 있다.
참고 물론 iOS 앱의 빌드는 mac의 사용이 필수적이다.
해당 iosApp 폴더에는 xcodeproj
확장자를 가진 프로젝트가 이미 생성되어있다.
해당 프로젝트를 열어보면 아래와 같은 구조를 확인할 수 있다.
실행하면 아래와 같은 화면을 가진 시뮬레이터가 실행된다.
이로서 KMM으로 Android와 iOS 플랫폼에 Hello, World! 를 출력해보았다.
]]>KMP는 Kotlin Multiplatform의 약자로, 하나의 코드로 여러 플랫폼을 타겟으로 제품을 빌드할 수 있게 해준다.
여기서는 Android와 iOS를 동시에 개발할 수 있도록 개발 환경 설정을 해보자.
다만, Window 기반 기기도 개발 환경을 설정할 수는 있지만, iOS 빌드를 위해선 macOS 기기가 필수적으로 요구된다.
필자는 macOS만 사용중이므로 해당 환경을 기준으로 작성하였다.
Android 개발 환경 구축을 위해 Android Studio를 설치한다.
설치 후 실행한 Android Studio에서 Kotlin Multiplatform Mobile 플러그인을 설치한다.
iOS 개발 환경 구축을 위해 Xcode를 설치한다.
Homebrew는 Ruby 언어로 개발된 macOS용 패키지 관리 도구이다.
Homebrew를 이용하면 필요한 파일을 터미널에서 바로바로 설치할 수 있다.
Homebrew 또한 아래와 같이 CLI 형태로 터미널에서 바로 설치를 진행할 수 있다.
1 | /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" |
설치가 된 상태라면 아래와 같이 출력된다.
참고 brew 홈페이지
KDoctor는 JetBrains사에서 공식적으로 제공하는 Kotlin Multiplatform을 위한 도구이다.
현재 운영체제나 JDK, Android Studio, Xcode, CocoaPods 등의 상태를 점검해주는 역할을 한다.
설치는 brew를 이용해 간단히 진행할 수 있다.
1 | brew install kdoctor |
Xcode 설치 후 실행한 적이 없을 경우 약관 동의를 안 받았기에, Xcode 관련 라이센스가 없다고 경고 메시지가 노출된다.
1 | Error: You have not agreed to the Xcode license. Please resolve this by running: |
Xcode를 실행하여 위의 약관에 동의하면 정상적으로 설치를 진행할 수 있다.
설치후 kdoctor를 명령어로 입력하면 아래와 같이 무엇이 미비되어있는 지 점검해준다.
kdoctor
명령어 수행 후 미비된 것으로 나온 cocoapods르 설치해보자.
cocoapods은 ruby 2.7.2 버전과 호환되므로, 현재 설치된 ruby의 버전을 먼저 확인해야한다.
1 | ruby -v |
위의 명령어를 실행하면 현재 설치된 ruby의 버전을 출력해준다.
현재 설치된 ruby는 v2.6.10 이므로 v2.7.6를 설치해주자.
ruby는 아래와 같이 특정 버전을 설치할 수 있다.
1 | brew install rbenv # rbenv 설치 |
다시 kdoctor를 수행해보자.
이제 KMM을 위한 개발 환경이 모두 마무리 되었다.
]]>거듭 강조했듯 객체는 협력을 위해 존재하며, 협력은 객체가 존재하는 이유와 문맥을 제공한다.
객체지향 설계의 목표는 적절한 책임을 수행하는 객체들의 협력을 기반으로 결합도가 낮고 재사용이 가능한 코드 구조를 창조하는 것이다.
결국 객체지향 패러다임의 장점은 설계를 재사용할 수 있다는 것이며, 재사용을 위해 객체들의 협력 방식을 일관성있게 만들 필요가 있다.
일관성은 설계에 드는 비용을 감소시키며, 과거의 해결 방법을 반복적으로 사용해서 유사한 기능을 구현하는 데 드는 리소스를 대폭 줄일 수 있게 해준다.
무엇보다 일관성있는 설계가 가져다주는 장점은 코드가 이해하기 쉬워진다는 것이다.
따라서 일관성있는 협력 패턴을 적용하여 이해하기 쉽고 직관적이며 유연한 코드를 작성할 수 있도록 하자.
반대로 일관성이 없는 경우엔 어떻게 될까?
일반적으로 일관성이 없는 경우 두 가지 케이스에서 불편함이 발생한다.
첫 번째는 새로운 구현을 추가해야하는 상황 이고, 두 번째는 기존의 구현을 이해해야 하는 상황 이다.
위 두 상황을 전부 이해하고 진행해야하는 건 개발자이고, 대부분의 업무가 위 상황에 놓일 것이기때문에 지속적으로 더 높은 리소스를 요구하게 될 것이다.
따라서 유사한 기능은 유사한 방식으로 구현하여 일관성을 유지하는 중요하다.
만약 특정 기능에 대해 여러가지 방식으로 구현되어있는 경우, 우리는 매번 요구사항을 해결하기 위한 최적의 방법을 찾도록 강요받게 되며 이는 리소스 투입의 증가를 야기한다.
일관성 있는 설계를 만드는 데 가장 좋은 방법은 다양한 설계를 경험해보는 것이다.
다양한 설계 경험을 통해 어떤 변경이 중요한지, 어떻게 변경을 다뤄야하는지를 판단할 수 있는 직관을 키워야한다.
이 직관을 빠르게 습득하기 위한 방법 중 하나가 줄이기 위해선 널리 알려진 디자인 패턴을 학습하고 변경이라는 문맥 안에서 학습한 패턴을 적용해보는 것이다.
다만, 디자인 패턴을 통해 반복적으로 적용할 수 있는 설계 구조를 제공한다고 하더라도 모든 케이스에 딱 맞는 디자인 패턴이 있는 것은 아니다.
따라서 아래의 원칙에 지키면서 일관성있는 협력을 만들도록 해보자.
대부분의 객체지향의 원칙과 개념들 역시 변경의 캡슐화를 목표로 한다.
새로운 요구사항을 접했을 때, 코드에서 바뀌는 부분이 있는지 검증하고 바뀌지않는 부분으로부터 분리하는 것이 첫 번째 설계 원칙이다.
우리는 여전히 캡슐화하면 데이터 은닉을 떠올린다.
데이터 은닉이란 외부에 공개된 메서드를 통해서만 객체의 내부에 접근할 수 있게 제한하여, 객체 내부의 상태 구현을 숨기는 기법을 의미한다.
그럼 캡슐화는 데이터 은닉과 동치일까?
아니다. 캡슐화는 데이터 은닉 이상의 의미를 내포하고 있다.
진정한 의미의 캡슐화는 소프트웨어 안에서 변할 수 있는 모든 개념을 감추는 것을 말하며, 개념 안에 객체 내부의 상태가 포함될 뿐이다.
우리는 설계에서 무엇이 변화할 가능성이 있는지 고려하고, 재설계없이 변경할 수 있는 것이 무엇인지 고려해서 변화하는 개념을 캡슐화해야한다.
이 캡슐화를 종류에 따라 분류해보자.
위와 같이 분류했듯이 캡슐화는 단순히 데이터 은닉만을 의미하는 것이 아니라, 코드 수정으로 인한 파급 효과를 제어할 수 있는 모든 기법을 의미한다.
다소 생소할 수 있는 개념은 객체 캡슐화와 서브타입 캡슐화를 좀 더 명세해보면 아래와 같다.
서브타입 캡슐화
먼저 변하지않는 부분으로부터 변하는 부분을 분리하여, 변하는 부분들의 공통적인 행동을 추상 클래스나 인터페이스로 추상화한다.
이후 변하는 부분들이 이 추상 클래스나 인터페이스를 상속받게 만들어 변하는 부분을 변하지않는 부분의 서브타입 계층 으로 만든다.
객체 캡슐화
서브타입 캡슐화를 통해 획득한 타입 계층을 변하지않는 부분에 합성한다.
단 변하지 않는 부분에서는 변경되는 구체적인 사항에 결합되서는 안되며, 의존성 주입을 통해 느슨한 결합도를 유지해야 한다.
이제 변하지않는 부분은 변하는 부분의 구체적인 종류에 대해서는 알지 못하게 된다.
일관성 있는 설계를 만들기 위한 절차를 알아보자.
상속과 합성은 객체지향 프로그래밍에서 가장 널리 사용되는 코드 재사용 기법이다.
상속은 슈퍼클래스와 서브클래스를 연결해서 슈퍼클래스의 코드를 재사용하도록 하기에 is-a
관계이고,
합성은 전체를 표현하는 객체가 부분을 표현하는 객체를 포함해서 부분 객체의 코드를 재사용하기에 has-a
관계라고 부른다.
상속과 합성은 코드 재사용이라는 목적 자체를 동일하지만, 구현 방벙부터 변경을 다루는 방식까지 모든 면에서 차이점을 가지고 있다.
상속은 서브클래스가 부모클래스의 대부분의 정의를 물려받아 다른 부분만 추가하거나 재정의함으로써 기존 코드를 쉽게 확장할 수 있지만, 슈퍼클래스와 서브클래스의 결합도가 높아지는 문제가 존재한다.
반면에 합성은 구현에 의존하지않으며 퍼블릭 인터페이스에 의존하기 때문에 포함된 객체의 내부 구현이 변경되더라도 그 영향이 최소화되어 안정적인 코드를 얻을 수 있게 된다.
간단하게 정리하면 아래 표와 같다.
구분 | 상속(is-a) | 합성(has-a) |
---|---|---|
목적 | 코드 재사용 | 코드 재사용 |
결합도 | 높음 | 낮음 |
의존성 | 슈퍼클래스의 구현 | 포함된 객체의 퍼블릭 인터페이스 |
관계 표현 | 클래스 사이의 정적 관계 | 객체 사이의 동적 관계 |
변경 가능 시점 | 코드 작성 시점(컴파일 타임) | 실행 시점(런타임) |
상속을 남용하는 경우엔 몇몇 문제점이 발생할 수 있다.
각 문제점을 리마인드해보고, 합성으로 변경해보도록 하자.
1. 불필요한 인터페이스의 상속 문제
1 | Properties properties = new Properties(); |
Java의 Properties
클래스는 Hashtable
클래스를 상속하고 있기에 put(K key, V value)
메서드도 접근이 가능하다.
문제는 Hashtable
클래스는 V
에 모든 타입을 적용할 수 있지만, Properties
의 getProperty(String key)
메서드는 값이 String
타입이 아니면 null을 반환한다.
애초에 setProperty(String key, String value)
메서드는 Hashtable
클래스의 put(K key, V value)
을 호출하기 때문이다.
즉 값의 탕비이 String
이 아니면 오류가 발생할 수 밖에 없다.
이 상속 관계를 합성으로 변경해보자.
1 | public class Properties { |
내부적으로 HashTable
을 포함하여 위의 문제를 해결할 수 있게 되었다.
또한 자체정의한 Properties
클래스가 제공하는 인터페이스를 통해서만 properties
변수에 접근할 수 있다.
2. 메서드 오버라이딩의 오동작 문제
메서드 오버라이딩의 오동작으로 InstrumentedHashSet
이 있다.
참고 이펙티브 자바에서도 컴포지션 챕터에서 동일한 클래스를 언급한다.
1 | public class InstrumentedHashSet<E> extends HashSet<E> { |
위의 클래스를 아래와 같이 쓰면 어떻게 될까?
1 | InstrumentedHashSet<String> languages = new InstrumentedHashSet<>(); |
실제 addCount
는 3이 아니라 6이 출력된다.
이는 super.addAll(c)
에서 HashSet
의 add()
메서드를 호출하기 때문이다.
이를 합성으로 변경하면 아래와 같다.
1 | public class InstrumentedHashSet<E> implements Set<E> { |
상속으로 인해 결합도가 높아지면 코드를 수정하는 데 필요한 작업의 양이 과도하게 늘어나는 경향이 있따.
가장 일반적인 상황은 작은 기능들을 조합해서 더 큰 기능을 수행하는 객체를 만들어야하는 경우이다.
이 경우 다음과 같은 문제점이 발생할 수 있다.
이처럼 상속의 남용으로 하나의 기능을 추가하기 위해 필요 이상으로 많은 수의 클래스를 추가해야하는 경우를 가리켜 클래스 폭발(class explision) 혹은 조합의 폭발(combinational explision) 문제라고 부른다.
클래스 폭발 문제는 서브클래스가 슈퍼클래스의 구현에 강하게 결합되도록 강요하는 상속의 근본적인 한계 때문에 발생하는 문제다.
이는 상속 관계의 결정이 컴파일 타임에 일어나고 이후 변경할 수 없기때문에 다양한 조합만큼 새로운 클래스를 계속해서 추가할 수 밖에 없다.
합성을 사용하면 상속으로 인해 발생하는 클래스의 증가와 중복 코드들을 간단하게 해결할 수 있다.
컴파일 타임에 발생하는 상속관계와는 달리 합성 관계는 런타임에 발생하기 때문이며, 컴파일 타임 의존성과 런타임 의존성이 거리만큼 좀 더 유연한 설계를 얻을 수 있는 점을 상기해보면
합성 방식이 상속보다 더 우아한 방법임은 자명하다.
그렇다면 무조건 합성을 사용하고 상속을 사용하지않아야하는 것일까?
이를 위해선 상속을 두 종류로 분리하여 생각할 수 있어야 한다.
주로 문제가 되는 것은 강결합을 유도하는 구현의 상속이며, 추상화를 위한 인터페이스의 상속이 결합도에 영향을 최소소하한다.
코드를 재사용하기 위한 한 가지 기법을 더 살펴보기로 하자.
이 기법은 믹스인이라는 이름으로 널리 알려져있으며, 상속과 합성의 특성을 모두 가지고 있는 디자인 패턴이다.
믹스인은 객체를 생성할때 코드 일부를 클래스 안에 주입하여 섞은 뒤 재사용하는 기법을 뜻하며, 컴파일 시점에 필요한 코드 조각을 조합하여 재사용한다.
설명 자체로는 상속과 유사해보일 수 있다. 하지만 상속은 슈퍼클래스와 서브클래스를 동일한 개념적인 범주로 묶어 is-a
관계를 달성하기 위한 것이라면,
믹스인은 is-a
관계의 정립 없이 코드를 다른 코드안에 섞어 넣는 것이기 때문이다.
또한 상속과 달리 믹스인은 유연하게 관계를 재구성할 수 있는 장점이 있다.
]]>