기능 요구와 수락 조건
중복·정원·마감 정책을 우선순위 있는 결과 코드와 상태 보존 조건으로 만들고 실행으로 확인합니다.
“신청을 잘 처리한다”는 말만으로는 가득 찬 모임에 이미 등록한 사람이 다시 신청할 때 어떤 결과를 내야 하는지 알 수 없습니다. 기능을 구현하기 전에 경계 상황의 결과를 결정해야 합니다.
기능 요구는 시스템이 해야 할 동작입니다. 수락 조건은 그 동작이 요구를 만족했다고 판단할 구체적인 조건입니다. 검증은 산출물이 정한 규칙에 맞는지 확인하고, 타당성 확인은 그 규칙이 사용자의 실제 문제를 해결하는지 확인합니다. 정원 검사가 정확해도 사용자가 대기 명단을 필요로 했다면 요구를 다시 확인해야 합니다.
입력·결과·변하지 않을 것
회원 ID는 영문 소문자로 시작하고, 뒤에 소문자·숫자·-를 포함할 수 있는 1~24자 문자열로 정합니다. 이름이나 실제 인증 정보를 대신한다는 뜻은 아닙니다. 불변 조건은 모든 유효한 상태에서 지킬 규칙입니다. 참가자 ID는 중복되지 않고 참가자 수는 정원을 넘지 않습니다.
| 요구 ID | 조건 | 결과 | 상태 |
|---|---|---|---|
| R1 | ID 형식이 잘못됨 | INVALID_MEMBER | 유지 |
| R2 | 유효 ID, 모임 마감 | CLOSED | 유지 |
| R3 | 열린 모임, 이미 참가 | ALREADY_REGISTERED | 유지 |
| R4 | 열린 모임, 새 ID, 정원 도달 | FULL | 유지 |
| R5 | 열린 모임, 새 ID, 자리 있음 | REGISTERED | 새 ID 한 명 추가 |
| R6 | 참가자가 취소 | CANCELLED | 그 ID 제거, 마감 여부 유지 |
위에서 아래로 먼저 만족한 조건을 사용합니다. 마감한 모임의 기존 참가자는 R2에 따라 CLOSED를 받습니다. 열린 모임의 중복 참가자는 가득 찼어도 R3을 받습니다. 조건이 겹칠 때의 순서를 문서에 남겼기 때문에 구현과 테스트가 같은 결정을 따를 수 있습니다.
SOFTWARE ENGINEERING · READING CLUB
업무 요구를 수락 사례와 자동 단언으로 잇는다
실행 결과에서 정답을 복사하지 않고 요구 R3에서 전제와 기대 값을 도출합니다.
업무 요구를 변경하면 수락 사례·안내·구현의 연결을 따라 함께 갱신합니다.
수락 조건 하나를 Given–When–Then으로 적어 봅니다. 이는 전제–행동–기대 결과를 구분하는 표현 방식입니다.
Given: 열린 모임의 정원은 2이고 min, hana가 참가한다.
When: min이 다시 신청한다.
Then: ALREADY_REGISTERED를 반환하고 명단은 min, hana 그대로다.전제를 생략하고 min 신청만 테스트하면 첫 신청과 재신청을 구분할 수 없습니다. 결과 코드만 확인하면 실패 중 명단이 바뀌는 오류도 놓칩니다.
처음 코어를 읽는 순서
앞 장의 변수·함수·배열에서 바로 모든 저장 기능을 이해하려 하면 어려울 수 있습니다. 먼저 createSession으로 빈 모임을 만들고, register의 거절 조건과 성공 결과를 읽습니다. 취소·마감은 그다음, 저장 자료를 검사하는 restoreSession은 마지막에 읽습니다. 첫 실행에서 저장 복원까지 외울 필요는 없지만, 모르는 표현을 그대로 통과시키지는 않습니다.
-
값의 종류와 조건:
typeof,===,&&,!typeof로 값의 종류를 확인하고===로 같은지 비교합니다.&&는 두 조건을 모두 만족하는지,!는 조건을 만족하지 않는지 판단합니다. 예를 들어 숫자12는 문자열"12"와 다른 입력이므로 문자열을 요구하는 검사에서 거절됩니다. -
ID 전체 검사: 정규식과
.test(value)정규식의
^[a-z]는 소문자로 시작한다는 조건이고, 뒤의[a-z0-9-]{0,23}$는 소문자·숫자·-를 0개부터 23개까지 더 허용하고 문자열 끝까지 검사한다는 조건입니다. 첫 글자와 합쳐 1자부터 24자까지입니다. 예를 들어a-1은 허용하지만1a·A·빈 문자열은 거절합니다. 전체 정규식은 아래validMember함수에서 확인합니다. -
객체에서 값 꺼내기:
{ id, title, capacity }매개변수createSession은 전달된 객체에서 같은 이름의 값을 꺼냅니다. 예를 들어 객체에capacity: 2를 전달하면 함수 안의capacity는 2입니다. 값을 꺼내는 매개변수와 새 객체를 만드는 반환식을 구분해 읽습니다. -
기존 값을 복사해 새 상태 만들기:
...{ ...session }은 기존 객체의 값을 새 객체에 펼칩니다. 배열에서는session.participants의 기존 원소들을 펼친 뒤memberId를 더해 새 참가자 배열을 만듭니다. 예를 들어 이전 명단[]는 그대로이고 새 명단은["min"]입니다. 아래 코어의 배열 펼침 표현을 이 순서대로 읽습니다. -
검사 함수를 전달하기:
=>,filter,some(id) => id !== memberId는 ID를 받아 취소할 ID와 다른지 판단하는 짧은 함수입니다.filter는 그 조건을 만족하는 값만 남기고,some은 조건을 만족하는 값이 하나라도 있는지 검사합니다. 예를 들어 min 취소 때filter는 min 이외의 ID만 남깁니다. 저장 복원에서는some으로 잘못된 ID가 있는지 확인합니다. -
서로 다른 ID 세기:
new Set(list).sizeSet은 중복을 제거한 집합이고.size는 그 집합의 원소 수입니다. 예를 들어["min", "min"]은 배열 길이가 2지만 서로 다른 ID는 1입니다. 두 개수가 다르면 중복 참가자가 저장됐음을 알 수 있습니다. -
잘못된 입력으로 실행 중단하기:
throwthrow new RangeError(...)는 모임 생성이나 복원을 중단하고 잘못된 입력을 호출자에게 알립니다. 예를 들어 정원 0으로 정상 모임을 만들지 않습니다. 오류를 던져 실행을 중단하는 것과 신청 결과 코드를 반환하는 것은 서로 다른 처리입니다. -
직접 변경 막기:
Object.freeze이 실습에서는 객체와 참가자 배열을 고정해 외부에서 직접 바꾸지 못하게 합니다. 예를 들어 기존 배열에
push하는 대신 참가자가 추가된 새 상태를 반환합니다. 아래 전체 코드가 고정하는 층과 적용 범위를 설명합니다.
정규식의 ^와 $는 전체 문자열의 시작과 끝, [a-z]는 소문자 한 글자, [a-z0-9-]는 이후에 허용할 글자, {0,23}은 그 이후 글자의 개수입니다. ===와 !==는 타입을 자동 변환하지 않고 같은지·다른지 비교합니다. &&는 앞 조건이 거짓이면 뒤 조건을 평가하지 않습니다. 정규식은 로그인이나 본인 확인을 수행하지 않습니다.
state(value)는 새 상태를 고정하는 보조 함수 이름이고 result.state는 처리 결과에 들어 있는 필드 이름입니다. 두 표현을 같은 호출로 읽지 않습니다. export는 함수를 다른 파일에 공개하고 다음 예제의 import가 가져옵니다. for (const [id, expected] of cases)는 각 두 값 배열에서 입력 ID와 기대 코드를 하나씩 꺼냅니다. JSON.stringify는 출력할 객체를 텍스트로 바꿉니다.
코드를 저장한 뒤 다음 절의 examples/ch2.mjs를 실행할 때 아래 순서를 먼저 예측합니다.
| 호출 | 직전 명단 | 선택한 조건 | 결과·다음 명단 |
|---|---|---|---|
| min 첫 신청 | 빈 명단 | 거절 조건이 모두 거짓 | REGISTERED·min |
| min 재신청 | min | R3 중복 | ALREADY_REGISTERED·min |
| hana 신청 | min | 빈 자리 있음 | REGISTERED·min, hana |
| jun 신청 | min, hana | R4 정원 도달 | FULL·min, hana |
실패 때는 result.state가 직전 상태이고 성공 때는 새 상태입니다. 호출 뒤 session = result.state로 다음 전제를 연결하기 때문에 첫 신청의 결과가 다음 신청에 반영됩니다. 이 추적을 설명한 뒤 전체 코어에서 각 줄이 어떤 요구를 지키는지 찾습니다. 파일 위치가 헷갈리면 실습 폴더와 import 경로를 다시 확인합니다.
상태를 읽는 세 칸: 입력·반환·다음 호출
const before = session은 당시 입력 객체를 가리킵니다. register는 성공할 때 새 객체를 반환하고 실패할 때 같은 입력 객체를 반환합니다. 어느 경우든 호출자의 변수 session을 자동으로 재대입하지는 않습니다.
| 호출 | before 명단 | result.state 명단 | 입력과 반환이 같은 객체인가 | session = result.state 뒤 |
|---|---|---|---|---|
| min 첫 신청 | 빈 배열 | min | 아니오 | min |
| min 재신청 | min | min | 예 | min |
| hana 신청 | min | min, hana | 아니오 | min, hana |
| jun 만석 거절 | min, hana | min, hana | 예 | min, hana |
성공 행에서 before.participants가 나중에 새 ID를 얻는 것이 아닙니다. 이전 상태와 새 상태를 나란히 보존할 수 있습니다. 실패 행의 같은 객체는 ‘아무 변경도 하지 않았다’는 계약을 확인하기에 유용합니다.
const before = session;
const result = register(before, "min");
console.log(before.participants, result.state.participants);
session = result.state;session을 let으로 선언한 이유는 변수를 새 상태에 연결하기 위해서입니다. 객체의 내용을 바꾸는 것과 변수의 대상을 바꾸는 것은 다릅니다. 마지막 재대입을 빼고 이전 빈 상태를 다시 입력하면 앞 신청 결과를 반영하지 못합니다. 아래 core의 두 층 freeze와 문자열 ID 가정은 그대로 유지합니다.
실행할 코어 파일
reading-lab/core.mjs를 만들고 아래 전체 내용을 저장합니다. 앞 문서의 초기 상태 생성에 결과 코드, 취소·마감·저장 상태 검증을 더했습니다. 여기서 반환하는 상태는 복사한 객체와 배열을 Object.freeze로 고정합니다. 이 값들의 필드는 문자열·숫자뿐이어서 두 층의 고정으로 이 실습의 외부 변경을 막습니다. 임의로 깊게 중첩된 객체까지 자동으로 고정하는 기능은 아닙니다.
core.mjs 전체 코드
// This lab deliberately models one process and in-memory state.
// It is not a production authentication or concurrent storage service.
function validMember(value) {
return typeof value === "string" && /^[a-z][a-z0-9-]{0,23}$/.test(value);
}
function state(value) {
return Object.freeze({ ...value, participants: Object.freeze([...value.participants]) });
}
export function createSession({ id, title, capacity }) {
if (typeof id !== "string" || !id.trim() || typeof title !== "string" || !title.trim()) {
throw new TypeError("id and title must be non-empty strings");
}
if (!Number.isInteger(capacity) || capacity < 1 || capacity > 100) {
throw new RangeError("capacity must be an integer from 1 to 100");
}
return state({ id, title, capacity, status: "OPEN", participants: [] });
}
export function register(session, memberId) {
let code;
if (!validMember(memberId)) code = "INVALID_MEMBER";
else if (session.status !== "OPEN") code = "CLOSED";
else if (session.participants.includes(memberId)) code = "ALREADY_REGISTERED";
else if (session.participants.length >= session.capacity) code = "FULL";
if (code) return { ok: false, code, state: session };
return {
ok: true,
code: "REGISTERED",
state: state({ ...session, participants: [...session.participants, memberId] }),
};
}
export function cancel(session, memberId) {
if (!validMember(memberId)) return { ok: false, code: "INVALID_MEMBER", state: session };
if (!session.participants.includes(memberId)) {
return { ok: false, code: "NOT_REGISTERED", state: session };
}
return {
ok: true,
code: "CANCELLED",
state: state({ ...session, participants: session.participants.filter((id) => id !== memberId) }),
};
}
export function closeSession(session) {
return state({ ...session, status: "CLOSED" });
}
export function restoreSession(raw) {
const initial = createSession(raw);
if (!["OPEN", "CLOSED"].includes(raw.status) || !Array.isArray(raw.participants)) {
throw new TypeError("invalid stored session");
}
if (raw.participants.some((id) => !validMember(id)) ||
new Set(raw.participants).size !== raw.participants.length ||
raw.participants.length > raw.capacity) {
throw new TypeError("stored participants violate the session rules");
}
return state({ ...initial, status: raw.status, participants: raw.participants });
}export는 다른 파일에서 가져올 함수를 공개합니다. 실패는 같은 상태 객체를 돌려주고 성공은 새 상태를 만듭니다. 따라서 호출자는 result.state를 다음 신청의 입력으로 사용합니다. register는 createSession이나 restoreSession으로 만든 유효한 상태를 전제로 합니다. 외부에서 받은 JSON을 곧바로 register에 넣지 않습니다.
수락 사례를 코드로 확인
reading-lab/examples/ch2.mjs에 저장하고 node examples/ch2.mjs로 실행합니다. 저장소 예제의 경로는 dev-note/scripts/software-engineering-lab/examples/ch2.mjs입니다.
import assert from "node:assert/strict";
import { createSession, register, cancel, closeSession } from "../core.mjs";
let session = createSession({ id: "reading-1", title: "주말 읽기 모임", capacity: 2 });
const cases = [
["min", "REGISTERED"], ["min", "ALREADY_REGISTERED"],
["hana", "REGISTERED"], ["jun", "FULL"], ["", "INVALID_MEMBER"],
];
for (const [id, expected] of cases) {
const before = session;
const result = register(session, id);
assert.equal(result.code, expected);
if (!result.ok) assert.equal(result.state, before);
session = result.state;
console.log(JSON.stringify({ id, code: result.code, count: session.participants.length }));
}
session = closeSession(session);
assert.equal(register(session, "jun").code, "CLOSED");
const cancelled = cancel(session, "min");
assert.equal(cancelled.code, "CANCELLED");
assert.equal(cancelled.state.status, "CLOSED");
console.log("closed cancellation", cancelled.code, cancelled.state.participants.join(","));assert.equal은 실제 값과 기대 값이 다르면 오류를 발생시킵니다. 실패한 신청에서는 반환된 상태가 이전 상태 객체와 같은지 검사하고, 마감 후 취소에는 상태가 계속 CLOSED인지 검사합니다. 결과는 등록·중복·등록·정원 초과·잘못된 ID 순서이며 마지막 참가자는 hana입니다.
독립 문제: 비슷해 보이는 요구 바꾸기
“정원이 차면 모든 신청은 FULL을 반환한다”로 정책을 바꾸려 합니다. 기존 R3과 어떤 충돌이 생기나요? 합의를 위해 제시할 사례와 바꿔야 할 테스트를 작성하세요.
힌트
가득 찬 상태에서 이미 등록한 ID와 새 ID를 각각 신청시킵니다. 상태 변화와 결과 코드를 분리합니다.
해설
가득 찬 모임의 기존 ID는 기존 정책에서는 ALREADY_REGISTERED, 새 정책에서는 FULL입니다. 새 ID는 둘 다 FULL이므로 새 ID 사례만으로 변경을 확인할 수 없습니다. 진행자에게 기존 참가자도 만석 메시지를 받아야 하는지 확인한 뒤 R3/R4 순서와 중복 수락 사례의 기대 코드를 함께 바꿉니다. 거절이 명단을 유지한다는 조건은 계속 검사합니다.
테스트 통과는 작성한 사례와 구현이 일치한다는 증거입니다. 모든 사용자 상황이 맞다는 보장은 아닙니다. 테스트 사례와 오라클에서 이 차이를 더 구체적으로 다룹니다.