일정·의존성·위험
작업 사이의 선행 관계로 가장 이른 시작과 종료를 계산하고, 뒤에서 앞으로 계산해 여유와 임계 경로를 찾습니다. 작업자를 함께 써야 할 때 일정이 어떻게 늘어나는지 실행해 보고, 위험과 이슈를 구별해 가능성, 영향, 대응, 담당, 발동 조건을 기록합니다.
네 작업의 기간을 모두 더하면 5단위이지만, 그중에는 동시에 진행할 수 있는 작업도 있습니다. 그렇다고 모든 작업을 한꺼번에 시작할 수도 없는데, 어떤 작업은 다른 작업의 결과가 있어야 시작할 수 있기 때문입니다. 이처럼 한 작업이 다른 작업의 결과를 먼저 필요로 하는 관계를 의존성(dependency)이라고 합니다. 이 글에서는 의존성을 따라 전체 기간과 여유를 계산하고, 작업자 같은 자원이 모자랄 때 일정이 어떻게 바뀌는지, 계획을 흔들 위험을 어떻게 기록하는지 다룹니다.
가장 이른 시작과 종료를 계산한다
정책 합의 A에 1단위, 코어 구현 B에 2단위, 안내 작성 C에 1단위, 통합 검토 D에 1단위가 걸린다고 해 봅시다. B와 C는 A가 끝난 뒤에, D는 B와 C가 모두 끝난 뒤에 시작할 수 있습니다. 이 기간은 계산을 연습하려고 정한 가상의 단위이며, 실제 교재를 만드는 데 걸린 시간과는 관계가 없습니다.
처음에는 작업마다 전담하는 사람이 한 명씩 있어 그 기간 내내 참여하고, B와 C는 서로 다른 사람이 동시에 맡을 수 있다고 가정합니다. 이 가정에서만 기간의 합 1 + 2 + 1 + 1 = 5를 들어간 노력의 합으로 읽을 수 있으며, 기다리는 시간이나 쉬는 날, 일을 넘겨주는 데 드는 시간은 생략합니다. 작업마다 참여하는 사람의 수가 다르거나 일부 시간만 참여한다면, 기간의 합과 노력의 합은 같지 않습니다.
분기 작업을 모두 기다리는 합류가 완료를 결정한다
선행 작업이 없는 A를 진행한다. 두 선행 작업이 끝난 뒤 D를 진행한다. 각 단계의 값과 전환 이유를 비교합니다.
SOFTWARE ENGINEERING · READING CLUB
분기 작업을 모두 기다리는 합류가 완료를 결정한다
화살표는 선행 제약입니다. 자원이 충분하면 B와 C는 함께 시작하며, D는 둘의 완료를 기다립니다.
강조된 A→B→D의 기간은 4단위, A→C→D는 3단위입니다. B와 C의 배치 위치는 직렬 수행을 뜻하지 않습니다. B·C를 같은 한 사람만 맡으면 5단위가 되는 계산은 본문에서 따로 추적합니다.
이 예의 조건
- 본문과 전체 도표의 예를 따라 t0 정책 시작부터 t3→4 합류까지 진행합니다.
관계도의 보충 설명
| 구분 | 전체 도표의 내용 |
|---|---|
| 관계 설명 | A 정책 1단위 뒤 B 코어 2단위와 C 안내 1단위가 병렬입니다. D 통합 1단위는 둘 다 끝나야 시작합니다. 선행 변은 A B, A C, B D, C D 네 개입니다. 자원이 충분하다는 가정에서 A B D 경로는 4단위, 노력 합은 5단위입니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| t0 정책 시작 선행 작업이 없는 A를 진행한다. |
|
|
| t1 병렬 시작 A가 끝나면 B와 C를 함께 시작한다. |
|
|
| t2 한 갈래 완료 C가 끝나도 D는 B를 기다린다. |
|
|
| t3→4 합류 두 선행 작업이 끝난 뒤 D를 진행한다. |
|
|
분기 작업을 모두 기다리는 합류가 완료를 결정한다
선행 작업이 없는 A를 진행한다. 두 선행 작업이 끝난 뒤 D를 진행한다. 각 단계의 값과 전환 이유를 비교합니다.
SOFTWARE ENGINEERING · READING CLUB
분기 작업을 모두 기다리는 합류가 완료를 결정한다
화살표는 선행 제약입니다. 자원이 충분하면 B와 C는 함께 시작하며, D는 둘의 완료를 기다립니다.
강조된 A→B→D의 기간은 4단위, A→C→D는 3단위입니다. B와 C의 배치 위치는 직렬 수행을 뜻하지 않습니다. B·C를 같은 한 사람만 맡으면 5단위가 되는 계산은 본문에서 따로 추적합니다.
이 예의 조건
- 본문과 전체 도표의 예를 따라 t0 정책 시작부터 t3→4 합류까지 진행합니다.
관계도의 보충 설명
| 구분 | 전체 도표의 내용 |
|---|---|
| 관계 설명 | A 정책 1단위 뒤 B 코어 2단위와 C 안내 1단위가 병렬입니다. D 통합 1단위는 둘 다 끝나야 시작합니다. 선행 변은 A B, A C, B D, C D 네 개입니다. 자원이 충분하다는 가정에서 A B D 경로는 4단위, 노력 합은 5단위입니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| t0 정책 시작 선행 작업이 없는 A를 진행한다. |
|
|
| t1 병렬 시작 A가 끝나면 B와 C를 함께 시작한다. |
|
|
| t2 한 갈래 완료 C가 끝나도 D는 B를 기다린다. |
|
|
| t3→4 합류 두 선행 작업이 끝난 뒤 D를 진행한다. |
|
|
작업을 시간축 위의 막대로 나타낸 그림을 간트 차트(Gantt chart)라 하고, 작업 사이의 선행 관계를 화살표로 이은 그림을 네트워크 다이어그램(network diagram)이라고 합니다. 그림처럼 C가 먼저 끝나도 D는 B가 끝날 때까지 기다려야 합니다. 다음 코드는 각 작업의 가장 이른 시작과 종료를 계산합니다. reading-lab/examples/ch8-3.mjs로 저장해 실행합니다.
import assert from "node:assert/strict";
const tasks = [
{ id: "A", duration: 1, after: [] },
{ id: "B", duration: 2, after: ["A"] },
{ id: "C", duration: 1, after: ["A"] },
{ id: "D", duration: 1, after: ["B", "C"] },
];
const finish = new Map();
for (const task of tasks) {
if (task.after.some((id) => !finish.has(id))) throw new Error("tasks must be in dependency order");
const start = Math.max(0, ...task.after.map((id) => finish.get(id)));
finish.set(task.id, start + task.duration);
console.log(task.id, start, finish.get(task.id));
}
assert.equal(finish.get("D"), 4);
console.log("기간의 합:", tasks.reduce((sum, task) => sum + task.duration, 0));A 0 1
B 1 3
C 1 2
D 3 4
기간의 합: 5Map은 작업 아이디와 종료 시각을 짝지어 저장합니다. has는 이미 계산했는지 확인하고, get은 저장한 시각을 꺼내며, set은 새 종료 시각을 기록합니다. task.after.map(...)은 선행 작업들의 종료 시각 목록을 만들고, Math.max(0, ...목록)은 그중 가장 늦은 시각을 고릅니다. 그래서 D는 B의 종료 3과 C의 종료 2 가운데 늦은 3에 시작해 4에 끝나며, 기간의 합은 5이지만 전체는 4단위 만에 끝납니다. 이 코드는 작업이 선행 작업보다 뒤에 오도록 적혀 있다고 전제하며, 그렇지 않으면 오류를 냅니다.
뒤에서 앞으로 계산해 여유를 찾는다
앞의 계산처럼 앞에서 뒤로 가며 구한 값을 가장 이른 시작(early start, ES)과 가장 이른 종료(early finish, EF)라고 합니다. 반대로 전체 완료 시각을 늦추지 않는 범위에서 가장 늦게 시작하고 끝낼 수 있는 시각을 가장 늦은 시작(late start, LS)과 가장 늦은 종료(late finish, LF)라고 합니다. 앞에서 뒤로는 ES = 선행 작업 EF의 최댓값, EF = ES + 기간을 구하고, 뒤에서 앞으로는 마지막 작업의 LF를 전체 완료 시각으로 둔 뒤 LF = 후행 작업 LS의 최솟값, LS = LF - 기간을 구합니다. 그리고 LS - ES를 그 작업의 총여유(total float)라고 합니다.
| 작업 | 앞으로 계산한 근거 | ES | EF | 뒤로 계산한 근거 | LS | LF | 총여유 |
|---|---|---|---|---|---|---|---|
| A | 선행 작업 없음 | 0 | 1 | B의 LS 1과 C의 LS 2 가운데 작은 값 | 0 | 1 | 0 |
| B | A의 EF 1 | 1 | 3 | D의 LS 3 | 1 | 3 | 0 |
| C | A의 EF 1 | 1 | 2 | D의 LS 3 | 2 | 3 | 1 |
| D | B의 EF 3과 C의 EF 2 가운데 큰 값 | 3 | 4 | 전체 완료 시각 4 | 3 | 4 | 0 |
C는 여유가 1이라 1단위 늦게 시작해도 D가 3에 시작할 수 있습니다. 반면 여유가 0인 A, B, D는 하나라도 늦어지면 전체 완료가 늦어지며, 이렇게 여유 없이 전체 완료 시각을 정하는 경로를 임계 경로(critical path)라고 합니다. 이 계산 방법을 임계 경로 기법(critical path method, CPM)이라고 부르며, 여기서는 작업자가 충분하다는 가정을 그대로 유지합니다.
작업자를 함께 써야 할 때
이번에는 B와 C를 같은 작업자 editor가 맡아야 하고, B부터 하며, 작업을 중간에 끊지 않는다고 가정합니다. 그러면 각 작업은 선행 작업이 끝난 시각과 맡은 작업자가 손이 비는 시각 가운데 늦은 쪽에 시작합니다. 다음 코드는 앞에서 뒤로, 뒤에서 앞으로의 계산과 작업자를 함께 쓰는 계산을 모두 담고 있습니다. reading-lab/examples/ch8-3-slack.mjs로 저장해 실행합니다.
function schedule(tasks) {
const times = new Map();
for (const task of tasks) {
if (task.after.some((id) => !times.has(id))) throw new Error("tasks must be in dependency order");
const es = Math.max(0, ...task.after.map((id) => times.get(id).ef));
times.set(task.id, { es, ef: es + task.duration });
}
const duration = Math.max(...[...times.values()].map((time) => time.ef));
for (const task of [...tasks].reverse()) {
const successors = tasks.filter((other) => other.after.includes(task.id));
const lf = successors.length ? Math.min(...successors.map((other) => times.get(other.id).ls)) : duration;
const time = times.get(task.id);
Object.assign(time, { ls: lf - task.duration, lf, slack: lf - task.duration - time.es });
}
return { duration, times };
}
function scheduleWithWorkers(tasks) {
const finish = new Map();
const freeAt = new Map();
for (const task of tasks) {
const ready = Math.max(0, ...task.after.map((id) => finish.get(id)));
const worker = task.worker ?? task.id;
const start = Math.max(ready, freeAt.get(worker) ?? 0);
finish.set(task.id, start + task.duration);
freeAt.set(worker, start + task.duration);
console.log(task.id, worker, "준비", ready, "시작", start, "종료", start + task.duration);
}
return Math.max(...finish.values());
}
const tasks = [
{ id: "A", duration: 1, after: [] },
{ id: "B", duration: 2, after: ["A"] },
{ id: "C", duration: 1, after: ["A"] },
{ id: "D", duration: 1, after: ["B", "C"] },
];
const { duration, times } = schedule(tasks);
for (const [id, { es, ef, ls, lf, slack }] of times) {
console.log(id, "ES", es, "EF", ef, "LS", ls, "LF", lf, "여유", slack);
}
console.log("완료 시각:", duration);
const shared = tasks.map((task) => (task.id === "B" || task.id === "C" ? { ...task, worker: "editor" } : task));
console.log("작업자를 함께 쓸 때 완료 시각:", scheduleWithWorkers(shared));A ES 0 EF 1 LS 0 LF 1 여유 0
B ES 1 EF 3 LS 1 LF 3 여유 0
C ES 1 EF 2 LS 2 LF 3 여유 1
D ES 3 EF 4 LS 3 LF 4 여유 0
완료 시각: 4
A A 준비 0 시작 0 종료 1
B editor 준비 1 시작 1 종료 3
C editor 준비 1 시작 3 종료 4
D D 준비 4 시작 4 종료 5
작업자를 함께 쓸 때 완료 시각: 5schedule의 둘째 반복문은 작업 목록을 [...tasks].reverse()로 뒤집어 D, C, B, A의 차례로 돌며 LS와 LF를 채우고, 출력은 앞의 표와 같습니다. Object.assign은 이미 있는 객체에 새 속성을 더합니다. 작업자를 함께 쓰면 C는 A가 끝난 1에 시작할 준비가 되었어도 editor가 B를 마치는 3까지 기다리고, 그 때문에 D도 4에 시작해 전체는 5단위가 걸립니다.
이 네 작업에서는 B와 C를 한 사람이 차례로 해야 하므로 5단위가 가능한 가장 짧은 기간이지만, 더 큰 일정에서 이렇게 정해진 순서로 작업자를 배정하는 방식이 언제나 가장 좋은 일정을 만드는 것은 아닙니다. 또 자원의 제약을 넣은 뒤에는 앞에서 구한 여유 표를 그대로 쓸 수 없습니다. 자원이 충분하다고 보고 구한 여유이기 때문입니다.
위험과 이슈를 기록한다
위험(risk)은 아직 일어나지 않았지만 일어날 수 있는 불확실한 사건이고, 이슈(issue)는 이미 일어나 해결해야 하는 문제입니다. 위험을 기록할 때는 일어날 가능성, 일어났을 때의 영향, 대응 방법, 맡은 사람, 그리고 위험이 현실이 되었다고 판단할 발동 조건을 함께 남깁니다.
| 위험 | 영향 | 대응 | 맡은 역할 | 발동 조건 |
|---|---|---|---|---|
| 기한까지 취소 정책을 정하지 못함 | 안내, 코어, 테스트를 다시 작업 | 초기 수락 사례로 먼저 합의 | 업무 정책 담당 | 정책 검토 기한이 지남 |
| 저장 형식이 서로 맞지 않음 | 명단을 되살리지 못함 | 버전과 유효성의 통합 실험 | 저장 담당 | 복원 실패나 중복 아이디 발견 |
| 바깥 서비스 연동이 늦어짐 | 일정과 업무가 멈춤 | 범위에서 빼거나 대체 흐름에 합의 | 바깥 연동 담당 | 연동 일정의 회신 기한이 지남 |
지금 취소 정책이 정해지지 않았다는 사실은 이미 있는 이슈입니다. 반면 합의 기한을 넘겨 안내와 구현을 다시 작업하게 될 수 있다는 것은 아직 일어나지 않은 위험이며, 실제로 기한을 넘겨 다시 작업해야 하게 되면 그때 이슈로 옮깁니다.
가능성과 영향에 점수를 매겨 곱한 값으로 우선순위를 정하기도 하지만, 순서를 나타내는 점수를 곱했다고 피해의 크기를 정확히 계산한 것은 아닙니다. 위험에 대한 대응에는 위험을 피하는 회피, 가능성이나 영향을 줄이는 완화, 다른 쪽에 넘기는 전가, 그대로 받아들이는 수용이 있으며, 어느 대응을 고르든 드는 비용과 남는 위험을 함께 적습니다. 위험을 받아들이기로 했더라도 맡은 사람과 발동 조건은 남겨 둡니다.
정리
- 의존성은 한 작업이 다른 작업의 결과를 먼저 필요로 하는 관계이며, 선행 작업 가운데 가장 늦게 끝나는 것이 시작 시각을 정합니다.
- 앞에서 뒤로 ES와 EF를, 뒤에서 앞으로 LS와 LF를 구하면 작업마다 총여유가 나오고, 여유가 0인 작업을 이은 경로가 임계 경로입니다.
- 기간의 합과 노력의 합은 참여하는 사람의 수와 참여 비율에 따라 달라지므로 따로 기록합니다.
- 작업자 같은 자원을 함께 써야 하면 선행 작업이 끝나도 기다려야 하며, 자원이 충분하다고 보고 구한 여유는 그대로 쓸 수 없습니다.
- 위험은 아직 일어나지 않은 사건이고 이슈는 이미 일어난 문제이며, 위험에는 가능성, 영향, 대응, 담당, 발동 조건을 남깁니다.
연습 문제
1. 작업자가 한 명뿐이라면
본문의 B와 C를 같은 사람이 맡아야 할 때 전체 기간은 어떻게 되나요? C의 기간이 1에서 3으로 늘어나는 경우도, 작업자가 충분할 때와 한 명뿐일 때로 나누어 판단하세요.
해설 보기
한 사람이 B와 C를 차례로 하면 1 + 2 + 1 + 1 = 5단위가 걸립니다. C가 3으로 늘어나면, 작업자가 충분할 때는 A→C→D가 5단위로 가장 길어 새로운 임계 경로가 되고, B의 여유는 1로 바뀝니다. 작업자가 한 명뿐이라면 B와 C를 차례로 해야 하므로 1 + 2 + 3 + 1 = 7단위가 걸립니다. 본문의 ch8-3-slack.mjs에서 C의 duration을 3으로 바꾸어 실행하면 이 값들을 확인할 수 있습니다.
2. 기간은 같고 사람 수가 다르다면
B에 두 사람이 2단위 내내 함께 참여하고 나머지 작업에는 한 사람씩 참여한다고 합시다. 기간과 선행 관계는 그대로입니다. 전체 기간과 노력의 합을 각각 구하고, 사람이 늘었으니 B의 기간을 1로 줄여도 되는지 판단하세요.
해설 보기
전체 기간은 선행 관계의 A→B→D에 따라 여전히 4단위입니다. 노력은 A의 1 × 1, B의 2 × 2, C의 1 × 1, D의 1 × 1을 더해 7 사람·단위입니다. 두 사람이 참여해 실제로 기간이 줄었다는 기록이나 B를 둘로 나눌 수 있다는 근거가 없으므로, B의 기간을 1로 바꿀 수는 없습니다. 기간, 참여하는 사람의 수, 참여 비율을 따로 기록해야 다른 계획과 비교할 수 있습니다.
3. 위험인가 이슈인가
다음을 위험과 이슈로 나누세요. ㉠ 저장 담당자가 다음 달에 휴가를 갈 수 있다. ㉡ 어제부터 테스트 서버에 접속할 수 없다. ㉢ 진행자가 정원 정책을 다시 바꿀지도 모른다. ㉣ 바깥 회원 시스템의 응답 형식이 지난주에 바뀌어 조회가 실패하고 있다.
해설 보기
㉠과 ㉢은 아직 일어나지 않았지만 일어날 수 있는 일이므로 위험입니다. 각각 대응 방법, 예를 들어 저장 코드를 다른 사람과 함께 검토해 두는 것과, 발동 조건, 예를 들어 휴가가 확정되는 것을 적어 둡니다. ㉡과 ㉣은 이미 일어나 작업을 막고 있는 문제이므로 이슈이며, 맡을 사람과 해결 기한을 정해 바로 다룹니다.
일정표는 지금 세운 가정을 보여 주는 계획일 뿐입니다. 다음 글 범위에서 기능점수까지에서는 규모를 추정하는 방법 가운데 하나인 기능점수를, 범위를 정하는 일부터 실제 계산까지 따라가 봅니다.