본문 바로가기

책임을 나누는 모듈

신청 판단, 새 상태 만들기, 입출력을 바뀌는 이유에 따라 나누고, 책임, 추상화, 정보 은닉, 캡슐화, 모듈화의 차이를 정리합니다. register를 세 함수로 나눈 뒤 core.mjs와 같은 결과를 내는지 비교해 리팩터링을 확인합니다.

신청 판단, 파일 저장, 화면 문구를 모두 한 함수에 넣으면 기능 하나를 바꿀 때 나머지까지 다시 읽고 이해해야 합니다. "정원이 찼을 때 안내를 더 친절하게 써 주세요"라는 요청 하나에 파일을 쓰는 코드까지 손대게 되는 것입니다. 모듈(module)은 이런 변경을 이해하고 다룰 수 있도록 프로그램을 나눈 단위입니다. 이 글에서는 앞 장의 신청 함수를 판단, 새 상태 만들기, 둘을 잇는 흐름으로 나누고, 나눈 뒤에도 결과가 같은지 실행으로 확인합니다.

책임, 추상화, 정보 은닉

모듈이 해야 할 일과 그 일에 필요한 정보를 묶어 책임(responsibility)이라고 합니다. "신청을 받아들일지 판단한다"는 책임에는 모집 상태, 명단, 정원이 필요하지만 파일 경로나 안내 문구의 색은 필요하지 않습니다. 반면 "결과를 화면에 보여 준다"는 책임에는 결과 코드와 안내 문구가 필요합니다.

추상화(abstraction)는 목적에 필요한 특징만 드러내고 나머지 세부는 잠시 감추어 다루는 것입니다. register(session, memberId)라는 이름으로 신청을 다루면, 호출하는 쪽은 안에서 includes를 쓰는지 배열을 어떤 순서로 복사하는지 매번 알 필요가 없습니다. 추상화는 어려운 이름을 붙이는 일이 아니라, 무엇을 넘기면 무엇을 돌려받는지를 읽기 쉽게 드러내는 일입니다.

정보 은닉(information hiding)은 나중에 바뀔 수 있는 내부 결정을 감추어, 바깥 코드가 그 결정에 기대지 않게 만드는 설계입니다. 명단을 배열에 두는지 파일에 두는지 몰라도 load와 save라는 정해진 함수로 쓸 수 있게 하는 것이 그 예입니다. 비슷한 말인 캡슐화(encapsulation)는 관련된 데이터와 동작을 한 단위로 묶는 방법입니다. 다만 객체 하나로 묶었다고 내부가 저절로 보호되지는 않으며, 내부 배열을 그대로 돌려주면 바깥 코드가 그 배열을 고칠 수 있습니다.

모듈화(modularization)는 이런 책임을 기준으로 시스템을 나누는 작업입니다. 나누는 단위는 함수, 객체, 파일, 패키지처럼 크기가 다양합니다. 언어마다 모듈을 따로 컴파일하거나 실행할 수 있는지가 다르므로, 한 언어의 특징을 모듈의 정의로 삼지 않습니다.

바뀌는 이유로 나눌 곳을 찾는다

어디서 나눌지는 앞으로 무엇이 바뀔지를 떠올려 보면 드러납니다. 바뀌는 이유가 다른 것끼리는 떨어뜨려 두어야 한쪽을 고칠 때 다른 쪽을 건드리지 않습니다.

예상되는 변경고칠 곳함께 바꿀 필요가 없는 것
신청 정책의 변경신청 판단안내 문구, 파일 이름
저장 위치의 변경저장정원 규칙, 명단 검사
안내 문구의 변경표현신청을 받아들이는 조건
입력 방식의 변경입력 변환이미 검사를 통과한 아이디에 대한 정책

단일 책임 원칙(single responsibility principle, SRP)은 "함수는 한 줄이어야 한다"거나 "클래스에는 메서드가 하나만 있어야 한다"는 뜻이 아닙니다. 같은 이유로 함께 바뀌는 것은 모으고, 다른 이유로 바뀌는 것은 나누라는 기준입니다. 그래서 여러 계산 단계가 한 책임에 속할 수도 있고, 반대로 utils라는 파일 하나에 서로 관계없는 책임이 모여 있을 수도 있습니다. Robert C. Martin의 설명도 이 원칙을 바뀌는 이유를 기준으로 설명합니다.

