ITE2037 · HANYANG UNIV · 2016 SPRING

객체지향프로그래밍

추적 파일 1,727개 중 저자가 쓴 것은 76개다. 그 76개 안에서 야심은 진짜지만, 야심을 실행하는 코드 경로마다 죽어 있거나 한 방향으로 고정되어 있다.

추적 파일1,727
저자 .java76 / 428
커밋90 / 117
기간2016.03–07
스택Java 8 · LOOT
C+종합
소견8 치명적13 중대5 경미합계 26

총평

이 레포에는 네 덩어리가 들어 있다. 주차별 기초 과제(assignment/OODP), 작은 축제용 게임 AI 봇 DEJAVA(클래스명은 TEJAVA), 4인 팀 프로젝트 IMF(project/IMF), 그리고 학기가 끝난 뒤 7월에 혼자 시작한 엔진 재작성 nimf. 나머지 1,600여 개 파일은 LOOT 게임 프레임워크, 수업이 배포한 L4G 시뮬레이터, 같은 반 학생 147명분의 봇 소스, TA 제공 코드, 그리고 Eclipse 워크스페이스 메타데이터다. 평가는 저자가 쓴 76개 파일 약 5,500줄에만 적용했다.

가장 잘한 것은 분명하다. DEJAVA는 "잉크 방울" 확산 모델 — 관측된 플레이어 위치에서 다음 턴 존재 확률을 사방으로 퍼뜨리고, 시야 밖 셀은 감쇠 계수를 곱해 신뢰도를 낮추는 — 을 TEJAVA.java:162–221에 실제로 구현했다. 상수는 짐작이 아니라 실험으로 골랐고, 그 실험을 위한 집계 하네스(TEST/TEST.java)와 Mathematica 노트북(doc/report/res/*.nb)까지 남아 있다. 학부 2학년이 게임 AI 과제에서 여기까지 가는 경우는 흔하지 않다.

가장 치명적인 것도 분명하다. 저자가 직접 설계한 코드 경로 중 여러 개가 실행되지 않거나, 입력과 무관하게 같은 값을 돌려준다. 감염자 상태의 이동 결정(TEJAVA.java:375–385)은 인자 조합 때문에 네 방향 점수가 전부 0이 되어 항상 Left를 반환한다. nimf의 타이머 이벤트 클래스는 static 초기화 블록에서 NullPointerException을 확정적으로 던진다(TimerEvent.java:45). ResourceManager.validate()는 예외를 던지고 같은 함수에서 잡아 버린 뒤 무조건 true를 반환한다. 그리고 master HEAD의 imf/Window.java는 게임 루프 전체가 주석 처리되어 있다. 즉 이 레포를 지금 클론해서 큰 축제를 실행하면 스플래시 화면만 그려진다.

중요한 완화 사실 하나. 큰 축제의 제출본은 멀쩡했다. 5d8f226(2016-06-26, "Update End of Project") 시점의 project/IMF/src/Window.java에는 processor.loop()를 호출하는 살아 있는 Update()가 있다. HEAD가 깨진 것은 7월 1일 7da901f "new Start"로 시작한 개인 재작성 때문이다. 학점을 다시 매긴다면 제출 시점을 봐야 하지만, 이 레포가 지금 이 사람의 엔지니어링을 어떻게 대변하는가를 묻는다면 HEAD가 답이다.

모듈주제핵심 판정등급
A1–A3기초 문법 · 배열 · 클래스/패키지계산해 놓고 안 쓰는 배열, 세 자리 수에서 터지는 인덱스. 크기는 작지만 틀렸다.C+
A6몬테카를로 π (Swing + 스레드)원 반지름과 샘플링 영역이 어긋나 π가 3.23으로 나온다. 과제의 목적 자체가 실패.C
작은 축제DEJAVA — L4G2EP2 게임 AI모델·실험·보고서는 진짜. 감염자 이동 분기가 항상 같은 방향을 반환.B-
큰 축제IMF — 2인 협동 퍼즐 (4인 팀)상속·제네릭·컴포지트가 실제로 쓰였다. 그러나 모든 예외를 빈 catch로 삼킨다.C+
nimf7월 개인 엔진 재작성 (코스 외)명세서가 코드보다 세 걸음 앞서 있다. 확정 NPE, out-param 오해, 문자열 결합 버그.D
위생레포 구성25 MB짜리 Eclipse 오류 리포트 인덱스, .class 385개, 남의 이름·학번 147개.D

무엇이 저자의 코드인가

이 레포는 단독 작업이 아니고, 추적 파일 대부분이 저자의 코드가 아니다. 아래 분리를 전제하지 않은 수치(예: 총 .java 428개, 총 127k줄)는 저자의 작업량으로 읽으면 틀린 값이 된다.

구분경로규모귀속
기초 과제assignment/OODP/15 파일 · 563줄배지운
봇 + 실험 하네스L4G2EP2/…/TEJAVA · Player_v8 · TEST3 파일 · 993줄배지운
IMF 본체project/IMF/src (network 제외)43 파일 · 3,060줄배지운 (팀 기여 포함 가능)
IMF 맵 에디터project/IMF_GENERATOR/src15 파일 · 859줄배지운
네트워크 계층project/IMF/src/imf/network8 파일 · 747줄이영수 (prevdev@gmail.com, 23커밋)
게임 프레임워크*/loot/*.java97 파일수업 배포 (LOOT v1.8)
시뮬레이터L4G2EP2/**/l4g, l4g2ep1140 파일수업 배포 (@author Racin)
동급생 제출 봇**/customplayers/*.java147 파일타 학생 — 저자 실력과 무관
TA 예제assignment/OODP_HW_*_by_TA5 파일조교
IDE 메타데이터assignment/.metadata, .recommenders174 파일Eclipse 자동 생성
중대

큰 축제는 4인 팀 프로젝트이고, 제안서에 팀원 실명이 있다

project/presentation/OODP_제안서.pptx에 팀 구성이 명시되어 있다. 이영수(팀장, 디자이너 겸 개발자), 박현규(매니저), 배지운(개발자), 이상헌(디자이너). 커밋 로그와도 일치한다 — 코드 기여자는 두 명뿐이고(MaybeS 90커밋, prevdev 23커밋), imf/network/* 8개 파일은 전부 이영수가 쓴 것이다. 아래 IMF 절의 지적은 imf/network를 제외한 나머지에만 적용했다.

제안서는 "6가지 Stage 게임"을 약속했다. 최종 트리의 맵 데이터는 data/map/stage1.xmldata/map/new.xml 두 개뿐이다. 6스테이지는 구현되지 않았다.

경미

git을 쓰면서 디렉터리 복사로 스냅샷을 떴다

L4G2EP2/ 아래에 old/, now/, final/ 세 벌이 거의 같은 프로젝트다. 같은 Classroom.java가 1,497줄 / 2,245줄 / 2,225줄로 세 번 존재한다. 브랜치나 태그를 쓰면 될 일을 폴더 복사로 해결했고, 그 결과 저장소의 .java 개수가 세 배로 부풀었다. project/IMF/data/default/default - 복사본.xml도 같은 습관이다.

기초 과제 — assignment/OODP

주차별 실습 문서(doc/*.pdf)를 따라간 연습 코드 15개 파일 563줄이다. 과제라기보다 실습 기록에 가깝고, 그 점 자체는 감점 사유가 아니다. 다만 이 규모에서도 계산해 놓고 버리는 코드와 경계 조건 붕괴가 나온다.

중대

AddNum은 결과를 계산해서 버리고 입력을 그대로 돌려준다

루프를 돌며 ret[i]를 채운 뒤 ret이 아니라 ary를 반환한다. 호출자는 value가 더해진 배열을 받았다고 믿지만 원본을 받는다. 바로 아래 AddOne(57–63행)은 같은 구조인데 ret을 제대로 반환하므로, 복사-붙여넣기 후 반환문만 고치지 않은 것이다. 다행히 mainAddOne만 호출해서 증상이 드러나지 않는다 — 즉 이 메서드는 한 번도 실행되지 않았다.

static int[] AddNum(int[] ary, int value) {
	int[] ret = new int[ary.length];
	for ( int i = 0; i < ary.length; i++ ) {
		ret[i] = ary[i] + value;
	}
	return ary;   // ← ret을 버린다
}
중대

369 게임이 110에서 ArrayIndexOutOfBoundsException으로 죽는다

한글 수사 배열 kor은 원소가 11개(인덱스 0–10)인데, first = currentNumber / 10은 세 자리 수에서 10을 넘는다. 100이면 first == 10이라 kor[10]("십")을 두 번 찍어 "십십"이 되고, 110이면 first == 11로 배열 밖을 읽는다. 입력에 상한이 없으므로(GetLastNumber는 사용자 입력을 그대로 받는다) 두 자리 이상을 입력하면 게임이 종료된다.

동일 로직을 Python으로 옮겨 확인했다: Say(99) → "구십구", Say(100) → "십십", Say(110) → IndexError(11).

String[] kor = {"", "일", "이", "삼", "사", "오", "육", "칠", "팔", "구", "십"};  // 11개
...
int first = currentNumber / 10;
int second = currentNumber % 10;
if (first > 1)
	System.out.print(kor[first]);   // ← currentNumber >= 110이면 밖
경미

Scanner를 반복 생성하고 닫지 않는다

Assignment1_1.java:13–17GetAge()는 호출될 때마다 System.in에 새 Scanner를 열고 닫지 않는다. main에서 세 번 호출되므로 세 개가 열린 채 남는다. 같은 주차의 Assignment1_2.java:17in.close()를 호출하므로 몰라서가 아니라 빠뜨린 것이다.

잘한 것

Assignment/Toy.java(필드 패키지-프라이빗, 생성자 하나)에서 toys/Toy.java(필드 private, 기본값을 static final 상수로 추출, 생성자 체이닝 this(defaultOfHead, …), ToyFactory 분리)로 가는 개선이 한 주 안에 실제로 일어났다. 3주차 과제가 요구한 캡슐화와 패키지 분리를 형식적으로가 아니라 이전 코드를 고쳐 쓰는 방식으로 체득했다.

Assignment 6 — 몬테카를로 π

LOOT 프레임워크 위에 점을 찍어 원 안팎 비율로 π를 추정하는 시각화(src/Window.java)와, 같은 계산을 백그라운드 스레드로 돌리는 utility/PiCalculator.java 두 벌이다. 과제의 전부가 "π가 나오는가"인데, 시각화 쪽은 나오지 않는다.

치명적

원의 반지름과 샘플링 영역이 어긋나 π가 3.23으로 나온다

중심은 (canvas_width-10)/2 = 295로 잡아 놓고 반지름은 canvas_height / 2 = 300을 쓴다. 그런데 표본은 Update()에서 rand.nextInt(canvas_width - 10), 즉 [0, 590)에서 뽑는다. 한 변 590인 정사각형 안에 반지름 300짜리 원을 넣으면 원이 사각형 밖으로 삐져나가므로, "원 안" 판정이 과대 계수된다. 반지름을 중심과 맞춘 295로 두어야 내접원이 된다.

동일한 표본 규칙을 Python으로 200만 회 재현해 확인했다: 현재 코드(반지름 300) → 3.2304, 반지름 295 → 3.1393. 2.8% 과대 추정이고, 이는 표본을 늘려도 수렴하지 않는 계통 오차다.

덧붙여 40행 double dist = isDistance(x,y);는 한 번도 읽히지 않고, 바로 다음 줄이 같은 함수를 다시 호출한다. 71행에서는 isIn(nX, nY)가 세 번째로 호출된다(65행에서 이미 in에 담아 두었다).

assignment/OODP/src/Window.java:34–42
private double isDistance(int x, int y) {
	int centerX = (settings.canvas_width-10)/2;   // 295
	int centerY = (settings.canvas_height-10)/2;  // 295
	return Math.sqrt((x-centerX)*(x-centerX) + (y-centerY)*(y-centerY));
}
private boolean isIn(int x, int y) {
	double dist = isDistance(x,y);   // ← 쓰이지 않음
	return isDistance(x,y) <= (settings.canvas_height / 2);  // 300 ≠ 295
}
중대

매 프레임 Dot을 만들어 레이어에 영구 누적한다 — 1µs 간격으로

Update()는 호출될 때마다 Dot을 하나 생성해 layer.children에 넣고, 제거하는 코드는 없다. 그리고 Assignment6.java:17settings.gameLoop_interval_ns = 1000, 즉 1마이크로초 주기를 요구한다. 프레임 루프가 실제로 이 속도를 못 내더라도 children은 단조 증가하므로 장시간 실행 시 힙이 고갈되고, layer.Draw(g)가 매 프레임 전체 리스트를 순회하므로 프레임마다 느려진다. 표본 수를 늘릴수록 정확해져야 하는 몬테카를로 과제인데 표본을 늘릴수록 멈춘다.

assignment/OODP/src/Window.java:71–72
Dot dot = new Dot(nX, nY, isIn(nX, nY));
layer.children.add(dot);   // 제거 없음
중대

스레드 간 공유 변수에 volatile이 없다 — 정지 요청이 전달된다는 보장이 없다

isStopRequested는 메인 스레드가 Stop()에서 쓰고 워커 스레드가 루프 조건에서 읽는다. pi는 반대 방향이다. 둘 다 volatile도 아니고 동기화 블록도 없다. JIT는 루프 안에서 값이 바뀌지 않는다고 판단해 isStopRequested 읽기를 루프 밖으로 끌어낼 수 있고, 그러면 Stop()을 불러도 워커가 영원히 돈다. Print()가 읽는 pi도 최신 값이라는 보장이 없다. 실습 문서가 "내 주인에 의해 Stop()이 호출되었는지"라고 주석까지 달아 준 변수인데 가시성 문제는 다루지 않았다.

표본 추출 자체는 문제가 없다. 같은 규칙을 재현하면 3.1373이 나와 π에 근접한다 — Window 쪽과 달리 여기서는 중심과 반지름이 맞아 있다.

private boolean isStarted;        // 내 주인에 의해 Start()가 호출되었는지
private boolean isStopRequested;  // volatile 아님
...
	for ( int i = 0; isStopRequested == false; i++ )   // 워커 스레드가 읽음
...
public void Stop() { isStopRequested = true; }         // 메인 스레드가 씀

작은 축제 — DEJAVA (TEJAVA.java, 462줄)

13×13 교실에서 96명이 생존자/감염자/시체/영혼 상태를 오가며 점수를 겨루는 턴제 시뮬레이터의 플레이어 서브클래스다. 매 턴 cells[][]에서 보이는 것만 관측할 수 있고, 다섯 개의 추상 메서드(Survivor_Move, Infected_Move, Soul_Spawn, Corpse_Stay, Soul_Stay)를 구현한다. 저자의 보고서(doc/report.docx)는 이 봇을 DEJAVA라 부르는데 클래스명은 TEJAVA다.

치명적

감염자 이동은 지도를 보지 않는다 — 항상 Left를 반환한다

보고서는 "감염상태에서는 … 만약 해당 위치에 시체가 나타날 확률이 높다면 적은 쪽으로 이동합니다"라고 쓰여 있다. 코드는 그 판단을 하지 않는다.

Infected_Move()getDirectionNotExp(expect[CORP], 0, false)를 호출한다. 인자 두 개가 문제다. mapPrefer == false이므로 getNearExpects 256행의 "그 칸 자신의 값" 대입이 건너뛰어져 exp[k]가 0으로 남고, expRev == 0이므로 이어지는 이웃 합산이 전부 × 0이 된다. 결과적으로 유효한 네 방향의 점수가 모두 정확히 0.0이다. getDirection(false, …)은 최솟값을 찾는데, 동점일 때 갱신 조건이 엄격한 <라서 첫 번째 유효 인덱스에서 멈춘다. DirectionCode의 선언 순서가 {Left, Right, Up, Down, Stay}이므로 반환값은 언제나 Left다(왼쪽 벽에 붙어 있을 때만 Right).

즉 감염자 상태의 전체 전략 — 시체 기댓값 맵을 계산하고, 확산시키고, 낮은 쪽을 고르는 — 이 한 개의 리터럴 0 때문에 상수 함수로 무너져 있다. 같은 줄의 조건문도 틀렸다: expect[HEIGHT][WIDTH] 배열인데 [nowX][nowY]로 행·열을 뒤집어 읽는다. 맵이 13×13 정사각이라 예외는 나지 않지만 항상 다른 칸의 값을 본다.

double exp[] = new double[4];              // 전부 0.0
for (int k = 0; k < dirSize; ++k) {
	...
	if (mapPrefer)                         // false → 건너뜀
		exp[k] = exps[ey][ex] * expect[CLAS][ey][ex];
	for (int kk = 0; kk < dirSize; ++kk) {
		...
		exp[k] += exps[...] * expRev;    // expRev == 0 → 0 더하기
	}
}
...
if (expect[CORP][nowX][nowY] >= EXPCONST[CORP])   // 행·열 전치
	return getDirectionNotExp(expect[CORP], 0, false);
치명적

시체 기댓값을 이웃 칸에 쓸 때만 범위 검사가 빠져 있다

같은 메서드 안의 다른 모든 이웃 접근(185행, 260행, 345행)은 isValidPoint로 감싸는데, ForEach_Players 람다 안의 이 한 줄만 검사가 없다. kk == 0일 때 dx[0] == -1이므로 py == 0인 플레이어(맨 윗줄)에 대해 expect[CORP][-1][…]을 쓰려 한다. kk == 2일 때는 dy[2] == -1이라 px == 0인 경우 열 인덱스가 −1이 된다.

조건은 "냉각 시간이 0인 감염자"이고 맵은 13×13에 96명이 들어차 있다. 가장자리 48칸 중 하나에 그런 감염자가 서 있으면 ArrayIndexOutOfBoundsExceptioninitialize() → 네 개의 오버라이드 전부로 전파된다. 게임 중 거의 확실히 발생하는 조건이다.

…/customplayers/TEJAVA.java:203–211
c.ForEach_Players( (info -> {
	int py = info.position.row, px = info.position.column;
	if (info.state == StateCode.Corpse && info.transition_cooldown > 0)
		expect[CORP][py][px] += DIRECTPR[CORP];
	else if (info.state == StateCode.Infected && info.transition_cooldown == 0)
		for (int kk= 0; kk < dirSize + 1; ++kk)
			expect[CORP][py + dx[kk]][px + dy[kk]] += DIRECTPR[INFE];
			// ← isValidPoint 없음. py==0, kk==0이면 인덱스 −1
}) );
중대

부활 위치 계산에서 행 루프가 WIDTH까지 돈다

Soul_Spawn() 안의 확산 루프에서 행 인덱스 y의 상한이 HEIGHT가 아니라 WIDTH다. 바로 위 411행과 아래 426행은 HEIGHT를 쓰므로 복사-붙여넣기 오류다. 13×13이라 지금은 같은 값이라 드러나지 않지만, Constants.Classroom_HeightClassroom_Width는 별개의 설정값이고 비정사각 맵에서는 행이 잘리거나 인덱스가 넘친다. 420행의 isValidPoint(y + dy[k], x + dx[k])도 인자 순서가 뒤집혀 있다 — 이 함수의 시그니처는 (Column, Raw)이고, 345행에서는 isValidPoint(x+dx[k], y+dy[k])로 올바르게 호출한다. 같은 파일 안에서 같은 함수를 두 가지 순서로 부른다.

…/customplayers/TEJAVA.java:411–422
for (int y = 0; y < HEIGHT; ++y)
	Arrays.fill(expTmp[INFE][y], 0);

for (int y = 0; y < WIDTH; ++y)        // ← 행인데 WIDTH
	for (int x = 0; x < WIDTH; ++x) {
		...
		if (!isValidPoint(y + dy[k], x + dx[k]))  // ← (Column, Raw) 순서 반대
			continue;
경미

쓰이지 않는 상수, 그리고 오타로 같은 값을 가리키는 두 상수

EXPSCORE는 원소 6개짜리 점수 임계값 배열인데 실제로 읽히는 인덱스는 SMAX(0)와 STOT(1)뿐이다. CMAX, CTOT, IMAX, ITOT는 정의만 되고 한 번도 쓰이지 않는다. 게다가 ITOT가 5가 아니라 4라서 IMAX와 같은 칸을 가리킨다 — 쓰였다면 650 대신 25를 읽었을 것이다. 같은 맥락으로 expectnew double[5]인데 상수 REVS = 5가 있고(배열 첨자로 쓰였다면 예외), 할당된 expect[COST]는 한 번도 쓰이지 않는다.

…/customplayers/TEJAVA.java:78–89
final int SMAX = 0; final int STOT = 1;
final int CMAX = 2; final int CTOT = 3;
final int IMAX = 4; final int ITOT = 4;   // ← 5여야 함
private final int[] EXPSCORE = { 35, 700, 70, 950, 25, 650 };
잘한 것

세 가지가 구체적으로 좋다. (1) 확산 모델이 시야 안/밖을 다르게 다룬다 — 189–200행에서 관측된 셀은 실제 인원수로, 미관측 셀은 이전 턴 추정치에 감쇠 계수 EXPCONST[REVS] = 0.4를 곱해 갱신한다. "내가 모르는 정보는 덜 믿는다"를 코드로 표현한 것이다. (2) 맵 가중치가 부호까지 맞다 — 114행의 e^(중심으로부터의 거리 × 0.8)은 가장자리에서 크고, 이 값을 최소화 탐색에 곱셈으로 넣으므로 구석을 피하고 최대화 탐색(자살 경로)에서는 구석을 택한다. (3) 상수를 실험으로 골랐다는 주장에 증거가 있다 — src/TEST/TEST.java(134줄)는 봇 종류별 6개 점수 축을 누적·평균 내 TSV로 떨구는 집계기이고, doc/report/res/에 Mathematica 노트북과 시각화 PNG가 남아 있다. 학부 과제에서 하이퍼파라미터를 "느낌"이 아니라 반복 실행 결과로 정했다는 흔적은 드물다.

다만

TEST.java는 트리 어디에서도 참조되지 않는다(Program.java, Classroom.java 어디에도 호출 없음). 실험할 때 시뮬레이터를 직접 고쳐 끼웠다가 배선을 남기지 않은 것이다. 재현 가능한 실험 설정이 아니라 일회용 흔적으로 끝났다. show()FileOutputStream도 성공 경로에서만 닫힌다(TEST.java:105, 132).

큰 축제 — IMF (본인 기여분)

2인 협동 퍼즐 게임. 저자가 맡은 부분은 오브젝트 모델(imf/object), 프레임 처리기(imf/processor), XML 데이터 파서(imf/data), 그리고 별도 맵 에디터(IMF_GENERATOR)다. 네트워크는 팀원 이영수가 맡았으므로 제외했다. 클래스 설계는 이 레포에서 가장 나은 부분이다 — 문제는 예외 처리와 정리(teardown)다.

치명적

master HEAD의 게임 루프가 통째로 주석 처리되어 있다

imf/Window.javaUpdate()는 250–277행 전체가 주석이고 본문은 return true; 한 줄이다. Initialize()도 160–242행이 주석이라 processor.install("interaction", …) 이하 다섯 개의 처리기가 하나도 설치되지 않는다. 필드로 선언된 interaction / keyboard / physics / mouse / scene(56–61행)은 전부 null인 채 남고, onReceived()는 본문 전체가 주석인 빈 메서드다. 결과적으로 imf.processor 816줄, imf.object 537줄, 팀원의 imf.network 747줄이 HEAD에서 모두 도달 불가 코드다. 실행하면 default.xml을 파싱해 스프라이트를 뷰포트에 붙이고 그리는 것으로 끝난다 — 입력도, 물리도, 네트워크도 없다.

공정하게 덧붙이면, 학기 제출본은 정상이었다. git show 5d8f226:project/IMF/src/Window.java(2016-06-26, "Update End of Project")에는 inputs.AcceptInputs()processor.loop()를 호출하는 391줄짜리 살아 있는 Window가 있다. 깨진 것은 7월 1일 7da901f "new Start"로 시작해 da4cfe1(7월 9일)에서 주석 처리가 확정된 개인 재작성 때문이다. 문제는 그 재작성이 완료되지 않은 채 기본 브랜치에 그대로 남았다는 것이다 — 이 레포를 여는 사람이 보는 것은 제출본이 아니라 이 상태다.

project/IMF/src/imf/Window.java:247–279
@Override
public boolean Update(long timeStamp)
{
	/*
	if (loading) return true;
	inputs.AcceptInputs();
	...
	processor.loop();
	return true;
	*/
	return true;
}
치명적

