ITE2038 · HANYANG UNIV · 2018 FALL

데이터베이스 시스템

ER 모델링·SQL·웹 애플리케이션 세 과제를 모두 제출했고 모두 동작하는 모양은 갖췄지만, 정작 데이터베이스 과목에서 평가받아야 할 관계 설계는 매번 JSON 문자열과 varchar로 우회했고, 세 과제 전부 한 번만 실행해봤으면 잡혔을 버그를 그대로 제출했다.

파일94
커밋16
기간2018.09–12
코드~3,100 LOC
스택Python · SQL · Node.js
C+종합
소견7 치명적10 중대5 경미합계 22

총평

한 학기 필수 과목의 과제 셋이다. Assignment 1은 배달 서비스 ER 모델링(Navicat 모델 + 내보낸 DDL), Assignment 2는 같은 주소록 시나리오를 파일 I/O와 SQL 두 방식으로 각각 구현하는 과제, Assignment 3은 판매자·구매자·배달부 세 역할이 있는 "배달의 한양" 웹 앱(Express + Sequelize + MySQL)이다. 전체 3,100줄 남짓이고 마지막 커밋은 2018-12-16이다. 세 과제 모두 제출 형태를 갖췄고, 특히 Assignment 3은 학부 과제치고 완성도 있는 풀스택 앱이다.

가장 잘한 것은 구체적으로 두 가지다. 첫째, Assignment 2의 Permission 데코레이터와 sudo 승격 패턴(lib/permission.py)은 학부생이 스스로 떠올리기 어려운 최소권한 설계다. 로그인 시 비밀번호 행을 읽을 때만 Admin으로 승격했다가 즉시 되돌린다(lib/manager.py:33). 둘째, Assignment 3에서 명세가 Delivery 테이블에 Stock 컬럼을 두라고 지시했는데도 그것을 비정규화로 판단하고 Order.count로 유도해서 계산한다(app/controllers/deliveries.js:10–14). 명세를 그대로 베끼지 않고 정규화 관점에서 되물은 유일한 지점이다.

가장 치명적인 것은 Assignment 3 전체에 인가(authorization)가 한 줄도 없다는 것이다. req.session은 레포 전체에서 app/routes/index.js의 로그인 핸들러와 세션 덤프 엔드포인트에만 등장한다. 모든 컨트롤러는 req.query를 그대로 Sequelize where에 넘기고 컬럼 필터 없이 전체 행을 JSON으로 반환하므로, GET /customer 한 번이면 전 고객의 평문 비밀번호와 카드번호 전체가 나온다. 이론이 아니라 실제 코드 경로다 — 배달부 화면이 바로 그 요청을 보낸다(static/scripts/delivery.js:6). 여기에 더해 MySQL root 비밀번호가 담긴 config.json이 커밋된 뒤 파일만 삭제됐고(히스토리에는 남아 있다), Google Maps API 키는 지금도 워킹 트리에 있다.

그리고 이 레포의 진짜 문제는 버그가 아니라 검증의 부재다. Assignment 2의 파일 I/O 결과물로 제출된 business_cards_updated/는 프로그램 출력이 아니라 손으로 쓴 파일이고(프로그램은 학생 변경분을 저장하지 않는다), dbms.sql은 오타 하나로 21개 INSERT가 전부 실패하며, Assignment 3의 판매자 화면은 주문에 담긴 메뉴를 영원히 빈 목록으로 표시한다. 셋 다 "한 번 돌려보기"로 5초 안에 드러나는 것들이다. 데이터베이스 과목인데 관계 설계는 매번 JSON 컬럼과 varchar 직렬화로 우회했고, 대신 ORM 래퍼와 웹 프레임워크 배선에 노력이 갔다.

과제주제핵심 판정등급
A1ER 모델링 (deliveryHY)모델 형태는 타당하나 내보낸 DDL이 실행 불가이고, 다치 속성을 varchar에 밀어넣어 정규화 과제의 요점을 비껴감C+
A2-1 (file)파일 I/O 주소록 관리자Step 3 미구현, 통합 뷰 미호출, 학생 변경분 미저장. 제출 결과물이 프로그램 출력이 아님C-
A2-1 (SQL)동일 시나리오 DBMS 버전stdents 오타로 INSERT 21건 전부 실패, 마지막 질의는 문법 오류. PK·FK 전무D+
A2-2수강신청 DB 질의 5문제2문제만 정답. 5번은 COUNT와 ORDER BY가 아예 없고 세미콜론 누락으로 파싱 실패D+
A3배달의 한양 웹 앱규모와 구조는 준수하나 인가 전무 + 평문 비밀번호 노출, 판매자가 주문 메뉴를 볼 수 없음C

Assignment 1 — ER 모델링

주어진 배달 서비스 시나리오를 ER 모델로 설계하는 과제다. Navicat Data Modeler로 9개 테이블(Seller, Service, Owner, Buyer, Agency, Order, Contact, Payment, Destination)을 그리고 모델 파일·캡처 이미지·내보낸 PostgreSQL DDL·설명 문서를 함께 제출했다. 개체 분할과 FK 배치 자체는 대체로 타당하다.

중대

제출된 DDL은 그대로 실행할 수 없다

