ITE1009 · HANYANG UNIV · 2015 FALL

C 프로그래밍

두 개의 레포가 한 디렉터리에 섞여 있다. 하나는 남의 책을 베껴 쓴 학습 기록이고, 다른 하나는 1학년이 혼자 만든 1,500줄짜리 Win32 음악 리스트 앱이다. 평가받아야 할 것은 후자뿐이다.

추적 파일196
커밋34
기간2015.10–2016.01
스택C / C++ Win32
C+종합
소견4 치명적8 중대7 경미합계 19

총평

레포는 두 덩어리다. 15-10-6부터 15-11-25까지의 날짜 디렉터리는 Zed Shaw의 Learn C the Hard Way를 따라 친 기록이고, Assignment#3·Assignment#4는 Win32 API로 만든 음악 리스트 GUI 애플리케이션이다. 앞쪽은 과제 산출물이 아니라 연습 기록이므로 저자의 실력으로 평가할 수 없다. 뒤쪽은 전부 저자가 쓴 코드이고, 1학년 1학기 C 수업 과제로서는 놀랄 만큼 야심 차다 — 템플릿 컨테이너, 가상 상속 기반 도형 계층, 더블 버퍼링, 접두사 역인덱스 검색까지 들어 있다.

가장 잘한 것: Assignment#4/musicList/musicList.cpp:31–63tableInsert. 곡 제목을 공백·슬래시·하이픈으로 쪼갠 뒤 각 단어의 모든 접두사를 unordered_map<string, vector<size_t>>에 등록한다. 즉 타이핑 한 글자마다 O(1) 조회로 증분 검색이 되게 만들어 뒀다. 1학년이 자력으로 역인덱스를 설계해 붙였다는 건 그 자체로 가산점이다.

가장 치명적인 것: Assignment#4의 유일한 변경분이 "저장 포맷을 XML로 바꾸기"인데, 그 XML 라이터가 모든 닫는 태그를 </name>으로 쓴다. 레포에 커밋된 list.xml, list2.xml, list3.xml 세 개 모두 XML 파서를 통과하지 못한다(아래에서 xmllint 출력으로 증명). 자기가 쓴 리더가 태그 이름을 보지 않도록 짜여 있어서 앱 안에서는 왕복이 되고, 그래서 이 버그는 저자에게 끝내 보이지 않았다.

덧붙여, 날짜 디렉터리 약 4,000줄 중 저자가 쓴 것은 15-11-09/LinkedList/15-11-03·15-11-25의 C++ 변환 시도뿐이다. 나머지는 Zed Shaw와 Paul Hsieh의 코드이고, 출처 표기는 bstrlib.c가 원본에 달고 있던 헤더 하나가 전부다. README는 59바이트이며 아무것도 설명하지 않는다.

구획내용핵심 판정등급
15-10-6 ~ 15-11-09LCTHW ex1–ex22, list/darray/hashmap거의 전부 책 필사. 출처 표기 없음. 평가 대상 아님
15-11-09/LinkedList단일 연결 리스트 (저자 작성)컴파일·동작함. find_index 반환값 모호, 입력 검증 없어 segfault 재현C+
15-11-03 / 15-11-25List의 C++ 클래스 변환 (저자 작성)컴파일 자체가 안 됨. 3주 뒤 한 글자도 안 고치고 재커밋F
Assignment#3musicList — Win32 GUI, 줄 단위 저장설계 야심적. 메모리·UB 결함 다수지만 기능은 성립B-
Assignment#4musicList — 저장 포맷을 XML로 교체과제의 전부인 XML이 XML이 아님. 커밋된 데이터 3개 전부 파싱 실패C

먼저: 어디까지가 저자의 코드인가

이 레포를 평가하기 전에 반드시 갈라야 하는 선이다. 날짜 디렉터리의 파일 헤더와 내용을 직접 대조한 결과는 다음과 같다.

치명적

날짜 디렉터리의 C 코드는 대부분 Learn C the Hard Way 예제이며, 출처가 어디에도 적혀 있지 않다

ex1.c부터 ex22_main.c까지가 책의 예제 번호를 그대로 쓴다. 내용도 그대로다 — ex7.c는 저자 이름 자리에 "Zed" "Shaw"를 넣어 출력하고, ex12.c"Zed A. Shaw"를 문자열로 들고 있으며, ex22_main.c:3const char *MY_NAME = "Zed A. Shaw";다. ex17.c:117에는 책이 독자에게 남긴 지시문까지 그대로 따라와 있다.

list.c, list_algo.c, darray.c, hashmap.c, dbg.h, minunit.h도 전부 같은 출처다. 헤더 가드가 lcthw_List_h, _lcthw_Hashmap_h, lcthw_List_algos_h로 되어 있다 — 책의 liblcthw 소스 트리에서 그대로 가져온 흔적이다. 함수 사이 공백이 printf( "..." ) ;, who-> name처럼 뭉개져 있는 것은 책 PDF에서 붙여넣었을 때 생기는 전형적인 패턴이다.

결정적 증거는 hashmap_algos.c다. 같은 디렉터리에 hashmap_algos.h가 있는데도 include 경로가 책 저장소 기준 그대로 남아 있어, 커밋된 상태로는 컴파일 자체가 불가능하다.