나누는 데에도 비용이 듭니다. 함수가 많아지면 호출 관계를 따라 읽어야 하고, 같은 정보를 여러 곳에 거듭 넘겨야 할 수도 있습니다. 한 번만 쓰는 한 줄짜리 코드까지 파일로 떼어 내면 오히려 읽기 어려워지므로, 그 규칙이 따로 설명할 만한지, 따로 시험하면 이득이 있는지, 정말 다른 때에 바뀌는지를 보고 정합니다.

신청의 외부 계약을 유지하며 내부 책임을 나눈다

유효한 member-c, OPEN, 빈 명단을 읽는다. 공개 register 계약에 따라 결과를 돌려준다. 각 단계의 값과 전환 이유를 비교합니다.

SOFTWARE ENGINEERING · CH4-1

신청의 외부 계약을 유지하며 내부 책임을 나눈다

호출자는 하나의 신청 결과를 받고 내부는 판단과 상태 생성을 분리합니다.

신청의 외부 계약을 유지하며 내부 책임을 나눈다신청 함수가 정책 판정을 받아 상태 생성에 전달하고 ok·code·state 결과로 호출자에게 돌려주는 내부 처리 흐름입니다.register외부에 공개할 신청 기능정책 판정입력을 읽고 코드 반환상태 생성성공일 때만 새 명단신청 결과ok · code · state

신청 함수가 정책 판정을 받아 상태 생성에 전달하고 ok·code·state 결과로 호출자에게 돌려주는 내부 처리 흐름입니다.

이 예의 조건

  • 본문과 전체 도표의 예를 따라 정책 판정부터 결과 반환까지 진행합니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
정책 판정

유효한 member-c, OPEN, 빈 명단을 읽는다.

  • 정책·새 상태 1: capacity1
  • 정책·새 상태 2: participants []
  • code REGISTERED
새 상태 생성

성공을 나타내는 코드일 때만 새 명단을 만든다.

  • 정책·새 상태 1: 새 participants [member-c]
  • 정책·새 상태 2: 입력 명단 [] 유지
  • 새 상태 객체 생성
결과 반환

공개 register 계약에 따라 결과를 돌려준다.

  • 정책·새 상태 1: ok=true
  • 정책·새 상태 2: code=REGISTERED
  • 정책·새 상태 3: state의 명단 [member-c]
  • 호출자에게 결과 반환

신청의 외부 계약을 유지하며 내부 책임을 나눈다

유효한 member-c, OPEN, 빈 명단을 읽는다. 공개 register 계약에 따라 결과를 돌려준다. 각 단계의 값과 전환 이유를 비교합니다.

SOFTWARE ENGINEERING · CH4-1

신청의 외부 계약을 유지하며 내부 책임을 나눈다

호출자는 하나의 신청 결과를 받고 내부는 판단과 상태 생성을 분리합니다.

신청의 외부 계약을 유지하며 내부 책임을 나눈다신청 함수가 정책 판정을 받아 상태 생성에 전달하고 ok·code·state 결과로 호출자에게 돌려주는 내부 처리 흐름입니다.register외부에 공개할 신청 기능정책 판정입력을 읽고 코드 반환상태 생성성공일 때만 새 명단신청 결과ok · code · state

신청 함수가 정책 판정을 받아 상태 생성에 전달하고 ok·code·state 결과로 호출자에게 돌려주는 내부 처리 흐름입니다.

이 예의 조건

  • 본문과 전체 도표의 예를 따라 정책 판정부터 결과 반환까지 진행합니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
정책 판정

유효한 member-c, OPEN, 빈 명단을 읽는다.

  • 정책·새 상태 1: capacity1
  • 정책·새 상태 2: participants []
  • code REGISTERED
새 상태 생성

성공을 나타내는 코드일 때만 새 명단을 만든다.

  • 정책·새 상태 1: 새 participants [member-c]
  • 정책·새 상태 2: 입력 명단 [] 유지
  • 새 상태 객체 생성
결과 반환

공개 register 계약에 따라 결과를 돌려준다.

  • 정책·새 상태 1: ok=true
  • 정책·새 상태 2: code=REGISTERED
  • 정책·새 상태 3: state의 명단 [member-c]
  • 호출자에게 결과 반환

판단과 새 상태 만들기를 나눈다