프레임 루프 전체가 하나의 빈 catch (Exception e) {}로 감싸여 있다

ProcessManager.loop()는 모든 처리기의 매 프레임 호출을 통째로 try로 묶고 빈 catch로 받는다. HashMap.forEach는 예외가 나면 순회를 중단하므로, 어느 한 처리기에서 NullPointerException 하나가 나면 그 뒤 처리기들은 그 프레임에서 조용히 건너뛰어진다. 로그도, 카운터도, 플래그도 없다. 물리가 멈췄는지 입력이 안 먹는지 구분할 방법이 프로세스 안에 존재하지 않는다.

같은 패턴이 Physics.doCollision()(148–150행, catch { return; })과 SpriteObject.invisible()(70–72행)에도 있다. 후자는 trigger_objectnull일 때의 NPE를 삼키려고 존재하는 catch다 — 즉 null 검사를 예외 처리로 대신하고 있다. 저자 코드 전체에서 빈 catch가 9곳, 로그만 찍고 넘기는 catch가 5곳이다.

덧붙여 forEachHashMap의 순회 순서를 따르므로 처리기 실행 순서가 명세되지 않는다. 키보드 → 물리 → 상호작용 → 씬 순서가 프레임마다 보장되어야 하는 게임 루프에서 이는 실질적 위험이다. LinkedHashMap 한 글자 차이로 해결되는 문제다.

