아키텍처와 관점
사용자 기능, 코드의 협력, 실행 위치, 요청의 순서를 서로 다른 관점으로 나누어 설명하고, 계층, 클라이언트-서버, 파이프-필터 같은 구조를 목적과 비용으로 비교합니다. 저장소를 load와 save로만 쓰는 신청 서비스를 실행해 보고, 두 요청을 동시에 처리하면 무엇이 깨지는지 확인합니다.
모듈을 잘 나누었더라도 모듈 그림만 보고는 프로그램이 실제로 어떻게 운영되는지 알 수 없습니다. "서비스 → 저장소"라는 그림은 서비스가 저장소를 부른다는 것은 알려 주지만, 둘이 같은 컴퓨터에서 실행되는지 다른 컴퓨터에서 실행되는지는 알려 주지 않습니다. 아키텍처는 시스템의 중요한 구성과 관계, 그리고 그렇게 정한 이유를 설명하며, 무엇을 묻느냐에 따라 서로 다른 관점의 설명이 필요합니다. 이 글에서는 같은 독서 모임 프로그램을 사용자 기능, 코드 구조, 실행 위치의 관점으로 나누어 설명하고, 저장소를 분리한 구조를 실행해 봅니다.
그림이 답할 질문을 먼저 정한다
소프트웨어 아키텍처(software architecture)는 시스템을 이루는 중요한 구성 요소, 그 사이의 관계, 바깥에서 볼 수 있는 성질, 그리고 이를 정한 원칙을 다룹니다. 모든 함수의 모든 줄을 담는 상세 설계와는 목적이 다르며, 무엇이 중요한지는 요구와 제약에 따라 달라집니다. 예를 들어 데이터를 잃지 않는 것이 중요하다면 저장 위치, 실패 처리, 복구 방법이 아키텍처의 주요 결정이 됩니다.
어떤 관심사를 어떤 표기 규칙으로 볼지를 정한 것을 관점(viewpoint)이라 하고, 그 관점으로 실제 시스템을 표현한 설명을 뷰(view)라고 합니다. 회원은 무엇을 할 수 있는지에, 개발자는 코드가 어떻게 나뉘고 서로 기대는지에, 운영자는 어디에서 실행되고 무엇이 고장 날 수 있는지에 관심을 둡니다. 이 모든 질문에 그림 한 장으로 답하려 하면 선 하나가 호출인지, 데이터의 흐름인지, 네트워크 연결인지 뜻이 섞여 버립니다.
| 질문 | 이 프로그램의 설명 | 따로 보아야 할 정보 |
|---|---|---|
| 무엇을 할 수 있는가 | 회원의 신청과 취소, 진행자의 모집 마감 | 유스케이스, 수락 조건 |
| 코드가 어떻게 협력하는가 | 입력과 표현 → 신청 서비스 → 저장소 | 모듈, 공개 연산, 의존 방향 |
| 어디에서 실행되는가 | 지금은 Node.js 프로세스 하나, 저장은 메모리 | 프로세스, 파일, 서버의 배치 |
| 요청이 어떤 순서로 처리되는가 | 읽기 → 판정 → 저장 → 성공 응답 | 실행 순서, 실패할 수 있는 지점 |
이런 관심사를 나누어 보는 방법으로 크루첸(Kruchten)의 4+1 뷰 모델이 널리 알려져 있습니다. 사용자의 목표를 보는 시나리오(유스케이스) 뷰를 중심으로, 내부의 역할을 보는 논리 뷰, 동시 실행과 상호작용을 보는 프로세스 뷰, 코드와 구성 단위를 보는 개발 뷰, 실행 장치와 연결을 보는 물리(배치) 뷰를 둡니다. 배치는 영어로 deployment라서 개발(development)과 헷갈리기 쉬우니 주의하고, 작은 실습마다 모든 뷰를 억지로 그릴 필요는 없습니다.
SOFTWARE ENGINEERING · CH7-1
논리 경계와 실행 위치를 구별한다
이 그림의 화살표는 기능 호출이며 세 책임은 현재 같은 Node 프로세스에서 실행됩니다.
입력과 표현은 신청 서비스를 호출하고 신청 서비스는 load/save 저장소 계약을 호출합니다. 별 컴퓨터나 네트워크 배치를 뜻하지 않습니다.
구성 요소와 실행 단위를 구별한다
컴포넌트(component)는 정해진 인터페이스를 통해 다른 부분과 협력하는 구성 단위입니다. 이 글에서는 신청 서비스와 저장소를 서로 다른 컴포넌트로 나누어 설명합니다. 반면 프로세스(process)는 운영체제에서 실행 중인 프로그램의 단위로, 모듈 파일 하나와 같은 뜻이 아닙니다. 위 그림의 세 부분은 파일이 셋이라도 지금은 모두 같은 Node.js 프로세스 안에서 import로 이어져 실행됩니다.
이 논리적인 구분을 실제로 네트워크 너머의 서비스로 나누면 호출하는 방식과 실패의 종류가 달라집니다. 원격 서비스를 부를 때는 연결이 끊기거나, 응답이 늦거나, 인증에 실패하거나, 같은 요청을 다시 보내야 하는 문제가 새로 생깁니다. 그래서 모듈이 잘 나뉘어 있다는 이유만으로 모듈마다 서버를 따로 두지 않으며, 필요한 품질과 팀의 운영 비용, 데이터의 일관성을 함께 보고 정합니다.
자주 쓰는 구조를 목적과 비용으로 읽는다
여러 시스템에서 되풀이해 쓰이는 구조에는 이름이 붙어 있습니다. 이름을 아는 것보다, 그 구조가 어떤 상황에서 도움이 되고 무엇을 대가로 치르는지를 함께 아는 것이 중요합니다.
| 구조 | 도움이 되는 상황 | 생각해야 할 비용과 한계 |
|---|---|---|
| 계층(layered) | 입력, 업무 규칙, 저장을 나누어 다룰 때 | 계층을 건너뛴 호출, 값만 넘기는 단계가 늘어남 |
| 클라이언트-서버(client-server) | 여러 사용자가 같은 기능을 함께 쓸 때 | 네트워크 실패, 서버 운영 |
| 파이프-필터(pipe-filter) | 단계마다 바꾼 결과를 다음 단계에 넘길 때 | 중간 데이터의 형식, 오류를 다음 단계에 전달하는 방법 |
| 저장소 중심(repository) | 여러 기능이 같은 데이터를 함께 쓸 때 | 공통 데이터 모델의 변경, 동시 수정 |
| MVC(model-view-controller) | 입력 처리, 화면, 업무 데이터를 나눌 때 | 프레임워크마다 실제로 나누는 방식이 다름 |
계층 구조는 파일 이름 세 개를 정하는 규칙이 아니라, 어떤 책임이 어떤 인터페이스를 통해 다른 책임에 접근하는지를 정하는 것입니다. 또 파이프-필터 그림의 화살표는 데이터가 바뀌어 흘러가는 방향이라, 코드가 어느 모듈을 부르는지 나타내는 화살표와 다를 수 있습니다. 구조의 이름을 붙였다고 성능이나 유지보수성, 보안이 저절로 좋아지지는 않습니다.
품질 요구를 결정의 이유로 바꾼다
"바꾸기 쉬워야 한다"는 품질 요구는 이 교재의 범위에서 "메모리 저장소를 JSON 파일 저장소로 바꾸어도 신청 규칙을 다시 쓰지 않는다"처럼 확인할 수 있는 문장으로 바꿀 수 있습니다. "명단을 잃지 않아야 한다"는 요구는 "프로그램을 다시 시작해도 저장된 명단을 읽을 수 있다"로 바꿉니다. 응답 시간이나 동시 사용자 수 같은 수치는 실제로 재지 않은 채 숫자를 만들어 품질을 주장하지 않습니다.
아키텍처의 결정은 기록으로 남깁니다. 이런 기록을 아키텍처 결정 기록(architecture decision record, ADR)이라고 하며, 해결하려는 문제, 제약, 검토한 선택지, 고른 이유, 감수하는 비용을 적습니다. 예를 들어 "혼자 공부하는 환경에서 저장의 구조를 이해하려고 파일 저장을 쓰며, 여러 사용자가 동시에 쓰는 서버의 저장 방식으로 보지 않는다"고 적어 둘 수 있습니다. 나중에 요구가 바뀌면 이 기록의 이유와 가정을 보고 결정을 다시 검토합니다.
저장소를 분리한 구조를 실행한다
다음 코드의 저장소는 load와 save 두 함수만 바깥에 보여 줍니다. async를 붙인 함수는 결과를 바로 돌려주지 않고 Promise를 돌려주며, await는 그 Promise의 작업이 끝날 때까지 기다렸다가 결과를 꺼냅니다. 지금은 메모리에 저장하지만, 나중에 파일에 저장하는 구현과 같은 모양을 쓰기 위해 처음부터 async로 정의했습니다. reading-lab/examples/ch7-1.mjs로 저장해 실행합니다.
import * as core from "../core.mjs";
function createMemoryStore(seed) {
let saved = structuredClone(seed);
return {
async load() {
return structuredClone(saved);
},
async save(next) {
saved = structuredClone(next);
},
};
}
function createRegistrationService(store) {
return {
async register(memberId) {
const session = core.restoreSession(await store.load());
const result = core.register(session, memberId);
if (result.ok) await store.save(result.state);
return result;
},
};
}
const store = createMemoryStore(core.createSession({ id: "book-07", title: "구조를 읽다", capacity: 1 }));
const service = createRegistrationService(store);
console.log((await service.register("member-a")).code);
console.log((await service.register("member-b")).code);
console.log("저장된 명단:", (await store.load()).participants.join(","));REGISTERED
FULL
저장된 명단: member-aimport * as core는 core.mjs가 공개한 함수를 모두 core라는 이름 아래로 가져오므로, 서비스의 register 메서드와 core.register를 구별해 읽을 수 있습니다. 신청 서비스는 저장소 안의 saved 변수에 직접 손대지 않고 load와 save만 부르며, 읽어 온 자료는 restoreSession으로 불변 조건을 검사한 뒤에 씁니다. 신청 규칙의 판단은 core.register에 맡기고, 서비스는 읽기, 판단, 성공했을 때의 저장을 차례로 이을 뿐입니다. 콘솔에 출력하는 것은 맨 아래의 호출하는 코드뿐입니다.
structuredClone은 객체와 배열을 안쪽까지 복사합니다. 저장소가 돌려준 값을 호출한 쪽에서 고쳐도 저장된 값이 바뀌지 않도록 하려는 것인데, 함수나 클래스의 인스턴스처럼 이 방법으로 복사할 수 없는 값도 있으므로 이 모델의 단순한 데이터에만 쓴다고 보아야 합니다. 또 await store.save(...)에서 오류가 나면 그 뒤의 return result에 이르지 못하고 오류가 호출한 쪽으로 전달되므로, 저장하지 못한 신청을 성공으로 알리지 않습니다. 이 경로는 실패하는 저장소를 넣어 시험할 수 있으며, 연습 문제 3에서 확인합니다.
더 깊이: await는 그 호출의 다음 줄만 기다린다
Promise는 나중에 성공한 값이나 실패를 알려 주기로 한 작업의 결과입니다. await는 자신이 쓰인 함수의 다음 줄만 멈추어 기다릴 뿐, 다른 요청이나 프로그램 전체를 잠그지 않습니다. 앞의 예제는 첫 신청이 끝난 뒤에 둘째 신청을 시작했으므로 차례대로 처리되었지만, 두 호출을 먼저 모두 시작해 두면 두 요청의 단계가 서로 엇갈릴 수 있습니다. 같은 서비스로 정원 2인 모임에 두 신청을 동시에 보내 보면 다음과 같습니다.
import * as core from "../core.mjs";
function createMemoryStore(seed) {
let saved = structuredClone(seed);
return {
async load() {
return structuredClone(saved);
},
async save(next) {
saved = structuredClone(next);
},
};
}
function createRegistrationService(store) {
return {
async register(memberId) {
const session = core.restoreSession(await store.load());
const result = core.register(session, memberId);
if (result.ok) await store.save(result.state);
return result;
},
};
}
const store = createMemoryStore(core.createSession({ id: "book-08", title: "동시에 읽다", capacity: 2 }));
const service = createRegistrationService(store);
const results = await Promise.all([service.register("min"), service.register("hana")]);
console.log(results.map((result) => result.code).join(" "));
console.log("저장된 명단:", (await store.load()).participants.join(","));REGISTERED REGISTERED
저장된 명단: hanaPromise.all은 여러 Promise가 모두 끝날 때까지 기다립니다. 두 요청이 모두 REGISTERED를 받았는데 저장된 명단에는 hana만 남았습니다. 두 요청이 저장하기 전에 모두 빈 명단을 읽었고, 각자 자기 아이디만 넣은 새 상태를 저장하면서 나중에 저장한 쪽이 먼저 저장한 min을 덮어썼기 때문입니다. 서비스와 저장소를 나누고 값을 복사했다고 해서 동시에 들어온 요청의 일관성까지 지켜지지는 않으며, 이 문제를 어떻게 막는지는 동시 저장과 복구에서 다룹니다.
정리
- 아키텍처는 중요한 구성 요소와 관계, 그렇게 정한 이유를 설명하며, 무엇이 중요한지는 요구와 제약에 따라 달라집니다.
- 관점은 관심사와 표기 규칙이고 뷰는 그 관점으로 표현한 설명이며, 사용자 기능, 코드 구조, 실행 위치는 서로 다른 그림으로 답합니다.
- 컴포넌트와 프로세스는 다른 단위이고, 모듈을 네트워크 서비스로 나누면 새로운 실패가 생깁니다.
- 계층, 클라이언트-서버, 파이프-필터 같은 구조는 도움이 되는 상황과 치르는 비용을 함께 보고 고르며, 결정은 이유와 함께 기록합니다.
await는 그 호출의 다음 줄만 기다리므로, 저장소를 분리해도 동시에 들어온 요청이 서로의 저장을 덮어쓸 수 있습니다.
연습 문제
1. 사진 변환 프로그램의 세 관점
사진을 읽어 작은 미리 보기와 원본 보관 파일을 만드는 프로그램이 있습니다. 지금은 컴퓨터 한 대에서 실행하지만, 나중에는 여러 작업자가 같은 폴더를 함께 처리할 수 있습니다. 사용자 기능, 코드 구조, 실행 위치의 관점으로 이 프로그램을 각각 설명하고, 변환 단계를 파이프-필터 구조로 나타내는 것이 알맞은지 판단하세요.
해설 보기
사용자 기능의 관점에서는 사진을 고르면 미리 보기와 보관 파일을 얻고, 실패하면 그 원인을 안내받는 프로그램입니다. 코드 구조의 관점에서는 읽기, 변환, 저장의 각 단계와 입력 및 표현을 맡는 부분으로 나뉘고, 실행 위치의 관점에서는 지금 프로세스 하나가 로컬 폴더의 파일을 다룹니다. 읽은 결과가 변환의 입력이 되고 변환한 결과가 저장의 입력이 되므로, 단계마다의 변화를 따라가기에는 파이프-필터 구조가 알맞습니다.
2. 작업자를 늘릴 때 생기는 실패
1번의 프로그램에서 작업자를 여러 명으로 늘리면 새로 확인해야 할 실패를 두 가지 이상 적으세요.
해설 보기
두 작업자가 같은 사진을 동시에 집어 같은 일을 두 번 하거나, 같은 보관 파일을 동시에 써서 한쪽이 다른 쪽의 결과를 덮어쓸 수 있습니다. 또 변환 도중에 작업자가 멈추면 중간 결과 파일만 남아, 다른 작업자가 그 사진을 이미 처리된 것으로 잘못 볼 수 있습니다. 컴포넌트 그림을 여러 장 복사한다고 작업을 나누어 주는 방법이나 실패한 작업을 복구하는 방법이 생기지는 않으므로, 이런 실패를 다루는 설계를 따로 해야 합니다.
3. 저장에 실패하는 저장소
본문의 createRegistrationService에 다음 저장소를 넘기고 await service.register("min")을 부르면 어떻게 되나요? 형식이 틀린 아이디 ""로 부르면 어떻게 다른가요?
const failingStore = {
async load() {
return core.createSession({ id: "book-09", title: "실패하는 저장", capacity: 2 });
},
async save() {
throw new Error("disk full");
},
};해설 보기
min의 신청은 규칙상 받아들여지므로 서비스가 save를 부르고, 여기서 던진 오류 때문에 return result에 이르지 못한 채 await service.register("min")이 disk full 오류로 실패합니다. 그래서 호출한 쪽은 try와 catch로 이 오류를 받아 저장하지 못했다고 안내해야 하며, 성공 결과를 받는 일은 없습니다. 반면 ""는 core.register에서 INVALID_MEMBER로 거절되어 save를 부르지 않으므로, 저장소가 고장 나 있어도 거절 결과가 그대로 돌아옵니다.
아키텍처의 설명은 그림과 인터페이스, 결정의 이유, 그리고 실제로 확인한 결과가 함께 있을 때 읽는 사람에게 도움이 됩니다. 다음 글 디자인 패턴의 목적과 선택에서는 되풀이되는 설계 문제를 디자인 패턴으로 풀되, 패턴의 이름보다 무엇이 바뀌는지를 먼저 찾는 방법을 다룹니다.
원자료와 더 읽기
장원사이버평생교육원 「소프트웨어공학」 PDF 물리 128–148쪽의 주제를 바탕으로 설명, 도표, 실습을 새로 구성했습니다. 모델의 표기는 OMG UML 2.5.1을, JavaScript 모듈은 MDN JavaScript 모듈을 참고합니다.