15-11-09/hashmap_algos.c:1–2· gcc -std=c99 -Wall 실행 결과
#include <lcthw/hashmap_algos.h>
#include <lcthw/bstrlib.h>

hashmap_algos.c:1:10: fatal error: 'lcthw/hashmap_algos.h' file not found
    1 | #include <lcthw/hashmap_algos.h>
      |          ^~~~~~~~~~~~~~~~~~~~~~~
15-10-20/ex17.c:117–118· 책의 지시문까지 그대로
 addr-> set = 1;
 // WARNING: bug, read the "How To Break It" and fix this
 char *res = strncpy( addr-> name, name, MAX_DATA) ;
경미

단, 원본 저작권 헤더를 지운 흔적은 없다

공정하게 짚으면, 저자가 남의 저작권 표기를 삭제한 정황은 없다. bstrlib.cbstrlib.h는 Paul Hsieh의 BSD/GPL 헤더를 온전히 달고 있다. 나머지 파일들(list.c, darray.c, dbg.h 등)은 원본에도 헤더 주석이 없는 파일들이다. 즉 이것은 표기를 뜯어낸 표절이 아니라, 새로 붙였어야 할 표기를 붙이지 않은 것이다.

그래도 지적은 유효하다. 날짜 디렉터리가 과제 제출물이 아닌 개인 학습 기록이라는 점을 감안해 "치명적"이 아닌 "레포 위생"으로 다루더라도, 59바이트짜리 README에 "이 디렉터리는 LCTHW 따라 치기"라는 한 줄이 없다는 것은 10년 뒤 이 레포를 보는 사람이 저자가 쓴 코드와 책 코드를 구분할 수 없게 만든다. 실제로 채점자인 나는 이 구분을 하기 위해 파일별로 헤더 가드와 문자열 리터럴을 대조해야 했다.

15-11-09/bstrlib.c:1–6· 보존된 유일한 출처 표기
/*
 * This source file is part of the bstring string library.  This code was
 * written by Paul Hsieh in 2002-2008, and is covered by the BSD open source
 * license and the GPL. Refer to the accompanying documentation for details
 * on usage and license.
 */
판정

따라서 이하의 품질 평가는 15-11-09/LinkedList/, 15-11-03·15-11-25list.cc/list.h, 그리고 Assignment#3·Assignment#4에만 적용한다. 이 세 덩어리가 저자가 직접 쓴 전부다.

Assignment#3 / #4 — musicList (Win32 GUI)

곡 목록을 불러와 이름/장르/앨범 세 가지 뷰로 보여주고, 증분 검색·추가·삭제·파일 저장을 지원하는 400×600 Win32 애플리케이션이다. 두 과제의 소스는 main.cpp 하나만 다르고 나머지는 동일하다 — diff로 확인한 변경분은 50줄이며, 전부 저장 포맷을 줄 단위 텍스트에서 XML로 바꾸는 작업이다. 즉 Assignment#4의 채점 대상은 사실상 그 50줄이다. macOS 환경이라 Win32 빌드는 하지 못했으므로, 아래 지적은 정독과 이식 가능한 부분의 부분 컴파일·데이터 파일 검증에 근거한다.

치명적

XML 라이터가 닫는 태그를 전부 </name>으로 쓴다 — 레포에 커밋된 데이터 파일 3개 전부가 XML이 아니다

musicListSave<name>만 제대로 닫고, <album>·<artist>·<genre> 세 줄은 복사·붙여넣기 상태 그대로 </name>으로 닫는다. 파일 첫 줄에는 <?xml version="1.0" encoding="UTF-8"?>를 당당히 박아 놓는다.

이 버그가 살아남은 이유가 더 나쁘다. 같은 커밋에서 작성된 리더(main.cpp:239)는 여는 태그의 접두사만 보고, 값은 "첫 >와 마지막 < 사이"로 잘라낸다. 닫는 태그 이름을 아예 검사하지 않는다. 그래서 앱은 자기가 뱉은 깨진 파일을 멀쩡히 다시 읽고, 저자에게는 아무 증상이 보이지 않는다. 이 포맷은 자기 자신하고만 왕복한다.

Assignment#4/musicList/main.cpp:280–283
out << "\t\t<name>"   << info.name   << "</name>"   << endl;
out << "\t\t<album>"  << info.album  << "</name>"   << endl;
out << "\t\t<artist>" << info.artist << "</name>"   << endl;
out << "\t\t<genre>"  << info.genre  << "</name>"   << endl;
Assignment#4/musicList/list.xml:5–7· xmllint --noout 실행 결과
list.xml:5: parser error : Opening and ending tag mismatch: album line 5 and name
		<album>Roman</name>
		                   ^
list.xml:6: parser error : Opening and ending tag mismatch: artist line 6 and name
		<artist>Sound Horizon</name>
list.xml:7: parser error : Opening and ending tag mismatch: genre line 7 and name
		<genre>111</name>
Assignment#4/musicList/main.cpp:239· 닫는 태그를 검사하지 않는 리더
args.push_back(temp.substr(temp.find_first_of('>') + 1,
    temp.find_last_of('<') - temp.find_first_of('>') - 1));
치명적

장르 뷰의 필드 순서가 "불러오기/삭제"와 "추가"에서 서로 다르다