@Override
public void loop()
{
	try {
		processors.forEach((name, processor)-> {
			if(processor != null)
				processor.loop();
		});
	} catch (Exception e) {

	}
}
중대

finalize()를 수명주기 메서드 이름으로 썼다 — GC 훅을 덮어쓴 것이다

IProcess 인터페이스가 void finalize()를 선언하고 다섯 개 처리기가 이를 구현한다. 그런데 finalize()java.lang.Object의 메서드이고, JVM이 객체를 회수하기 직전에 임의의 시점에 부르는 종료자다. 이름을 이렇게 잡으면 두 가지가 생긴다. (1) 이 클래스들이 전부 finalizable로 등록되어 GC가 별도 큐를 거치게 된다. (2) ProcessManager가 GC 대상이 되면 JVM이 ProcessManager.finalize()를 호출하고, 그 안에서 processors.forEach(… processor.finalize())가 돌아 아직 살아 있을 수 있는 하위 처리기들의 참조를 null로 만든다. 의도한 명시적 정리와 GC의 암묵적 호출이 구분되지 않는다. destroy(), dispose(), shutdown() 중 아무 이름이나 골랐으면 없었을 문제다.

public interface IProcess<T, R> extends IProcessProperty<T, R>
{
	void initilize(IProcess manager);
	void loop();
	void process();
	void finalize();   // ← Object.finalize() 재정의
}
중대

