본문 바로가기

저장 방식 변경과 의존성

업무 코드가 load와 save라는 저장소 인터페이스에만 기대도록 의존성 주입과 의존 역전을 적용하고, 메모리 저장소와 JSON 파일 저장소를 바꾸어 끼워 같은 결과를 내는지 실행합니다. 저장 실패와 망가진 파일을 업무 거절과 구별하고, 이 교체가 해결하지 못하는 동시성과 내구성의 한계를 정리합니다.

메모리에 저장한 명단은 프로그램을 끝내면 사라집니다. JSON 파일에 저장하면 다음에 실행할 때 다시 읽을 수 있지만, 이번에는 파일을 읽거나 쓰다 실패하는 일, 파일이 망가지는 일, 여러 곳에서 동시에 고치는 일이라는 새로운 문제가 생깁니다. 그래서 저장 방식을 바꾼다는 것은 함수 이름을 맞추는 일이 아니라, 무엇을 보존하고 무엇을 복사하며 실패를 어떻게 알릴지에 대한 약속을 맞추는 일입니다. 이 글에서는 같은 업무 함수에 메모리 저장소와 파일 저장소를 바꾸어 넣어 결과가 같은지 확인하고, 파일 저장이 새로 가져오는 한계를 정리합니다.

업무가 필요로 하는 저장 인터페이스

여기서는 모임 하나의 상태를 저장하는 작은 인터페이스를 씁니다. 앱의 모든 기능을 다시 구현하지 않고, 모집 마감이라는 행동 하나로 저장소를 바꾸었을 때 무엇이 달라지는지 확인합니다.

연산결과와 부수 효과지켜야 할 약속
await load()모임 상태를 돌려준다호출한 쪽이 돌려받은 값을 고쳐도 저장된 상태는 바뀌지 않는다
await save(next)모임 상태 전체를 저장한다성공하면 끝나고, 실패하면 오류를 던진다
업무 함수읽기, 새 상태 만들기, 저장저장에 성공하기 전에는 완료했다고 응답하지 않는다

이 인터페이스에서는 파일이 없을 때 빈 명단을 만들어 돌려주지 않습니다. "아직 만들지 않은 모임"과 "있던 파일을 잃어버린 모임"을 똑같이 빈 명단으로 다루면, 데이터를 잃은 사실이 드러나지 않기 때문입니다. 그래서 예제는 처음 저장을 따로 한 뒤에 읽으며, 실제 앱에서도 새 모임을 만드는 동작과 기존 모임을 여는 동작을 나누어 설계할 수 있습니다.

의존의 방향을 정한다

함수가 쓸 협력 객체를 함수 안에서 만들지 않고 바깥에서 넘겨받는 방식을 의존성 주입(dependency injection)이라고 합니다. 아래 예제의 closeUsingStore(store)는 특정 파일 이름이나 메모리 변수를 알지 못한 채, load와 save를 가진 객체를 받아서 씁니다. 어떤 저장소를 넘길지는 프로그램이 시작하는 곳에서 정합니다.

의존 역전 원칙(dependency inversion principle, DIP)은 중요한 업무 규칙이 자주 바뀌는 구체적인 구현이 아니라 필요한 인터페이스에 기대도록 하라는 설계 방향입니다. 이 예제에서도 업무 함수는 Node.js의 파일 함수를 직접 부르지 않습니다. 다만 객체를 인수로 넘기는 문법을 썼다고 의존이 잘 설계된 것은 아니며, 넘기는 객체의 인터페이스가 여전히 파일 경로나 내부 배열의 형식에 묶여 있다면 효과가 줄어듭니다.

SOFTWARE ENGINEERING · CH7-3

업무는 필요한 계약을 사용하고 저장 구현을 교체한다

실선은 업무의 계약 의존, 점선은 구현이 충족하는 계약을 뜻합니다.

업무는 필요한 계약을 사용하고 저장 구현을 교체한다모집 종료 업무는 load/save 계약에 의존하고 메모리와 JSON 파일 구현이 같은 계약을 충족합니다. 화살표는 의존이며 데이터 흐름이나 실행 순서가 아닙니다.사용하는 계약구현구현모집 종료 업무특정 저장 위치를 모름저장소 계약async load · save메모리객체 사본JSON 파일UTF-8 텍스트