Navicat이 내보낸 파일을 손대지 않고 제출했다. 앞부분 드롭 구문 중 네 줄은 제약 이름이 빈 문자열이라 어떤 PostgreSQL에서도 문법 오류다. 그 앞의 DROP CONSTRAINT·DROP TABLE 20줄도 IF EXISTS가 없어 새 데이터베이스에서는 전부 실패한다. 즉 이 스크립트를 실제로 한 번이라도 돌려본 흔적이 없다.

Assignment1/deliveryHY.sql:13–16
ALTER TABLE "Owner" DROP CONSTRAINT "";
ALTER TABLE "Buyer" DROP CONSTRAINT "";
ALTER TABLE "Agency" DROP CONSTRAINT "";
ALTER TABLE "Contact" DROP CONSTRAINT "";   // ← 제약 이름이 빈 문자열
중대

정규화 과제인데 다치 속성을 varchar 한 칸에 밀어넣었다

판매자의 영업일과 영업시간, 서비스의 이벤트 목록이 전부 varchar(255) 한 칸이다. 요일 집합이나 이벤트 목록은 명백한 다치 속성이고, 이 과목이 가르치려는 1NF 분해 대상 그 자체다. "월,화,수" 같은 문자열로 저장하면 "수요일에 여는 가게" 질의를 쓸 수 없다. 별도 BusinessHour(sellerId, day, open, close) 테이블이 정답이다.

같은 파일에서 타입 선택도 여러 군데 어긋난다. Destination.nameint4로 선언돼 있고, README가 "opened date"라고 설명한 Seller.createdAt은 날짜가 아니라 time(0)(시:분:초)이다.

Assignment1/deliveryHY.sql:36–38, 47, 116
"createdAt" time(0) NOT NULL,        // README:25 "opened date" — 날짜가 없다
"business_day" varchar(255),
"business_hour" varchar(255),
...
"events" varchar(255),              // Service, README:31 "keep event informations"
...
"name" int4 NOT NULL,                // Destination.name
중대

Contact 테이블에 기본키가 없고, 소유자를 세 개의 nullable FK로 표현했다

연락처의 소유자가 판매자인지 구매자인지 배달대행인지를 세 개의 nullable FK로 나타냈는데, "셋 중 정확히 하나만 NULL이 아니다"를 강제하는 CHECK 제약이 없다. 세 개 다 NULL인 고아 연락처, 세 개 다 채워진 연락처를 모두 삽입할 수 있다. 기본키도 없어서 완전히 동일한 행을 무한히 넣을 수 있고, UNIQUE는 buyerId 쪽에만 걸려 있어 판매자·배달대행 연락처는 중복 방지도 안 된다.

덧붙여 README는 이 관계를 일관되게 Contract라고 부르는데(README:71, 74, 77) 실제 테이블 이름은 Contact다. 문서와 스키마가 서로 다른 이름을 쓰고 있고, 제약 이름만 SellersContracts로 문서 쪽 오타를 따라갔다.

Assignment1/deliveryHY.sql:97–105
CREATE TABLE "Contact" (
"number" varchar(16) NOT NULL,
"carrier" varchar(32),
"sellerId" int4,          // 셋 다 nullable
"buyerId" int4,
"agencyId" int4,
UNIQUE ("buyerId", "number")    // ← PK 없음, CHECK 없음
)
경미

문서가 선언한 관계 하나가 스키마에 없다

README:78은 OrderService, Buyer, Agency에 속한다고 적었지만, 실제 Order 테이블(deliveryHY.sql:85–96)에는 buyerId가 없다. paymentIddestinationId를 거쳐 간접적으로만 구매자에 닿으므로, 결제수단을 지우면 주문의 주인을 잃는다. 문서가 코드보다 앞서 있는 전형적인 예다.

Agency.activity_location은 README:46이 "korean-style postal address를 보관"한다고 설명하는데 varchar(8)이다. 한국식 주소는 8자에 들어가지 않는다.

잘한 것

주소를 address_city / address_county / address_district로 쪼개고 좌표를 PostgreSQL point 타입으로 잡은 것은 이 과제에서 실제로 정규화를 적용한 유일한 지점이다. FK 11개를 빠짐없이 걸었고 참조 방향도 어긋난 곳이 없다.

Assignment 2 — 파일 I/O vs DBMS

명세(docs/DB_Assignment02.pdf)는 같은 주소록 시나리오를 두 번 구현하라고 요구한다. 한 번은 CSV 파일만으로(2-1 file), 한 번은 DBMS로(2-1 SQL). 관리자는 한양인 주소록을 관리하고, 학생은 자기 개인 주소록만 보되 목록을 볼 때는 두 주소록이 합쳐져 나와야 하며, Step 3에서는 email 컬럼을 local_part / domain_name으로 분리해 저장하고 도메인 도수분포표를 내야 한다. 2-2는 수강신청 DB에 대한 SQL 다섯 문제다.

치명적

WHERE 조건이 두 개 이상이면 무조건 죽는다