Timer 스레드를 만들고 끝내 cancel()하지 않는다 — JVM이 종료되지 않는다

Physics.java:24Interaction.java:20이 각각 필드 초기화로 new Timer()를 만든다. java.util.Timer는 기본적으로 비데몬 스레드를 띄우므로, 취소하지 않으면 메인 창을 닫아도 JVM이 살아 있는다. 두 클래스의 finalize()는 참조만 null로 밀 뿐 timer.cancel()을 부르지 않는다. 주석 처리된 Window.Destroy()가 스테이지 전환마다 new ProcessManager를 만들도록 되어 있었으므로, 스테이지를 넘길 때마다 좀비 타이머 스레드가 두 개씩 누적되는 구조였다.

거기에 Physics.java:82는 가시 장애물과 겹쳐 있는 동안 매 프레임 timer.schedule(new DeadTask(), 5000)을 호출한다. 75 fps 설정이므로 가시에 1초 닿아 있으면 사망 콜백이 약 75개 예약된다. 중복 예약을 막는 플래그가 없다.

그리고 그 DeadTask.run()은 Timer 스레드에서 manager.property.setter("dead")를 호출해 게임 상태를 바꾼다. 같은 상태를 게임 루프 스레드가 매 프레임 읽고 AWT가 그린다. 동기화는 없다.