그림은 신청 하나를 정책 판단, 새 상태 만들기, 결과 반환의 세 단계로 나누어 보여 줍니다. 이 구조대로 기능 요구와 수락 조건의 register를 세 함수로 나누어 봅니다. registrationCode는 입력을 읽고 결과 코드 하나만 돌려주며, nextRegistrationState는 그 코드에 따라 새 상태를 만들고, register는 둘을 이어 앞 장과 같은 { ok, code, state }를 돌려줍니다.

이렇게 나눈 nextRegistrationState는 올바르게 판단된 코드를 받는다고 전제합니다. 아무 호출자나 REGISTERED를 넣을 수 있다면 중복 검사와 정원 검사를 건너뛰고 명단을 늘릴 수 있습니다. 그래서 실제로 파일을 나눌 때는 register만 export로 공개하고 나머지 둘은 파일 안에 감추는 것이 좋으며, 아래 예제는 흐름을 보이려고 한 파일에 모두 두었습니다.

다음 코드를 reading-lab/examples/ch4-1.mjs로 저장하고 node examples/ch4-1.mjs로 실행합니다. 나눈 register의 결과를 core.mjs의 register와 비교하기 위해, core.mjs의 함수는 coreRegister라는 다른 이름으로 가져옵니다.

import { createSession, closeSession, register as coreRegister } from "../core.mjs";

function registrationCode(session, memberId) {
  if (typeof memberId !== "string" || !/^[a-z][a-z0-9-]{0,23}$/.test(memberId)) return "INVALID_MEMBER";
  if (session.status !== "OPEN") return "CLOSED";
  if (session.participants.includes(memberId)) return "ALREADY_REGISTERED";
  if (session.participants.length >= session.capacity) return "FULL";
  return "REGISTERED";
}

function nextRegistrationState(session, memberId, code) {
  if (code !== "REGISTERED") return session;
  const participants = Object.freeze([...session.participants, memberId]);
  return Object.freeze({ ...session, participants });
}

function register(session, memberId) {
  const code = registrationCode(session, memberId);
  return { ok: code === "REGISTERED", code, state: nextRegistrationState(session, memberId, code) };
}

const session = createSession({ id: "book-05", title: "느리게 읽기", capacity: 1 });
const result = register(session, "member-c");
console.log(result.code, result.state.participants.join(","));
console.log("입력 상태 보존:", session.participants.length === 0);

const states = [session, result.state, closeSession(result.state)];
const ids = ["member-c", "member-d", "Member-e", "1st", "a_b", " member-f", "x".repeat(24), "x".repeat(25)];
let matches = 0;
for (const state of states) {
  for (const id of ids) {
    if (JSON.stringify(register(state, id)) === JSON.stringify(coreRegister(state, id))) matches += 1;
  }
}
console.log("core와 결과가 같은 사례:", matches, "/", states.length * ids.length);
REGISTERED member-c
입력 상태 보존: true
core와 결과가 같은 사례: 24 / 24

import { register as coreRegister }의 as는 가져온 함수에 다른 이름을 붙입니다. 첫 두 줄은 정원이 1인 빈 모임에 member-c가 등록되고 처음 상태는 그대로라는 것을 보여 주며, 그림의 세 단계가 그대로 실행된 것입니다. 마지막 줄은 빈 모임, 정원이 찬 모임, 마감된 모임의 세 상태에서 여덟 가지 아이디로 신청한 24가지 사례가 모두 core.mjs와 같은 결과를 냈다는 뜻입니다. "x".repeat(24)는 x를 24번 이은 문자열이라 허용되는 가장 긴 아이디이고, 25자짜리는 형식에서 벗어납니다. JSON.stringify로 바꾼 두 결과의 문자열을 비교하면 ok, code, 명단을 한꺼번에 비교할 수 있습니다.

이처럼 바깥에서 보이는 동작은 그대로 두고 안쪽 구조만 고치는 작업을 리팩터링(refactoring)이라고 합니다. 그래서 리팩터링을 한 뒤에는 고치기 전의 구현과 같은 입력에 같은 결과를 내는지 확인해야 하며, 검사 순서나 결과 코드까지 바꾸었다면 구조의 변경과 동작의 변경이 함께 일어난 것이므로 따로 나누어 확인합니다. registrationCode는 파일을 쓰거나 화면에 출력하지 않아 같은 입력에 늘 같은 코드를 돌려주므로, 이렇게 많은 사례를 바로 실행해 확인하기 쉽습니다.

실제 파일로 나눌 때

