먼저 확인: 붙인 코드에는 API 키·토큰·비밀번호로 보이는 값이 없습니다.
요약
회원이 자기 수업 예약을 수업 시작 3시간 전까지 취소하면 예약 상태를 cancelled 로 바꾸는 API 입니다. 취소되면 그 수업의 대기자 전원에게 자리가 났다는 문자를 보냅니다.
설명과 다르게 동작하는 점
- 「회원이 직접」: 요청한 사람이 그 예약의 주인인지 보지 않습니다. 예약 번호만 알면 다른 회원의 예약도 취소됩니다(2-1).
- 「자리가 나면」: 이미 취소된 예약을 또 취소해도 성공으로 처리하고 문자를 다시 보냅니다. 새로 난 자리가 없는데도 보냅니다(1-1).
- 「대기자에게」: 대기자 전원에게 보냅니다. 1순위 한 명에게만 보내려던 것이라면 설명과 다릅니다(확인 필요).
특히 보라고 하신 두 가지
- 취소 가능 시간 계산: 식은 맞습니다. 다만
starts_at 을 저장한 형식에 따라 9시간 어긋날 수 있습니다(1-4, 확인 필요).
- 대기자가 많을 때: 느려집니다. 문자를 한 명씩 기다리며 보내고, 다 보낸 뒤에야 응답합니다(3-1).
지적 (심각한 것부터)
1. 버그
1-1. 이미 취소된 예약을 또 취소할 수 있고, 그때마다 문자가 다시 갑니다
- 위치:
UPDATE reservations ... 줄과 그 앞. status 를 보지 않습니다.
- 문제: 같은 예약에 취소 요청이 두 번 오면 두 번 다 성공합니다. 앞에서
status 를 읽어 확인하는 것만으로는 부족합니다. 두 요청이 거의 동시에 오면 둘 다 UPDATE 전에 읽어서 둘 다 「아직 취소 전」을 보기 때문입니다.
- 예: 회원이 버튼을 두 번 누르거나 앱이 네트워크 오류로 요청을 다시 보내면 응답은 둘 다 200 이고, 대기자는 같은 문자를 두 번 받습니다. 실제로 난 자리는 하나입니다.
- 고친 코드: 상태 확인과 변경을 UPDATE 한 문장에 넣고 바뀐 행 수를 봅니다. PostgreSQL 은 같은 행을 바꾸는 UPDATE 를 줄 세우고, 뒤에 온 쪽은 앞쪽이 끝난 뒤 조건을 다시 확인하므로 하나만 성공합니다(기본 격리 수준 기준).
const result = await db.result(
"UPDATE reservations SET status = 'cancelled' WHERE id = $1 AND status <> 'cancelled'",
[id]
);
if (result.rowCount === 0) {
return res.status(409).json({ error: "이미 취소된 예약이에요" });
}
- 확신: 「확실」. 다만
status 에 'cancelled' 말고도 취소하면 안 되는 값(예: 출석 처리)이 있는지는 「확인 필요」입니다. status 에 들어가는 값 목록을 보면 확인됩니다.
1-2. 예약이 없으면 서버 오류가 납니다
- 위치:
hoursLeft 계산 줄의 reservation.starts_at
- 문제:
oneOrNone 은 행이 없으면 null 을 돌려주는데, 확인 없이 starts_at 을 읽습니다.
- 예: 없는 예약 번호로 요청하면
Cannot read properties of null (reading 'starts_at') 로 500 이 납니다. Express 4 라면(추측, 1-5) 500 도 나가지 않고 요청이 매달립니다.
- 고친 코드:
if (!reservation) {
return res.status(404).json({ error: "예약을 찾을 수 없어요" });
}
1-3. 문자 한 건이 실패하면 나머지 대기자는 못 받고, 회원은 취소가 실패한 줄 압니다
- 위치:
for (const w of waitlist) 반복문
- 문제: 취소는 이미 DB 에 반영됐는데, 문자 발송 오류가 그대로 응답 오류가 되고 반복문도 거기서 멈춥니다.
- 예: 대기자 10명 중 세 번째 번호에서
sendSms 가 예외를 던지면 네 번째부터는 문자를 못 받고 회원은 500 을 받습니다. 회원이 다시 누르면 지금 코드에서는 또 취소되고 앞의 두 명이 문자를 또 받습니다.
- 고친 코드: 응답을 먼저 보내고 문자는 건마다 실패를 잡습니다. 코드는 3-1 과 같습니다.
- 확신: 흐름은 「확실」.
sendSms 가 실패할 때 예외를 던지는지(아니면 결과값으로 알리는지)는 「추측」이고, sendSms 코드를 보면 확인됩니다.
1-4. 취소 가능 시간 계산: 식은 맞고, 시간대는 확인 필요입니다
(시작 시각 - 지금) / 3600000 은 남은 시간을 시간 단위로 정확히 냅니다. 경계값도 설명대로 동작합니다.
| 남은 시간 | 결과 | 설명과 맞는지 |
|---|
| 3시간 1분 | 취소됨 | 맞음 |
| 정확히 3시간 | 취소됨(3 < 3 은 거짓) | 맞음(「3시간 전까지」) |
| 2시간 59분 | 400 | 맞음 |
| 이미 시작함(음수) | 400 | 맞음 |
starts_at 이 null | 1970년으로 계산돼 400 | 막히긴 하지만 문구가 엉뚱함. 칸이 NOT NULL 이면 해당 없음(추측) |
- 위치:
hoursLeft 계산 줄
- 문제(추측):
starts_at 이 timestamp(시간대 없음) 칸이면, pg-promise 가 쓰는 pg 드라이버는 그 값을 Node 서버의 시간대 시각으로 읽습니다. 한국 시각을 저장해 두고 서버가 UTC 로 돌면 수업을 실제보다 9시간 늦게 계산합니다.
- 예: 한국 시각 19:00 수업을
19:00 으로 저장했다면 서버는 UTC 19:00(한국 시각 다음 날 04:00)으로 읽습니다. 그래서 수업이 시작하고 6시간이 지날 때까지 취소가 됩니다.
- 확인 방법: psql 에서
\d reservations 로 starts_at 형식을 봅니다. timestamptz 면 지금 코드 그대로 맞습니다. 서버 시간대는 Intl.DateTimeFormat().resolvedOptions().timeZone 으로 봅니다.
- 고친 코드(
timestamp 이고 한국 시각을 저장한 경우만): 근본 해결은 칸을 timestamptz 로 바꾸는 것입니다. 당장은 시간대를 밝혀 DB 에서 비교합니다.
const reservation = await db.oneOrNone(
"SELECT *, starts_at AT TIME ZONE 'Asia/Seoul' >= now() + interval '3 hours' AS can_cancel FROM reservations WHERE id = $1 AND member_id = $2",
[id, req.user.id]
);
// 404 확인 뒤
if (!reservation.can_cancel) {
return res.status(400).json({ error: "수업 3시간 전부터는 취소할 수 없어요" });
}
- 확신: 식과 경계값은 「확실」, 시간대 문제는 「추측」(칸 형식을 보면 확정됩니다)
1-5. Express 4 라면 이 함수 안의 오류가 오류 처리기로 가지 않습니다
- 위치:
async (req, res) => { ... } 전체
- 문제: Express 4 는 async 함수에서 난 오류를 잡지 않습니다. 응답이 나가지 않아 요청이 타임아웃까지 매달리고, Node 15 이상에서는 처리되지 않은 Promise 거부로 서버 프로세스가 꺼질 수 있습니다. Express 5 는 이런 오류를 알아서
next(err) 로 넘기므로 해당 없습니다.
- 예: DB 연결이 잠깐 끊긴 사이에 취소 요청이 오면 응답이 오지 않습니다.
- 확인 방법:
package.json 의 express 버전, express-async-errors 같은 감싸는 장치를 쓰는지, process.on("unhandledRejection") 이 있는지.
- 고친 코드(Express 4 일 때):
app.post("/api/reservations/:id/cancel", async (req, res, next) => {
try {
// 기존 본문
} catch (err) {
next(err);
}
});
1-6. 숫자가 아니거나 아주 큰 id 는 500 이 납니다
- 위치:
const id = req.params.id;
- 문제: 2-2 처럼 값을 따로 넘겨도, id 칸이 integer 라면 PostgreSQL 이 형식 오류를 내서 500 이 됩니다. 0·음수는 행이 없어 404 로 끝나고, 빈 값은 이 경로로 들어오지 않습니다.
- 예:
/api/reservations/99999999999/cancel 은 integer 범위를 넘어 500 이 납니다.
- 고친 코드(id 가 integer 라는 가정. bigint·uuid 면 검사를 바꿉니다):
const id = Number(req.params.id);
if (!Number.isInteger(id) || id < 1 || id > 2147483647) {
return res.status(404).json({ error: "예약을 찾을 수 없어요" });
}
- 확신: 「추측」(
\d reservations 로 id 형식을 보면 확인됩니다)
2. 보안
2-1. 본인 예약인지 확인하지 않습니다
- 위치: 첫 SELECT 와 UPDATE 의 WHERE
- 문제: 설명은 「회원이 직접」인데 코드는
req.user 를 쓰지 않습니다.
- 예: 회원 A 가 회원 B 의 예약 번호로 요청하면 B 의 예약이 취소되고 200 이 돌아옵니다. 이 경로 앞에 로그인 검사가 없다면(추측) 로그인하지 않은 사람도 취소할 수 있습니다.
- 고친 코드: 주인 조건을 SELECT 에 넣습니다. 남의 예약도 404 로 돌려주면 그 번호의 예약이 있는지조차 알려 주지 않습니다.
if (!req.user) {
return res.status(401).json({ error: "로그인이 필요해요" });
}
const reservation = await db.oneOrNone(
"SELECT * FROM reservations WHERE id = $1 AND member_id = $2",
[id, req.user.id]
);
- 확신: 「확실」. 로그인 검사 미들웨어가 이 경로에 걸려 있는지는 「추측」이고, 라우터 설정을 보면 확인됩니다. 걸려 있어도
if (!req.user) 는 두는 편이 안전합니다.
2-2. SQL 문장에 입력값을 그대로 붙입니다(SQL 주입)
- 위치: 세 쿼리의
${id}, ${reservation.class_id}
- 문제: 백틱 문자열의
${} 는 JavaScript 가 먼저 채우므로, pg-promise 는 값이 이미 박힌 SQL 을 받습니다. pg-promise 에도 ${name} 문법이 있지만, 그건 따옴표 문자열과 값 객체를 따로 넘길 때만 안전하게 바뀝니다. 그리고 req.params.id 는 요청한 사람이 마음대로 정하는 문자열입니다.
- 예:
/api/reservations/abc/cancel 로 요청하면 쿼리가 WHERE id = abc 가 되고, PostgreSQL 은 abc 를 칸 이름으로 읽어 column "abc" does not exist 오류를 냅니다. 입력이 값이 아니라 SQL 문법으로 읽힌다는 뜻이라, 이 자리에 SQL 조각이 들어오면 UPDATE 가 의도하지 않은 행까지 바꿀 수 있습니다.
- 고친 코드: 값은
$1 자리로 따로 넘깁니다. class_id 는 DB 에서 읽은 값이라 바로 노려지기는 어렵지만 같은 방식으로 바꿉니다.
const reservation = await db.oneOrNone("SELECT * FROM reservations WHERE id = $1", [id]);
const waitlist = await db.any("SELECT phone FROM waitlist WHERE class_id = $1", [reservation.class_id]);
3. 성능
3-1. 문자를 한 명씩 기다리며 보내고, 다 보낸 뒤에야 응답합니다
- 위치:
for (const w of waitlist) { await sendSms(...) }
- 문제: 응답 시간이 「대기자 수 × 문자 한 건 시간」만큼 늘어납니다.
- 예: 대기자 200명, 문자 한 건에 0.3초(가정)면 응답까지 약 1분입니다. 앱이나 프록시가 그 전에 끊으면 회원은 실패로 보지만 취소는 이미 돼 있습니다.
- 고친 코드: 응답을 먼저 보내고, 문자는 20건씩 묶어 동시에 보냅니다. 한 건이 실패해도 나머지는 계속 갑니다(1-3 도 함께 해결).
res.json({ ok: true });
// 응답을 보낸 뒤에 보낸다. 문자가 실패해도 취소는 이미 끝났다
notifyWaitlist(reservation.class_id).catch((err) => console.error("대기자 알림 실패", err));
async function notifyWaitlist(classId: number) {
const waitlist = await db.any("SELECT phone FROM waitlist WHERE class_id = $1", [classId]);
for (let i = 0; i < waitlist.length; i += 20) {
await Promise.all(
waitlist.slice(i, i + 20).map((w) =>
sendSms(w.phone, "자리가 났어요! 지금 예약하세요").catch((err) => console.error("대기자 문자 한 건 실패", err))
)
);
}
}
- 확인 필요: 20건은 임의로 정한 값입니다. 문자 서비스가 정한 초당 발송 한도를 넘지 않게 맞춥니다. 자리 하나에 대기자 전원에게 보내는 게 의도인지도 확인 필요입니다. 1순위에게만 보내는 것이라면 이 문제는 대부분 사라집니다.
- 한계(추측, 배포 환경을 보면 확인됩니다): 응답 뒤에 보내는 방식은 서버가 재시작되면 남은 문자가 사라지고, 서버리스처럼 응답과 함께 실행이 멈추는 환경에서는 아예 안 갈 수 있습니다. 대기자가 수백 명을 넘거나 재시도·발송 기록이 필요해지면 작업 큐로 옮기는 게 맞습니다.
- 확신: 느려지는 것은 「확실」.
sendSms 가 Promise 를 돌려준다는 것은 「추측」입니다(원래 코드가 await 하고 있어 그럴 가능성이 높습니다).
3-2. 대기자 표가 커지면 class_id 조회가 느려질 수 있습니다
- 위치:
SELECT phone FROM waitlist WHERE class_id = ...
- 문제:
waitlist.class_id 에 인덱스가 없으면 조회할 때마다 대기자 표 전체를 훑습니다.
- 예: 대기자 표가 수십만 행이 되면 취소 한 번마다 전부 읽습니다.
- 고친 코드(인덱스가 없을 때):
CREATE INDEX ON waitlist (class_id);
- 확신: 「추측」(
\d waitlist 로 인덱스를 보면 확인됩니다). 반복문 안의 DB 호출은 없습니다. 대기자 목록은 한 번에 읽습니다.
4. 읽기 쉬움
- 크게 손볼 곳은 없습니다. 함수가 짧고 위에서 아래로 읽힙니다.
- 「취향」
3600000 과 3 에 이름을 붙이면(MS_PER_HOUR, CANCEL_DEADLINE_HOURS) 규칙이 바뀔 때 찾기 쉽습니다.
내보내기 전에 꼭 고칠 것 / 나중에 해도 되는 것
꼭 고칠 것
- 본인 예약 확인(2-1)
- SQL 에 값을 직접 붙이지 않기(2-2)
- 이미 취소된 예약과 동시 요청 막기(1-1)
- 예약이 없을 때 404(1-2)
- 문자를 응답 뒤로 옮기고 건마다 실패 잡기(1-3, 3-1)
- 확인해서 해당하면 고치기:
starts_at 형식과 서버 시간대(1-4), Express 버전(1-5)
나중에 해도 되는 것
- id 형식 검사(1-6)
- 대기자가 많아지면 문자 발송을 작업 큐로(3-1)
waitlist.class_id 인덱스 확인(3-2)
- 상수 이름 붙이기(4, 「취향」)
최종 코드
가정: id 는 integer, starts_at 은 timestamptz, Express 4 입니다. 모두 「추측」이고 주석에 표시했습니다.
app.post("/api/reservations/:id/cancel", async (req, res, next) => {
try {
if (!req.user) {
return res.status(401).json({ error: "로그인이 필요해요" });
}
// id 가 integer 칸이라는 가정(추측). bigint·uuid 면 이 검사를 바꾼다
const id = Number(req.params.id);
if (!Number.isInteger(id) || id < 1 || id > 2147483647) {
return res.status(404).json({ error: "예약을 찾을 수 없어요" });
}
const reservation = await db.oneOrNone(
"SELECT * FROM reservations WHERE id = $1 AND member_id = $2",
[id, req.user.id]
);
if (!reservation) {
return res.status(404).json({ error: "예약을 찾을 수 없어요" });
}
// starts_at 이 timestamptz 라는 가정(확인 필요). timestamp 면 1-4 처럼 DB 에서 비교한다
const hoursLeft = (new Date(reservation.starts_at).getTime() - Date.now()) / 3600000;
if (hoursLeft < 3) {
return res.status(400).json({ error: "수업 3시간 전부터는 취소할 수 없어요" });
}
// 상태 확인과 변경을 한 문장으로: 같은 요청이 동시에 와도 하나만 성공한다
const result = await db.result(
"UPDATE reservations SET status = 'cancelled' WHERE id = $1 AND status <> 'cancelled'",
[id]
);
if (result.rowCount === 0) {
return res.status(409).json({ error: "이미 취소된 예약이에요" });
}
res.json({ ok: true });
// 응답을 보낸 뒤에 보낸다. 문자가 실패해도 취소는 이미 끝났다
notifyWaitlist(reservation.class_id).catch((err) => console.error("대기자 알림 실패", err));
} catch (err) {
next(err); // Express 5 면 try/catch 없이도 된다
}
});
async function notifyWaitlist(classId: number) {
const waitlist = await db.any("SELECT phone FROM waitlist WHERE class_id = $1", [classId]);
// 20건씩 동시에 보낸다(문자 서비스 발송 한도에 맞춰 조정: 확인 필요). 한 건이 실패해도 나머지는 보낸다
for (let i = 0; i < waitlist.length; i += 20) {
await Promise.all(
waitlist.slice(i, i + 20).map((w) =>
sendSms(w.phone, "자리가 났어요! 지금 예약하세요").catch((err) => console.error("대기자 문자 한 건 실패", err))
)
);
}
}
테스트
- 남의 예약: 회원 A 로 로그인하고 회원 B 의 예약(수업 5시간 뒤) 번호로 취소 요청 → 404. B 예약의
status 는 그대로, sendSms 호출 0번.
- 같은 요청 동시에 두 번: 본인 예약(수업 5시간 뒤), 대기자 3명. 실제 PostgreSQL 에 대고 같은 취소 요청 두 개를
Promise.all 로 동시에 보냄 → 응답은 200 하나와 409 하나. sendSms 는 모두 3번(대기자마다 1번).
- 문자 한 건 실패: 본인 예약(수업 5시간 뒤), 대기자 3명, 두 번째 번호에서
sendSms 가 예외를 던지게 모의 → 응답 200, status 는 cancelled, sendSms 는 3번 모두 호출됨.
문자는 응답 뒤에 나가므로 sendSms 호출 횟수는 잠깐 기다린 뒤 셉니다.
잘한 점
DB 를 바꾸기 전에 취소 가능 시간을 먼저 확인하고 바로 돌려보내는 순서가 맞고, 오류 문구도 회원이 바로 알아들을 말이라 고칠 때 그 흐름을 그대로 살렸습니다.
reservations 의 id·starts_at 칸 형식, Express 버전, 그리고 대기자 전원에게 보내는 게 의도인지 알려 주시겠어요? 「추측」과 「확인 필요」로 남긴 곳을 확정해 드리겠습니다.