genreList는 별도 타입을 만들지 않고 musicInfo의 필드 자리를 바꿔 끼워 재사용한다. 파일에서 읽을 때(:232)는 (name, genre, album, artist) 순으로 섞어 넣고, 삭제할 때(:438, :567)도 같은 순서로 섞어 찾는다. 여기까지는 일관적이다.

그런데 다이얼로그로 곡을 추가하는 두 경로(:459, :549)는 섞지 않은 inputed를 그대로 넣는다. 결과적으로 앱 실행 중에 추가한 곡은 장르 뷰에서 앨범 자리에 원래 앨범이 들어가 그룹 헤더가 어긋나고, 같은 곡을 다시 지우려 하면 섞인 키로 찾기 때문에 genreList에서만 삭제에 실패한다. 파일에서 불러온 곡과 방금 추가한 곡이 서로 다른 규칙으로 저장되는 상태다.

근본 원인은 필드 셔플이라는 발상 자체다. 뷰마다 정렬 키가 다르다면 정렬 비교자를 바꿨어야 했다 — listElements::sort()(musicList.cpp:72–85)에 이미 람다 비교자 두 개가 들어 있으니 세 번째를 추가하면 끝날 일이었다.

Assignment#4/musicList/main.cpp:230–232 (불러오기) vs :457–459 (추가)
// 불러오기 — 장르 뷰만 필드를 섞어 넣는다
musicList.insert(createInfo(args[0], args[1], args[2], args[3]));
albumList.insert(createInfo(args[0], args[1], args[2], args[3]));
genreList.insert(createInfo(args[0], args[3], args[1], args[2]));

// 추가 — 섞지 않는다
size_t idx = musicList.insert(inputed);
albumList.insert(inputed);
genreList.insert(inputed);
중대

table<T>::remove는 문법적으로 컴파일되지 않는 코드다 — 아무도 호출하지 않아서 들키지 않았다

table.h:106–109의 본문은 세미콜론도 return도 없고, 멤버 함수 find를 호출하지 않고 이름만 값으로 넘긴다. 템플릿이라 실체화되기 전까지는 파싱만 통과한다. remove(size_t)가 이것을 호출하지만 remove(size_t) 자체를 프로젝트 어디서도 부르지 않는다 — grep으로 확인한 유일한 호출 지점은 table.h:103, 즉 자기 자신뿐이다.

즉 이 프로젝트는 "죽은 코드가 있어서" 빌드되는 것이다. 저자가 언젠가 테이블에서 아이템 하나를 지우려고 손을 대는 순간 빌드가 깨진다. 확인을 위해 object.h만 스텁으로 치환한 뒤 명시적으로 실체화해 보았다.

Assignment#4/musicList/table.h:100–109· g++ -std=c++11 -fsyntax-only
template <typename T>
bool table<T>::remove(size_t index)
{
	return this->remove(this->find(index));
}
template <typename T>
bool table<T>::remove(T object)
{
	this->indexTable.erase(this->find)
}

./table.h:108:31: error: reference to non-static member function must be called
./table.h:108:36: error: expected ';' after expression
2 errors generated.
중대

indexTablevector 내부를 가리키는 댕글링 포인터 맵이다

생성자와 insert&(this->raw.back()), 즉 vector<T> 원소의 주소를 unordered_map<size_t, T*>에 저장한다. push_back은 용량 초과 시 버퍼를 재할당하므로, 두 번째 insert부터 이전에 저장한 포인터가 전부 무효가 된다. WM_CREATE에서 topIcon·botIcon에 각각 4개씩 넣으므로 재할당은 실제로 일어난다.

지금 프로그램이 안 죽는 이유는 indexTable읽는 코드가 없기 때문이다 — 조회는 전부 raw.at(index)로 한다. 쓰기만 하고 읽지 않는 필드가 UB의 뇌관으로 남아 있는 상태다. 인덱스 → 원소 매핑이 필요했다면 vector의 첨자가 이미 그 매핑이고, 애초에 이 맵이 존재할 이유가 없다.

덧붙여 table<T>bool visible;은 생성자(:44–51)에서 초기화되지 않는다. main.cpptopBar, botBar, topIcon, botIcon 네 개에만 setVisible을 부르고 topBarText에는 부르지 않는다. FOREACH_TABLE(table.h:11)이 getVisible()로 순회 시작 위치를 정하므로, 매 WM_PAINT마다 초기화되지 않은 bool을 읽는다.

Assignment#4/musicList/table.h:44–51, :80
template <typename  T>
table<T>::table()
{
	T temp;
	this->raw.push_back(temp);
	this->indexTable.insert({ raw.size() - 1, &(this->raw.back()) });
	this->type = temp.type;
	this->nullity = temp;
}   // ← bool visible; 는 여기서 초기화되지 않는다

// insert() 도 같은 방식 — push_back 재할당 시 위 포인터는 전부 무효
	this->indexTable.insert({ this->raw.size() - 1, &(this->raw.back()) });
중대

괄호 없는 abs 매크로 때문에 스크롤 감속이 한쪽 방향에서만 동작한다