모든 조회·수정·삭제가 거쳐 가는 findIndex가 조건 배열을 np.logical_and에 펼쳐 넣고 뒤에 ones 두 개를 덧붙인다. np.logical_and은 ufunc이라 위치 인자를 최대 3개(x1, x2, out)만 받는다. 조건이 0개면 (ones, ones), 1개면 (cond, ones, ones)로 우연히 맞아떨어지지만 — 뒤쪽 ones가 조건이 아니라 출력 버퍼로 소비되는 것이다 — 조건이 2개가 되는 순간 인자 4개가 되어 TypeError다.

README가 직접 광고한 WHERE [COND, ...] 쉼표 문법이 바로 이 경로를 탄다. 저자가 문서에 적은 사용법대로 쓰면 프로그램이 죽는다.

Assignment2/2-1/lib/manager.py:90–100
def findIndex(self, target: T, where: dict = {}) -> np.ndarray:
    return np.where(
        np.logical_and(
            *[self.values[target][:,column] == value for column, value in zip(...)],
            np.ones(np.size(self.values[target], 0), dtype=np.bool),
            np.ones(np.size(self.values[target], 0), dtype=np.bool)
        )                             // ← 조건 2개 + ones 2개 = 위치 인자 4개
    )[0]
실행 확인python 3.11 / numpy 2.4.6 · 레포 사본에서 main.py 직접 실행
>> SELECT sname, sex FROM STUDENTS WHERE sname=정남아
[['정남아' 'female']]
>> SELECT sname FROM STUDENTS WHERE sex=female, grade=4
Traceback (most recent call last):
TypeError: logical_and() takes from 2 to 3 positional arguments but 4 were given
치명적

제출된 결과 파일은 프로그램이 만든 것이 아니다

학생 모드의 커밋이 빈 함수다. 개인 주소록에 대한 create/update/remove는 전부 메모리에만 반영되고 디스크에 쓰이지 않는다. 유일하게 파일을 쓰는 곳은 load()인데, 방금 읽은 프레임을 그대로 되쓰기 때문에 내용이 바뀌지 않는다.

레포에 커밋된 private_2009003125.csv는 실제로 입력 business_cards/Grass_corp.csv와 바이트 단위로 같다(공백 제거만 다름). 반면 명세가 요구한 반영 결과로 제출된 business_cards_updated/Grass_corp.csv에는 main.py가 수행하는 변경(이상해씨→이상해풀, 리피아 추가)이 들어 있으나 main.py가 반드시 수행하는 "임의 1건 삭제"는 반영돼 있지 않다. 즉 프로그램을 돌려서 얻은 파일이 아니라 손으로 편집한 파일이다.

Assignment2/2-1/lib/manager.py:24–27, 117–125
def load(self, t: T, filename, columns: list):
    frame = pd.read_csv(filename, ...)
    ...
    frame.to_csv('private_{}.csv'.format(self.__session.user), ...)  // 읽자마자 그대로 되쓰기

@Permission.require(Permission.Student)
def commit_student(self):
    pass                              // ← 학생 변경분은 영원히 저장되지 않음
실행 확인main.py 실행 후 산출 파일 비교
$ diff business_cards/Grass_corp.csv private_2009003125.csv
IDENTICAL (student edits not persisted)
치명적

Step 3의 핵심 요구(email 컬럼 분리 저장)가 두 버전 모두 빠져 있다

명세 6쪽은 "email 열을 local_part 열과 domain_name 열로 분리하여 저장한다"와 "저장 형태가 달라진 이후에도 출력 형태는 변화가 없어야 한다"를 요구한다. 이건 스키마 변경 후에도 뷰가 유지되는지를 묻는, 이 과목의 핵심 개념 문제다.

구현된 것은 도수분포표뿐이다. emailFrequencysplit('@') 결과로 Counter를 만들어 반환할 뿐 컬럼을 바꾸지도, 저장하지도 않는다. 레포 전체에 local_part/domain_name이라는 문자열은 한 번도 등장하지 않고, 커밋된 contacts.csv의 헤더는 여전히 sid,phone,email이다. SQL 버전도 마찬가지로 dbms.sqlALTER TABLE이 0회 나온다 — 명세가 힌트로 ALTER TABLECREATE TABLE ... AS ...를 콕 집어 줬는데도 그렇다.

확인 명령레포 루트
$ grep -rl 'local_part\|domain_name' Assignment2/
NONE
$ grep -ci 'alter table' Assignment2/2-1/dbms.sql
0
$ head -1 Assignment2/2-1/contacts.csv
sid,phone,email
중대

"두 주소록을 합쳐 보여주는" 기능은 정의만 되고 한 번도 호출되지 않으며, 호출하면 터진다

명세 3쪽·5쪽이 반복해서 요구하는 "한양인 주소록 + 개인 주소록을 같이 보여주기"에 대응하는 함수는 elements()뿐이다. 레포 전체에서 이 이름이 등장하는 곳은 정의된 줄 하나가 전부다 — main.py도, REPL도 부르지 않는다.

그리고 실제로 개인 주소록에 대해 호출하면 동작하지 않는다. __values_admin에는 T.business 키 자체가 없어서 KeyError가 난다. 설령 키가 있었더라도 contacts는 3열, business는 4열이라 np.vstack이 실패한다.

Assignment2/2-1/lib/manager.py:74–78 (유일한 출현 위치)
def elements(self, t: T) -> np.ndarray:
    return np.vstack([
        Permission.Admin.sudo(self.__session, lambda: self.values[t]),
        Permission.Student.sudo(self.__session, lambda: self.values[t])
    ])
