1. 짧은 평가, 느낀점
책을 읽게 된 것은 단순한 계기인데, 같은 회사의 동료인 권국헌님의 택배를 대신 받은 것이 시작이었다. 국헌님은 평소에 실수나 빈틈을 별로 보여주지 않으시고, 가끔가다 슬랙으로 좋은 팁들을 전수해 주시는 자주 해주셨으면 좋겠지만 훌륭한 시니어 엔지니어시다.
어쨌든, 택배를 받고 국헌님의 책상으로 갔더니 이 책이 놓여있었다. 국헌님이 읽으시는 “수련”책이라니, 과연 어떤 책일지 궁금해졌다. 그래서 그날 바로 구매를 했던 것으로 기억한다.
C, C++의 허들
책의 거의 모든 예제 코드들이 C로 되어있고, 가끔씩 고수준 언어
라며 C++과 java코드가 예제로 나온다. 이는 생각보다 읽는데 시간이 걸리는 원인이 되었다. C, java코드를 아예 읽지 못하는 것은 아니지만, 긴 예제 코드에 포인터와 메모리 할당, 해제함수들이 곁들여 지면서 상당한 고통을 만들어 내었다.
옛날 책이라 어쩔 수 없는 부분이지만, 만약 내가 C나 java코드를 아예 읽을 줄 몰랐더라면 진작에 덮어버렸을 것 같다. 조금 아쉬운 부분이라고 할까.
공감하기 힘든 부분들
몇 가지 부분들이 있었는데, 지금 생각나는 것들 중 하나는 ‘테스트’ 항목에서 직접 자동화된 테스트를 만드는 법에 대한 부분이 대표적이다. “그냥 test framework를 쓰면 끝나는일 아닌가?”라는 생각이 먼저 들었고, 지면을 많이 할애할 부분도 아니었다고 생각한다. 다만 책을 쓰는 시점에는 지금처럼 친절한 test framework가 없었기 때문에 그랬던 것인가, 하는 생각도 든다.
정확한 파트는 기억이 나지 않지만, 위의 예시 처럼 종종 공감하기 힘든 부분들이 있었다(아! C의 macro에 대한 이야기도 있었다). 그런 부분이 나올때 마다 부담없이 슥슥 넘어가거나 전하고자 했던 메시지를 찾는데 시간을 더 쏟았다.
그럼에도 불구하고
사실 K문고 사이트에서 이 책의 평점을 1점을 준 사람이 있다. 그 사람의 평을 요약하자면 “대부분 C로 예제 코드가 쓰여 있으며, 실제로 쓸만하고 적용할 만한 내용이 없다. 저자 자신만의 코딩세계를 보여주는 그들만의 잔치다.” 라고 부정적으로 쓰여있다.
물론 위의 몇몇 아쉬운 부분들이 있지만 책이 전달하는 전체젝인 메시지는 정말 좋다고 생각한다. 각각의 파트에서 놓치기 쉽거나 간과하기 쉬운 부분들을 잘 짚어주고 있으며, 근거나 설명도 꽤나 충실하다. 그렇기때문에, C가 익숙한 사람들이라면 코드를 읽는 즐거움이 있을 것이고, 본인처럼 C가 익숙하지 않다면 각자의 언어나 상황에 적용해 보는 즐거움이 있을것이라 생각한다.
위의 ‘실제로 쓸만하고 적용할 만한 내용이 없다.‘라는 평가는 너무 박한 평가라는 생각이 든다. 본인이 생각하기에, 책의 예시를 그대로 적용하는 것이 아니라, 그 예시들이 전달하려고 하는 내용을 좀 더 추상적인 개념으로 받아들이면 된다고 본다. 그것만으로도, 이 책은 (특히 주니어를 막 벗어나려고 하는 사람들은) 충분히 읽을 가치가 있다고 생각한다.
2. 요약
8장과 9장은 요약하지 않았다. 호환성을 생각하고 표준을 준수하라는 내용을 장황하게 늘어놓을 필요는 없다고 생각했기 때문이다.
스타일
- 이름
- 전역변수에는 서술적인 이름을, 지역변수에는 짧은 이름을 붙인다.
- 문맥과 유효범위를 생각하여 이름을 짓는다. 예를들면 for loop에서 index를 뜻하는 ‘i’라는 변수에 쓸데 없이 자세한 이름을 붙일 필요가 없다.
-
일관성을 지킨다.
- class내에서 멤버변수를 가리키는 표현을 통일한다.
-
함수의 이름은 능동형을 쓴다.
- 변수는 명사, 함수 이름은 동사를 쓴다(ex; date.getTime()).
- Boolean을 return하는 함수는 ‘is’라는 prefix를 붙인다.
- 정확한 이름을 쓴다. 함수의 동작과 이름이 다르면 곤란하다.
- 표현식과 문장
- 일관성있게 들여쓰기를 한다. 프로그램의 구조가 더 명확하게 보이는 효과가 있다.
-
표현식을 자연스럽게 쓴다.
- if, ternary는 입 밖으로 소리내어 읽어본다.
- 부정을 포함하는 조건문은 이해하기 힘들다.
// bad if(!(a > b) || !(c >= d)) // good if((a <= b) || (c < d))
- 괄호를 사용하여 애매함을 해소한다. 여러 연산이 섞여 있는 경우 괄호로 묶어주면 이해하기 쉽다.
- 복잡한 표현식은 잘게 나눈다.
-
명료하게 쓴다.
- Ternary의 경우 짧은 표현식에만 사용한다.
- 명료함의 기준은 길이가 아닌 코드의 이해시간이다.
++
같이, 부수효과를 일으킬 수 있는 표현식을 조심한다.- 일관성과 관용표현
- 동일한 연산은 동일한 방법으로 처리한다.
-
다중결정이 필요하면 elseif를 사용한다.
- 개인적으로 fast-fail을 선호하며, elseif보다는 switch case를 선호하는 편이다.
- 개인적인 경험상 elseif는 코드가 정리되어있지 않고, 의식의 흐름에 따라 나온 코드가 방치되고 있는 듯한 느낌이 든다.
- cond()함수를 사용해 보는 것도 좋은 경험이라고 생각한다.
- 그래도 if 중첩보다는 낫다.
- 매직넘버
- 매직넘버에는 항상 이름을 붙여준다.
- 언어에서 제공하는 표현을 사용한다.
const a = [1..10]
//bad
const aLength = 10
//good
const aLength = a.length
- 주석
- 명확한 코드에는 주석을 쓰지 않는다.
-
함수, 전역변수, 상수, 클래스의 필드 등 필요할 때 쓴다.
- 특히, js-doc같은 것이나, 복잡한 함수에 대한 설명을 기술하는 것 등.
- 나쁜 코드는 주석으로 설명하지 말고 즉시 refactoring한다.
-
주석과 코드가 모순되면 안되며, 주석으로 인해 혼란을 일으키면 안된다.
- 주석이 길어진다면, refactoring신호로 받아들인다.
- 좋은 코드는 주석이 적게 필요하다는 것을 항상 기억한다.
- 이렇게 하는 이유가 무엇인가?
- 읽기 쉽고 이해하기 쉬우면 에러도 적고 분량도 적다.
- 지저분한 코드는 나쁜 코드다. 읽기 힘들고, 잘 망가진다.
- 결국 코드를 작성하는 습관이 중요하다. 그렇기 때문에, 좋은 습관을 들이는 것이 중요하다.
알고리즘과 데이터 구조
- 검색
- 데이터의 수가 적으면 순차검색(linear search)은 빠르게 동작한다.
- 대규모 검색은 이진검색(binary search)을 사용하는 것이 빠르다.
- 정렬
-
Quick sort는 어떤 상황에서든 무난히 쓸 수 있다.
-
이상적인 상황에서, O(n log n)의 복잡도를 보이며, pivot에 대해 원소들이 균등하게 나뉘지 않았다면 O(n^2)에 가까워 질수도 있다.
-- Haskell quicksort::Ord a => [a] => [a] quicksort [] = [] quicksort (x:xs) = let smaller = [a | a <- xs, a <= x] bigger = [a | a <- xs, a > x] in quicksort smaller ++ [x] ++ bigger
-
- 크기가 커지는 배열들
- 정렬된 배열에 원소 n개를 한번에 하나씩 넣는 연산은 O(n^2)이므로, n이 큰 경우 피하는 것이 좋다(삭제 역시 마찬가지).
- 메모리 할당 연산을 줄이려면, 배열 크기 변경은 일정한 chunck 단위로 이루어 져야 한다.
- 배열은 쓰기쉽고, index 접근 시 O(1)이 걸리며, search, sort구현이 쉽다.
- 원소가 자주 바뀌는 집합이라면, 다른 데이터 구조를 고려한다.
- List
- List의 크기는 가변적이며, 변경에 대한 비용이 배열보다 적다.
- 검색시, list는 node의 pointer를 따라가야 하기 때문에 O(n)이 걸리고, 개선 방법도 없다.
- Double-linked list는 오버헤드가 크지만, 마지막 아이템을 찾는 연산과 현재 아이템을 제거하는 연산이 O(1)이 된다.
- 변경이 자주일어나고 random access가 자주 일어난다면, linear 구조가 아닌 hash table을 사용하는 것이 좋다.
- Tree
- Node마다 하나의 값을 갖고, 0개 이상의 다른 node를 가리킬 수 있는 node들의 계층적 집합이다.
- Binary search tree는 root부터 시작해 작은 값은 left, 큰 값은 right에 넣는 구조이다.
- Leaf는 자식이 없는 node를 뜻한다.
- 정렬된 데이터가 들어온다면, right 혹은 left에 계속 쌓여 결국 list가 된다.
- B-tree는 여러 자식을 가질 수 있는 tree이다.
- Hash table
- Key와 value를 연결하며, 동적인 데이터를 저장 / 삽입 / 삭제에 효과적인 데이터 구조이다.
- Key를 hash function을 통해 적당한 정수범위에 균등하게 분포하는 hash value로 만들고, 이것을 배열의 index로 활용하는 구조이다.
- Hash table에서 item을 꺼내오는 일은 O(1)이 걸린다.
- 요약
-
알고리즘을 선택하는 단계
- 여러 알고리즘과 처리해야하는 데이터 구조, 크기 등을 평가한다.
- 만약 데이터의 양이 적다면 간단한 것을 선택한다.
- 라이브러리, 언어에서 기본 제공하는 것을 사용한다.
- 데이터 구조의 특징을 이해하고, 각 연산에 드는 비용을 계산한다.
- 주로 사용하는 연산에 이점이 있는 알고리즘, 데이터 구조를 선택한다.
3. 설계와 구현
- 단순한 알고리즘과 데이터 구조를 선택하는 일은 중요하다. 주어진 문제를 시간안에 해결할 수 있는 적당한 것을 고른다.
- 사용할 알고리즘을 토대로 데이터 구조를 선택한다.
- 처음부터 설계를완벽하게 하기란 쉽지 않다. 점진적인 개선이 매우 중요하다.
4. 인터페이스
설계의 핵심은 서로 대립하는 목표와 제약 사이에서 균형을 잡는 것이다.
- 인터페이스: 어떤
서비스와 접근권한
을 제공할 것인가를 결정한다. 일관성, 편리함, 쉬운 사용이 중요하다. - 정보은닉: 어떤 정보를 드러내고 어떤 정보를 숨길것인가를 결정한다. 인터페이스는 분명한 접근 경로를 제공함과 동시에 세부구현을 숨긴다.
- 자원관리, 에러관리: 메모리나 한정된 자원은 누가 관리할 것인가를 결정한다. 누가 에러를 감지하고 알릴것인가 역시 결정해야 한다.
- 프로토타입 라이브러리
- 한 번에 좋은 인터페이스를 얻을 수는 없기때문에, 개인적인 목적이나 접근방식의 현실성을 보는 용도로 사용한다.
- 이를 통해 설계에 대한 사전 리뷰가 가능하다.
- 다른 사람이 쓸 수 있는 라이브러리
- 이 시점에서 인터페이스, 정보은닉, 자원관리, 에러처리를 고려한다.
- 라이브러리에서 사용자에게 필요한 함수를 구분하고, 공개한다.
- 사용자가 알 필요가 없는 세부구현(메모리 할당, 내부함수의 호출시점 등)을 인터페이스 내부에 숨기고, 고립시킨다.
- 파일의 open / close, 메모리의 할당 / 해제 같은 한 쌍의 작업은 같은 위치, 같은 수준에서 실행한다.
- 에러 발생시 그대로 멈추면 안된다. Caller가 적절한 동작을 할 수 있게 에러 상태를 return해야 한다.
- 명세서
- 개인적인 프로젝트라도 명세서가 있으면 도움이 된다.
- 개발하며 깨닫게 된 것들을 명세서에 계속 반영하여 갱신한다.
- 인터페이스 원칙
- 서비스를
제공
하는 코드와사용
하는 코드의 경계선이다. - 단순하고 범용으로 쓸 수 있고, 규칙적이고 결과를 예상할 수 있으며, 튼튼하고 사용자의 기능 변경에 유연함을 보여야 한다.
-
구현 세부사항을 숨긴다.
- 추상화, 모듈화를 통해 사용자와 관련없는 구현 내부를 숨긴다.
- 숨겨진 부분들은 사용자와 상관없이 변경이 가능하므로, 인터페이스를 확장하거나 refactoring할 수 있다.
- 전역변수보다는 함수 parameter로 데이터를 전달받고, 사용자가 내부 변수에 마음대로 접근, 수정하지 못하게 한다.
-
서로 겹치지 않게 기본 항목들을 선택한다.
- 인터페이스는 딱 필요한 만큼의 기능만 제공하고, 그 기능들이 지나치게 중첩되어서는 안된다.
- 같은 일을 수행하는 방법을 여러개 제공하지 않는다.
- 폭넓기 보다는 압축된 인터페이스를 지향한다.
-
사용자가 모르는 곳에서 일을 꾸미지 않는다.
- 비밀파일이나 변수를 만들고 쓴다거나, 전역데이터를 변경하지 않는다.
- 전달받은 데이터를 직접 수정하지 않는다.
- 단일 인터페이스를 만들 때, 인터페이스의 구현자의 편의를 위해 또다른 인터페이스를 만들지 않는다(솔직히 이해가 잘 안가는 부분임).
-
어디서나 같은 방식으로 처리한다.
- 일관성과 규칙성을 가지고 비슷한 것들은 비슷한 수단으로 처리한다. 인자의 순서, return값 등이 이에 해당된다.
- 비슷한 인터페이스가 이미 존재한다면(3rd party 라이브러리 등), 그 인터페이스와의 일관성 역시 목표로 잡아야 한다.
- 자원관리
-
자원은 결자해지 한다.
- 같은 라이브러리, 패키지, 인터페이스를 써서, 그 자원을 할당한 곳에서 해제까지 책임지게 한다.
- 어떤 자원을 할당하고 해제하는 동작이 인터페이스를 넘나들지않게 한다.
- 중단, 재시도, 실패
- 에러메시지를 출력하고 프로그램을 끝내는 것은 작은프로그램에서 어울릴 수도 있다.
- 큰 프로그램에서는 라이브러리가 사용될 때, 복구 불가능한 에러가 발생하는 경우 해야할 동작을 생각해 보아야 한다. 이를테면 word의 경우 문서를 에러 직전으로 복구하여 저장하고 있어야 한다.
- 특정 환경에서는 에러발생 시 출력되는 메시지가 노이즈를 발생시킬 수도 있기 때문에 따로 log file에 저장한다.
-
에러는 저수준에서 잡고, 고수준에서 처리한다.
- Callee가 아닌 caller쪽에서 에러처리를 결정한다. Callee는 적당한 에러코드를 caller에 return한다.
- 점잖게 실패하는 방식을 채택하면 좋다. 이를테면 null, NaN등을 return한다.
- 다양한 예외값을 구분한다.
-
예외적 상황일때만 예외처리 한다.
- 예를들어, 정상적으로 파일을 읽어 파일의 끝이 나왔을 때는 예외가 아닌 return 값으로 처리한다. C의 경우 file을 끝까지 읽으면 -1이 return된다. 하지만, 파일을 열 수 없다면 예외를 발생시킨다.
- 예외는 제어 흐름을 바꾸기 때문에, 버그가 발생하기 쉽게 구조를 꼬아놓을 수도 있다. 예외는 정말 예외적인 상황을 위해 아낀다.
- 사용자 인터페이스
-
에러에 관한 출력은 가능한 모든 정보를 포함하고, 문맥을 이해할 수 있는 메시지여야 한다.
- ‘입력된 값이 너무 길다’ 보다는 ‘입력은 최대 20자까지 가능하다’가 더 적절하다.
- 방어적 프로그래밍은 비정상 입력이 발생해도 프로그램이 버틸 수 있게 도와준다.
5. 디버깅
좋은 프로그래머는 코드 작성 시간과 비슷한 시간을 디버깅에 쏟으며, 실수에서 배움을 얻는다.
디버깅을 줄여주는 방법은 다음과 같다.
- 좋은 설계와 스타일, 잘 설계된 인터페이스
- 경계조건 테스트
- assertion
- 코드의 sanity 테스트
- 방어적 프로그래밍
- 한정된 전역변수
- 디버거
- 디버거는 적재적소에 사용하면 훌륭한 도구지만, 때로는 한줄의 print문이 더 효과적일 때가 있다. 늘 그렇듯, 상황에 맞는 도구를 사용한다.
- 디버거는 문제가 생겼을 때 상태를 확인하는 용도로 사용하고, 원인은 스스로 생각하여 분석하는 편이 낫다.
- 실마리가 뚜렷한 쉬운 버그
-
자주 나오는 패턴을 찾는다.
- “예전에 본 적 있어”라는 것은 문제를 이해하기 시작했다는 신호이다.
- 흔한 버그는 눈에 띄는 특징이 있다. 예를들어, javascript에서 type을 체크하지 않고 string type 변수을 넘겨 number type 변수와 더하는 것이 이에 해당한다.
-
가장 최근에 변경한 부분을 검사한다.
- 최근 변경부터 주의깊게 살펴보며, git등을 이용해 변경이력을 보는 것이 도움된다.
-
같은 실수를 반복하지 않는다.
- 버그를 고쳤다면, 같은 실수를 한 부분이 없는지 복기해 본다.
- 익숙하다고 안심하면 쉬운코드에서도 버그가 발생한다.
- 오늘의 디버깅을 내일로 미루지 않는다. 문제가 더 커지기 전에 문제를 해결한다.
-
스택 추적값을 확인한다.
- 디버거를 활용해 스택 추적값을 살펴본다.
- 디버거가 제공하는 변수값 역시 좋은 힌트이다.
-
작성 전에 읽는다.
- 코드를 주의깊게 읽은 뒤 한동안 손대지 않고 생각해 본다.
- 프로그램의 중요 부분을 목록화 하여 종이에 나열하면 다른 관점으로 문제를 볼 수 있다.
- 코드에서 잠시 떨어져 있으면 선입견과 오해가 희석된다.
- 키보드를 두드리고 싶은 열망에 저항하라.
-
코드를 타인에게 설명한다.
- 효과가 탁월한 방법으로, 버그를 자기자신에게 설명하는 효과가 있다.
- 실마리가 없는 어려운 버그
-
버그를 재현할 수 있게 만든다.
- 쉽게 버그를 드러낼 수 있게 만든다.
- 매번 발생시킬 수 없다면 이유를 생각해 본다.
-
각개격파 한다.
- 버그가 나타나는 가장 작은 입력을 만들어 가능성을 좁힌다.
- 에러에만 집중한 테스트 케이스를 찾아본다.
- Binary search를 이용해 입력값을 반씩 테스트 해보거나 코드 자체에 적용해 본다.
- 숫자가 의미하는 바를 생각해 본다.
- 결과를 출력해 탐색범위를 좁힌다.
- 자가검증코드를 작성한다.
- 로그파일을 작성한다.
- 최후의 수단
-
좋은 디버거를 써서 프로그램을 한 단계씩 살펴본다. 디버거는 종종 생각하는 방법을 바꿔준다.
- 디버거를 통해 사소한 오타, 연산자의 우선순위 실수, 미처 알지 못한 변수의 변화, 타입 등등 많은 것을 발견할 수 있다.
- 잠시 휴식을 취하며 다른 일을 하거나 동료에게 도움을 요청한다.
- 다시 디버깅을 시작할 때 이전과 같은 루트를 따라가지 않는다.
- 메모리 누수는 오동작의 주요 원인 중 하나이다.
- 재현이 불가능한 버그
- 이 말은, 프로그램을 실행할 때마다 바뀌는 정보를 이용하고 있는 부분에 문제의 가능성이 있을수도 있다는 뜻이다.
- 모든 변수가 초기화 되었는지 확인하고, dangling pointer(유효하지 않은 주소값에 대한 참조)문제도 의심해 본다.
- 환경변수, 경로, 파일 등을 살펴본다.
- 타인의 버그
- 위의 모든 방법을 그대로 적용한다.
- 그 전에 프로그램의 구성과 제작자의 생각을
탐사
하는 시간을 갖는다. - 우선 타인의 버그를 확실히 검증하고, 프로그램의 버전을 확인해 본다. 그 후 제작자에게 단독으로 실행 가능한 최소한의 케이스를 알려준다.
- 요약
- 우선 실마리에 대해 생각해 본다.
- 문제의 범위를 좁히고, 지역화 하여 각개격파(divide and conquer)한다.
- 다른 사람에게 코드를 설명하거나 디버거를 이용한다.
- 버그를 찾아 고쳤다면, 복기해 보고 비슷한 패턴의 버그를 찾아 확실히 제거한다.
6. 테스트
- 개발하면서 테스트하기
-
경계에서 테스트하기
- 조건분기, loop의 마지막, 빈입력, 개행 등을 테스트한다.
-
사전, 사후조건 테스트하기
- 어떤 코드가 실행되기 전과 후의 기대값을 테스트한다.
-
assertion을 사용한다.
- 인터페이스를 검증할 때 유용하다.
- 다만 프로그램이 중단되기 때문에, 보통은 자제해야 한다.
- 방어적으로 프로그래밍한다.
- Return값을 검사한다.
- 체계적인 테스트
- TDD가 아니더라도, 프로그램을 전부 작성한 후 테스트를 넣지 말고, 개발하며 함께 작성한다.
- 단순한 부분을 먼저 테스트하고, 커버리지를 측정한다.
- 자동화
- 테스트를 CI/CD를 이용해 자동화한다.
- 부하 테스트
- 입력이 대량으로 들어오는 상황을 테스트한다.
- 누가 테스트하는가?
- 개발자 자신이 스스로 먼저 테스트한 다음, 실제 사용자들이 테스트하는 과정을 거쳐야 한다.
7. 성능
컴퓨터의 성능이 좋아진 지금에도, 프로그램의 성능은 항상 고민해야 한다. 하지만 정말로 병목이 있거나, 프로그램을 명확하면서도 더 빠르게 만들 수 없다면 최적화를 진행하지 않는다.
그렇기 때문에, 최적화의 첫원칙은 하지 않는다이다.
- 시간 측정과 프로파일링
-
시간 측정을 자동화(UNIX의 경우
time
명령어를 이용한다)하고 프로파일러를 사용한다.- 프로파일러를 사용해
hot spot
을 찾고, 그 부분에 집중한다.
- 프로파일러를 사용해
- 속도를 위한 전략
- 상황에 맞게 더 나은 알고리즘, 데이터 구조를 사용한다. 때에 따라 공간 복잡도를 포기한다.
- 코드를 튜닝한다. 그 후 측정한 결과가 유효했을 때만 코드를 수정한다.
- 중요하지 않은 것을 최적화 하지 않는다.
- 코드 튜닝
- 튜닝을 하게 되면 반드시 테스트를 진행한다.
- 공통된 표현식(연산)을 하나로 모은다.
- 비싼 연산을 싼 연산으로 대체한다.
- Loop가 짧다면, 펼쳐서 제어한다.
for(let i = 0; i < 3l i++>) {
a[i] = b[i] + c[i]
}
// to be
a[0] = b[0] + c[0]
a[1] = b[1] + c[1]
a[2] = b[2] + c[2]
- 빈번히 사용하는 값은 캐싱한다.
-
결과값을 미리 계산한다.
- sine같은 함수를 이용한다면, 360개의 입력에 대한 결과 테이블을 미리 만들어 사용한다.
- 근사치를 사용한다.
- 공간을 위한 전략
- 최소 데이터 타입을 사용한다.
- 쉽게 계산되는 값을 저장하지 않는다.
- 요약
- 제일 나중에 걱정할 만한 부분임을 잊지 않는다.
- 조금의 변경을 통해 가장 큰 차이를 만들 수 있는 작은 부분에 집중한다.
- 검증과 측정을 반복하고, 벤치마크 결과를 기록한다.