모집 종료 업무는 load/save 계약에 의존하고 메모리와 JSON 파일 구현이 같은 계약을 충족합니다. 화살표는 의존이며 데이터 흐름이나 실행 순서가 아닙니다.

그림의 화살표는 데이터가 흘러가는 방향이 아니라, 업무가 저장소 인터페이스를 쓰고 두 구현이 그 인터페이스를 따른다는 의존의 방향입니다. 실행할 때 어느 구현이 동작할지는 시작하는 곳에서 무엇을 넘기느냐에 달려 있습니다.

파일에 쓰고 다시 읽어 본다

아래 예제는 Node.js에 들어 있는 모듈만 씁니다. mkdtemp는 운영체제의 임시 폴더 안에 이번 실행만을 위한 새 폴더를 만들고, 파일은 그 안의 session.json으로 정합니다. 그래서 다른 데이터를 건드리지 않으며, 예제가 끝나면 만든 파일과 빈 폴더만 지웁니다. reading-lab/examples/ch7-3.mjs로 저장해 실행합니다.

import { mkdtemp, readFile, writeFile, unlink, rmdir } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createSession, register, closeSession, restoreSession } from "../core.mjs";

function createMemoryStore(seed) {
  let value = structuredClone(seed);
  return {
    async load() {
      return structuredClone(value);
    },
    async save(next) {
      value = structuredClone(next);
    },
  };
}

function createJsonStore(filePath) {
  return {
    async load() {
      return JSON.parse(await readFile(filePath, "utf8"));
    },
    async save(next) {
      await writeFile(filePath, JSON.stringify(next, null, 2), "utf8");
    },
  };
}

async function closeUsingStore(store) {
  const current = restoreSession(await store.load());
  const next = closeSession(current);
  await store.save(next);
  return next;
}

const seed = register(createSession({ id: "book-10", title: "저장을 바꾸다", capacity: 2 }), "member-a").state;
const directory = await mkdtemp(join(tmpdir(), "reading-lab-"));
const filePath = join(directory, "session.json");
try {
  const memory = createMemoryStore(seed);
  const file = createJsonStore(filePath);
  await file.save(seed);
  console.log("메모리:", (await closeUsingStore(memory)).status);
  console.log("JSON:", (await closeUsingStore(file)).status);
  const reopened = createJsonStore(filePath);
  console.log("새 저장소 객체로 읽기:", (await reopened.load()).status);
  console.log("처음 상태:", seed.status);
} finally {
  await unlink(filePath).catch((error) => {
    if (error.code !== "ENOENT") throw error;
  });
  await rmdir(directory);
}
메모리: CLOSED
JSON: CLOSED
새 저장소 객체로 읽기: CLOSED
처음 상태: OPEN

closeUsingStore를 한 글자도 바꾸지 않고 메모리 저장소와 파일 저장소를 차례로 넣었는데, 두 경우 모두 모집이 마감되었습니다. 셋째 줄은 저장소 객체를 새로 만들어 같은 파일을 읽은 결과이므로, 앞의 객체가 메모리에 들고 있던 값이 아니라 실제 파일의 내용을 읽었다는 것을 보여 줍니다. 다만 프로그램을 끝냈다가 다시 시작하거나 전원이 꺼지는 상황까지 시험한 것은 아닙니다.

readFile과 writeFile은 UTF-8 글자로 파일을 읽고 쓰며, JSON.stringify(next, null, 2)는 객체를 두 칸씩 들여 쓴 JSON 문자열로 바꿉니다. JSON은 문자열, 숫자, 참과 거짓, 배열, 객체, null 같은 데이터만 나타내므로 함수나 객체의 동작은 저장하지 못합니다. finally 블록은 중간에 오류가 나도 임시 파일을 지우려고 시도하는데, 파일이 아직 없다는 ENOENT 오류만 무시하고 다른 삭제 오류는 숨기지 않습니다. rmdir는 빈 폴더만 지우므로 안에 있는 다른 파일까지 한꺼번에 지울 위험이 없습니다.

파일 오류는 업무 거절과 다르다

FULL이나 CLOSED는 모임을 정상적으로 읽은 뒤에 내린 정책의 판단입니다. 반면 디스크에 쓰지 못한 것은 정책의 판단이 아니라 저장을 마치지 못한 장애입니다. 저장을 await로 기다리지 않고 바로 성공을 돌려주면, 사용자에게 완료되었다고 알린 뒤에 저장이 실패할 수 있습니다.

