본문 바로가기

인터페이스·객체·관계

명단 객체의 행동 계약과 외부 수정 반례를 실행하고 객체 관계·상속·치환을 구별합니다.

어떤 함수가 참가 명단을 반환했는데 호출자가 push로 회원을 추가할 수 있다면, 정원·중복 검사를 우회할 수 있습니다. ‘객체 안에 넣었으니 보호되었다’는 말만으로는 부족합니다. 인터페이스는 외부가 사용할 연산과 행동 약속이고, 객체는 관련 상태와 동작을 묶는 구현 방법입니다.

이 본문에서는 클래스 문법을 먼저 요구하지 않고 JavaScript 객체와 함수로 명단의 경계를 만듭니다. 입력·반환값·원본 보존을 읽을 수 있으면 따라갈 수 있습니다. 모임 전체 정책이 아니라 명단 관리 책임에 집중합니다.

객체·클래스·인스턴스의 범위를 구별한다

객체(object)는 프로그래밍에서 속성과 동작을 가진 값입니다. { id: "book-01", capacity: 2 }도 객체입니다. 클래스(class)는 객체의 생성과 동작을 정의하는 문법이며 인스턴스(instance)는 그 클래스에서 생성한 개별 객체를 말할 때 쓰는 용어입니다.

JavaScript는 객체 리터럴과 함수가 반환한 객체도 사용할 수 있으므로 모든 객체를 반드시 클래스의 인스턴스로 만들 필요는 없습니다. 현실의 사물을 전부 클래스 하나씩으로 옮기는 것보다 어떤 책임과 상태를 함께 관리할지 먼저 결정합니다. 책·회원·모임이 명사라는 이유만으로 상속 관계가 생기지는 않습니다.

인터페이스(interface)는 호출하는 쪽이 알아야 할 접점입니다. 여기서는 list(), add(memberId), remove(memberId)와 각 연산의 결과를 약속합니다. JavaScript에는 Java의 interface 선언과 같은 문법이 없지만 함수·객체의 형태와 테스트로 계약을 표현할 수 있습니다. 선언 이름만 같고 결과 의미가 다르면 같은 계약이 아닙니다.

문법보다 행동 계약을 먼저 쓴다

연산입력·결과보장할 행동
list인수 없음 → ID 배열외부가 바꿔도 내부 명단은 유지
add유효한 ID → boolean최초 추가 true, 중복 false
removeID → boolean존재하면 제거 true, 없으면 false

이 명단 객체는 정원이나 OPEN/CLOSED를 판단하지 않습니다. 모임 신청 서비스가 그 정책을 판단한 뒤 명단 연산을 쓸 수 있습니다. 단순 명단과 신청 기능을 같은 것으로 부르면 정책이 빠진 객체를 외부에 공개할 위험이 있습니다. 앞 장의 register 계약은 여전히 별도입니다.

반환값을 자세히 적는 이유가 있습니다. add가 true를 반환하면서 실제로 추가하지 않거나, 중복 ID를 더하면서 false를 반환하면 호출자에게 같은 모양의 함수가 있어도 호환되지 않습니다. 실패·변경·부수효과를 포함해 약속해야 합니다.

SOFTWARE ENGINEERING · CH4-3

공개 연산으로 명단의 변경 통로를 제한한다

읽은 사본을 바꾸어도 내부 명단은 바뀌지 않습니다.

공개 연산으로 명단의 변경 통로를 제한한다외부 호출은 list·add·remove 인터페이스를 통해 내부 ID 목록을 다루고 list의 반환값은 외부 수정이 내부로 전달되지 않는 사본입니다.호출하는 코드명단의 내부 변수는 모름공개 연산list · add · remove내부 명단중복 없는 ID 배열list 반환값외부가 수정할 수 있는 사본

외부 호출은 list·add·remove 인터페이스를 통해 내부 ID 목록을 다루고 list의 반환값은 외부 수정이 내부로 전달되지 않는 사본입니다.

외부 수정이 내부로 새지 않게 한다

아래 createRoster는 함수 안의 ids를 기억하는 객체를 반환합니다. 반환된 메서드가 바깥 함수의 변수를 계속 사용할 수 있는 것을 클로저(closure)라고 합니다. ids를 공개 속성으로 돌려주지 않고, list도 사본을 반환합니다.

저장소 최상위 폴더에서 다음 명령을 실행합니다. Node.js가 설치되어 있어야 하며 패키지를 추가로 설치하지 않습니다. 두 번째 인수를 생략하면 이 장의 예제를 모두 실행합니다.

