후기
Chapter 1. 코드 품질
소프트웨어는 코드로 만들어진다.
코드가 의도한 대로 작동하고 기존의 기능이 여전히 잘 작동한다고 확신하기 위한 다양한 과정과 점검이 이루어진다.
이러한 과정을 종종 소프트웨어 개발 및 배포 프로세스라고 부른다.
1.
개발자가 코드베이스의 로컬 복사본을 가지고 작업하면서 코드를 변경한다
2.
작업이 끝나면 코드 검토를 위해 변경된 코드를 가지고 병합 요청을 한다
3.
다른 개발자가 코드를 검토하고 변경을 제안할 수 있다
4.
작성자와 검토자가 모두 동의하면 코드가 코드베이스에 병합된다
5.
배포는 코드베이스를 가지고 주기적으로 일어난다.
6.
테스트에 실패하거나 코드가 컴파일되지 않으면 코드베이스에 병합되는 것을 막거나 코드가 배포되는 것을 막는다.
고품질 코드는 일반적으로 좀 더 신뢰할 수 있고 유지보수가 쉬우며, 버그가 적은 소프트웨어를 생산한다.
코드 품질을 높이는 것에 관한 많은 원칙들은 소프트웨어가 처음에 만들어지는 방식을 보장하는 것뿐만 아니라.
이후에 요구 사항이 진화하고 새로운 상황이 등장하더라도 그 방식을 계속 유지할 수 있는 것에 관심을 둔다.
코드 품질을 높이기 위한 네 가지 목표가 존재한다.
1.
코드는 작동해야 한다.
우리가 코드를 작성하는 이유는 문제를 해결하기 위함이다. 코드는 우리가 해결하려고 하는 문제를 해결해야 한다.
이것은 또한 버그가 없다는 것을 의미하고 모든 요구 사항을 충족하는 것을 의미한다.
2.
코드는 작동이 멈추면 안 된다.
당장 돌아가는 코드를 만들기는 쉽지만, 변화하는 환경과 요구 사항에도 불구하고 계속 작동하는 코드를 만드는 것은 훨씬 더 어렵다.
코드가 계속 작동하도록 보장하는 것은 소프트웨어 엔지니어가 직면하는 큰 과제 중 하나이며, 코딩의 모든 단계에서 고려해야 할 사항이다.
3.
코드는 변경된 요구 사항에 적응할 수 있어야 한다.
코드나 소프트웨어가 시간이 지남에 따라 어떻게 변할지 완벽하고 정확하게 예측하는 것은 불가능하다.
하지만 어떻게 변할지 정확히 알지 못한다고 해서 변한다는 사실 자체를 완전히 무시해야 하는 것은 아니다.
4.
코드는 이미 존재하는 기능을 중복 구현해서는 안 된다.
이미 있는 코드는 다시 작성하지 않는다는 개념은 양방향으로 적용된다.
어떤 하위 수준의 문제를 해결하기 위해 다른 개발자가 이미 코드를 작성했다면, 그것을 해결하기 위해 자신의 코드를 작성하기 보다는 그들의 코드를 사용해야 한다.
마찬가지로 어떤 하위 문제를 해결하기 위해 자신이 이미 코드를 작성했다면, 다른 개발자들이 동일한 문제를 해결하기 위해 자신만의 코드를 다시 작성하지 않도록 쉽게 재사용할 수 있는 방식으로 코드를 구성해야 한다.
위의 코드 품질을 높이기 위한 네 가지 목표에 부합하기 위한 코드 품질의 여섯 가지 핵심 요소는 다음과 같다.
1.
코드는 읽기 쉬워야 한다.
코드를 작성하고 난 후 어느 시점이 되면 다른 개발자가 그 코드를 읽고 이해해야 하는 상황이 반드시 온다.
코드의 가독성이 떨어진다면, 다른 개발자가 그 코드를 이해하는 데 많은 시간을 들여야 한다.
또한, 코드의 기능에 대해 잘못 이해하거나 몇 가지 중요한 세부 사항을 놓칠 가능성 역시 크다.
2.
코드는 예측 가능해야 한다.
도움이 되거나 똑똑한 일을 수행하는 코드를 작성할 때, 아무리 좋은 의도를 가지고 잇더라도 예상을 벗어난 동작을 수행하는 위험이 있을 수 있다.
코드가 예상에서 벗어나는 일을 한다면, 그 코드를 사용하는 개발자는 그 상황을 알지 못하거나 그 상황에 대처할 생각을 하지 못할 것이다.
이로 인해 문제의 코드와는 전혀 상관없어 보이는 부분에서 명백하게 이상한 일이 발견되기 전까지 시스템은 계속 비정상적으로 작동한다.
3.
코드를 오용하기 어렵게 만들라.
자신이 작성하는 코드는 종종 다른 코드에 의해 호출되는데, 그 코드가 잘못 사용된다면 중요한 일이 수행되지 않거나 이상하게 동작하지만 눈에 띄지 않는다는 것을 의미할 수도 있다.
코드를 오용하기 어렵거나 불가능하게 하면 코드가 작동할 뿐만 아니라 계속해서 잘 작동할 가능성을 극대화할 수 있다.
4.
코드를 모듈화하라.
코드를 외부에 의존하지 않고 실행할 수 있는 모듈로 나누는 것은 변화하는 요구 사항에 더 쉽게 적응할 수 있는 코드를 작성하는데 도움이 된다.
모듈화된 시스템은 일반적으로 이해하기 쉽고 추론하기 쉬운데, 기능이 관리 가능한 단위로 나누어지고 기능 단위 간 상호작용이 잘 정의되고 문서화되기 때문이다.
코드가 모듈화되어 작성되면 처음에 작동이 시작되고 그 후에도 계속해서 잘 작동할 가능성이 커진다.
5.
코드를 재사용 가능하고 일반화할 수 있게 작성하라.
코드가 재사용할 수 있고 일반화되어 있으면 다른 사람들은 그 코드를 코드베이스의 여러 부분에서, 그리고 하나 이상의 상황에서 사용할 수 있고, 여러 가지 문제를 해결할 수 있다.
이런 코드는 시간과 노력을 절약해주고 더 신뢰할 수 있는데, 그 이유는 실제 서비스 환경에서 이미 시도되고 테스트된 논리를 재사용하기 때문이다.
즉, 어떤 버그라 할지라도 이미 발견되고 해결되었을 가능성이 크다는 것을 의미한다.
6.
테스트가 용이한 코드를 작성하고, 제대로 테스트하라.
이 항목은 제목에 ‘테스트가 용이한 코드를 작성하라’와 ‘제대로 테스트하라’라는 두 가지 중요한 개념이 포함되어 있다.
테스트로 코드의 기능을 견고하게 만들지 않으면 복잡해진 코드베이스에서 실수하는 문제가 발생할 수 있다.
테스트 용이성은 테스트 대상이 되는 코드를 가리키며 해당 코드가 얼마나 테스트하기 적합한지를 나타내는데 코드의 테스트 용이성이 낮으면 제대로 테스트하는 것이 불가능할 수 있다.
이러한 핵심 요소들을 다 갖춰 네 가지 목표를 이룬 고품질 코드의 작성은 일정을 지연시키는가?
이 질문에 대한 답은 단기적으로는 고품질 코드를 작성하는 데 시간이 더 걸릴 수 있다는 것이다.
하지만 한번 사용하고 버릴 사소한 유틸리티성 프로그램이 아닌 좀 더 중요한 소프트웨어를 개발하고 있다면, 일반적으로 고품질 코드를 작성하는 것이 중장기적으로는 개발 시간을 단축해준다.
Chapter 2. 추상화 계층
코드 작성의 목적은 문제 해결이다.
문제를 어떻게 해결하는가도 중요하지만 그것들을 해결하는 코드를 어떻게 구성하는가도 중요하다.
코드를 구성하는 방법은 코드 품질의 기본적인 측면 중 하나이며, 코드를 잘 구성한다는 것은 간결한 추상화 계층을 만드는 것으로 귀결될 때가 많다.
코드 작성은 복잡한 문제를 계속해서 더 작은 하위 문제로 세분화하는 작업이다.
어떤 문제를 하위 문제로 계속해서 나누어 내려가면서 추상화 계층을 만든다면, 같은 층위 내에서는 쉽게 이해할 수 있는 몇 개의 개념만을 다루기 때문에 개별 코드는 특별히 복잡해 보이지 않을 것이다.
깨끗하고 뚜렷한 추상화 계층을 구축하면 코드 품질의 네 가지 목표를 달성할 수 있다.
실제로 추상화 계층을 생성하는 방법은 코드를 서로 다른 단위로 분할하여 단위 간의 의존 관계를 보여주는 의존성 그래프를 생성하는 것이다.
추상화 계층을 명확하게 구분하기 위해 코드를 작성하며 고려해야 할 측면이 두 가지 있다.
1.
코드를 호출할 때 볼 수 있는 내용
a.
퍼블릭 클래스, 인터페이스 및 함수(메서드)
b.
이름, 입력 매개변수 및 반환 유형이 표현하고자 하는 개념
c.
코드 호출 시 코드를 올바르게 사용하기 위해 알아야 하는 추가 정보
2.
코드를 호출할 때 볼 수 없는 내용
a.
구현 세부 사항
대부분의 프로그래밍 언어는 코드를 다른 단위로 나누기 위해 함수, 클래스, 인터페이스, 패키지의 언어 요소를 자유롭게 사용할 수 있다.
•
함수
어떤 로직을 새로운 함수로 구현하면 대부분 유익하다.
각 함수에 포함된 코드가 하나의 잘 써진 짧은 문장처럼 읽히면 이상적이다.
함수를 작게 만들고 수행하는 작업을 명확하게 하면 코드의 가독성과 재사용성이 높아진다.
•
클래스
여러 개발자들은 단일 클래스의 이상적인 크기의 기준으로 줄 수, 응집력, 관심사의 분리를 제시한다.
대부분 300줄보다 긴 클래스는 너무 많은 개념을 다루므로 분리해야 한다고 경고한다.
응집력과 관심사의 분리에 대해 생각할 때는 서로 관련된 여러 가지 사항을 하나의 사항으로 간주하는 것을 어느 수준에서 해야 유용할지 결정해야 한다.
너무 많은 일을 하는 거대한 클래스를 만들면 코드 품질의 저하로 이어질 때가 많다.
그러므로 클래스 구조를 설계할 때 코드 품질의 네 가지 목표를 충족하는지 신중하게 생각하면 좋다.
•
인터페이스
계층 사이를 뚜렷이 구분하고 구현 세부 사항이 계층 사이에 유출되지 않도록 하기 위해 사용할 수 있는 한 가지 접근법은 어떤 함수를 외부로 노출할 것인지를 인터페이스를 통해 결정하는 것이다.
하나의 추상화 계층에 대해 두 가지 이상의 다른 방식으로 구현을 하거나 향후 다르게 구현할 것으로 예상되는 경우 인터페이스를 정의하는 것이 좋다.
주어진 추상화 계층에 대해 한 가지 구현만 있고 향후에 다른 구현을 추가할 계획이 없더라도 여전히 인터페이스를 통해 추상화 계층을 표현해야 하는가는 아래와 같은 장,단점이 존재한다.
장점
1.
퍼블릭 API를 매우 명확하게 보여준다.
2.
한 가지 구현만 필요하다는 가정이 틀렸을 때 대응하기 쉽다.
3.
mock 객체나 페이크 객체를 통해 테스트를 쉽게 할 수 있다.
4.
같은 클래스가 두 개 이상의 서로 다른 추상화 계층에 구현을 제공할 수 있다.
단점
1.
인터페이스를 정의하려면 새로운 파일, 더 많은 코드를 작성해야 한다.
2.
다른 개발자가 코드를 이해하고 논리적으로 탐색하기 어려워질 수 있다.
추상화 계층을 너무 많이 생성하여 층이 너무 얇아진다면 장점도 많지만 비용도 늘어나게 된다.
어떤 것이 더 나을지 확실하지 않다면, 너무 많은 계층을 남용하는 결과를 가져오더라도 계층을 여러 개로 나누는 것이 한 계층 안에 모든 코드를 집어넣는 것보다는 낫다.
그렇다면 마이크로서비스는 어떤가?
마이크로서비스는 일반적으로 꽤 간결한 추상화 계층을 제공하는 것이 사실이지만, 대개 크기와 범위를 기준으로 마이크로서비스를 나누기 때문에 여전히 그 내부에서 적절한 추상화 계층을 고려하는 것이 유용하다.
올바른 추상화 및 코드 계층을 만드는 것은 여전히 중요하다.
Chapter 3. 다른 개발자와 코드 계약
자신이 작성한 코드를 다른 개발자가 작업해야 하고, 반대로 다른 개발자가 작업한 코드를 자신이 작업해야 할 때도 있다.
고품질 코드를 작성할 때 가장 중요한 고려 사항 중 하나는 다른 개발자가 변경하거나 코드와 상호작용할 때 발생할 수 있는 문제는 없는지, 또 발생한다면 그 문제를 어떻게 완화할 수 있는지를 이해하고 선제적으로 조치하는 것이다.
코드를 작성할 때 다음 세 가지를 고려하는 것이 유용하다.
자신에게 분명하다고 해서 다른 사람에게도 분명한 것은 아니다.
자신의 로직에 너무 익숙해서 모든것이 분명해 보이기 때문에 어떤 것이 왜 그런 방식인지, 문제를 왜 그 방식으로 해결하고 있는지에 대해서는 거의 생각하지 않아도 될 것이다.
하지만 어느 시점에 이르면 다른 개발자가 여러분이 작성한 코드와 상호작용하거나, 여러분의 코드를 변경하거나, 여러분의 코드가 의존하고 있는 코드를 변경해야 할 수도 있다는 것을 기억해야 한다.
이것을 항상 고려하고 코드가 어떻게 사용되어야 하는지, 무엇을 하는지, 그리고 왜 그 일을 하고 있는지를 설명하는 것이 유용하다.
다른 개발자는 무의식중에 여러분의 코드를 망가뜨릴 수 있다.
여러분이 작성한 코드는 다른 코드로부터 전혀 영향을 받지 않은 채 독립적으로 있는 것이 아니라, 끊임없이 변화하는 코드 위에 놓여 있고, 여러분의 코드를 기반으로 계속해서 변화하는 코드 역시 끊임없이 작성된다.
다른 개발자의 코드 변경으로 인해 여러분 자신의 코드가 작동하지 않거나 오용하는 결과를 가져온다면, 무언가 문제가 있을 때 코드 컴파일이 중지되거나 테스트가 실패하도록 만들어서 이 문제가 해결되기 전까진 코드베이스에 병합되면 안 된다.
시간이 지나면 자신의 코드를 기억하지 못한다.
자신에게는 분명한데 다른 사람에게는 분명하지 않을 수 있다는 것, 혹은 다른 사람들이 무의식중에 자신의 코드를 작동하지 않게 만드는 것과 관련해 살펴본 모든 내용이 어느 순간에 자신에게 적용된다.
배경지식이 거의 없거나 전혀 없는 사람에게도 자신의 코드가 이해하기 쉬어야 하고, 잘 작동하던 코드에 버그가 발생하는 것이 어려워야 한다.
이렇게 하는 것은 다른 사람에게 호의를 베푸는 것이기도 하지만 미래의 자신에게도 유익한 일이다.
다른 개발자가 여러분의 코드를 사용하거나 여러분의 코드에 의존하는 코드를 수정할 때, 그들은 여러분의 코드를 어떻게 사용해야 하는지 그 코드가 무슨 일을 하는지 파악해야 한다.
여러분이 작성한 코드를 어떻게 사용해야 하는지 알아내기 위해 다른 개발자가 할 수 있는 일은 다음과 같다.
함수, 클래스, 열거형 등의 이름을 살펴본다.
이름을 살펴보는 것은 개발자들이 새로운 코드의 사용 방법을 알아내기 위해 실제로 사용하는 주된 방법 중 하나다.
패키지, 클래스, 함수의 이름을 책의 목차라고 생각할 수 있다.
이 이름들을 살펴보는 것은 하위 문제를 해결할 코드를 찾기 위한 편리하고 빠른 방법이다.
함수와 생성자의 매개변수 유형 또는 반환값의 유형 같은 데이터 유형을 살펴본다.
컴파일이 필요한 정적 유형의 언어에서는 데이터 유형을 인식하고 올바르게 사용해야 한다. 그렇지 않으면 코드가 컴파일되지 않는다.
따라서 유형 시스템을 사용하는 언어로 코드를 작성하는 것은 다른 개발자가 코드를 오용하거나 오작동할 수 없도록 하기 위한 좋은 방법 중 하나다.
함수/클래스 수준의 문서나 주석문을 읽어본다.
자바독과 같은 공식적인 코드 내 문서, 함수 및 클래스 수준의 비공식적인 주석문, README.md와 같은 외부 문서들은 모두 매우 유용하지만 어느정도까지만 신뢰할 수 있다.
다른 개발자가 이 문서들을 읽을 것이라는 보장이 없으며 실제로 읽지 않을 때가 많고
설령 읽더라도 잘못 해석할 수 있으며 문서의 업데이트가 제대로 안 될 수 있기 때문이다.
직접 와서 묻거나 채팅/이메일을 통해 문의한다.
이 접근법 또한 상당히 효과적일 수 있지만 코드의 사용법을 설명하기에는 신뢰하기 어려운 방법이다.
코드를 많이 작성할수록 답하는 데 더 많은 시간을 써야하며 코드 작성자가 휴가를 가거나 회사를 떠나면 코드에 대해 물어볼 사람이 없다.
또한 1년이 지나면 자기 자신도 그 코드를 기억하지 못하기 때문에 어느 정도 제한된 기간 동안만 효과가 있다.
여러분이 작성한 함수와 클래스의 자세한 구현 코드를 읽는다.
코드 사용 방법에 대한 가장 확실한 답을 얻을 수 있는 방법은 코드의 자세한 구현 세부 사항을 살펴보는 것이다.
하지만 이 접근법은 실용적이지도 않고 코드의 양이 많으면 효과를 얻기 힘들다.
코드 계약은 다른 사람들이 어떻게 코드를 사용할지, 그리고 코드가 무엇을 할 것으로 기대할 수 있는지에 대한 원칙이다.
코드를 호출하는 사람에게 무언가를 설정하거나 입력(선결 조건)을 제공해야 할 요건을 부여하고, 호출 결과 일어날 일 혹은 반환될 값(사후 조건)에 대한 기대를 갖게 한다.
코드의 계약을 정의할 때 명확한 부분과 세부 조항이 존재한다.
•
계약의 명확한 부분
◦
함수와 클래스 이름 : 호출하는 쪽에서 이것을 모르면 코드를 사용할 수 없다.
◦
인자 유형 : 호출하는 쪽에서 인자의 유형을 잘못 사용하면 코드는 컴파일조차 되지 않는다.
◦
반환 유형 : 호출하는 쪽에서 함수의 반환 유형을 알아야 한다.
◦
검사 예외 : 호출하는 코드가 이것을 처리하지 않으면 코드는 컴파일되지 않는다.
•
세부 조항
◦
주석문과 문서
◦
비검사 예외
주석문과 문서의 형태로 된 세부 조항은 간과하고 넘어갈 때가 많기 때문에 다른 개발자들이 해당 코드를 사용할 때 모든 세부 조항을 다 알지 못할 가능성이 크다.
따라서 세부 조항을 사용하는 것은 신뢰할 만한 방법이 아니고 세부 조항에 너무 많이 의존하면 오용하기 쉬운 취약한 코드가 될 가능성이 크다.
다른 개발자가 코드를 올바르게 사용하기 위해 세부 조항에 의존하기보다 잘못된 일을 하는 것을 처음부터 불가능하게 만드는 것이 좋다.
코드가 어떤 상태에 들어갈 수 있는지 혹은 입력이나 반환으로 어떤 데이터 유형을 취할 수 있는지 신중하게 생각해보면 이렇게 변경하는 것이 가능할 때가 있다.
코드가 오용되거나 잘못 설정되면 컴파일조차 되지 않도록 하는 것이 목표다.
컴파일러를 사용하여 코드 계약을 확인하는 것에 대한 대안으로 런타임 검사를 사용할 수 있다.
체크
코드 계약 조건을 확인하기 위한 일반적인 방법은 체크를 사용하는 것이다.
체크는 전제 조건 검사나 사후 상태 검사가 있으며 코드 계약이 준수되었는지 확인하기 위한 추가적인 로직이다.
가능하다면 처음부터 세부 조항은 피하는 것이 바람직하며 코드에 체크가 많이 있으면 세부 조항을 없애는 것에 대해 고려해봐야 한다.
어서션
어서션은 코드 계약을 준수하도록 강제하기 위한 방법이라는 점에서 체크와 매우 유사하다.
코드가 개발 모드에서 컴파일 되거나 테스트가 실행될 때, 어서션은 체크와 거의 같은 방식으로 동작한다.
조건이 위반되면 오류가 명백하게 보이거나 예외가 발생한다.
어서션과 체크 사이의 주요 차이점은 배포를 위해 빌드할 때 어서션은 보통 컴파일에서 제외된다는 점이다.
Chapter 4. 오류
코드 실행되는 환경은 불완전하다.
모든 것이 잘못될 수 있고 잘못될 것이기 때문에 오류 사례를 신중하게 생각하지 않고는 견고하고 신뢰성 높은 코드를 작성할 수 없다.
오류에 대해 생각할 때 소프트웨어가 작동을 계속할 수 있는 오류와 작동을 계속할 합리적인 방법이 없는 오류로 구분하는 것이 유용할 때가 많다.
복구 가능성
복구 가능한 오류
일반적으로 시스템 외부의 무언가에 의해 야기되는 오류에 대해서는 대부분 시스템 전체가 표나지 않고 적절하게 처리하기 위해 노력해야 한다.
낮은 층위의 코드에서 오류를 시도하고 복구하는 것은 장점이 별로 없고, 오류 처리 방법을 알고 있는 더 높은 층위의 코드로 오류를 전송해야 하는 경우가 많다.
복구할 수 없는 오류
오류를 복구할 수 있는 방법이 없다면, 유일하게 코드가 할 수 있는 합리적인 방법은 피해를 최소화하고 개발자가 문제를 발견하고 해결할 가능성을 최대화하는 것이다.
이후 살펴볼 신속한 실패라는 개념과 요란한 실패라는 개념은 바로 이 대한 것이다.
호출하는 쪽에서만 오류 복구 가능 여부를 알 때가 많다
대부분의 오류는 한 코드가 다른 코드를 호출할 때 발생하고 코드는 종종 재사용되고 여러 곳에서 호출된다.
간결한 추상화 계층을 만들고자 한다면 일반적으로 코드의 잠재적 호출자에 대한 가정을 가능한 한 하지 않는 것이 좋다.
함수를 작성하거나 수정하는 시점에 오류로부터 복구할 수 있는지 혹은 복구해야 하는지 여부를 항상 알 수 있는 것은 아니기 때문이다.
자신의 코드가 어떻게 사용되어야 하는지에 대해 스스로에게는 명백해 보일 수 있을지라도 다른 사람들에게는 분명하지 않을 수도 있다는 점을 이해해야 한다.
호출하는 쪽에서 오류로부터 복구하기를 원할 것이라고 판단하는 것은 좋은 일이지만, 오류가 발생할 수 있다는 것조차 인식하지 못한다면 그것을 제대로 처리하지 못할 것이다.
호출하는 쪽에서 복구하고자 하는 오류에 대해 인지하도록 하라
다른 코드가 자신의 코드를 호출할 경우, 호출 시 오류가 발생한다는 것을 사전에 알 수 있는 실질적인 방법이 없는 경우가 많다.
따라서 함수 작성자는 이 함수에서 오류가 발생할 수 있다는 가능성을 호출하는 쪽에서 확실하게 인지하도록 해야 한다.
그렇지 않으면 이 함수를 호출하는 개발자가 오류를 처리하는 코드를 작성하지 않은 상태에서 오류가 발생하는 경우 개발자의 예상과는 다른 결과를 초래할 수 있다.
견고성 vs 실패
오류가 발생할 때, 다음 중 하나를 선택해야 한다.
•
실패, 더 높은 코드 계층이 오류를 처리하게 하거나 전체 프로그램의 작동을 멈추게 한다.
•
오류를 처리하고 계속 진행한다.
오류가 있더라도 처리하고 계속 진행하면 더 견고한 코드라고 볼 수 있지만, 오류가 감지되지 않고 이상한 일이 발생하기 시작한다는 의미도 될 수 있다.
신속하게 실패하라
신속하게 실패하기는 가능한 한 문제의 실제 발생 지점으로부터 가까운 곳에서 오류를 나타내는 것이다.
복구할 수 있는 오류의 경우 호출하는 쪽에서 오류로부터 훌륭하고 안전하게 복구할 수 있는 기회를 최대한으로 제공하고, 복구할 수 없는 오류의 경우 개발자가 문제를 신속하게 파악하고 해결할 수 있는 기회를 최대한 제공한다.
두 경우 모두 소프트웨어가 의도치 않게 잠재적으로 위험한 상태가 되는 것을 방지한다.
요란하게 실패하라
요란한 실패는 간단히 말하자면 오류가 발생하는데도 불구하고 아무도 모르는 상황을 막고자 하는 것이다.
이를 위한 가장 명백한 방법은 예외(또는 이와 유사한 것)를 발생해 프로그램이 중단되게 하는 것이다.
다른 방법은 오류 메시지를 기록하는 것인데 개발자가 얼마나 부지런하게 로그를 확인하는지, 혹은 로그에 방해되는 다른 메시지가 얼마나 있는지에 따라 오류 메시지가 무시될 수도 있다.
코드가 실패할 때 신속하고, 요란하게 오류를 나타내면 개발 도중이나 테스트하는 동안에 버그가 발견될 가능성이 크다.
그렇지 않더라도 배포된 후에 오류 보고를 보기 시작할 것이고 보고 내용으로부터 버그가 발생한 위치를 정확히 알 수 있는 이점이 있다.
복구 가능성의 범위
일반적으로 소프트웨어를 견고하게 작성하는 것이 좋다.
한 번의 잘못된 요청으로 인해 전체 서버의 동작이 멈추는 것은 바람직하지 않다.
그러나 오류를 알아차리지 못한 채 시스템이 계속 동작하지 않도록 하는 것 또한 중요하기 때문에 코드가 요란하게 실패해야 한다.
이 두 목표는 양립하지 못할 때가 많다.
가장 요란스럽게 실패하는 것은 프로그램이 멈추도록 하는 것이지만, 이것은 분명 소프트웨어를 견고하지 못하게 만든다.
이에 대한 해결책은 프로그래밍 오류가 발견되면 개발자가 이를 알아차릴 수 있도록 프로그래밍 오류를 기록하고 모니터링하는 것이다.
오류를 숨기지 않음
오류를 숨기는 것은 복구할 수 있는 오류와 복구할 수 없는 오류 모두에 문제를 일으킨다.
호출하는 쪽에서 복구하고자 할 수도 있는 오류를 숨기면, 호출하는 쪽에서 오류로부터 복구할 수 있는 기회를 없애는 것이다.
정확하고 의미 있는 오류 메시지를 표시하거나 다른 동작을 하는 대신, 잘못된 일이 일어날 수 있음을 전혀 알지 못하게 되고, 이것은 곧 소프트웨어가 의도한 대로 작동하지 않을 가능성이 크다는 것을 의미한다.
복구할 수 없는 오류를 숨기면 프로그래밍 오류가 감춰진다.
앞에서 신속하게 실패하는 것과 요란하게 실패하는 것에 대해 살펴봤듯이, 이러한 오류들은 개발팀이 알아야만 고칠 수 있다.
그것들은 숨긴다는 것은 개발팀이 그 오류에 대해 전혀 알지 못할 수도 있다는 것을 의미하며, 버그는 꽤 오랫동안 알아차리지 못한 채 그대로 남아 있을 수 있다.
이 두 경우 모두 에러가 발생하면 일반적으로 호출하는 쪽에서 예측한 대로 코드가 실행되지 않는다는 것을 의미한다.
오류를 감추면 호출하는 쪽에서는 모든 것이 잘 작동하고 있다고 가정하지만 실제로 코드는 제대로 동작을 못할 것이다.
잘못된 정보를 출력하거나 일부 데이터를 손상시키거나 마침내 작동이 멈출 수 있다.
기본값 반환
오류가 발생하고 함수가 원하는 값을 반환할 수 없는 경우 기본값을 반환하는 것이 간단하고 쉬운 해결책처럼 보일 때가 있다.
코드에 기본값을 두는 것이 유용한 경우가 있을 수 있지만, 오류를 처리할 때는 대부분의 경우 적합하지 않다.
잘못된 데이터로 시스템이 제대로 작동하지 못하게 만들고 오류가 나중에 이상한 방식으로 나타날 수 있기 때문에 신속한 실패와 요란한 실패의 원리를 위반하는 것이다.
널 객체 패턴
널 객체는 개념적으로 기본값과 유사하지만 이것을 더 확장해서 더 복잡한 객체(클래스 등)를 다룬다.
널 객체는 실제 반환값처럼 보이지만 모든 멤버 함수는 아무것도 하지 않거나 의미 없는 기본값을 반환한다.
디자인 패턴에 관한 한 이것은 양날의 검이다.
널 객체 패턴을 사용하는 것이 꽤 유용한 경우가 있지만, 오류 처리에 사용되는 것은 바람직하지 않다.
아무것도 하지 않음
코드가 무언가를 반환하지 않고 단지 어떤 작업을 수행하는 경우, 문제가 발생할 때 가능한 한 가지 옵션은 오류가 발생했다는 신호를 보내지 않는 것이다.
호출하는 쪽에서는 코드에서 작업이 의도대로 완료되었다고 가정하기 때문에 일반적으로 이렇게 하는 것은 바람직하지 않다.
오류가 발생할 때 이 오류를 기록하면 약간 개선된 것이긴 하지만 여전히 바람직하지 않은 코드다.
오류 전달 방법
오류가 발생하면 일반적으로 더 높은 계층으로 오류를 알려야 한다.
오류로부터 복구할 수 없는 경우 이는 일반적으로 프로그램의 훨씬 더 높은 계층에서 실행을 중지하고, 오류를 기록하거나 전체 프로그램의 실행을 종료하는 것을 의미한다.
오류로부터의 복구가 잠재적으로 가능한 경우, 일반적으로 즉시 호출하는 쪽(또는 호출 체인에서 한두 수준 위의 호출자)에 오류를 알려 정상적으로 처리할 수 있도록 해야 한다.
오류를 알리는 방법은 크게 두 가지 종류로 나뉜다.
•
명시적 방법
코드를 직접 호출한 쪽에서 오류가 발생할 수 있음을 인지할 수 밖에 없도록 한다.
그것을 처리하든, 이전 호출자에게 전달하든, 아니면 그냥 무시하든 간에 어떻게 처리할지는 호출하는 쪽에 달려 있다.
하지만 무엇을 하든 그것은 적극적인 선택의 결과다.
오류가 발생할 가능성이 코드 계약의 명확한 부분에 나타나 있기 때문에 오류를 모르고 넘어갈 수 있는 방법은 거의 없다.
•
암시적 방법
코드를 호출하는 쪽에 오류를 알리지만, 호출하는 쪽에서 그 오류를 신경 쓰지 않아도 된다.
오류가 발생할 수 있음을 알기 위해서는 문서나 코드를 읽는 등의 적극적인 노력이 필요하다.
문서에 오류가 언급되어 있다면 코드 계약의 숨겨진 세부 조항이다.
가끔 오류가 여기서조차 언급되지 않을 때도 있는데, 이 경우엔 오류가 계약 내용에 전혀 없는 것이 된다.
명시적 오류 전달 기법 | 암시적 오류 전달 기법 | |
코드 계약에서의 위치 | 명확한 부분 | 세부 조항 혹은 아예 없음 |
호출하는 쪽에서 오류 발생 가능성에 대해 아는가? | 그렇다. | 알 수도 있고 모를 수도 있다. |
기법의 예 | 검사 예외
널 반환 유형 (널 안전성의 경우)
옵셔널 반환 유형
리절트 반환 유형
아웃컴 반환 유형 (반환값 확인이 필수인 경우)
스위프트 오류 | 비검사 예외
매직값 반환 (피해야 한다)
프로미스 또는 퓨처
어서션
체크 (구현에 따라 달라짐)
패닉 |
요약: 예외
많은 프로그래밍 언어들은 예외라는 개념을 가지고 있다.
이것은 코드에서 오류나 예외적인 상황이 발생한 경우 이를 전달하기 위한 방법으로 고안되었다.
예외가 발생할 때 콜 스택을 거슬러 올라가는데 예외를 처리하는 코드를 만나거나, 더 이상 올라갈 콜 스택이 없을 때까지 그렇게 한다.
더 이상 올라갈 콜 스택이 없는 경우에는 오류 메시지를 출력하고 프로그램이 종료된다.
자바는 검사 예외와 비검사 예외의 개념을 모두 가지고 있다.
예외를 지원하는 대부분의 주요 언어는 비검사 예외만 가지고 있으므로 자바 이외의 거의 모든 언어에서 예외라는 용어는 일반적으로 비검사 예외를 의미한다.
명시적 방법: 검사 예외
컴파일러는 검사 예외에 대해 호출하는 쪽에서 예외를 인지하도록 강제적으로 조치하는데, 호출하는쪽에서는 예외 처리를 위한 코드를 작성하거나 자신의 함수 시그니처에 해당 예외 발생을 선언해야한다.
함수가 예외를 처리하지도 않고, 자신의 함수 시그니처에 선언하지도 않으면 코드는 컴파일되지 않는다.
따라서 검사 예외를 사용하는 것은 오류를 전달하기 위한 명시적인 방법이다.
암시적 방법: 비검사 예외
비검사 예외를 사용하면 다른 개발자들은 코드가 이 예외를 발생시킬 수 있다는 사실을 전혀 모를 수 있다.
이 경우에는 함수에서 어떤 예외를 발생시키는지 문서화하는 것이 바람직하지만 개발자가 문서화하는 것을 잊어버릴 때가 있다.
설사 문서화를 하더라도 이것은 코드 계약의 세부 조항이다.
앞에서 살펴본 것처럼 세부 조항은 코드 계약 내용을 전달하는데 있어 신뢰할 만한 방법이 아닐 때가 많다.
따라서 비검사 예외는 오류가 발생할 수 있다는 것을 호출하는 쪽에서 인지하리라는 보장이 없기 때문에 오류를 암시적으로 알리는 방법이다.
명시적 방법: 널값이 가능한 반환 유형
함수에서 널값을 반환하는 것은 특정값을 계산하거나 얻는 것이 불가능함을 나타내기 위한 효과적이고 간단한 방법이다.
사용 중인 언어가 널 안전성을 지원하는 경우 널값이 반환될 수 있다는 것을 호출하는 쪽에서 강제적으로 인지하고, 그에 따라 처리할 수 밖에 없다.
따라서 (널 안전성을 지원할 때) 널 값이 가능한 반환 유형을 사용하는 것은 오류를 전달하기 위한 명시적인 방법이다.
널 안전성을 지원하지 않는 언어를 사용하는 경우 옵셔널 반환 유형을 사용하는 것이 좋다.
명시적 방법: 리절트 반환 유형
널값이나 옵셔널 타입을 반환할 때의 문제 중 하나는 오류 정보를 전달할 수 없다는 것이다.
호출자에게 값을 얻을 수 없음을 알릴 뿐만 아니라 값을 얻을 수 없는 이유까지 알려주면 유용하다.
이러한 경우에는 리절트 유형을 사용하는 것이 적절할 수 있다.
언어가 리절트 유형을 지원하거나 혹은 (자신만의 리절트 유형을 정의할 때) 다른 개발자들이 그 유형에 익숙하다고 가정하면, 리절트 유형을 반환 유형으로 사용하는 것은 오류가 발생할 수 있다는 점을 분명히 하는 것이 된다.
따라서 리절트 반환 유형을 사용하는 것은 오류를 알리는 명시적인 방법이다.
명시적 방법: 아웃컴 반환 유형
어떤 함수들은 값을 반환하기보다는 단지 무언가를 수행하고 값을 반환하지는 않는다.
어떤 일을 하는 동안 오류가 발생할 수 있고 그것을 호출한 쪽에 알리고자 한다면, 함수가 수행한 동작의 결과를 나타내는 값을 반환하도록 함수를 수정하는 것이 한 가지 방법이 될 수 있다.
아웃컴 반환 유형을 반환할 때 호출하는 쪽에서 반환값을 강제적으로 확인해야 한다면 이것은 오류를 알리는 명백한 방법이다.
암시적 방법: 프로미스 또는 퓨처
비동기적으로 실행하는 코드를 작성할 때 프로미스나 퓨처(혹은 이와 동등한 개념)를 반환하는 함수를 작성하는 것이 일반적이다.
많은 언어에서 프로미스나 퓨처는 오류 상태도 전달할 수 있다.
프로미스나 퓨처를 사용할 때 일반적으로 오류 처리를 강제로 해야 하는 것은 아니고, 해당 함수에 대한 코드 계약의 세부 조항을 잘 알지 못하면 오류 처리 코드를 추가로 작성해야 한다는 것을 모를 수 있다.
따라서 프로미스나 퓨처를 사용한 오류 전달은 암시적인 방법이다.
암시적 방법: 매직값 반환
매직값(또는 오류 코드)은 함수의 정상적인 반환 유형에 적합하지만 특별한 의미를 부여하는 값이다.
매직값이 반환될 수 있다는 것을 알려면 문서나 코드를 읽어야 한다.
따라서 이것은 암시적 오류 전달 기법이다.
매직값을 사용하여 오류를 알리는 일반적인 방법은 -1을 반환하는 것이다.
매직값은 코드 계약의 명백한 부분을 통해 호출하는 쪽에 알릴 수 없어서 예상을 벗어나는 결과를 가져올 수도 있고 버그로 이어질 수 있다.
복구할 수 없는 오류의 전달
현실적으로 복구할 가능성이 없는 오류가 발생하면 아래와 같은 방법으로 신속하게 실패하고, 요란하게 실패하는 것이 최상의 방법이다.
•
비검사 예외를 발생
•
프로그램이 패닉이 되도록 (패닉을 지원하는 언어를 사용하는 경우)
•
체크나 어서션의 사용
이러한 경우 프로그램(또는 복구 불가능한 범위 내에서)이 종료되는데, 이는 개발자들이 뭔가 잘못되었음을 알아차린다는 것을 의미하고, 생성된 오류 메시지는 대개 스택 트레이스나 줄 번호를 제공하여 오류가 발생한 위치를 명확하게 알려준다.
오류를 복구할 방법이 없을 때는 이것이 합리적이다.
왜냐하면 이 경우 자신을 호출한 쪽에 오류를 전달하는 것 외에는 할 수 있는 방법이 없기 때문이다.
호출하는 쪽에서 복구하기를 원할 수도 있는 오류의 전달
호출하는 쪽에서 복구하기를 원할 수도 있는 오류를 전달하고자 할 때, 비검사 예외와 명시적 오류 전달 기법 중 어느 것을 사용해야 하는지에 대한 논쟁이 있다.
그 전에 먼저 기억해야 할 점은 팀이 동의한 철학이 다른 어떤 주장보다도 중요하다는 점이다.
비검사 예외를 사용해야 한다는 주장
•
코드 구조 개선
대부분의 오류 처리가 코드의 상위 계층에서 이루어질 수 있기 때문에 (명시적인 기술을 사용하는 대신) 비검사 예외를 발생시키면 코드 구조를 개선할 수 있다.
오류가 높은 계층까지 거슬러 올라오면서 전달되고, 그 사이에 있는 코드는 오류 처리를 할 필요가 없다.
중간에 있는 계층은 (특정 작업을 다시 시도하는 등) 원한다면 예외 중 일부를 처리할 수 있지만, 그렇지 않으면 오류가 최상위 오류 처리 계층으로 전달된다.
사용자 응용 프로그램이라면 오류 처리 계층은 오류 메시지를 UI에 표시할 수 있다.
서버나 백엔드 프로세스라면 오류 메시지가 기록될 수 있다.
이 접근법의 핵심 장점은 오류를 처리하는 로직이 코드 전체에 퍼지지 않고 별도로 몇 개의 계층에만 있다는 점이다.
•
개발자들이 무엇을 할 것인지에 대해서 실용적이어야 함
개발자들이 너무 많은 명시적 오류 전달을 접하면 결국 잘못된 일을 한다.
명시적인 오류 전달 방식을 사용하면 코드의 계층을 올라가면서 오류를 반복적으로 전달하고 이를 처리하는 일련의 작업이 필요한데, 이런 번거로운 작업 대신 개발자는 편의를 도모하고 잘못된 작업을 하고 싶은 마음이 들 수 있다.
예를 들어 예외를 포착하고도 무시한다거나, 널이 가능한 유형을 확인도 하지 않고 널이 불가능한 유형으로 변환을 하는 것이다.
명시적 기법을 사용해야 한다는 주장
•
매끄러운 오류 처리
비검사 예외를 사용한다면 모든 오류를 매끄럽게 처리할 수 있는 단일 계층을 갖기가 어렵다.
예를 들어 사용자 입력이 잘못되면 해당 입력 필드 바로 옆에 오류 메시지를 보여주는 것이 타당하다.
입력을 처리하는 코드를 작성하는 엔지니어가 오류 시나리오를 알지 못하고 더 높은 수준으로 전달되도록 내버려 둔다면, 이는 사용자 친화적이지 않은 오류 메시지를 UI에 표시할 수 있다.
•
실수로 오류를 무시할 수 없다
어떤 호출자의 경우에는 실제로 오류를 처리해야 하는 경우가 있을 수 있다.
비검사 예외가 사용되면 적극적인 의사 결정이 들어갈 여지는 줄어들고 대신 기본적으로 잘못된 일(오류를 처리하지 않는 일)이 일어나기 쉽다.
좀 더 명확한 오류 전달 방식을 사용하면 잘못된 일이 기본적으로 혹은 실수로 인해 일어나지 않는다.
•
개발자들이 무엇을 할 것인지에 대해서 실용적이어야 함
비검사 예외가 코드베이스 전반에 걸쳐 제대로 문서화된다는 보장이 없고, 개인적인 경험에 비추어 보면 문서화되지 않는 경우가 많다.
이는 종종 어떤 코드가 어떤 예외를 발생시킬 것인지 확실하게 알지 못한다는 것을 의미하고, 이로 인해 예외를 처리하는 것이 당황스러운 두더지 잡기 게임이 될 수 있다.
비검사 예외를 사용하든 명시적인 오류 전달 처리 방식을 사용하든 어느 경우라도 문제가 있을 수 있다는 것을 알아야 한다.
필자의 의견: 명시적 방식을 사용하라
필자의 경험상 비검사 예외의 사용은 코드베이스 전반에 걸쳐 완전히 문서화되는 경우가 매우 드물며, 이것이 의미하는 바는 해당 함수에 대해 발생 가능한 오류와 이에 대한 처리를 어떻게 해야 하는지 개발자가 확실하게 알기란 거의 불가능하다는 것이다.
더 바람직하지 않은 상황은 팀 내에서 각자 개발자들이 서로 다른 접근 방식을 사용하는 것이다.
따라서 팀원들이 오류 전달에 대한 철학에 동의하고 그것을 따르는 것이 가장 바람직하다.
컴파일러 경고를 무시하지 말라
컴파일러는 오류뿐만 아니라 대부분의 컴파일러는 경고 메시지도 출력한다.
컴파일러 경고는 어떤 식으로든 코드가 의심스러우면 표시를 하는데, 이것은 버그에 대한 조기 경고일 수 있다.
이러한 경고에 주의를 기울이면 코드베이스에 병합되기 훨씬 전에 코드로부터 프로그래밍 오류를 발견하고 제거할 수 있다.