main.cpp:7이 표준 라이브러리 이름 abs를 삼항 연산자 매크로로 덮어쓴다. 전체를 감싸는 괄호가 없다. main.cpp:128에서 abs(X) / 10으로 쓰는 순간 / 10이 삼항의 거짓 가지에만 결합한다.

결과적으로 X > 0일 때는 X 전체가, X ≤ 0일 때는 X/10y에 더해진다. 10ms 타이머로 도는 감속 애니메이션이 한쪽 방향에서는 10배 튄다. 같은 매크로를 쓴 :120은 우연히 (abs(...)) / 10처럼 한 겹 더 싸여 있어 정상 동작한다 — 같은 함수 안에서 두 줄이 서로 다르게 동작하는 셈이다.

Assignment#4/musicList/main.cpp:7, :120, :128
#define abs(x) (x) > 0 ? (x) : (-(x))   // 전체를 감싸는 괄호 없음

nList->y -= (abs(nList->y)) / 10;                          // :120  우연히 정상
nList->y += (abs(nList->y - SIZEW + nList->maxY())/ 10);  // :128  /10 이 거짓 가지에만 붙음
중대

메시지 루프가 PeekMessage로 CPU 코어 하나를 계속 태운다

애니메이션은 전부 SetTimer/WM_TIMER로 돌아가므로 게임 루프가 필요 없다. 그런데 WinMainGetMessage 대신 PeekMessage(PM_REMOVE)를 조건 없이 반복한다. 메시지가 없어도 루프가 멈추지 않으므로 유휴 상태에서 코어 하나를 100% 점유한다. 한 글자 수정(while (GetMessage(&messages, NULL, 0, 0)) { ... })으로 해결되는 문제다.

바로 위 :55SetProcessWorkingSetSize(hwnd, -1, -1)도 잘못된 호출이다. 이 API의 첫 인자는 프로세스 HANDLE인데 윈도우 HWND를 넘기고 있다. 반환값을 검사하지 않아 실패가 조용히 묻힌다. 워킹셋을 줄이려던 의도로 보이지만 실제로는 아무 일도 일어나지 않는다.

Assignment#4/musicList/main.cpp:55, :61–68
SetProcessWorkingSetSize(hwnd, -1, -1);   // HANDLE 자리에 HWND
...
while (messages.message != WM_QUIT)
{
	if (PeekMessage(&messages, NULL, 0, 0, PM_REMOVE))   // 메시지 없어도 계속 회전
	{
		TranslateMessage(&messages);
		DispatchMessage(&messages);
	}
}
중대

파일 다이얼로그를 열 때마다 객체가 새고, 파서 버퍼도 해제되지 않는다

new OpenFileDialog() / new SaveFileDialog()가 네 곳(:476, :493, :572, :618)에 있고 대응하는 delete가 하나도 없다. grep으로 확인한 main.cpp 전체의 delete 개수는 0이다. 더구나 두 클래스의 생성자는 각각 FileName = new TCHAR[MAX_PATH]를 또 할당하므로 다이얼로그 한 번당 두 덩이가 샌다. 이 객체들은 스택에 두면 될 것들이라 new 자체가 불필요했다.

musicListOpenmalloc(257)도 마찬가지로 해제되지 않는다. 이 함수는 WM_CREATE와 파일 열기 때마다 호출된다. 참고로 sizeof(char) * 257을 할당하고 getline(buffer, 256)으로 읽는데, istream::getline은 두 번째 인자에 널 종료를 포함한 크기를 받으므로 257은 그냥 근거 없는 여유분이다.

Assignment#4/musicList/main.cpp:217–218· grep "delete" main.cpp → 0건
char * buffer = (char*)malloc(sizeof(char) * 257);
while (in.getline(buffer, 256))
{ ... }
// 함수 끝(:267)까지 free(buffer) 없음
중대

파일이 조금만 어긋나면 args[0..3]이 범위 밖을 읽는다

</music>을 만나면 무조건 args[0]부터 args[3]까지 접근한다. args의 크기를 한 번도 확인하지 않는다. <music> 블록 안에 필드가 세 개만 있거나, 파일이 중간에 잘렸거나, 사용자가 필드 하나를 지운 XML을 열면 vector::operator[]가 경계 밖을 읽는다 — at()이 아니므로 예외도 던지지 않는다.

같은 클래스의 다른 코드에서는 raw.at(idx)처럼 at()을 쓰고 있어, 저자가 at()의 존재를 모르는 것은 아니다. 외부에서 들어오는 데이터를 다루는 딱 이 지점에서만 검사가 빠졌다.

Assignment#4/musicList/main.cpp:228–234
else if (temp == "</music>")
{
	musicList.insert(createInfo(args[0], args[1], args[2], args[3]));
	albumList.insert(createInfo(args[0], args[1], args[2], args[3]));
	genreList.insert(createInfo(args[0], args[3], args[1], args[2]));
	args.clear();
}   // args.size() 검사 없음
경미

operator<의 마지막 항이 this->genre다 — 복사·붙여넣기 오타

좌변과 우변을 같은 모양으로 이어 붙이다가 마지막 항만 info.genre 대신 this->genre가 되었다. 양변에 같은 값이 들어가므로 genre는 비교에 아무 영향을 주지 않는다. 이 연산자는 바로 아래 std::less<musicInfo> 특수화(:42–48)가 그대로 위임받아 쓴다.