실행 확인학생(정남아) 로그인 후 호출
elements(contacts) ok: (23, 3)
elements(business) ERR: KeyError <T.business: 6>
치명적

dbms.sql은 오타 하나로 21개 INSERT가 전부 실패한다

students 대신 stdents라는 테이블을 만들어 놓고 이후 모든 INSERT는 students를 대상으로 한다. 존재하지 않는 테이블이다. 파일 마지막 질의도 domaincount(*) 사이에 쉼표가 빠져 문법 오류다. Step 1의 UPDATE·DELETE는 서브쿼리로 students를 참조하므로 연쇄적으로 전부 실패한다. 이 파일은 단 한 번도 실행된 적이 없다.

Assignment2/2-1/dbms.sql:25–27, 91–95
CREATE TABLE stdents (sid CHAR(16), password CHAR(16), ...);

INSERT INTO students VALUES ('2009003125','125125125','정남아',...);
...
SELECT
    split_part(email, '@', 2) AS domain     // ← 쉼표 누락
    count(*) AS scount
FROM contacts
실행 확인sqlite3 :memory: < dbms.sql
Parse error near line 27: no such table: students
Parse error near line 28: no such table: students
Parse error near line 29: no such table: students
... (21건 전부 동일)
중대

2-2: 다섯 문제 중 둘만 맞다

1번(강의실 20개 이상인 건물): rooms라는 존재하지 않는 컬럼을 SELECT하고, building_id가 양쪽 테이블에 있어 모호하며, name·admin이 GROUP BY에도 집계함수에도 들어가지 않았다. 표준 SQL과 MySQL ONLY_FULL_GROUP_BY에서 모두 거부된다.

4번(수용인원 100명 이상인 강의실의 건물): FULL OUTER JOIN 뒤에 WHERE Rooms.capacity >= 100을 붙였다. 이 WHERE가 NULL 행을 전부 제거하므로 외부 조인이 내부 조인으로 퇴화한다 — 굳이 FULL OUTER를 쓴 의미가 사라진다. 게다가 MySQL은 FULL OUTER JOIN을 지원하지 않고, DISTINCT가 없어 큰 강의실이 여럿인 건물은 중복 출력된다.

5번(학과별 과목 수를 세어 상위 10개 학과 출력): 정답에 반드시 있어야 할 COUNT(*)ORDER BY ... DESC둘 다 없다. 출력되는 것은 개수 없는 학과 코드 10개이고, 그 10개는 "가장 많은" 10개가 아니라 임의의 10개다. 그 위의 SELECT course_id FROM Classes는 세미콜론이 없어 다음 문장과 붙으면서 파싱 자체가 깨진다.

맞은 것은 2번(INSERT)과 3번(UPDATE) 두 문제다.

Assignment2/2-2/sql.sql:19–25
SELECT course_id
FROM Classes                       // ← 세미콜론 없음

SELECT SUBSTRING(course_id, 1, 3) AS major
FROM Classes                       // COUNT(*) 없음
GROUP BY SUBSTRING(course_id, 1, 3)
LIMIT 10;                          // ORDER BY 없이 자른 임의의 10개
실행 확인sqlite3 :memory: < sql.sql
Parse error near line 1: no such table: Buildings
... (테이블 미제공은 정상)
Parse error near line 19: near "SELECT": syntax error
중대

데모 스크립트를 세 번 돌린 결과가 그대로 제출 데이터가 됐다

main.py는 실행할 때마다 홍길동 행을 무조건 append한다(중복 검사 없음). 커밋된 students.csv에는 동일한 홍길동 행이 3개, contacts.csv에는 동일한 hong@ 행이 3개 들어 있다. sid는 이 시나리오에서 기본키인데, 제출된 "데이터베이스"에 기본키 중복 행이 3중으로 남아 있다. 원본 raw/students.csv는 21줄, 제출본은 24줄이다.

같은 파일에 다른 학생들의 평문 비밀번호가 그대로 들어 있고(2009003125,125125125 등), main.py:47, 74, 89는 그 비밀번호를 소스에 하드코딩해 로그인한다. 과제 제공 데이터라 해도, 비밀번호 컬럼을 그대로 소스에 박아 넣는 습관은 Assignment 3에서 실제 사고로 이어진다.

Assignment2/2-1/students.csv:22–24
2016001234,xxx,홍길동,male,6,1999002345,1
2016001234,xxx,홍길동,male,6,1999002345,1
2016001234,xxx,홍길동,male,6,1999002345,1
경미

REPL에 예외 처리가 전혀 없고, 디버그 출력이 남아 있다

main.py:131–163의 입력 루프에는 try/except가 한 줄도 없다. FROM 없는 SELECT는 언패킹 오류, 없는 테이블 이름은 T.fromStringNone을 반환해 KeyError: None, 값에 =,가 들어가면 조건 파싱이 조용히 어긋난다. 한 번 터지면 세션 전체가 종료되고 커밋 안 된 변경은 사라진다.

또 README는 "you can write query to execute insert, update, remove"라고 적었지만 REPL에는 INSERT 분기가 없다. 그리고 제출 코드에 print("WTF", ...)가 남아 있다.