JavaScript에서는 export로 공개할 함수를 정하고 import로 가져옵니다. 하지만 함수를 다른 파일로 옮겼다고 책임이 저절로 잘 나뉘는 것은 아닙니다. 바깥 코드가 안쪽 변수와 보조 함수까지 모두 가져다 쓴다면, 파일만 나뉘었을 뿐 서로 기대는 관계는 그대로 남습니다.

예제는 설치 없이 비교할 수 있게 한 파일에 함수를 두었지만, 실제 앱이라면 registration.mjs가 register를 공개하고, presenter.mjs가 결과 코드를 안내 문구로 바꾸며, main.mjs가 입력과 출력을 이을 수 있습니다. 파일을 옮길 때는 상대 경로, 공개하는 함수의 이름, 두 파일이 서로를 가져오는 순환 import가 생기지 않는지도 확인합니다. JavaScript의 import와 export는 MDN의 모듈 안내에 자세히 나와 있습니다.

나누었지만 나아지지 않은 경우

정원 검사를 checkCapacity라는 함수로 떼어 냈는데, 그 안에서 파일을 읽고 안내문까지 출력한다고 해 봅시다. 이름은 검사인데 실행하려면 디스크의 파일과 콘솔을 준비해야 하고, 결과를 돌려주지 않으니 다른 화면에서 다시 쓰기도 어렵습니다. 책임을 잘 나누었는지는 파일의 개수가 아니라 각 함수가 무엇을 받고, 무엇을 돌려주며, 그 밖에 무엇을 바꾸는지로 판단해야 합니다.

함수가 값을 돌려주는 것 말고 바깥에 일으키는 변화를 부수 효과(side effect)라고 합니다. 파일 쓰기, 화면 출력, 넘겨받은 객체의 수정이 그 예입니다. 사용자가 얻는 결과에는 저장과 출력이 꼭 필요하므로 부수 효과 자체가 나쁜 것은 아니지만, 계산과 바깥의 변화가 어디에서 일어나는지 알 수 있어야 시험하기도, 실패를 처리하기도 쉽습니다.

더 깊이: 바뀌는 이유는 누구에게서 오나

정보 은닉의 생각은 David Parnas가 1972년 12월 Communications of the ACM에 발표한 논문 "On the Criteria To Be Used in Decomposing Systems into Modules"로 거슬러 올라갑니다. Parnas는 간단한 프로그램 하나를 두 가지 방식으로 나누어 비교한 뒤, 처리 순서를 그린 순서도(flowchart)를 바탕으로 모듈을 나누기 시작하는 것은 거의 언제나 잘못이라고 결론지었습니다. 그 대신 어렵거나 바뀌기 쉬운 설계 결정의 목록에서 시작해, 모듈마다 그런 결정 하나를 다른 모듈에게서 감추도록 설계하라고 제안했습니다.

앞에서 링크한 Robert C. Martin의 글은 이 결론을 인용하면서 단일 책임 원칙이 같은 방향이라고 밝히고, 바뀌는 이유를 사람으로 풀어 설명합니다. 버그 수정이나 리팩터링은 프로그래머의 일이지 코드가 바뀌는 이유가 아니며, 바뀌는 이유는 그 동작을 정하고 요구하는 사람이나 조직에서 온다는 것입니다. 그래서 급여 계산은 재무 책임자(CFO) 쪽이, 근무 시간 보고서의 형식과 내용은 감사와 검토를 관리하는 운영 책임자(COO) 쪽이 정한다면, 두 동작을 한 클래스에 두면 한쪽의 요청이 다른 쪽이 믿고 쓰던 동작을 망가뜨릴 수 있다고 경고합니다.

이 기준을 앞에서 본 예상되는 변경의 표에 대 보면, 신청 정책은 모임의 규칙을 정하는 진행자가, 안내 문구는 화면을 다루는 쪽이, 저장 위치는 운영을 맡은 쪽이 바꾸자고 요청할 것입니다. 표의 행을 "누가 이 변경을 요청하는가"로 다시 읽으면, 같은 사람이 요청하는 것을 한곳에 모았는지 점검할 수 있습니다.

