목적: Software engineering의 이해
4장. 실용주의 편집증PRAGMATIC PARANOIA
30. 완벽한 소프트웨어는 만들 수 없다
4.1. 계약에 의한 설계
DBC(Design by Contract)
버트란드 마이어Bertrand Meyer는 아이펠Eiffel이라는 언어를 만들면서 계약에 의한 설계 개념을 개발했다. 이것은 단순하지만 강력한 기법으로, 프로그램의 정확성을 보장하기 위해 소프트웨어 모듈들의 권리와 책임을 문서화(및 이에 대해 동의)하는 데에 초점을 맞춘다. 정확한 프로그램이란 무엇인가? 스스로 자신이 하는 일이라고 주장하는 것보다 많거나 적지도 않게 딱 그 만큼만 하는 프로그램을 말한다. 이 주장을 문서화하고 검증하는 것이 계약에 의한 설계Design by Contract, DBC의 핵심이다.
주(註): 계약에 의한 설계가 유용하지 않은 것은 아니지만, 촉박한 일정과 잦은 요구사항 변화에 시달리고 있는 한국 개발자의 입장에서는 또 다른 일거리로 보입니다. 따라서 개념 정도만 이해하고 프로그램에는 강화된 주석을 다는 정도가 어떨까 생각해봅니다.
4.2. 죽은 프로그램은 거짓말을 하지 않는다(문제가 있다면 작동을 멈추어라)
(에러가 있다면)망치지 말고 멈추라
가능한 한 빨리 문제를 발견하게 되면, 좀 더 일찍 시스템을 멈출 수 있다는 이득이 있다. 게다가 프로그램을 멈추는 것이 할 수 있는 최선일 때가 많다. 다른 대안이라곤 깨어진 데이터를 중요한 데이터베이스에다 기록하는 정도이다.
자바 언어와 라이브러리는 이 철학을 포용했다. 런타임 시스템에서 뭔가 예상하지 못한 것이 발생하면 RuntimeException을 던진다. 만약 이 예외가 잡히지catch 않으면 프로그램의 최상위 수준까지 스며 나올 것이고, 결국 stack trace를 출력하며 프로그램을 멈춰버릴 것이다.
분명히 실행 중인 프로그램을 그냥 종료해 버리는 것은 때로 적절치 못하다. 해제되지 않은 자원resource이 남아 있을 수도 있고, 로그 메시지를 기록할 필요가 있을 수도 있고, 열려있는 트랜잭션을 청소해야 하거나, 다른 프로세스들과 상호작용해야 할 필요가 있을지도 모른다. 그렇지만 기본 원칙은 똑같다. 방금 불가능한 뭔가가 발생했다는 것을 코드가 발견한다면, 프로그램은 더 이상 유효하지 않다고 할 수 있다. 이 시점 이후로 하는 일은 모두 수상쩍은 게 된다. 되도록 빨리 종료해야 할 일이다. 일반적으로, 죽은 프로그램이 입히는 피해는 절름발이 프로그램이 끼치는 것보다 훨신 덜한 법이다.
4.3. 단정적 프로그래밍(출시 후에도 단정문을 켜두어라)
“이 코드를 지금부터 30년 동안이나 사용하지는않을 테니까, 연도에 두 자리 수를 사용해도 괜찮아.” “이 애플리케이션을 외국에서 사용하는 일은 절대 없을 텐데 뭐하러 국제화하지?” “ count는 음수가 될 수 없어.” “이 printf는 실패할 수 없어.”
이런 류의 자기기만을 훈련하지 말자, 특히 코딩할 때는.
33. 단정문을 사용해서 불가능한 상황을 예방하라
“하지만 물론 그건 절대 일어나지 않을 거야.”라는 생각이 든다면, 그걸 확인하는 코드를 추가하라. 이걸 하는 가장 간단한 방법은 단정문assertion을 사용하는 것이다.
단정은 알고리즘의 동작을 검사하는 데 유용하게 쓰일 수도 있다. 여러분이 아주 영리한 정렬 알고리즘을 작성했다고 하자. 그렇다면 그것이 제대로 작동하는지 확인하라.
for (int I = 0; I < num_entries-1; i++) { assert(sorted[i] <= sorted[i+1]); }
물론, 단정문에 전달된 조건은 부작용이 있으면 안 된다. 또한, 컴파일 중에 단정 기능이 꺼져 있을 수도 있다는 걸 기억하라. 실행 되어야만 하는 코드는 절대 assert 속에 두지 마라.
진짜 에러처리 대신으로 단정을 사용하지는 마라. 단정은 결코 일어나면 안되는 것들을 검사한다. 다음과 같은 코드를 작성하길 원하는 건 아닐 것이다.
printf(“Enter ‘y’ of ‘n’ : “); ch = getchar(); assert((ch == ‘Y’) || (ch == ‘N’)) /* 좋은 생각이 아니다 */
그리고 단정이 실패할 때, 지원되는 assert 매트로 호출이 exit한다고 해서 여러분이 작성하는 버전도 그래야 할 이유는 없다. 만약 리소스를 해제할 필요가 있다면, 단정 실패가 예외를 생성하게 하거나, 출구로 logjmp를 하게 하거나, 에러 핸들러를 호출하게 하라.
단정 기능을 켜두라
단정문에 관한 한 가지 일반적인 오해가 널리 퍼져 있다. 그 오해는 대략 다음과 비슷한 것이다.
단정은 코드에 과부하overhead를 준다. 단정은 결코 일어날 수 없는 것들을 검사하기 때문에 코드 속 버그에 의해서만 촉발될 것이다. 일단 코드가 테스트되고 선적된 다음에는 더 이상 단정이 필요하지 않고, 코드 실행이 빨라지도록 단정을 꺼버려야 한다. 단정은 디버깅 도구일 뿐이다.
여기에는 두 가지 공공연한 잘못된 가정이 있다. (1) 첫째, 테스트가 모든 버그를 발견한다는 가정이다. 그러나 실제로는 복잡한 프로그램이라면 어느 것이든, 여러분의 코드가 거칠 수 있는 경우의 수에서 극히 작은 일부분이라도 테스트할 가망성은 낮다는 것이다. (2) 둘째, 낙관주의자들은 여러분의 프로그램이 험한 세상에서 돌아간다는사실을 잊는다. 테스트 중에 누군가가 게임을 해서 메모리를 모두 잡아먹는다든가, 로그 파일이 하드 드라이브를 채워버린다든가 하는 일이 일어나지는 않을 것이다. 그러나 생산공정에서 프로그램이 돌아가는 중에는 일어날 수 있다. 첫째 방어선은 모든 가능한 에러를 체크하는 것이고, 둘째는 놓친 것들을 잡아내기 위해 단정문을 쓰는 것이다.
프로그램을 출시할 때 단정 기능을 꺼버리는 것은, 공중 곡예를 하면서 연습으로 한 번 건너봤다고 그물 없이 건너는 것과 비슷하다. 극적인 가치야 있겠지만, 생명보험을 들기는 어렵다.
퍼포먼스 문제가 있다 할지라도, 정말 문제가 되는 단정문만 끄도록 하자.
단정과 그 부작용side effect
에러를 발견하려고 넣은 코드가 오히려 새로운 에러를 만드는 결과를 낳는다면 상당히 당황스러울 것이다. 단정문을 쓸 때에도 조건을 평가하는 코드가 부작용이 있다면 이런 일이 발생할 수 있다. 예를 들어 자바에서 다음과 같은 코드를 작성하는 것은 그리 좋은 생각이 아니다.while (iter.hasMOreElements()) { Test.ASSERT(iter.nextElement()) != null); Object obj = iter.nextElement(); // … }ASSERT 안에 있는 .nextElement() 호출은 이 호출이 돌려주는 원소 다음으로 반복자iterator를 이동시키는 부작용이 있다. 그러므로 이 반복문은 컬렉션 원소의 절반만 처리하게 된다. 다음과 같이 작성하는 것이 좋다.
while (iter.hasMoreElements()) { Object obj = iter.nextElement(); Test.ASSERT(obj != null); // ... }이는 디버깅 행위가 디버깅되는 시스템의 행동을 바꿔버리는, 일종의 '하이젠버그Heisenbug'적 문제다.
4.4. 언제 예외를 사용할까
우리는 모든 가능한 에러를 체크하는 것이 좋다고 했다. 그렇지만, 실제로 이렇게 하다보면 코드가 꽤 지저분해질 수 있다. 특히 “루틴 하나에 리턴문 하나만”프로그래밍 학파를 따른다면 더 그렇다.
retcode = OK; if (socket.read(name) != OK) { retcode = BAD_READ; } else { processName(name); if (socket.read(address) != ok) { retcode = BAD_READ; } else { processAddress(address); if (socket.read(telNo) != OK) { retcode = BAD_READ; } else { // 기타 등등 } } } return retcode;
다행스럽게도, 만약 해당 프로그래밍 언어가 예외를 지원한다면, 이 코드를 훨씬 깨끗하게 재작성할수 있다.
Retcode = OK; try { sock.read(name); process(name); socket.read(address); processAddress(address); socket.read(telNo); // 기타 등등 } catch (IOException ex) { retcode = BAD_READ; Logger.log(“Error reading individual: “ + ex.getMessage()); } Return retcode;
모든 에러 처리가 한 장소로 옮겨지며서, 이제 정상적인 컨트롤의 흐름이 명확히 보인다.
무엇이 예외적인가
예외에 문제가 있다면 이걸 언제 사용할지 아는 것이다. 우리는 예외가 프로그램의 정상 흐름의 일부로 사용되는 일은 거의 없어야 한다고 믿는다. 예외는 의외의 상황을 위해 남겨두어야 한다. 잡히지 않는 예외는 프로그램을 종료시킬 것이라고 가정하고, ‘모든 예외 처리기exception handler를 제거해도 이 코드가 여전히 실행될까.’라고 자문해 보자. 만약 그 답이 ‘아니오’라면 아마도 예외가 비예외적인 상황에서 사용되고 있는 것이다.
예를 들어, 코드가 어떤 파일을 열어 읽으려고 하는데 그 파일이 존재하지 않는다면 예외가 발생해야 하는가?
우리의 답은, ‘경우에 따라 다르다.’이다. (1) 만약 파일이 꼭 있어야만 한다면, 예외가 출동해 마땅하다. 뭔가 예상치 못한 일이 벌어진 것이다. 있어야 될 파일이 사라져 버렸으니 말이다. (2) 반면에 파일이 반드시 있어야만 하는 것인지에 대해 아무 생각이 없다면, 그 파일을 찾을 수 없다는 것이 그리 예외적인 일이 아닐 것이며, 따라서 에러를 반환하는 것이 적절하다.
첫째 경우, 다음 코드는 모든 유닉스 시스템에 존재해야 하는 /etc/passwd 파일을 연다. 실패하면 FileNotFoundException을 호출자에게 전달한다.
public void open_passwd() throws FileNotFoundException { // 다음은 FileNotFoundException을 던질 수도 있다. Ipstream = new FileInputStream(“/etc/passwd”); // … }
그렇지만 둘째 경우는 명령행에서 사용자가 지정한 파일을 여는 것일 수 있다. 여기에서는 예외가 당연시 되는 것은 아니기 때문에 코드가 좀 다르다.
public Boolean open_user_file(String name) throws FileNotFoundException { File f = new File(name); if (!f.exists()) return false; ipstream = new FileInputStream(f); return true; }
FileInputStream 호출은 여전히 예외를 생성할 수 있다는 사실에 주의하라. 예외가 생기면 이 루틴은 자신이 처리하지 않고 위로 넘긴다. 하지만 그 예외는 오로지 정말 예외적인 상황 하에서만 생성될 것이다. 단순히 존재하지 않는 파일을 열려고 하는 것으로는 일반적인 에러 반환만 생긴다.
주(註): 사용자가 지정한 파일은 존재할 수도 그렇지 않을 수도 있다. 따라서 예외를 던지는 것보다는 에러를 반환하는 것이 적절하다.
34. 예외는 예외적인 문제에 사용하라
왜 우리는 이런 식으로 예외에 접근할 것을 제한하는가? 예외가 있다는 것은 즉 컨트롤의 이동이 즉각적이고 로컬하지 않다는 것을 말한다. 일종의 연쇄 goto같은 것이다. 예외를 정상적인 처리 과정의 일부로 사용하는 프로그램은 고전적인 스파게티 코드의 가독성 문제와 관리성 문제를 전부 떠안게 된다. 이런 프로그램은 캡슐화 역시 깨트린다. 예외 처리를 통해 루틴과 그 호출자들 사이의 결합도가 높아져 버린다.
4.5. 리소스 사용의 균형(시작한 것은 끝내라)
코딩할 때 우리는 모두 리소스를 관리한다. 메모리, 트랜잭션, 쓰레드, 파일, 타이머 등 사용에 어떤 제한이 있는 모든 종류의 것을. 대개의 경우, 리소스 사용은 예측할 수 있는 패턴을 따른다. 리소스를 할당하고, 사용한 다음, 해제deallocate한다.
그렇지만, 많은 개발자들은 리소스 할당과 해제를 다루는 일관된 계획을 갖고 있지 않다. 그래서 우리는 간단한 팁 하나를 제안하고자 한다.
35. 시작한 것은 끝내라
시작한 것은 끝내라 팁이 가르쳐주는 것은, 이상적으로 말해서 리소스를 할당하는 루틴이 해제 역시 책임져야 한다는 것이다.
/** * 리펙토링refactoring 전 */ void readCustomer(const char *fName, Customer *cRec) { cFile = fopen(fName, “r+”); fread(cRec, sizeof(*cRec), 1, cFile); } void writeCustomer(Customer *cRec) { rewind(cFile); fwrite(cRec, sizeof(*cRec), 1, cFile); fclose(cFile); } void updateCustomer(const char *fName, double newBalance) { Customer cRec; readCustomer(fName, &cRec); cRec.balance = newBalance; writeCustomer(&cRec); } /** * 리펙토링refactoring 후 */ void readCustomer(FILE *cFile, Customer *cRec) { fread(cRec, sizeof(*cRec), 1, cFile); } void writeCustomer(FILE *cFile, Customer *cRec) { rewind(cFile); fwrite(cRec, sizeof(*cRec), 1, cFile); } void updateCustomer(const char *fName, double newBalance) { FILE *cFile; Customer cRec; cFile = fopen(fName, “r+”); readCustomer(cFile, &cRec); if (newBalance >- 0.0) { cRec.balance = newBalance; writeCustomer(cFile, &cRec); } fclose(cFile); }
이제 해당 파일에 대한 모든 책임은 updateCustomer 루틴에 있다. 루틴은 파일을 열고 (자신이 시작한 것을 끝맺으면서) 종료 전에 닫는다. 루틴은 파일의 사용을 균형잡는다. 열기와 닫기가 동일 장소에 있고, 모든 열기에 대해 사응하는 닫기가 있다는 것도 분명해 보인다. 리펙터링refactoring 덕분에 지저분한 전역 변수까지도 제거되었다.
중첩 할당
리소스 할당의 기본 패턴을 확장해서 한 번에 하나 이상의 리소스를 필요로 하는 루틴에 적용할 수 있다. 두 가지 제안만 더 하겠다.
1. 리소스를 할당한 순서의 반대로 해제하라. 이렇게 해야 한 리소스가 다른 리소스를 참조하는 경우에도 리소스를 고아로 만들지 않는다.
2. 코드의 여러 곳에 동일한 리소스 집합을 할당하는 경우, 할당 순서를 언제나 같게 하라. 교착deadlock 가능성이 줄어들 것이다. (프로세스 B가 resource2를 이미 확보하고서 resource1을 획득하려고 하고 있는데 프로세스 A가 resource1을 가진 상태로 resource2를 막 요청하려고 한다면, 이 두 개의 프로세스는 영원히 기다리게 될 것이다.)
객체와 예외
할당과 해제의 균형은 클래스의 생성자constructor와 소멸자destructor를 생각나게 한다. 클래스는 하나의 리소스를 대표하며, 생성자는 그 리소스 타입의 특정 객체를 제공하고, 소멸자는 그것을 현 스코프에서 제거한다.
만약 객체지향 언어로 프로그래밍을 한다면, 리소스를 클래스 안에 캡슐화하는 것이 유용하다고 느낄 것이다. 특정 리소스 타입이 필요한 때마다 그 클래스의 객체를 생성하면 된다. 그 객체가 스코프를 벗어나거나 가비지 콜렉터가 객체를 수거해 가면 객체의 소멸자가 클래스로 감싸진 리소스를 해제한다.
이 접근법은 C++과 같이 예외 때문에 리소스 해제가 방해받을 수 있는 언어로 작업을 할 때에 특별히 쓸모 있다.
균형과 예외
예외를 지원하는 언어는 리소스 해제에 복잡한 문제가 있을 수 있다. 예외가 던져진 경우, 그 예외 이전에 할당된 모든 것이 깨끗이 청소된다고 어떻게 보장할 수 있겠는가?
자바에서 리소스 사용의 균형
C++과 달리 자바에서는 게으른 방식의 자동 객체 삭제를 사용한다. 참조가 없는 객체들은 가비지 콜렉션의 후보가 되며, 만약 가비지 콜렉션이 그 객체들을 지우려고 하기만 한다면, 객체의 finalize 메소드가 호출될 것이다. 더 이상 대부분 메모리 누수 책임을 지지 않게 되어 개발자에게는 아주 편해진 일이지만, C++ 방식대로 자원을 청소하도록 구현하기는 어려워졌다. 다행스럽게도 사려 깊은 자바 언어의 설계자들은 이것을 보상하기 위한 기능 하나를 추가해두었다. finally절이 그것이다. try 블록에 finally절이 들어있다면, 그 절 안의 코드들은 try 블록 안의 코드가 한 문장이라도 실행되면 반드시 실행되도록 되어 있다. 예외가 던져지더라도 (심지어 try 안에 있는 코드에서 return 명령이 실행되더라도) 상관없다. finally절 안의 코드는 반드시 실행된다. 이 말은 다음과 같은 코드로 리소스 사용의 균형을 잡을 수 있다는 뜻이다.
public void doSomething() throws IOException { File tmpFile = new File(tmpFileName); FileWriter tmp = new FileWriter(tmpFile); try { // 무슨 작업인가 한다 } finally { tmpFile.delete(); } }
이 루틴에서 사용하는 임시파일은 루틴에서 어떻게 나가든 지워야 한다. finally블록이 우리가 뜻하는 바를 이렇게 간결하게 표현해 준다.
리소스 사용의 균형을 잡을 수 없는 경우
기본적인 리소스 할당 방식이 아예 적절하지 않은 경우도 있다. 보통 동적 자료 구조형을 사용하는 프로그램에서 이런 일이 많이 생긴다. 한 루틴에서 메모리의 일정 영역을 할당한 다음 어떤 더 큰 구조에 그것을 연결하며, 한동안 그대로 쓰이는 식이다.
여기에서 메모리 할당에 대한 의미론적인 불변식을 정하는 일이 필요해진다. 집합적인 자료구조 안의 자료에 대해 책임을 지는 게 누구인지 정해놓아야 한다. 최상위 구조의 할당을 해제할 경우 어떤 일이 일어나는가? 주로 다음 세 가지 방법 가운데 선택할 수 있다.
1. 최상위 구조 자신이 자기 안에 들어있는 하위 구조들의 할당을 해제할 책임이 있다. 하위 구조들은 또다시 재귀적으로 자기 안에 들어있는 자료들을 해제할 책임이 있고, 이런 식을 진행된다.
2. 최상위 구조에서 그냥 할당이 해제된다. 그 안에서 참조하던 (다른 곳에서 참조하지 않는) 구조들은 모두 연결이 끊어져 고아가 된다.
3. 최상위 구조는 하나라도 하위 구조를 가지고 있을 경우 자기의 할당 해제를 거부한다.
여기에서 선택은 각 데이터 구조의 상황에 따라 달라진다. 하지만 어떤 경우라도 어떤 것을 선택할지 명백하게 결정하고 그에 따라 일관성 있게 구현해야 한다.
균형을 점검하기
정말로 리소스가 적절하게 해제되었는지 실제로 점검하는 코드를 늘 작성하는 것이 언제나 좋다고 우리는 생각한다. 대부분의 애플리케이션에서 이 말은 보통 리소스의 종류마다 래퍼를 만들고 그 래퍼들이 모든 할당과 해제의 기록을 유지하는 것을 뜻한다. 코드 속에서 프로그램 논리에 따르자면 자원들이 반드시 이런 상태에 있어야 한다고 말할 지점들이 있을 것이다. 래퍼들을 사용해서 정말 그런지 점검하라. 예를 들어 요청이 들어오면 처리하는, 실행시간이 긴 프로그램은 아마 자신의 주처리 루프 맨 위에 다음 요청이 도착하기를 기다리는 단일 지점을 두고 있을 것이다. 이 지점은 지난번 처리가 끝났을 때 리소스 사용량이 증가하지 않았는지 검사하기에 좋은 장소다.
출처
인사이트 - 앤드류 헌트, 데이비드 토머스의 실용주의 프로그래머The Pragmatic Programmer