중대

null 검사가 세 번의 역참조 뒤에 온다

조건식은 왼쪽부터 평가되므로 onull이면 target.zPosition(o)o.collision에서 이미 NPE가 난다. o == null은 절대 true로 평가되지 않는 죽은 조건이다. 그리고 그 NPE는 위 doCollision의 빈 catch(148행)가 삼킨다 — 방어 코드를 넣었다고 믿게 만드는 코드가 실은 아무것도 막지 않는 구조다.

같은 메서드 위쪽 57–61행에는 public static boolean collision(SpriteObject, SpriteObject) { return true; }가 있다. 이름이 collision인데 인자를 보지 않고 true만 돌려준다. 호출하는 곳은 없다. 140행 boolean exc = false;도 마찬가지로 이후 한 번도 대입되지 않아 153행 if (!exc)가 항상 참이다 — catch에서 exc = true로 표시하려던 흔적만 남고 실제로는 return으로 빠져나간다.

if (!target.zPosition(o) || o.collision == false || o.trigger_hide == true || o == null || ...)
	return state_next_jump;
//   ↑ 여기 도달했다면 o는 이미 null이 아니다
중대

점프가 중력 0인 상태에서 속도를 NaN으로 만든다

a_y가 0이면 0 / Math.abs(0)은 부동소수점에서 예외가 아니라 NaN이다. v_y = NaN이 되고, 이후 doMove()pos_y + v_y를 계산하면 위치도 NaN이 되어 영구히 복구되지 않는다(NaN은 어떤 비교에도 false를 돌려주므로 충돌 보정 분기가 전부 통과된다). PhysicalObjecta_y 기본값은 0이고(PhysicalObject.java:17), 중력은 Windowme.a_y = -0.98로 따로 넣어 준다 — 즉 그 대입이 빠진 오브젝트에서 점프하면 즉시 발생한다. 부호를 뽑으려면 Math.signum을 쓰거나 a_y == 0을 먼저 걸러야 한다.

case 4:
	if(state_jump)
		return;
	state_jump = true;
	target.v_y = 15 * -((target.a_y) / Math.abs(target.a_y));   // a_y==0 → NaN
	break;
경미

의도적 fall-through로 순서를 만드는 switch

Interaction.setter()switch (object.first)"hover"/"leave" → "act" → "act_emotion" → "act_emotion_partner" → "dead"/"act_partner"/"act_child"까지 break 없이 흘러내리고, 각 case 안에서 다시 object.first.equals("act") 같은 검사로 실행 여부를 판단한다. 즉 switch가 분기 역할을 하지 않고 진입점 선택 역할만 하며, 실제 분기는 중복된 문자열 비교가 한다. 가운데에 case를 하나 추가하면 위쪽 모든 케이스의 동작이 조용히 바뀐다.