정리

  • 책임은 모듈이 해야 할 일과 그 일에 필요한 정보이며, 모듈화는 책임을 기준으로 시스템을 나누는 작업입니다.
  • 추상화는 필요한 특징만 드러내는 것이고, 정보 은닉은 바뀔 수 있는 내부 결정을 감추는 것이며, 캡슐화는 데이터와 동작을 한 단위로 묶는 방법입니다.
  • 단일 책임 원칙은 같은 이유로 바뀌는 것을 모으고 다른 이유로 바뀌는 것을 나누라는 기준이며, 나누는 데에도 비용이 듭니다.
  • 리팩터링은 바깥에서 보이는 동작을 유지하며 안쪽 구조를 고치는 작업이므로, 고치기 전과 같은 입력에 같은 결과를 내는지 확인합니다.
  • 부수 효과가 없는 계산은 시험하기 쉬우며, 책임이 잘 나뉘었는지는 파일의 개수가 아니라 입력, 출력, 부수 효과로 판단합니다.

연습 문제

1. 출석 보고 함수 나누기

출석 보고 함수 하나가 학생 명단을 읽고, 출석률을 계산하고, 장학금 기준을 판정하고, HTML 보고서를 만들어 파일에 저장합니다. 학교는 장학금 기준과 보고서 모양을 서로 다른 때에 바꿉니다. 함수를 어떻게 나눌지 이름, 입력, 출력을 제안하고, 파일 없이 시험할 수 있는 계산 두 가지를 고르세요.

해설 보기

loadAttendance가 기록을 읽고, attendanceRate가 출석률을 계산하며, isEligible이 정해진 기준으로 장학금 대상인지를 돌려줍니다. renderReport는 계산 결과를 HTML 문자열로 바꾸고, saveReport는 그 문자열을 파일에 저장합니다. 출석률 계산과 기준 판정은 배열과 숫자만 넘겨 시험할 수 있고, 파일을 읽고 쓰다 생기는 오류는 loadAttendance, saveReport와 이를 부르는 흐름에서 처리합니다. 계산 결과를 HTML 문자열로만 주고받으면, 장학금 기준을 다른 곳에서 다시 쓰려 할 때 문자열을 다시 해석해야 하므로 계산과 표현을 나누어 둡니다.

2. 학생마다 파일을 나누자는 제안

1번에서 "학생마다 평균을 계산하는 파일을 하나씩 만들자"는 제안이 나왔습니다. 이 제안이 도움이 되는지 판단하세요.

해설 보기

도움이 되지 않습니다. 학생마다 계산 규칙이 같다면 함수 하나와 반복문으로 충분하며, 학생마다 파일을 나누면 학생이 늘 때마다 파일과 import가 늘고, 계산 규칙을 바꿀 때 모든 파일을 고쳐야 합니다. 이 제안은 바뀌는 이유가 아니라 데이터의 개수를 기준으로 나눈 것입니다. 나눌 곳은 장학금 기준과 보고서 모양처럼 바뀌는 이유가 갈라지는 지점입니다.

3. 이것도 리팩터링인가

본문 예제의 registrationCode에서 정원 검사 줄을 중복 검사 줄보다 위로 옮겼습니다. 이 변경은 리팩터링인가요? 본문 예제를 실행하면 마지막 줄이 어떻게 나올지 예상하세요.

해설 보기

리팩터링이 아닙니다. 정원이 찬 열린 모임에 이미 신청한 회원이 다시 신청하면, 원래는 ALREADY_REGISTERED였던 결과가 FULL로 바뀌어 바깥에서 보이는 동작이 달라지기 때문입니다. 본문의 세 상태 가운데 이 조건에 맞는 것은 정원이 찬 열린 모임에 member-c가 다시 신청하는 한 사례뿐이므로, 마지막 줄은 core와 결과가 같은 사례: 23 / 24가 됩니다. 이 변경이 필요하다면 요구 R3과 R4의 순서부터 바꾸고, 동작의 변경으로 따로 확인해야 합니다.

입력, 결과, 그대로 남아야 할 상태를 설명할 수 있으면 모듈 사이를 어디서 나눌지가 드러납니다. 다음 글 결합도와 응집도에서는 나뉜 모듈이 서로에게 얼마나 기대는지, 한 모듈 안의 기능이 얼마나 함께 바뀌는지를 비교합니다.


원자료와 더 읽기

장원사이버평생교육원 「소프트웨어공학」 PDF 물리 128–139·159–169쪽의 주제를 바탕으로 설명, 도표, 실습을 새로 구성했습니다. 설계 기준은 단일 책임 원칙에 대한 원저자의 설명을, 파일 단위의 모듈은 MDN JavaScript 모듈을 참고합니다.