"사용자들이 사진을 공유할 수 있는 기능이 필요하다" 와 같은 문제가 있다고 해보자.
이 문제를 해결하기 위해선
사진 저장, 사진과 사용자의 연결, 사진 보여주기 와 같은 하위 문제를 해결해야한다.
우리는 이렇듯 상위 수준의 문제를 풀 때 여러 개의 하위 문제들로 나누며 이는 코드를 작성할 때도 마찬가지다.
코드를 구성하는 방법은 코드 품질의 기본적인 측면 중 하나이며, 코드를 잘 구성한다는 것은 간결한 추상화 계층을 만드는 것으로 귀결될 때가 많다.
이 장에서는 추상화 계층이 무엇을 의미하는지, 문제를 추상화 계층으로 나누는 방법, 나눠진 추상화 계층을 반영하도록 코드를 구성하는 방법에 대해 알아본다.
왜 추상화 계층을 만드는가?
사용자의 어떤 장치에서 실행되면서 서버에 메시지를 보내는 코드를 작성한다고 가정해보자.
다음 네 가지 간단한 개념만 다루면 된다.
- 서버의 URL
- 연결
- 메시지 문자열 보내기
- 연결 닫기
코드 또한 단순하다.
HttpConnection connection = HttpConnection.connect("http://example.com/server");
connection.send("Hello server");
connection.close();
상위 수준에서 이것은 꽤 간단한 문제처럼 보인다.
하지만 클라이언트 장치에서 서버로 'Hello server' 라는 문자열을 보내는데는 아래와 같은 엄청나게 복잡한 일이 일어난다.
- 전송할 수 있는 형식으로 문자열 직렬화
- HTTP 프로토콜의 모든 복잡한 동작
- TCP 연결
- 사용자의 장치가 와이파이 혹은 셀룰러 네트워크에 연결되어 있는지 여부 확인
- 데이터를 라디오 신호로 변조
- 데이터 전송 오류 및 수정
이렇게 서버에 메시지를 보내야한다는 상위 수준의 문제를 위해 해결해야할 하위 문제가 굉장히 많다.
이런 하위 문제들은 일련의 층을 이루어 해결되는데
최상위 수준에선 HTTP 프로토콜이 어떻게 구현되는지 알 필요 없이 서버에 메시지를 보내는 것에만 신경을 쓰면서 코드를 작성할 수 있다.
HTTP 프로토콜을 구현하는 엔지니어는 데이터가 무선 신호에 변조되는 방법에 대해 아무것도 몰라도 문제가 없었을 것이다.
HttpConnection 코드를 구현한 개발자는 물리적인 데이터 전송을 추상적인 개념으로 생각할 수 있었을 것이다.
이것을 추상화 계층(layers of abstraction)이라고 한다.
어떤 문제를 하위 문제로 계속해서 나누어 내려가면서 추상화 계층을 만든다면, 같은 층위 내에서는 쉽게 이해할 수 있는 몇 개의 개념만을 다루기 때문에 개별 코드는 특별히 복잡해 보이지 않을 것이다.
소프트웨어 엔지니어로서 문제를 해결할 때 이것이 목표가 되어야 한다.
깨끗하고 뚜렷한 추상화 계층을 구축하면 1장에서 살펴봤던 코드 품질의 네가지 핵심 요소를 달성할 수 있다.
가독성
모든 코드의 세부 사항을 이해하는 것은 불가능하지만 일부의 높은 계층의 추상화를 이해하고 사용하기는 쉽다.
즉 좋은 추상화 계층은 개발자가 한 번에 적은 계층과 개념만 다루면 된다는 것을 의미한다.
모듈화
HttpConnection의 예를 다시 보자.
사용자가 데이터를 전송할 때 와이파이를 사용하는 경우와 데이터를 사용하는 경우 각각의 모듈을 갈아끼워질 것이다.
그러므로 이런 상황 변화를 상위 수준 코드에선 고려할 필요가 없어진다.
재사용성 및 일반화성
HttpConnection의 예에서 TCP/IP 및 네트워크 연결을 처리하는 시스템의 대부분은 웹소켓과 같은 다른 유형의 연결에 필요한 하위 문제를 해결하는 데도 사용될 수 있다.
테스트 용이성
신뢰할 수 있는 코드를 작성하려면 하위 문제에 대한 해결책이 견고하게 작동하는지 확인해야한다.
코드가 추상화 계층으로 깨끗하게 분할되면 각 하위 문제에 대한 해결책을 완벽하게 테스트하는 것이 훨씬 쉬워진다.
이 책의 독자들이라면 API라는 말을 한 번쯤은 들어봤을 것이라고 생각한다.
API란 Application Programming Interface이다. 그렇다 API는 인터페이스이다.
그러므로 API는 서비스를 사용할 때 알아야 할 개념을 형식화하고, 서비스의 모든 구현 세부 사항은 뒤로 감춘다.
API는 호출하는 쪽에 공개할 개념만 정의하면 되고 그 이외의 모든 것은 구현 세부 사항이기 때문에 코드를 API의 관점에서 생각하면 추상화 계층을 명확하게 만드는 데 도움이 된다.
함수
함수는 한 가지 작업만 해야한다. 하지만 '한 가지 작업' 이라는 말은 정확하고 과학적이지 않다.
단일 업무라는 것이 해석하기 나름이고 다른 함수를 호출하더라도 여전히 if문이나 for문과 같은 약간의 제어 흐름이 필요하다.
이때 함수를 작성했다면 작성한 코드를 문장으로 만들어보면 좋다. 문장을 만들기 어렵거나 너무 어색하면 함수가 너무 길다는 것을 의미하고 더 작은 함수로 나누는 것이 유익할 것이다.
여러가지 작업을 하는 함수는 가독성을 해친다. 여러 일을 하는 수십 수백 줄의 함수를 쉽게 이해할 수 있겠는가?
커다란 함수에서 여러 하위 함수들을 분리해내는 것은 가독성뿐만 아니라 재사용성에서도 이점을 가져온다.
분리된 하위 함수들이 다른 곳에서도 사용될 수 있기 때문이다.
사실 이렇게 글을 적어 놓으면 너무나 당연한 것처럼 보인다. (이걸 모르는 사람이 있어? 라고 생각할지도)
책의 예시는 어렵진 않지만 그래도 항상 보던 너무 뻔한 예시는 아니다.
예시를 보면 작성한 코드를 문장으로 만들어보라는 말이 더욱 잘 느껴진다.
예시를 적어주고 싶지만 너무 길어서 생략한다. 예시는 책의 36, 37페이지이다.
클래스
클래스를 만드는데는 아래와 같은 이론과 경험 법칙들이 거론되곤 한다.
줄 수(number of lines)
클래스의 코드 라인은 300줄을 넘어가면 안된다는 규칙을 들어본 적이 있을 것이다. (없다면 유감..)
이 말은 클래스 줄 수가 무조건 300줄을 넘으면 안된다는 말은 아니다. (안드로이드 프레임워크에서 제공하는 클래스들은 300줄을 넘기 십상이다)
다만 혹시 300줄을 넘었다면 당신이 무언가를 잘못하고 있을 수도 있다는 경고정도로 생각하면 된다.
응집력(cohesion)
응집력은 한 클래스 내의 모든 요소들이 얼마나 잘 속해 있는지를 보여주는 척도이다.
어떤 것들이 어떻게 결속되어 있는지 분류할 수 있는 방식이 많이 있는데 두 가지 예를 보여주겠다.
- 순차적 응집력
커피 한 잔을 만들 때 원두를 갈고 커피를 추출해야한다. (원두를 갈지 않고 커피를 추출할 방법은 없다)
원두를 갈아내는 과정의 산출물은 커피를 추출하는 과정에 투입된다.
그러므로 우리는 갈고 추출하는 것 사이에 서로 응집력이 있다고 결론 지을 수 있다.
- 기능적 응집력
이것은 몇 가지 요소들이 모여서 하나의 일을 성취하는 데 기여할 때 발생한다.
하나의 일이에 대한 정의는 매우 주관적이지만 한가지 예를 들어보겠다.
케이크를 만들기 위해 필요한 모든 장비를 부엌의 전용 서랍에 보관하는 것이다.
반죽을 섞을 그릇, 나무 숟가락, 그리고 케이크 통은 케이크를 만들기 위해 모두 필요하며 함께 있어야한다.
관심사 분리
게임 콘솔은 TV에 동일한 제품으로 묶이지 않는다.
게임을 좋아하더라도 사는 집의 크기에 따라 원하는 TV의 크기가 달라질 것이다.
TV와 게임 콘솔이 분리되어있다면 어떤 TV를 살지를 고려할 때 게임 콘솔은 전혀 고려하지 않아도 된다.
위와 같은 것들을 고려하면서 우리는 한 클래스가 오직 하나의 일만 할 수 있도록 노력한다.
책에선 그렇지 못한 예시를 보여준다. (진짜 예시를 보여주고 싶지만 코드가 너무 길다)
여기가 이번 장을 읽으면서 가장 재밌는 부분이었다.
한 가지 일만 하지 않는 클래스에 대한 예시는 늘 뻔하고 너무 당연한 것들만 봐왔다.
하지만 이번 예시를 보았을 땐, "어? 나도 저러는 것 같은데..?" 하는 생각이 들었다.
(물론 누군가에겐 이것 또한 뻔한 예시일지도..)
한 가지 일만 하지 못하는 클래스를 고치면서 책은 계속 1장에서 배운 내용을 생각한다.
가독성, 모듈화, 재사용&일반화, 테스트 용이성
앞으로 클래스를 만들 때 너무 커진다 싶으면 저 네 가지를 꼭 생각해보자.
인터페이스
계층 사이를 뚜렷하게 구분하고 구현 세부 사항이 계층 사이에 유출되지 않도록 하기 위해 사용할 수 있는 한 가지 접근법은 어떤 함수를 외부로 노출할 것인지를 인터페이스를 통해 결정하는 것이다.
추상화 계층을 깔끔하게 구현하는 코드를 만드는 데 있어 인터페이스는 매우 유용한 도구다. 주어진 하위 문제에 대해 둘 이상의 서로 다른 구체적인 구현이 가능하고 이들 구현 클래스 사이에 전환이 필요할 때는 추상화 게층을 나타내는 인터페이스를 정의하는 것이 가장 좋다.
하지만 책에서는 하나의 문제에 대해 여러 구체적 구현이 존재하지 않는다. 한 가지 구현만 존재한다.
그럼 왜 인터페이스를 썼을까? (굉장히 흥미로운 부분이다)
퍼블릭 API를 매우 명확하게 보여준다.
이 계층에서 사용해야 하는 기능과 사용하지 말아야하는 기능에 대해 혼동할 일이 없다. 개발자가 구현 클래스에 새 퍼블릭 함수를 추가하더라도 상위 계층은 인터페이스에만 의존하기 때문에 해당 함수는 상위 계층에 노출되지 않는다.
한 가지 구현만 필요하다고 잘못 추측한 것일 수 있다.
원래 코드를 작성할 때는 또 다른 구현이 필요하지 않을 것이라고 확신하더라도 한두 달 후에는 이러한 가정이 잘못된 것으로 판명될 수 있다.
예를 들어 몇 달 뒤 현재의 구현이 효과적이지 못해 전혀 다른 방식으로 구현하게 될 수 있다. 이때 새로운 구현 클래스를 만들어 실험하기도, 변경하기도 편하다.
(왜? 인터페이스가 아니었다면 기존 클래스가 삭제된 순간 상위 계층에 수많은 부분에서 빨간 줄이 그어질 것이다. 하지만 인터페이스라면 의존성을 주입해주는 부분만 고쳐주면된다)
테스트를 쉽게 할 수 있다.
구현 클래스가 특별히 복잡하거나 네트워크에 의존하는 작업을 수행한다면 테스트 중에 목이나 페이크 객체로 대체할 수 있다. 만약 인터페이스가 아니라면 목 객체로 갈아 끼울 수 없다.
이 뿐만 아니라 랜덤 값을 활용한 로직이 있다고 해보자, 랜덤값을 생성하는 부분을 인터페이스로 분리해놓지 않으면 우린 랜덤값을 예측해서 테스트하는 무당이 되어야한다.
같은 클래스로 두 가지 하위 문제를 해결할 수 있다.
한 구현 클래스가 두 개 이상의 서로 다른 추상화 게층에 구현을 제공할 수도 있다.
예를 들어 LinkedList 구현 클래스는 List 및 Queue 인터페이스를 모두 구현한다. 즉, 어떤 상황에선 큐의 구현 클래스, 어떤 상황에선 리스트의 구현 클래스가 될 수 있는 것이다. 이로서 일반화의 가능성을 높일 수 있다.
물론 인터페이스에 장점만 있는 것은 아니다. 단점 또한 존재한다.
우선 더 많은 작업이 필요하다. 단순히 코드량이 증가한다는 말이다. 클래스 하나만 작성하면 될 것을 인터페이스까지 만들어야하기 때문이다.
다음으로 코드가 복잡해질 수 있다. 다른 개발자가 코드를 추상화된 계층으로 보지 않고 내부 구현을 보려고 할 때, 바로 구현 클래스로 이동하지 못한다.
실제로 개발할 때 이게 진짜 짜증나는 부분인데, 로직을 이해하기 위해 내부가 보고싶어 ctrl + 클릭으로 따라가면 구현 클래스가 나오는게 아니라 인터페이스가 나온다. 그럼 난 또 해당 인터페이스를 구현하는 클래스를 다시 찾아야한다.
(가끔 못 찾을 때도 있다.. 특히 언어나 프레임워크 내부 구현 볼 때;;;;)
하지만 책에선 단점에 비해 장점이 너무나도 크다고 말한다. (물론 단점이 있으니 의미없는 추상화는 지양하라고 한다)
그러므로 한 계층이 너무 많은 일을 하는 것이 너무 적은 일을 하는 것보다 더 위험하기 때문에, 나눠야할지 고민된다면 차라리 나누라고 말한다.
나름 재밌게 읽은 2장이었다. 1장은 좀 뻔한 내용들이었다.
물론 누군가는 2장도 뻔한 내용이라고 생각할 수 있다.
실제로 함수와 클래스가 하나의 작업, 일만 해야된다, 분리해야된다 이런 말들은 귀에 못이 박히게 듣던 말들이다.
하지만 나에겐 지금까지 봤던 글들 중 가장 예시가 와닿았던 내용이었다.
주제는 뻔했지만 예시가 마음에 들었다.
지금까지 본 글들은 잘못된 경우에 대한 예시가 대부분 웃음이 나오는 것들이었다면
이 책은 나름 어? 나도 저랬던 것 같은데 하는 부분이 있었다.
앞으로 추상화 계층을 분리할 때 네 가지를 항상 생각해야겠다. 너무 뻔한 주제들이지만 예시를 떠올리며..
- 가독성
- 모듈화
- 재사용성&일반화
- 테스트 용이성
'독서 > 좋은 코드, 나쁜 코드' 카테고리의 다른 글
1장 코드 품질 (0) | 2023.08.05 |
---|