Pair.java:17–19에도 같은 계열의 부주의가 있다: equalsSecondcompare.second.equals(first)로 두 번째 값을 첫 번째 값과 비교한다. 호출하는 곳이 없어 증상은 없지만, Object.equals(Object)를 오버라이드하지 않고 equals(Pair)로 오버로드했기 때문에 PairHashMap이나 List.contains에 넣으면 동일성 비교로 떨어진다. hashCode()도 없다.

public boolean equalsFirst(Pair compare) {
	return compare.first.equals(first);
}
public boolean equalsSecond(Pair compare) {
	return compare.second.equals(first);   // ← second여야 함
}
잘한 것

OOP 과목의 요구 자체는 충족했고, 흉내가 아니다. 상속 사슬이 실제 역할 분담을 반영한다 — DrawableObject3D(프레임워크) → SpriteObject(이름·텍스처·바운딩 박스) → PhysicalObject(속도·가속도) → PlayerObject/PartnerObject, 그리고 별도로 SpriteObjectContainerObjectTriggerObject. IProcessProperty<T,R>IProcess<T,R>가 확장하고 다섯 처리기가 각기 다른 타입 인자로 구현하는 것도 제네릭의 실용적 사용이다. 특히 ProcessManagerIProcess구현하면서 동시에 IProcess들을 관리하는 컴포지트 구조는 2학년이 패턴 이름을 모르고 도달했다면 좋은 신호다. 가장 큰 클래스가 235줄(Physics)이라 God class도 없다.

2016년 5~6월 커밋 로그에 fix collision, fix trigger floor, fix timer, button leave fix, fix transfor position 등 19개의 수정 커밋이 있다. 남이 지적해 준 흔적이 아니라 돌려 보며 스스로 잡은 기록이다.

nimf — 7월의 재작성 (코스 외)

학기 종료 직후 시작한 개인 작업이다. 목표는 분명하다 — 게임 로직을 Java에서 빼내 XML로 기술하는 데이터 주도 엔진. project/IMF/data/IMF명세서.md가 그 문법을 상세히 정의한다: 스프라이트 16개 속성, 스크립트 11개 이벤트, attribute="visible=true" 조건, delay, fin/over 명령. 문제는 명세서가 코드보다 세 걸음 앞서 있고, 그 격차가 "덜 만들었다"가 아니라 "만든 것도 실행되지 않는다"라는 점이다.

치명적

타이머 이벤트 클래스는 로드되는 순간 확정적으로 NullPointerException을 던진다

clear()queue.clear()를 호출한 다음 줄에서 queue를 생성한다. 그리고 이 clear()는 19–21행의 static 초기화 블록에서 불린다 — 그 시점에 queuenull이다. 따라서 TimerEvent 클래스가 처음 참조되는 순간 <clinit>에서 NPE가 나고 ExceptionInInitializerError로 번진다. 조건부 버그가 아니라 100% 재현되는 결정적 실패다.

ScriptObject.run() 126행이 event == ALWAYS인 스크립트마다 TimerEvent.put(this)를 부르므로, 명세서가 정의한 always 이벤트는 한 번도 동작한 적이 없다. 이 코드가 단 한 번이라도 실행됐다면 즉시 드러났을 버그라는 점이 중요하다 — nimf 전체가 컴파일만 되고 실행 검증 없이 커밋되었다는 뜻이다.

같은 클래스 32행의 while ((now = queue.peek()).first <= counter)도 큐가 비면 peek()null을 돌려주므로 NPE다. 그리고 54행 timer.schedule(task, 1)scheduleAtFixedRate가 아니라 1회성 예약이라 이 태스크는 평생 한 번만 실행된다.

project/IMF/src/nimf/event/TimerEvent.java:19–21, 40–46
static {
	clear();              // ← queue는 아직 null
}
...
public static void clear() {
	task = null;  task = new TimerEventTask();
	timer = null; timer = new Timer();
	queue.clear();            // ← NPE
	queue = new PriorityQueue<...>(...);   // ← 생성은 그 다음
}
치명적

명세서의 첫 번째 예제가 스크립트 엔진을 크래시시킨다

rvalue.indexOf("." + 1)에서 "."int 1+로 묶으면 Java는 문자열 결합을 한다 — 즉 indexOf(".1")이다. 의도는 rvalue.indexOf(".") + 1이었고, 같은 파일 150행은 l.substring(l.indexOf(".")+1)로 올바르게 쓰여 있다. 괄호 하나가 어긋난 결과, 값에 ".1"이라는 부분 문자열이 없으면 indexOf-1을 돌려주고 substring(-1)StringIndexOutOfBoundsException을 던진다.

119행 가드가 rvalue에 마침표가 있으면 이 분기에 들어가게 되어 있는데, 명세서 78–81행이 예로 든 button.texture="btn_hover.png";의 우변 "btn_hover.png"가 정확히 그 조건이다. 문서가 안내하는 대로 XML을 쓰면 엔진이 죽는다. 설령 예외를 피해도 SpriteManager.get("\"btn_hover")null을 돌려줘 NPE가 된다.

if (rvalue.indexOf(".") != -1)
	rvalue = SpriteManager.get(rvalue.substring(0, rvalue.indexOf(".")))
	                      .get(rvalue.substring(rvalue.indexOf("." + 1)));
	                      //                        ↑ ".1"을 찾는다. indexOf(".") + 1 이어야 함
치명적

Java의 인자 전달을 C의 out-parameter로 오해했다 — attribute 기능 전체가 무효

명세서 62행은 attribute를 "타겟의 속성 상태를 지정합니다. 비워두면 언제나 참입니다. 예) attribute="visible=true""라고 정의한다. 구현은 이렇다: parse(attribute, attr_name, attr_value)를 호출하고, 그 안에서 파라미터 attr_nameattr_value에 대입한다. Java는 참조 을 복사해 넘기므로 이 대입은 지역 변수만 바꾸고 사라진다. 게다가 이 메서드는 static이라 인스턴스 필드 this.attr_name에 쓸 방법 자체가 없다. 즉 ScriptObject.attr_nameattr_value는 영원히 빈 문자열이고, 스크립트 조건 실행은 구현되지 않은 것과 같다.