파일에서 읽은 데이터도 프로그램 안의 함수가 돌려준 값처럼 믿어서는 안 됩니다. JSON의 문법이 맞더라도 정원이 음수이거나, 아이디가 중복되거나, 상태가 정하지 않은 문자열일 수 있기 때문입니다. 그래서 closeUsingStore는 읽은 자료를 restoreSession으로 검사한 뒤에 씁니다. 다음 코드는 저장에 실패하는 저장소와 망가진 자료를 돌려주는 저장소로 이 두 경로를 확인합니다. reading-lab/examples/ch7-3-failure.mjs로 저장해 실행합니다.

import assert from "node:assert/strict";
import { createSession, closeSession, restoreSession } from "../core.mjs";

async function closeUsingStore(store) {
  const current = restoreSession(await store.load());
  const next = closeSession(current);
  await store.save(next);
  return next;
}

const seed = createSession({ id: "book-11", title: "실패하는 저장", capacity: 2 });
const failing = {
  async load() {
    return structuredClone(seed);
  },
  async save() {
    throw new Error("write failed");
  },
};
await assert.rejects(closeUsingStore(failing), /write failed/);
assert.equal(seed.status, "OPEN");
console.log("저장 실패가 호출한 쪽에 전달됨, 처음 상태:", seed.status);

const broken = {
  async load() {
    return { ...structuredClone(seed), capacity: -1 };
  },
  async save() {
    throw new Error("must not be called");
  },
};
await assert.rejects(closeUsingStore(broken), RangeError);
console.log("망가진 자료는 저장하기 전에 거절됨");
저장 실패가 호출한 쪽에 전달됨, 처음 상태: OPEN
망가진 자료는 저장하기 전에 거절됨

assert.rejects는 await로 기다린 작업이 정해진 오류로 실패하는지 확인합니다. 저장에 실패하면 closeUsingStore는 성공을 돌려주지 않고 오류를 호출한 쪽으로 넘기며, 처음 상태는 OPEN 그대로입니다. 정원이 -1인 자료는 restoreSession 안의 createSession이 RangeError로 거절하므로 저장 단계까지 가지 않습니다. 실제 화면이라면 이렇게 전달된 오류를 받아 입력과 현재 상태를 보존하고 장애를 안내해야 하며, 읽기에 실패했다고 빈 명단으로 덮어쓰거나 알 수 없는 오류를 "이미 신청함" 같은 업무 결과로 바꾸어 보여 주지 않습니다.

이 교체가 해결하지 못하는 문제

save가 성공적으로 끝났다고 해서, 여러 요청의 읽기와 판단과 쓰기가 하나의 나눌 수 없는 작업이 되는 것은 아닙니다. 아키텍처와 관점에서 보았듯이 두 요청이 같은 명단을 읽고 서로 다른 새 명단을 저장하면, 나중에 쓴 쪽이 먼저 쓴 쪽을 덮어씁니다. 같은 파일에 쓰기를 동시에 시작하는 문제도 따로 있으며, Node.js 파일 API 문서는 Promise 기반 파일 작업이 끝나는 시점과 같은 파일에 여러 번 쓸 때의 주의점을 설명합니다.

파일을 덮어쓰는 도중에 실패하면 내용의 일부만 남을 수도 있습니다. 임시 파일에 다 쓴 뒤 이름을 바꾸어 교체하는 방법, 백업, 운영체제와 파일 시스템이 주는 약속, 접근 권한을 검토할 수 있지만, 이 예제는 내구성과 동시성, 중간에 끊기지 않는 교체를 다루지 않습니다. 여러 사용자가 쓰는 웹 서비스라면 트랜잭션과 제약 조건, 동시 수정의 제어를 제공하는 데이터베이스 같은 저장 방식을 따로 골라야 합니다.

결국 바꾸기 쉽게 나눈 구조와 안전하게 운영되는 저장은 서로 다른 확인 대상입니다. 이 글에서 확인한 것은 같은 인터페이스로 두 구현을 부른 정상 흐름, 그리고 저장 실패와 망가진 자료가 업무 결과와 구별되어 전달되는 경로입니다.