Assignment2/2-1/main.py:35
    print("WTF", {
        "sid": db.findOne(["sid"], T.students, {"sname": "김다현"})[0]
    })
잘한 것

lib/permission.pyPermission.require 데코레이터 + sudo 조합은 학부 과제에서 보기 드문 설계다. 특히 manager.py:33의 로그인 경로 — 학생 비밀번호를 확인하기 위해 그 조회 한 번만 Admin으로 승격했다가 원래 권한으로 되돌리는 — 는 최소권한 원칙을 스스로 구현한 것이다. 명세가 요구한 "학생은 자기 주소록만" 을 데이터 구조 두 벌(__values_admin / __values_user)로 물리적으로 분리한 판단도 맞다.

Assignment 3 — 배달의 한양 웹 애플리케이션

판매자·구매자·배달부 세 역할이 한 로그인 화면을 공유하는 배달 앱을 만드는 과제다(docs/DB_Assignment03_v3.pdf). Express + Sequelize + MySQL 8에 jQuery/Bootstrap 프런트, 8개 모델, 8개 컨트롤러, 3개 역할별 화면, Google Maps 연동까지 들어 있다. 이 레포에서 가장 큰 덩어리이고 구조적으로도 가장 야심 찬 코드다. 모델 자동 로딩, 라우트 자동 마운트, 컨트롤러 분리 같은 배선은 실무 프로젝트 수준으로 잡혀 있다.

치명적

인가가 존재하지 않고, 그 결과 전 사용자의 평문 비밀번호와 카드번호가 공개 API로 나온다

req.session은 레포 전체에서 app/routes/index.js 한 파일에만 등장한다 — 로그인 시 쓰기 3회, 세션 덤프 엔드포인트 1회. 어떤 라우터도, 어떤 컨트롤러도 세션을 읽어 "이 사람이 이 자원의 주인인가"를 묻지 않는다. 미들웨어는 하나도 없다(next()가 앱 코드에 단 한 번도 나오지 않는다).

동시에 모든 index 컨트롤러가 req.query를 그대로 Sequelize where에 넣고 attributes 필터 없이 전체 행을 JSON으로 뱉는다. Customer·Seller·Delivery 모델은 모두 passwd 컬럼을 가지고 있고 비밀번호는 해싱 없이 평문으로 저장된다(로그인이 passwd: req.body.password로 직접 비교한다). 따라서 로그인하지 않은 누구든 GET /customer 한 번으로 전 고객의 이름·전화번호·이메일·평문 비밀번호·카드번호와 계좌번호 JSON·현재 좌표를 통째로 받는다.

이건 가정이 아니라 앱이 실제로 밟는 경로다. 배달부 화면이 주문마다 바로 그 요청을 보낸다.