이것은 실수가 아니라 언어 모델의 오해다. C에서 char **out을 넘기던 습관이 그대로 옮겨 왔다. 학부 2학년 OOP 과목에서 잡혀야 할 가장 기본적인 개념 중 하나다.

parse(attribute, attr_name, attr_value);   // 호출부 (94행)
...
private static void parse(String attribute, String attr_name, String attr_value) {
	int pos = attribute.indexOf('=');
	if (pos == -1) return;
	attr_name  = attribute.substring(0, pos);      // 지역 변수에 대입, 버려짐
	attr_value = attribute.substring(pos + 1);
}
중대

이벤트 열거자의 값이 중복돼 마우스 이벤트와 오브젝트 이벤트를 구분할 수 없다

명세서 51–61행은 object@enter/leave/join/interactmouse@enter/leave/join/interact를 서로 다른 8개 이벤트로 정의한다. 열거자는 이 8개에 TIMER까지 합쳐 2,3,4,5 네 개의 값만 나눠 쓴다 — TIMER, oENTER, mENTER가 모두 2다. 그리고 103행이 ScriptManager.s_[event.get()]로 그 값을 배열 첨자로 쓴다. 결과적으로 마우스가 객체에 올라간 사건과 캐릭터가 객체에 닿은 사건이 같은 버킷에 들어가 구별되지 않는다. 버킷은 11개 할당되는데 6개만 쓰인다.

같은 파일 21–23행의 public enum Attribute { }는 본문이 비어 있다. 선언만 있고 상수가 하나도 없으며 참조하는 곳도 없다.

public static enum Event {
	NEVER(0), ALWAYS(1), TIMER(2),
	oENTER(2), oLEAVE(3), oJOIN(4), oINTERACT(5),
	mENTER(2), mLEAVE(3), mJOIN(4), mINTERACT(5);
...
ScriptManager.s_[event.get()].put(name, this);   // 값 충돌 = 버킷 충돌
중대

validate()가 자기가 던진 예외를 자기가 삼키고 무조건 참을 반환한다

리소스 경로가 실제 파일인지 확인하려고 ValidateException을 던지는데, 같은 메서드의 catch가 빈 블록으로 잡고, finally도 비어 있고, 마지막 줄은 조건 없는 return true;다. 존재하지 않는 텍스처 경로도 전부 통과한다. 호출부인 put() 28행의 if(validate(path))는 항상 참이므로 if 자체가 의미가 없다.

이어지는 44–46행 get(name)은 찾지 못하면 null.second로 NPE를 낸다. imf/Window.java:145ResourceManager.get("texture").forEach(...)로 무방비 호출하므로, 텍스처가 하나도 없는 맵을 열면 시작하자마자 죽는다.

private static boolean validate(String path) {
	try {
		 if (!(new File(path)).isFile()) {
				throw new ValidateException();
		 }
	} catch (ValidateException v) {
	} finally {
	}
	return true;   // ← 무엇을 넣든 true
}
중대

설정 파서가 입력을 검증하지 않고, 예외가 나면 파일 핸들을 흘린다

line.split("=")의 결과에서 s[1]을 검사 없이 읽는다. 빈 줄, 주석 줄, = 없는 줄이 하나라도 있으면 ArrayIndexOutOfBoundsException이 나는데, catchFileNotFoundExceptionIOException만 잡으므로 이 예외는 밖으로 나가 Window 생성자를 터뜨린다. 39행의 (String)path.get(...)도 참조된 키가 없으면 null 캐스팅 후 replace(…, null)로 NPE다.

그리고 br.close()try 블록 안 마지막 줄에 있어서, 위 어느 예외든 발생하면 BufferedReader가 닫히지 않는다. Java 7의 try-with-resources를 쓰면 한 줄로 끝나는 문제고, 이 프로젝트는 이미 람다(Java 8)를 쓰고 있으므로 언어 버전 제약도 아니다.

부수적으로 Window.java:67"data/IMF.inf"를 읽는데 실제 파일은 data/imf.inf다. macOS 기본 파일시스템(대소문자 구분 없음)에서는 열리지만 Linux에서는 FileNotFoundExceptionprintStackTrace → 기본 경로만 남은 채 조용히 진행된다.

String s[] = line.split("=");
... s[1] ...            // ← 길이 검사 없음
...
		br.close();          // ← try 안. 위에서 던지면 도달 못 함
	} catch (FileNotFoundException e) { e.printStackTrace();
	} catch (IOException ee)          { ee.printStackTrace(); }
경미

같은 속성의 setter와 getter가 서로 다른 값을 쓴다

set("w", "32")radius_x = 32로 저장하는데 get("w")radius_x * 2 = 64를 돌려준다. 왕복이 항등이 아니다. 이름이 w(width)인 이상 어느 한쪽이 틀렸고, 부모 DrawableObject3D가 반지름 기준인 것을 보면 set/2를 빼먹은 쪽이다 — 명세서대로 w="32"라 쓴 스프라이트가 화면에서 64픽셀 폭으로 그려진다. h도 같다.

같은 클래스 51–55행에는 더 근본적인 문제가 있다. Attribute.forEach((s)->set(s, data.get(s)))가 16개 속성을 전부 시도하는데, XML에 없는 속성은 data.get(s)null이고 set은 그것을 Integer.valueOf(null)에 넘긴다. 속성 하나만 생략해도 NumberFormatException이다. 명세서 어디에도 16개 전부가 필수라는 말은 없다.

case w:  radius_x = Integer.valueOf(value); return;        // set: w → radius
...
case w:  return Double.toString(radius_x * 2);            // get: radius → w

레포 위생

추적 파일 1,727개 중 174개가 Eclipse 워크스페이스 메타데이터다(assignment/.metadata/, .recommenders/). 프로젝트가 아니라 IDE 자체를 커밋한 것이다. 그 안에서 가장 큰 파일은 assignment/.metadata/.plugins/org.eclipse.epp.logging.aeri.ide/…/remote-history/_1.fdt25 MB다 — Eclipse 오류 리포트 서버의 Lucene 인덱스이며, 레포 전체에서 단일 최대 파일이다. 두 번째는 10 MB짜리 JDT 인덱스(3104682415.index)다. 코드는 한 줄도 들어 있지 않다.