정리

  • 저장 방식을 바꿀 때는 함수 이름이 아니라 보존, 복사, 실패에 대한 약속을 맞추어야 합니다.
  • 의존성 주입은 협력 객체를 바깥에서 넘겨받는 방식이고, 의존 역전 원칙은 업무 규칙이 구체적인 구현이 아닌 인터페이스에 기대게 하는 방향입니다.
  • 같은 인터페이스를 따르는 메모리 저장소와 파일 저장소는 업무 함수를 고치지 않고 바꾸어 끼울 수 있습니다.
  • 저장 실패는 업무 거절과 다른 장애이며, 파일에서 읽은 자료는 불변 조건을 검사한 뒤에 씁니다.
  • 저장소를 분리해도 동시 수정, 쓰는 도중의 실패, 내구성 같은 운영상의 문제는 따로 다루어야 합니다.

연습 문제

1. 실패를 false로 알리는 설정 저장소

설정 프로그램은 loadSettings와 saveSettings로 테마와 글자 크기를 저장합니다. 기존 저장소는 저장에 실패하면 false를 돌려주는데, 새 업무 함수는 실패하면 오류가 던져진다고 가정합니다. 기존 저장소를 그대로 넣으면 어떤 문제가 생기며, 어떻게 맞추어야 할까요?

해설 보기

await로 false를 기다려도 오류가 되지 않으므로, 업무 함수는 저장에 실패했는데도 다음 줄로 넘어가 완료했다고 알립니다. 어댑터에서 saveSettings가 false를 돌려주면 오류를 던지도록 바꾸거나, 반대로 모든 구현과 호출하는 쪽이 { ok, code } 같은 결과 객체로 실패를 주고받기로 통일해야 합니다. 어느 쪽이든 실패를 알리는 방식은 하나로 정하고, 모든 구현이 같은 방식을 따르게 합니다.

2. 읽은 설정의 검사와 공통 시험

1번의 설정 파일에 fontSize: "크게"가 들어 있어도 JSON의 문법은 맞습니다. 읽은 설정에서 검사할 항목 두 가지와, 메모리 구현과 파일 구현에 똑같이 실행할 시험 두 가지를 제안하세요.

해설 보기

읽은 테마가 허용하는 목록에 들어 있는지, 글자 크기가 숫자이며 허용하는 범위 안에 있는지를 검사합니다. JSON을 해석하는 것과 그 값이 업무 규칙에 맞는지 검사하는 것은 다른 단계입니다. 두 구현에 똑같이 실행할 시험으로는 저장한 값을 다시 읽으면 같은 값이 나오는지, 읽어 온 객체를 바깥에서 고쳐도 저장된 값이 바뀌지 않는지를 들 수 있습니다. 여기에 실패하는 구현을 넣었을 때 성공 안내가 나오지 않는지도 함께 확인하면 좋습니다.

3. 파일이 없으면 빈 명단으로

"파일이 없으면 오류를 내지 말고 빈 명단을 돌려주자, 그러면 처음 실행할 때 편하다"는 제안이 나왔습니다. 이 제안의 위험과 다른 방법을 설명하세요.

해설 보기

누군가 파일을 지웠거나 경로를 잘못 설정해 기존 명단을 찾지 못했을 때도 빈 명단이 정상처럼 돌아옵니다. 그 상태에서 신청을 하나 받아 저장하면 빈 명단에 한 명만 있는 파일로 덮어써, 데이터를 잃었다는 사실조차 드러나지 않습니다. 새 모임을 만드는 동작과 기존 모임을 여는 동작을 나누어, 열 때 파일이 없으면 오류로 알리고 만들 때만 빈 명단으로 시작하는 편이 안전합니다.

저장 구현을 바꾸어도 업무의 인터페이스는 그대로 유지할 수 있지만, 실행 환경의 위험은 달라집니다. 다음 글 동시 저장과 복구에서는 두 요청이 같은 상태를 읽는 상황을 단계별로 재현하고, 버전을 비교해 저장하는 방법과 백업을 검사해 되살리는 방법을 실행해 봅니다.


원자료와 더 읽기

장원사이버평생교육원 「소프트웨어공학」 PDF 물리 128–148·181–185·197–200쪽의 주제를 바탕으로 설명, 도표, 실습을 새로 구성했습니다. 파일 동작은 Node.js 24 파일 API를 참고하며, 예제는 따로 만든 임시 파일을 차례로 읽고 쓰는 것만 확인할 뿐 운영 환경의 저장 내구성이나 동시성을 확인하지 않습니다.