app/controllers/customers.js:5–7
index: (req, res, query) => m.Customer.findAll({
    where: assert.object(req.query),     // 사용자 입력이 그대로 where로
},).then(r => res.json(r)),              // passwd·payments 포함 전체 컬럼
$.get(`/customer?customer_id=${order.customer_id}`, customers => {
  // 배달부 브라우저가 고객의 평문 비밀번호와 결제수단 전체를 내려받는다
Assignment3/example.sql:25 (시드 데이터)
INSERT INTO Customers (name, phone, local, domain, passwd, payments, lat, lng)
VALUES ("소비자", "12345678987", "Customer", "hanyang.ac.kr", "123456",
  '[{"type":"card","data":{"card_num":8543466885434668}}, ...]', ...);
확인 명령Assignment3/
$ grep -rn 'req.session' app/ index.js
app/routes/index.js:31:  res.json(req.session);
app/routes/index.js:50,59,68:  req.session.user = user;
// 그 외 어디에도 없음 — 인가 검사 0건
치명적

인증 없이 아무 계정의 비밀번호나 바꿀 수 있다

PUT /seller/:seller_idm.Seller.update(req.body, ...)를 호출한다. 본문에 어떤 컬럼이 들어오든 그대로 반영되는 mass assignment이고, 앞서 말했듯 세션 검사가 없다. 판매자 화면의 "개인정보 변경"이 실제로 이 엔드포인트에 {name, passwd}를 보낸다. 같은 요청을 로그인 없이 임의의 seller_id로 보내면 그 계정을 탈취할 수 있다. PUT /customer/:id/payment도 마찬가지로 경로 이름과 달리 passwd를 포함한 모든 컬럼을 갱신한다. DELETE /seller/:id, DELETE /order/:id, DELETE /menu/:id도 전부 무인증이다.

app/controllers/sellers.js:16–24 · app/routes/seller.js:9–12
update: (req, res) => {
    m.Seller.update(req.body, {          // 본문 전체를 무검증 반영
      where: { seller_id: req.params.seller_id }   // 세션과 대조하지 않음
    })...
sellers.route('/:seller_id').put(seller.update)   // 미들웨어 없음
치명적

판매자는 주문에 무슨 메뉴가 들어 있는지 영원히 볼 수 없다

orders.menus 하나를 세 클라이언트가 서로 다른 형식으로 읽는다. 구매자는 {메뉴ID: 수량} 객체를 JSON.stringify해서 쓰고, 구매자 화면과 배달부 화면은 JSON.parse로 읽는다. 그런데 판매자 화면만 그 문자열을 쉼표로 잘라 parseInt한다. {"3":2,"5":1}을 쉼표로 자르면 ['{"3":2', '"5":1}']이고 parseInt는 둘 다 NaN이다. 결과적으로 필터가 항상 빈 배열을 반환한다.

음식을 만들어야 하는 사람이 주문 내역을 못 보는 것이므로 이 앱의 핵심 흐름이 끊긴다. 판매자 화면을 한 번만 열어봤으면 바로 보였을 것이다. 같은 파일 안에 정답(JSON.parse)이 두 벌이나 있는데도 세 번째 구현만 다르다.

static/scripts/customer.js:355 (쓰는 쪽)
menus: JSON.stringify(menus),   // {"3":2,"5":1}
static/scripts/seller.js:7, 11 (읽는 쪽)
const omenu = new Set(order.menus.split(',').map(m => parseInt(m, 10)));
...
menus.filter(m => omenu.has(m.menu_id)).map(m => m.name).join(', ')
실행 확인node v22 · 두 코드를 그대로 실행
stored menus column = {"3":2,"5":1}
seller.js omenu = [ NaN ]
seller sees: []
customer/delivery sees: ["3","5"]
중대

구매자의 개인정보 변경은 존재하지 않는 엔드포인트를 호출한다

명세 7쪽은 구매자에게도 판매자와 동일한 개인정보 관리(이름·비밀번호 변경)를 요구한다. 프런트는 PUT /customer/:customer_id를 부르는데 라우터에는 GET /PUT /:customer_id/payment만 등록돼 있다. 매칭되는 라우트가 없으므로 404가 떨어지지만, jQuery success 콜백이 없는 오류 처리 탓에 사용자에게는 아무 일도 일어나지 않는다. 판매자 쪽 동일 기능은 라우트가 있어서 동작한다 — 두 화면을 복사·수정하다 한쪽만 라우트를 빠뜨린 것으로 보인다.

static/scripts/customer.js:422–428 vs app/routes/customer.js:5–9
// 프런트가 부르는 곳
url: `/customer/${session.user.customer_id}`,  type: 'PUT',
data: { name: ..., passwd: ... }

// 등록된 라우트 전부
customers.route('/').get(customer.index);
customers.route('/:customer_id/payment').put(customer.update);
중대

명세가 못 박은 "배달 개수 4개 이하" 필터가 구현되지 않았다

명세 6쪽: "주문 확인 시: 가장 가깝고, 배달 개수(Stock)가 4개 이하인 배달부를 5명 표시." 서버는 배달부마다 현재 배달 건수를 stack으로 정확히 계산해 내려보내고, 프런트는 그 값을 화면에 표시까지 한다. 그런데 정렬 후 5명을 자를 뿐 stack <= 4 조건을 어디서도 걸지 않는다. 필요한 데이터를 다 만들어 놓고 마지막 한 줄을 안 쓴 것이다.

같은 함수에 별개 버그가 하나 더 있다. #order-delivery 셀렉트를 비우지 않고 append만 하므로, 주문 A의 확인 모달을 열었다가 닫고 주문 B를 열면 B의 배달부 목록에 A의 후보가 그대로 남는다.

static/scripts/seller.js:52–54
deliveries.sort((l, r) => dist(l) - dist(r)).slice(0, 5).forEach(delivery => {
  // ← .filter(d => d.stack <= 4) 가 없다. stack 값은 바로 아래에서 출력만 한다.
  $('#order-delivery').append($(`<option value="${delivery.delivery_id}">${delivery.name}-${delivery.stack}...`));
  // ← 이전 주문의 option 을 비우지 않음
});
중대

데이터베이스 과목인데 핵심 관계 하나만 FK가 없고, 나머지는 JSON 문자열로 대체됐다

init.sqlorders → customers / stores / destinations에 InnoDB FK를 제대로 걸었다. 그런데 배달 앱에서 가장 중요한 관계인 orders → deliveriesint(11) DEFAULT '0'짜리 맨 정수다. 배달부를 삭제해도 주문은 사라진 ID를 계속 가리키고, 존재하지 않는 delivery_id를 넣어도 DB가 막지 않는다. 실제로 "미배정"을 NULL이 아니라 0이라는 매직 넘버로 표현하기 때문에 FK를 걸 수 없는 구조가 됐다.

여기에 orders.menus(주문-메뉴 다대다)는 varchar(512) 직렬화, customers.payments·stores.phone_nums·stores.schedules·stores.tags는 전부 JSON 컬럼이다. 결제수단은 bidbanks를 가리키지만 JSON 안이라 FK가 없다. 관계형 모델링을 평가하는 과목에서 관계 대부분을 문자열로 우회한 셈이고, 그 대가로 "특정 메뉴가 이번 달에 몇 번 팔렸나" 같은 질의는 SQL로 쓸 수 없다. (명세가 JSON 컬럼을 예시로 준 부분도 있으나, 주문-메뉴 다대다는 명세에 없는 본인 선택이다.)

Assignment3/init.sql:166–183
CREATE TABLE `orders` (
  `menus` varchar(512) ...,        // 주문-메뉴 다대다를 문자열로
  `delivery_id` int(11) DEFAULT '0',  // FK 없음
  ...
  CONSTRAINT `orders_ibfk_1` FOREIGN KEY (`customer_id`) REFERENCES `customers` ...,
  CONSTRAINT `orders_ibfk_2` FOREIGN KEY (`store_id`)    REFERENCES `stores` ...,
  CONSTRAINT `orders_ibfk_3` FOREIGN KEY (`destination_id`) REFERENCES `destinations` ...
)   // ← deliveries 로 가는 제약만 없다
중대

서버가 준 문자열을 그대로 jQuery 템플릿에 끼워 저장형 XSS가 열린다

가게 이름·주소·설명·태그·메뉴 이름이 전부 이스케이프 없이 템플릿 리터럴에 들어가고 $(...)로 파싱된다. jQuery의 $(htmlString)은 실제 DOM을 생성하므로 <img src=x onerror=...> 같은 값이 즉시 실행된다. 같은 화면에서 메뉴 이름도 동일하게 처리되는데(customer.js:108), POST /menu는 인증이 없으므로 누구든 임의의 가게에 name을 심을 수 있다. 그리고 그 스크립트가 실행되는 페이지는 고객의 결제수단이 떠 있는 화면이다.

static/scripts/customer.js:85–89
<p class="s-name">${store.sname}</p>
<p class="s-addr">${store.address}</p>
${store.tags.map(tag => `<span class="badge...">${tag}</span>`).join('')}
<p>${store.description}</p>
경미

다른 회사 프로젝트의 뼈대가 그대로 묻어 있다

package.json의 repository URL이 네이버 사내 GitHub의 웹툰 CIC 프로젝트를 가리킨다. 같은 맥락에서 app/lib/io.js 65줄은 재귀 mkdir/rmdir, rename, Promise 기반 read/write 같은 파일 시스템 유틸 15개를 정의하는데, 이 앱에서 실제로 쓰이는 것은 writeSynchash 둘뿐이다(index.js:10). 나머지 13개는 호출자가 없다. 스캐폴딩 재사용 자체는 합리적이지만, 제출 전에 쓰지 않는 것을 걷어내고 남의 레포 주소를 지우는 정리는 필요했다.

같은 줄에 secretStringconst/let 없이 대입되는 암묵적 전역이라 strict mode나 ES 모듈에서는 ReferenceError다. 그리고 config.app.port를 읽어 놓고 app.listen(80, ...)으로 하드코딩해 설정값이 죽어 있다(80번 포트라 root 권한도 필요하다).

Assignment3/package.json:9–12 · Assignment3/index.js:10, 30
"repository": { "type": "git",
  "url": "git@oss.<전 직장 사내 Git 호스트>:<조직>/<사내 레포명>.git" },
...
lib.io.writeSync('secret', secretString = lib.io.hash());   // 암묵적 전역
app.listen(80, ...)                                    // config.app.port 미사용
경미

라우트 자동 마운트가 라우터를 자기 자신에 마운트하고, 컨트롤러 메서드 둘은 배선되지 않았다

routes/index.js는 디렉터리를 훑어 router.use('/'+name, route)로 자동 마운트하는데, 시드 객체가 { router }라서 첫 반복에서 router.use('/router', router)가 실행된다. 라우터가 자기 자신의 하위에 붙어 /router/router/router/...가 무한히 매칭된다. 아래 if (route.associate) 분기는 associate를 정의한 라우트 파일이 하나도 없어 죽은 코드다.

한편 controllers/stores.jscreate·destroyroutes/store.js가 GET 두 개만 등록하므로 도달할 수 없다. 명세 6쪽의 "가게 설명·태그 관리"에 대응하는 UI도 없어서, 가게 정보 편집 기능은 컨트롤러만 있고 실제로는 미구현이다.

app/routes/index.js:10, 20–27
const routes = { router };                 // 자기 자신이 목록에 들어감
...
Object.entries(routes).forEach(([name, route]) => {
    if (route.associate) { route.associate(routes); }   // 항상 false — 죽은 분기
    else { router.use(`/${name}`, route); }         // name === 'router' 일 때 자기 마운트
});
경미

세션 덤프 엔드포인트가 열려 있고, 로그인 체인이 DB 오류를 비밀번호 오류로 위장한다

GET /session은 "Session debug"라는 주석과 함께 세션 객체를 통째로 JSON으로 내보낸다. 세 화면이 모두 이 엔드포인트에 의존하므로 지울 수 없게 설계됐다.

로그인은 Seller → Customer → Delivery를 .catch()로 이어 붙인 3단 체인이다. 명세의 "세 역할이 같은 로그인 화면"을 만족시키는 접근 자체는 맞지만, DB 연결 실패나 res.sendFile 오류까지 전부 다음 단계로 흘러가 최종적으로 "Invalid email or password"가 된다. 원인 진단이 불가능하고, 첫 단계가 응답을 보낸 뒤 예외가 나면 두 번째 단계가 두 번째 응답을 시도한다.

app/routes/index.js:29–32
// Session debug
router.get('/session', (req, res) => {
  res.json(req.session);
});
잘한 것

명세 12쪽은 Delivery 테이블에 Stock(현재 배달 건수) 컬럼을 두라고 지시했다. 그 지시를 따르지 않고 Order.count({where:{delivery_id}})로 매번 유도해 계산했다(app/controllers/deliveries.js:8–15). 배달 건수는 주문 테이블에서 완전히 결정되는 값이므로 별도 컬럼은 갱신 이상(update anomaly)을 부르는 비정규화다. 조교가 준 스키마를 정규화 관점에서 거절한 유일한 판단이고, 이 레포에서 데이터베이스 수업의 내용이 실제로 적용된 몇 안 되는 지점이다. (다만 배달부 한 명당 COUNT 질의가 한 번씩 나가서 GET /delivery 한 번에 N+1 질의가 된다. 유도값 유지 자체는 옳고, 필요하면 GROUP BY delivery_id 한 방으로 줄이면 된다.)

반복되는 패턴

  1. 문서가 코드보다 앞선다. A2 README는 "SELECT/UPDATE/DELETE로 insert·update·remove를 실행할 수 있다"고 광고하지만 REPL에 INSERT 분기가 없고, 문서가 직접 예시로 든 쉼표 여러 조건 문법은 TypeError로 죽는다. A1 README는 OrderBuyer에 속한다고 적었지만 스키마에 buyerId가 없고, 같은 개체를 문서는 Contract, 스키마는 Contact로 부른다. A3에서는 프런트가 존재하지 않는 PUT /customer/:id를 호출한다. 세 과제 모두에서 같은 방향의 어긋남이다.
  2. 한 번도 실행하지 않고 제출한다. deliveryHY.sql은 빈 제약 이름 때문에 파싱이 안 되고, dbms.sqlstdents 오타로 INSERT 21건이 전부 실패하며, sql.sql은 세미콜론 하나가 빠져 문장 두 개가 붙는다. A3 판매자 화면은 주문 메뉴를 항상 빈 목록으로 그린다. 넷 다 "실행 한 번"이면 즉시 드러나고, 셋은 sqlite로 5초 만에 재현된다.
  3. 인프라는 공들이고 검증은 생략한다. 권한 데코레이터, sudo 승격, 모델·라우트·라이브러리 자동 로딩 같은 프레임워크 배선에는 실무급 노력이 들어갔지만, 테스트는 한 줄도 없고 실행 로그도 남아 있지 않다. 그 결과 elements()처럼 명세가 요구한 기능이 정의만 된 채 아무도 부르지 않고, 불러보면 KeyError가 나는 상태로 제출된다.
  4. 관계형 모델링을 문자열로 우회한다. A1은 영업일·영업시간·이벤트를 varchar(255)에, A2 SQL은 개인 주소록 3개를 스키마가 동일한 pContacts1/2/3 테이블 3벌에(PK·FK 하나도 없이), A3은 주문-메뉴 다대다를 varchar(512)와 JSON 컬럼에 담았다. 데이터베이스 과목에서 정작 평가 대상인 정규화를 세 번 다 비껴간 셈이다.
  5. 시크릿과 산출물을 구분 없이 커밋한다. MySQL root 비밀번호가 담긴 config.json, Google API 키, 다른 학생들의 평문 비밀번호가 든 CSV, 그리고 한때 커밋됐다가 지워진 318,699줄짜리 예제 CSV(menu.csv 하나가 131,208줄)가 모두 히스토리에 남아 있다. .gitignoreconfig.json을 적은 것은 유출 이후였다.

지금 손본다면

  1. 노출된 자격증명을 폐기한다. Google API 키(site/customer.html:199)를 콘솔에서 삭제하고, 히스토리에 남은 MySQL 비밀번호는 해당 계정을 교체한다. 비용은 10분, 유일하게 지금도 실제 위험이 남아 있는 항목이다.
  2. A3에 인가 미들웨어 한 개를 붙이고 passwd를 기본 제외한다. router.use 앞단에 세션 확인 미들웨어를 두고, 세 사용자 모델에 defaultScope: { attributes: { exclude: ['passwd'] } }를 건 뒤 비밀번호를 bcrypt로 해싱한다. 50줄 남짓으로 치명적 결함 세 개 중 둘이 닫힌다.
  3. findIndexnp.logical_and.reduce로 바꾼다. 조건 리스트를 np.logical_and.reduce([...] or [ones])로 접으면 0개·1개·N개가 모두 동작하고, README가 광고한 다중 조건 문법이 비로소 사실이 된다. 한 줄짜리 수정이다.
  4. orders.menusorder_items(order_id, menu_id, count) 테이블로 승격한다. 판매자 화면의 파싱 버그가 구조적으로 사라지고, 세 클라이언트가 형식을 각자 해석할 여지도 없어지며, 메뉴별 판매 집계를 SQL로 쓸 수 있게 된다. 이 과제가 원래 평가하려던 것이 바로 이 결정이다.
  5. 각 과제에 실행 로그 한 편씩을 남긴다. dbms.sql을 실제 DB에 돌린 출력, main.py를 돌려 나온 private_*.csv, A3의 주문 한 건을 끝까지 진행한 스크린샷. 손으로 만든 business_cards_updated/를 프로그램 출력으로 대체하는 것만으로 A2의 판정이 달라진다.