.gitignore*.class, .metadata, .recommenders가 들어 있지만 이미 추적 중인 파일에는 소급 적용되지 않는다. 결과적으로 .class 파일 385개가 그대로 남아 있고 빌드 산출물 project/IMF/imf.jar, 토너먼트 배포본 L4G2EP2/final/run/OODP_L4G2EP2_100ME_v1_0.jar도 커밋되어 있다. L4G2EP2/에서는 같은 상황을 인지하고 cf08096("Update Finalize L4G")에서 .metadata를 삭제했는데, assignment/에는 같은 정리를 적용하지 않았다.

외부 의존성 project/IMF/lib/json-simple-1.1.1.jar이 바이너리로 들어 있다. 2016년 학부 과제라는 맥락에서 빌드 도구 없이 JAR를 직접 넣는 것 자체는 이상하지 않지만, 라이선스 파일이나 출처 표기는 없다.

README는 4줄짜리 디렉터리 안내가 전부다. 빌드 방법도, 실행 방법도, "작은 축제는 DEJAVA"라는 이름 외에 무엇을 만들었는지도 없다. 그리고 그 이름조차 코드와 다르다 — README와 보고서는 DEJAVA, 클래스는 TEJAVA다.

반복되는 패턴

  1. 계산한 것을 쓰지 않는다. AddNumret을 채우고 ary를 반환한다(A2-2). isIndist를 계산하고 버린 뒤 같은 함수를 다시 부른다(A6). validate()가 검사 결과를 삼키고 true를 반환한다(nimf). parse()가 파싱 결과를 지역 변수에 넣고 끝낸다(nimf). 네 경우 모두 "제대로 계산하는 코드"가 먼저 있고, 그 결과가 밖으로 나가는 마지막 한 줄에서 끊긴다.
  2. 인자 하나가 전체 로직을 상수로 만든다. DEJAVA의 감염자 이동이 expRev = 0mapPrefer = false의 조합으로 항상 Left가 된다. 이런 종류의 붕괴는 한 번만 돌려 보면 즉시 보이므로, 작성한 코드 경로 중 실행해 보지 않은 것이 있다는 직접 증거다. TimerEvent의 확정 NPE도 같은 증거다.
  3. 예외를 진단이 아니라 제어 흐름으로 쓴다. 저자 코드에 빈 catch가 9곳, printStackTrace만 하는 것이 5곳이다. SpriteObject.invisible()의 catch는 null 검사 대신이고, ResourceManager.validate()는 자기가 던진 예외를 자기가 잡는다. ProcessManager.loop()는 프레임 전체를 하나로 묶어 삼킨다 — 무엇이 언제 실패했는지 알 방법이 프로그램 안에 없다.
  4. 행과 열을 일관되게 다루지 않는다. isValidPoint(Column, Raw)TEJAVA.java:345에서는 맞게, :420에서는 뒤집어 부른다. expect[CORP][nowX][nowY](380행)는 전치 접근이고, :414는 행 루프를 WIDTH로 돈다. 맵이 13×13 정사각이라 전부 잠복 상태로 남았다 — 즉 테스트가 우연히 이 계열 버그를 전부 가려 주었다.
  5. 문서가 코드보다 앞선다. IMF명세서.md는 8개 이벤트를 정의하지만 열거자는 4개 값만 쓴다. attribute 조건은 구현이 없다. 예제로 실린 XML 한 줄은 엔진을 크래시시킨다. 보고서는 "시체 확률이 낮은 쪽으로 이동"을 설명하지만 코드는 그 판단을 하지 않는다. 제안서는 6스테이지를 약속했고 맵은 2개다. 모든 경우에 설계 문서는 정확하고 구체적이며, 어긋난 것은 구현이다.
  6. 정리(teardown)를 습관적으로 빠뜨린다. Scanner(A1-1), FileOutputStream(TEST), BufferedReader(SettingParser), 비데몬 Timer 두 개(Physics, Interaction), 무한 증가하는 layer.children(A6). 여는 코드는 늘 있고 닫는 코드는 성공 경로에만 있거나 아예 없다.

지금 손본다면

  1. master를 제출본 상태로 되돌리거나, 재작성을 브랜치로 옮겨라. 지금 이 레포를 여는 사람은 동작하지 않는 큰 축제를 본다. git checkout -b nimf-rewrite 후 master를 5d8f226 기준으로 복원하면 5분이고, 레포의 인상이 "미완성 재작성"에서 "완성된 팀 프로젝트 + 진행 중인 개인 실험"으로 바뀐다.
  2. .metadata.class를 히스토리에서 제거하라. git filter-repo --path assignment/.metadata --path assignment/.recommenders --invert-paths로 174개 파일과 25 MB 단일 파일이 사라진다. *.class 385개도 같다. 클론 시간이 아니라 신뢰의 문제다 — IDE 워크스페이스를 통째로 커밋한 레포는 그 자체로 판정을 내리게 한다.
  3. 동급생 147명의 파일을 지워라. **/customplayers/에서 본인 TEJAVA.java만 남기면 된다. 개인정보(실명·학번) 문제이자, 남의 코드 101개가 본인 작업으로 오인될 여지를 없애는 일이다. README에 "봇 소스는 TEJAVA.java 하나"라고 한 줄 적으면 더 좋다.
  4. DEJAVA의 Infected_Move 한 줄을 고쳐라. getDirectionNotExp(expect[CORP], 0, false)에서 expRevEXPCONST[CORP]로, mapPrefertrue로 바꾸면 보고서가 설명한 동작이 실제로 나온다. 그리고 203–211행 람다에 isValidPoint를 한 줄 넣으면 가장자리 크래시가 사라진다. 이 봇은 두 줄 차이로 완성작이 될 수 있었다.
  5. catch를 전부 throw 또는 로그로 바꿔라. 최소한 ProcessManager.loop()만이라도. 지금 구조에서는 어느 처리기가 어느 프레임에 죽었는지 알 수 없고, 그래서 위 버그들이 개발 중에 드러나지 않았다. 진단 가능성이 없는 코드는 고칠 수 없는 코드다.
  6. IProcess.finalize()dispose()로 개명하고 timer.cancel()을 넣어라. 이름 하나와 호출 두 개로 GC 훅 오염과 비데몬 스레드 누수가 동시에 없어진다. 리팩터링 비용은 거의 0이다.