실제 피해는 제한적이다 — keySearchsort/unique(main.cpp:158–159)에서 검색 결과 중복 제거에만 쓰이고, name·album·artist가 전부 같고 genre만 다른 곡은 드물기 때문이다. 그래도 문자열을 연결해서 비교하는 방식 자체가 취약하다. std::tie(name, album, artist, genre) < std::tie(...) 한 줄이면 오타도 나지 않고 (a="x", b="y")와 (a="xy", b="")를 혼동하지도 않는다.

bool operator< (const MusicInfo& info) const {
	return (this->name + this->album + this->artist + this->genre)
	     < (info.name + info.album + info.artist + this->genre);
}
경미

XOR 스왑, 중복된 drawProc, 매 프레임 테이블 복사

drawing.cpp:94preC ^= posC ^= preC ^= posC;는 한 표현식 안에서 preC를 두 번 수정하므로 C++14 이전 기준 미정의 동작이다. 같은 줄을 std::swap(preC, posC)로 쓰면 UB도 없고 의도도 드러난다.

:99–100은 완전히 동일한 drawProc 호출이 두 번 연속이다. 매 행마다 같은 사각형을 두 번 그린다.

draw_loop_text(drawing.cpp:59)와 draw_loop_blt·draw_loop_proc(drawing.h:29, :42)이 table<T>값으로 받는다. WM_PAINT마다 vectorunordered_map이 통째로 복사된다. 게다가 복사본의 indexTable은 원본 vector를 가리키는 포인터를 그대로 들고 온다 — 위에서 지적한 댕글링 문제가 여기서 한 겹 더 꼬인다. const table<T>&로 받았어야 한다. 바로 옆의 draw_loop_list는 실제로 listElements&로 참조를 받고 있어서, 저자가 참조를 모르는 게 아니라 템플릿 쪽에서만 놓친 것으로 보인다.

Assignment#4/musicList/drawing.cpp:94, :99–100
preC ^= posC ^= preC ^= posC;   // 한 표현식에서 preC 2회 수정
...
drawProc(dc, 0, yy + 1, SIZEW, list.margin, 1, 0, posC, 0, Rectangle);
drawProc(dc, 0, yy + 1, SIZEW, list.margin, 1, 0, posC, 0, Rectangle);  // ← 동일 호출 중복
경미

한글 음악 앱인데 검색창에 한글을 입력할 수 없다

검색 입력을 WM_KEYDOWN에서 가상 키 코드로 직접 처리하는데, 받아들이는 범위가 'A'–'Z''0'–'9'뿐이다. IME를 거치는 한글은 WM_KEYDOWN으로 오지 않으므로 한 글자도 입력되지 않는다. WM_CHAR(또는 WM_IME_CHAR)를 받았어야 한다. 저장된 list3.xml의 곡 제목이 모두 영문이라 테스트에서도 드러나지 않았을 것이다.

같은 맥락에서 musicList.h:78transform(..., toupper)도 위험하다. char를 그대로 toupper에 넘기는데 CP949 한글 바이트는 음수라 UB다. 인덱스 구축 경로(tableInsertString)에도 같은 호출이 있어, 한글 곡명은 검색 인덱스 자체가 어떻게 만들어질지 보장되지 않는다.

Assignment#4/musicList/main.cpp:394–404
default:
	if (wParam >= 'A' && wParam <= 'Z')
	{
		topBarText.find(2).text += (char)(wParam - 'A' + 'a');
		keySearch(topBarText.find(2).text, hwnd);
	}
	else if (wParam >= '0' && wParam <= '9')
	{ ... }
	break;   // 한글 경로 없음
경미

다이얼로그를 취소해도 곡이 추가된다

musicInputProcINPUT_OK를 눌렀을 때만 전역 inputed를 채운다(:93–101). 하지만 X 버튼으로 닫아도 WM_CLOSE → DestroyWindow → WM_DESTROY → PostQuitMessage 경로를 타고 내부 메시지 루프가 빠져나온 뒤, 호출부(:457, :547)는 성공/취소 구분 없이 무조건 musicList.insert(inputed)를 실행한다. inputed는 전역이므로 첫 취소에는 빈 곡이, 이후 취소에는 직전에 입력했던 곡이 다시 들어간다.

다이얼로그 프로시저는 이미 bool musicInput이라는 전역 플래그를 쓰고 있으니, INPUT_OK 분기에서 성공 여부를 따로 세팅하고 호출부에서 검사하면 세 줄로 끝난다.

경미

default: 블록 안에 case 레이블을 숨겨 놓았다