node dev-note/scripts/software-engineering-lab/examples/ch4.mjs ch4-3
ch4-3 실행 예제
function createRoster(initialIds = []) {
  let ids = [...initialIds];
  if (ids.some((id) => typeof id !== "string" || !/^[a-z][a-z0-9-]{0,23}$/.test(id))
    || new Set(ids).size !== ids.length) {
    throw new Error("Invalid initial roster");
  }
  return {
    list() { return [...ids]; },
    add(memberId) {
      if (typeof memberId !== "string" || !/^[a-z][a-z0-9-]{0,23}$/.test(memberId)) {
        throw new Error("Invalid member");
      }
      if (ids.includes(memberId)) return false;
      ids = [...ids, memberId];
      return true;
    },
    remove(memberId) {
      if (!ids.includes(memberId)) return false;
      ids = ids.filter((id) => id !== memberId);
      return true;
    },
  };
}
const roster = createRoster(["member-a"]);
const leakedCopy = roster.list();
leakedCopy.push("outside");
console.log("밖에서 바꾼 사본:", leakedCopy.join(","));
console.log("실제 명단:", roster.list().join(","));
console.log("추가 결과:", roster.add("member-b"), roster.add("member-b"));
console.log("삭제 결과:", roster.remove("member-a"));

외부 사본은 member-a,outside지만 실제 명단은 member-a입니다. 추가 결과는 true false이고 삭제 결과는 true입니다. 사본을 수정해도 내부 명단이 유지되고 중복 연산이 한 ID를 두 번 넣지 않는지 확인합니다.

이 모델의 요소는 문자열이므로 배열 사본으로 외부 수정을 차단할 수 있습니다. 객체 목록을 저장한다면 [...ids]처럼 배열만 복사해도 내부 객체는 공유될 수 있습니다. 무조건 ‘복사했으니 안전’이라고 말하지 말고 노출한 값의 깊이와 변경 통로를 확인합니다.

클래스를 사용하는 구현에서는 JavaScript의 private 필드처럼 내부 상태를 감추는 문법을 쓸 수 있습니다. 그러나 모든 속성에 getter/setter를 붙이는 것으로 충분하지 않습니다. setParticipants(anyArray)가 아무 배열이나 받아 버리면 불변식은 다시 우회됩니다. 필요한 행동 단위의 연산을 공개해야 합니다.

관계를 그릴 때 대상과 방향을 적는다

구조 모델의 선은 실행 순서와 다릅니다. 회원과 모임의 참가 관계는 누가 어떤 모임에 참가하는지를 나타내고, 서비스가 저장소를 사용하는 의존은 코드 변경 영향과 호출 계약을 나타냅니다.

관계의미프로젝트에서 생각할 예
연관대상 사이의 지속적 관련회원은 여러 모임에 참가할 수 있다
의존다른 계약·타입·연산을 사용신청 서비스가 저장소의 load/save를 사용
일반화더 일반적인 타입의 행동을 구체화같은 계약을 지키는 하위 타입
실체화인터페이스를 실제 구현메모리 저장소가 저장소 계약 구현
합성전체가 부분의 소유·수명을 관리모임에 속한 구성 요소의 소유를 명시할 때

모든 ‘가지고 있다’ 관계에 합성의 마름모를 붙이지 않습니다. 한 회원이 여러 모임과 연결된다고 모임을 지울 때 회원 자체도 지워야 한다는 뜻은 아닙니다. 관계 표기는 소유와 수명 규칙을 확인한 뒤 정합니다. UML의 의존 방향은 사용하는 쪽에서 사용되는 계약 쪽이며 데이터가 흘러가는 방향과 항상 같지는 않습니다.

상속과 다형성을 행동으로 검토한다

다형성(polymorphism)은 같은 계약을 사용하는 코드가 여러 구현을 다룰 수 있는 성질입니다. 저장소 구현이 달라도 await store.load()를 호출하는 예가 뒤 장에 나옵니다. 상속(inheritance)은 기존 클래스의 특성을 이어받아 확장하는 수단이며 다형성의 유일한 방법은 아닙니다. 함수를 인수로 전달하거나 같은 형태의 객체를 사용하는 방식도 있습니다.