WM_KEYDOWN 처리의 switch에서 default:가 블록을 열고, 그 안에 case 'O', case 'S', case 'F'가 들어 있다(:472–517). C++ 문법상 유효하고 실제로 동작하지만(Duff's device와 같은 원리), default가 나머지 case보다 위에 있는 형태라 읽는 사람은 이 세 단축키가 죽은 코드라고 오해하기 쉽다. 그냥 default를 맨 아래로 내리면 될 일이다.

같은 함수 :639–641에는 default: break; break;로 도달 불가능한 break가 하나 더 있다.

잘한 것

musicList.cpp:31–63 tableInsert는 곡 제목을 " /-" 구분자로 토큰화한 뒤 각 토큰의 모든 접두사를 해시 맵에 등록해, 한 글자 입력마다 O(1) 조회로 증분 검색이 되게 만들었다. :52–53에서 sort+unique로 중복 토큰을 걷어내는 것까지 되어 있다. 1학년 1학기 C 수업 과제에서 자력으로 역인덱스를 설계해 붙인 것은 명백한 가산점이다. 같은 파일 :72–85sort()가 람다 비교자로 1차·2차 정렬 키를 분리한 것도 제대로 된 선택이다 — 이 도구를 이미 손에 쥐고 있었기 때문에, 위에서 지적한 "필드 셔플" 결함이 더 아쉽다.

15-11-09/LinkedList — 저자가 직접 쓴 연결 리스트

날짜 디렉터리에서 유일하게 저자가 처음부터 쓴 C 코드다. 한글 주석이 함수마다 달려 있고, 동봉된 option.txt에는 빌드 커맨드(gcc list_test.c list.c -std=c99 -o list)가 적혀 있다. 그 커맨드에 -Wall -Wextra를 붙여 실제로 빌드하고 실행했다.

중대

find_index가 "없음"과 "0번째"를 같은 값으로 반환한다 — 없는 값을 찾으면 "1번째에 있음"이라고 답한다

find_index는 찾으면 인덱스를, 못 찾으면 0을 반환한다(list.c:31). 첫 번째 원소의 인덱스도 0이므로 두 경우를 구별할 수 없다. 호출부는 이 값을 그대로 1 + find_index(...)로 출력한다.

실행으로 재현했다. 리스트에 5만 넣고 77을 검색하면 존재하지 않는 값에 대해 위치를 단언한다.

15-11-09/LinkedList/list.c:24–32· list_test.c:42
int find_index(list * head, int value)
{
	int result = 0;
	for (list * iter = head->next; iter != NULL; iter = iter->next, result++)
		if (iter->value == value)
			return result;
	return 0;   // ← 0번째와 구분 불가
}

// list_test.c:42
printf("%d는 %d번째에 있음\n", input, 1 + find_index(List, input));
실행 결과· printf 'i\n0 5\nf\n77\nq\n' | ./list
어디에 무엇을 삽입할것인가?: 5 ->
무엇을 할 것인가?: 무엇을 찾을것인가?: 77는 1번째에 있음
5 ->
중대

존재하지 않는 노드 뒤에 삽입하면 즉시 SIGSEGV — 재현됨

list_test.c:24find의 결과를 검사 없이 insert_next에 넘긴다. find는 못 찾으면 NULL을 반환하고, insert_next는 첫 줄부터 node->next를 역참조한다(list.c:83). 존재하지 않는 위치를 지정해 삽입하면 바로 죽는다.

대화형 입력과 직접 호출 두 경로 모두에서 종료 코드 139(SIGSEGV)를 확인했다. 근본 원인은 scanf의 반환값을 한 번도 확인하지 않는 것과 같은 뿌리다 — list_test.c:19, :31, :40 세 곳 모두 반환값을 버리므로, 숫자가 아닌 입력이 들어오면 arg/input:14에서 초기화되지 않은 채 그대로 쓰인다.

15-11-09/LinkedList/list_test.c:19–24· 실행 종료 코드 139
int arg, input;                       // :14 초기화 없음
...
scanf("%d %d", &arg, &input);         // :19 반환값 미검사

if (!list_length(List))
	insert(List, input);
else if (!exist(List, input))
	insert_next(input, find(List, arg));   // NULL 가능 → list.c:83 에서 역참조

$ printf 'i\n0 5\ni\n99 7\nq\n' | ./list ; echo $?
139
경미

malloc 검사 없음, 도달 불가능한 방어 코드, 전체 누수

create()(list.c:9–15)는 malloc 반환값을 확인하지 않고 바로 result->next에 쓴다. value 필드는 초기화하지 않는다 — 헤드 노드가 쓰레기 값을 들고 다니지만 순회가 head->next부터 시작하도록 일관되게 짜여 있어 증상은 나지 않는다.

remove_node(:64–65)의 if (prev == NULL) return 0;은 도달할 수 없다. find_front_of는 최소한 head를 반환하므로 절대 NULL이 아니다. 주석("prev가 없으면 삭제할 수 없고 0을 반환합니다")까지 붙어 있어 저자가 이 사실을 몰랐음을 보여준다. 반대로 정말 필요한 방어 — node가 이 리스트에 속하지 않을 때 find_front_ofwhile이 리스트 끝을 넘어가는 것 — 은 빠져 있다.

프로그램 종료 시 노드를 해제하는 코드가 없다. 다만 이 구조에서 free있는 곳(remove_node:73)의 짝은 맞다.

잘한 것

-Wall -Wextra로 빌드했을 때 경고가 unused parameter 'argc'/'argv' 두 개뿐이다. 같은 레포의 다른 파일(책에서 베껴 온 15-10-20/ex11.c:10non-void function does not return a value)보다 오히려 깨끗하다. 그리고 헤더(list.h)에 함수마다 한 줄씩 한글로 계약을 적어 두었다 — 무엇을 반환하고 없을 때 무엇을 주는지까지 명시되어 있다. 1학년 1학기에 헤더를 문서로 쓰는 습관이 잡혀 있는 것은 드물다.

15-11-03 / 15-11-25 — C++ 변환 시도

책의 C 연결 리스트를 C++ 클래스로 다시 쓰려는 시도다. 저자의 순수 창작물이지만, 컴파일이 되지 않는다. 그리고 레포에서 가장 마지막 날짜 디렉터리가 바로 이 파일이다.

치명적

한 번도 컴파일되지 않은 파일이 3주 간격으로 두 번 커밋됐다 — 두 사본은 바이트 단위로 동일하다

15-11-03/list.cc15-11-25/list.ccdiff로 비교하면 차이가 없다. list.h도 동일하다. 2015-11-03에 커밋된 파일이 2015-11-25 커밋("2015-11-25")에서 한 글자도 수정되지 않은 채 새 디렉터리로 복사됐다. 이 두 커밋이 레포의 마지막 실습 기록이다.

g++ -std=c++11로 빌드하면 첫 오류가 list.h:11에서 나온다. 문제가 한두 개가 아니다 — 아래는 확인한 것 전부다.

  • list.h:11–12List가 선언되기 전에 ListNode의 생성자 인자로 쓰인다(unknown type name 'List'). 전방 선언한 것은 ListNode 자기 자신이다.
  • list.h:11 — 게다가 ListNode(List* next, List* prev)는 타입이 잘못됐다. 멤버 next_/prev_ListNode*다.
  • list.h:29–30List(), ~List() 뒤에 세미콜론이 없다.
  • list.h:52L->frist() 오타. first다.
  • list.cc:56~List::List(). 올바른 문법은 List::~List().
  • list.cc:75neww ListNode(...) 오타.
  • list.cc:64, 76this->last_->next = node. next는 멤버 함수이고 필드 이름은 next_이며 private다. 바로 위에 setNext를 정의해 놓고 쓰지 않는다.
  • list.cc:24delete(this->value_). void*delete하는 것은 미정의 동작이다.
  • list.cc:84–87List_removevoid*를 선언해 놓고 아무것도 반환하지 않는다.
  • 헤더는 List_push(List*, void*)로 선언하고 정의는 List::List_push(void*)다. 선언과 정의가 하나도 맞지 않는다. 애초에 멤버 함수에 List* 인자가 남아 있는 것 자체가 C 코드를 기계적으로 옮기다 만 흔적이다.

정직하게 말하면 이것은 "실패한 연습"이지 "제출한 과제"가 아니다. 하지만 컴파일 한 번만 돌려봤으면 즉시 알 수 있는 상태로 3주를 방치한 뒤 그대로 재커밋했다는 점은 그 자체가 습관의 문제다. 같은 시기 15-11-0415-11-09list.c도 바이트 단위로 동일하게 중복 커밋되어 있다.

15-11-25/list.h:11, :29–30, :52· g++ -std=c++11 -c
	ListNode(List* next, List* prev);            // List 미선언 + 타입도 틀림
	...
	List()                                    // 세미콜론 없음
	~List()
	...
#define LIST_FOREACH(L, B, C) ... for(c = L->frist(); ...)   // first 오타

./list.h:11:11: error: unknown type name 'List'
./list.h:29:8: error: expected ';' at end of declaration list
list.cc:8:11: error: out-of-line definition of 'ListNode' does not match any declaration
15-11-25/list.cc:56, :64, :75
~List::List()                       // List::~List() 이어야 함
{
}
void List::List_push(void * value)
{
	ListNode* node = new ListNode(this->last_, NULL, value);
	this->last_->next = node;             // next 는 함수, 필드는 private next_
}
void List::List_unshift(void *value)
{
	ListNode* node = neww ListNode(NULL, this->first_, value);

커밋 패턴과 꾸준함

커밋 34개, 2015-10-07부터 2016-01-06까지 3개월. 브랜치는 master 하나뿐이고 숨겨진 구현은 없다. 밀도는 명확하게 두 국면으로 갈린다.

10-07 ~ 11-08 (책 따라 치기, 14커밋): 수업 날짜에 맞춰 주 1–2회씩 규칙적으로 올라온다. 커밋 메시지는 15-10-20, 11-03처럼 날짜 그 자체다 — 무엇을 했는지가 아니라 언제 했는지를 기록하고 있다. 학습 기록으로서는 정직하지만 메시지로서는 정보가 0이다.

11-08 ~ 11-15 (musicList, 12커밋): 8일 동안 12개. table created!object fixed! table fixed!list Created!find_button created!finsh_fix classesFINISHFinishedclass diagram fixedsorted!commited!fix colorquick settingquick key setting added. 느낌표가 붙기 시작하고, 기능 단위로 쪼개지고, FINISH 이후에도 네 번 더 손을 댄다. 과제 마감 직전에 몰아친 스프린트지만 커밋이 기능 단위로 끊겨 있고 완료 선언 후에도 다듬고 있다 — 이건 좋은 신호다.

그 뒤 11-25에 실패한 list.cc를 재커밋하고, 12-12 last commit, 이듬해 1월에 프로젝트 이름과 README를 고치며 끝난다. 즉 수업이 끝나자 레포도 끝났다. 스스로 이어 붙인 흔적은 없다.

git log 전체에서 "버그를 발견해 고쳤다"고 읽을 수 있는 커밋은 object fixed! table fixed!fix color 정도다. 무엇이 왜 틀렸는지 적은 메시지는 하나도 없다. 이 보고서가 지적한 결함 중 커밋 히스토리에서 스스로 잡아낸 것은 확인되지 않는다.

반복되는 패턴

  1. 검증 루프를 닫지 않는다. XML 라이터의 </name> 버그는 리더가 태그 이름을 안 보기 때문에 살아남았고, table<T>::remove의 문법 오류는 아무도 호출하지 않아서 살아남았고, list.cc의 컴파일 오류 11개는 컴파일을 한 번도 안 돌려서 살아남았다. 셋 다 "코드를 실제로 실행/빌드해서 결과를 확인한다"는 한 동작이 빠져서 생긴 것이다. 반대로 실제로 빌드하고 실행한 LinkedList-Wall -Wextra에서 경고 두 개로 깨끗하다.
  2. 외부에서 들어오는 데이터를 검사하지 않는다. args[0..3]size() 검사 없음(main.cpp:230), find()NULL 검사 없음(list_test.c:24, SIGSEGV로 재현), scanf 반환값 검사 없음(3곳), malloc 반환값 검사 없음, SetProcessWorkingSetSize 반환값 검사 없음. 내부 로직에서는 at()과 경계 체크를 쓸 줄 알면서, 경계를 넘어오는 지점에서만 일관되게 빠진다.
  3. 손이 닿지 않는 곳에 자원을 놓아둔다. delete 0개에 new 4개, free 0개에 malloc 1개(main.cpp). 반면 LinkedList처럼 자기가 자료구조를 직접 설계한 곳에서는 malloc/free의 짝이 맞는다. 소유권 개념이 없는 게 아니라, API 호출 주변에서만 놓친다.
  4. 자료구조를 재해석해서 재사용한다. musicInfo의 필드 자리를 바꿔 genreList로 쓰는 방식이 대표적이다. 새 타입이나 새 비교자를 만드는 대신 기존 것의 의미를 비틀어 끼우고, 그 비틀림을 코드 세 군데에서 각각 되돌려야 한다 — 그리고 실제로 한 군데(:459)에서 되돌리는 걸 잊었다. table<T>indexTable도 같은 성격이다: vector 첨자가 이미 하는 일을 포인터 맵으로 한 번 더 한다.
  5. 레포를 설명하지 않는다. 59바이트 README, 날짜뿐인 커밋 메시지, 출처 표기 없음. 빌드 산출물 .exe/.o 17개와 이미지 85개를 포함해 바이너리 102개가 196개 추적 파일 중 절반 이상을 차지하고, 팩 크기가 19.14 MiB다. 커밋된 .gitignore는 Visual Studio 표준 템플릿 그대로여서 *.exe·*.o를 무시하지 않는다 — 템플릿을 가져다 놓고 자기 프로젝트에 맞게 읽어보지는 않은 것이다.

지금 손본다면

  1. README에 세 줄 추가. "날짜 디렉터리는 Learn C the Hard Way (Zed A. Shaw) 예제 따라 치기, bstrlib은 Paul Hsieh, Assignment#3/#4가 직접 작성한 과제". 비용 5분. 이 레포를 보는 사람이 저자의 실력을 오판하지 않게 만드는, 가장 효과가 큰 한 수다. 지금 상태로는 채용 담당자가 bstrlib.c 2,962줄을 저자의 것으로 셀 수도, 반대로 musicList까지 베낀 것으로 의심할 수도 있다.
  2. main.cpp:281–283</name> 세 개를 고친다. 비용 3문자. 그리고 리더가 닫는 태그를 실제로 검사하도록 바꾼다 — 그래야 다음번에 같은 버그가 났을 때 앱이 스스로 알려준다. 검증은 xmllint --noout list.xml 한 줄이면 된다.
  3. main.cpp:459:549genreList.insert(createInfo(inputed.name, inputed.genre, inputed.album, inputed.artist))로 맞춘다. 비용 2줄. 더 나은 수정은 필드 셔플을 없애고 listElements::sort()에 장르 기준 비교자를 하나 더 추가하는 것이다 — 그러면 :232, :438, :567의 셔플 세 군데가 모두 사라진다.
  4. PeekMessage 루프를 GetMessage로 바꾸고, abs 매크로를 지운다. 비용 2줄. 앞의 것은 유휴 CPU 100%를 0%로 만들고, 뒤의 것은 <cstdlib>std::abs로 대체하면 :128의 연산자 우선순위 버그가 함께 사라진다.
  5. table<T>에서 indexTableremove를 통째로 삭제한다. 비용: 삭제뿐. 읽히지 않는 댕글링 포인터 맵과 컴파일 불가능한 함수를 동시에 없앤다. 남는 것은 vector<T> raw와 그 첨자뿐이고, 그것으로 충분하다. 같은 김에 bool visiblebool visible = false;로 초기화하면 topBarText의 미초기화 읽기도 닫힌다.
  6. 빌드 산출물을 추적에서 제거한다. .gitignore*.exe, *.o를 추가하고 git rm --cached. 커밋된 .exe 17개는 그 자체로 이 레포에서 가장 오래된 보안·위생 부채다.