오버라이딩(overriding)은 하위 클래스가 상위 클래스의 메서드 구현을 다시 정의하는 일입니다. 오버로딩(overloading)은 같은 이름의 호출을 인수 형식·개수 등에 따라 여러 정의로 제공하는 기능을 말하며 지원 방식은 언어마다 다릅니다. JavaScript에서 같은 이름의 메서드를 여러 번 쓴다고 Java식 오버로딩 집합이 만들어지지 않습니다. 기본 인수나 명시적 분기로 호출 형태를 처리합니다.

JavaScript의 +는 숫자 덧셈과 문자열 연결에 사용할 수 있지만 이 동작을 모든 언어의 다형성 설명으로 옮기지 않습니다. C의 +는 산술 또는 정해진 포인터 연산이며 문자열 두 개를 연결하는 연산자가 아닙니다. 원자료 p176의 C 문자열 연결 설명은 언어를 혼동한 부분입니다. WG14 C11 위원회 초안 6.5.6절에서 피연산자 제약을 확인할 수 있습니다.

리스코프 치환 원칙(LSP)은 하위 타입이 상위 타입을 기대한 코드에서 그 행동 약속을 지켜 대체될 수 있어야 한다는 기준입니다. 이름과 메서드 수가 같은 것만으로 충족되지 않습니다. 정상 추가를 항상 거부하는 읽기 전용 명단을 쓰기 가능한 명단 자리에 넣으면 계약을 어깁니다. 메서드를 재정의하지 않아도 다른 상태 규칙 때문에 계약이 깨질 수 있습니다.

설계 원칙을 이번 사례에 적용한다

SRP는 명단 관리와 출력 문구의 변경 이유를 나누는 데, ISP는 읽기만 필요한 코드에 쓰기 기능까지 요구하지 않는 데 쓸 수 있습니다. OCP는 새 정책을 추가할 때 안정된 호출부를 유지할 경계를 찾는 방향이고, DIP는 업무 흐름이 저장 방식보다 필요한 계약에 의존하게 하는 방향입니다. 이 원칙들은 코드를 전부 상속 계층으로 만들라는 지시가 아닙니다. 변화와 비용을 함께 검토해야 합니다.

독립 문제: 읽기 전용 목록의 계약

도서 목록에는 list()와 add(book)가 있습니다. 검색 화면은 읽기만 하고 관리자 화면은 추가도 합니다. 새 읽기 전용 구현은 add를 호출하면 언제나 오류를 던집니다.

이 구현을 기존 쓰기 가능한 목록 자리에 넣어도 되는지 판단하고, 검색 화면이 필요한 계약을 따로 제안합니다. list가 내부 도서 객체를 그대로 반환할 때 생길 반례도 작성합니다.

힌트

‘읽기 전용이 더 엄격하니 안전하다’와 호출자의 쓰기 약속을 지키는지는 다릅니다. 배열뿐 아니라 도서 객체의 제목도 바꿔 봅니다.

풀이

쓰기 가능한 목록을 기대한 관리자는 정상 책 추가를 요구하므로 항상 오류를 던지는 구현으로 대체할 수 없습니다. 검색 화면은 list만 갖는 읽기 계약을 받으면 됩니다. 관리자에는 읽기와 쓰기를 충족하는 계약을 제공합니다.

const books = list(); books[0].title = "바뀜";으로 내부 제목이 변하면 외부 수정이 새어 나온 것입니다. 사본·읽기 전용 값·변경 연산 등 적절한 경계를 택하고 그 행동을 테스트합니다. 하나의 큰 인터페이스에 모든 기능을 넣기보다 소비자에게 필요한 역할을 설명해야 합니다.

다음 연결

객체는 경계를 표현하는 도구이고 인터페이스는 그 경계의 사용 약속입니다. 다음 본문은 코드 이름·형식·검토를 통해 이 약속을 다른 사람이 읽고 실제 결함을 발견할 수 있게 만드는 방법을 다룹니다.


원자료와 더 읽기

장원사이버평생교육원 「소프트웨어공학」 PDF 물리 170–185쪽의 주제를 바탕으로 설명·도표·실습을 새로 구성했습니다.

MDN JavaScript 클래스, OMG UML 2.5.1을 참고합니다. 원자료 p176–177의 오버로딩/오버라이딩 혼동과 p182–183의 치환 설명은 행동 계약 기준으로 바로잡았습니다.

이 명단 객체도 앞 장의 회원 ID 형식(소문자 시작 1~24자)을 지킵니다. 다만 모집 상태와 정원은 Session 쪽 책임이므로 이 객체가 신청 시스템 전체를 대신하지 않습니다. 조회 사본과 중복 방지, ID 검사는 별도의 계약으로 확인합니다.