인터페이스·객체·관계
객체, 클래스, 인스턴스, 인터페이스를 구별하고, 명단 객체가 지킬 행동을 표로 정한 뒤 바깥의 수정이 내부로 새지 않는지 실행으로 확인합니다. 연관, 의존, 일반화, 실체화, 합성 관계와 다형성, 오버라이딩과 오버로딩, 리스코프 치환 원칙을 행동을 기준으로 정리합니다.
어떤 함수가 참가 명단을 돌려주었는데 호출한 쪽이 그 배열에 push로 회원을 더할 수 있다면, 정원 검사와 중복 검사를 건너뛰고 명단을 바꿀 수 있습니다. 그래서 "명단을 객체 안에 넣었으니 보호된다"는 말만으로는 부족합니다. 인터페이스는 바깥에서 쓸 수 있는 연산과 그 연산이 지킬 행동이고, 객체는 관련된 상태와 동작을 묶어 구현하는 방법입니다. 이 글에서는 클래스 문법 없이 함수와 객체로 명단을 감싸 보고, 이어서 객체 사이의 관계와 상속을 행동을 기준으로 살펴봅니다.
객체, 클래스, 인스턴스, 인터페이스
프로그래밍에서 객체(object)는 속성과 동작을 가진 값입니다. { id: "book-01", capacity: 2 }도 객체입니다. 클래스(class)는 객체를 만드는 방법과 그 객체의 동작을 정의하는 문법이고, 인스턴스(instance)는 그 클래스로 만든 개별 객체를 가리킬 때 쓰는 말입니다.
JavaScript에서는 중괄호로 바로 적은 객체나 함수가 만들어 돌려준 객체도 쓸 수 있으므로, 모든 객체를 클래스의 인스턴스로 만들 필요는 없습니다. 현실의 사물을 하나씩 클래스로 옮기기보다, 어떤 상태와 책임을 함께 다룰지부터 정합니다. 책, 회원, 모임이 모두 명사라는 이유만으로 서로 상속 관계가 생기지는 않습니다.
인터페이스(interface)는 호출하는 쪽이 알아야 할 접점입니다. 이 글의 명단은 list(), add(memberId), remove(memberId) 세 연산과 각 연산의 결과를 인터페이스로 정합니다. JavaScript에는 Java의 interface 같은 선언 문법이 없지만, 객체가 가진 함수의 모양과 그 행동을 확인하는 시험으로 인터페이스를 나타낼 수 있습니다. 함수 이름이 같아도 결과의 뜻이 다르면 같은 인터페이스라고 할 수 없습니다.
문법보다 행동을 먼저 정한다
명단 객체가 지킬 행동을 표로 정하면 다음과 같습니다.
| 연산 | 입력과 결과 | 지켜야 할 행동 |
|---|---|---|
list | 인수 없음, 아이디 배열을 돌려줌 | 돌려받은 배열을 바꾸어도 내부 명단은 그대로다 |
add | 유효한 아이디, true나 false를 돌려줌 | 처음 더하면 true, 이미 있으면 false이고 명단은 그대로다 |
remove | 아이디, true나 false를 돌려줌 | 있으면 빼고 true, 없으면 false이고 명단은 그대로다 |
이 명단 객체는 정원이나 모집 상태를 판단하지 않습니다. 신청을 처리하는 쪽이 기능 요구와 수락 조건의 정책을 먼저 판단한 뒤 명단의 연산을 쓰는 구조입니다. 단순한 명단과 신청 기능을 같은 것으로 여기면, 정책 검사가 빠진 객체를 바깥에 그대로 공개하게 될 수 있습니다.
결과를 이렇게 자세히 적는 데는 이유가 있습니다. add가 true를 돌려주면서 실제로는 더하지 않거나, 중복 아이디를 더하면서 false를 돌려준다면, 함수의 모양이 같아도 호출하는 쪽에서는 쓸 수 없는 구현입니다. 그래서 인터페이스에는 거절할 때의 결과와 상태의 변화, 부수 효과까지 함께 적어야 합니다.
SOFTWARE ENGINEERING · CH4-3
공개 연산으로 명단의 변경 통로를 제한한다
읽은 사본을 바꾸어도 내부 명단은 바뀌지 않습니다.
외부 호출은 list·add·remove 인터페이스를 통해 내부 ID 목록을 다루고 list의 반환값은 외부 수정이 내부로 전달되지 않는 사본입니다.
바깥의 수정이 안으로 새지 않게 한다
그림처럼 호출하는 코드는 명단의 내부 변수를 모르고, 공개된 세 연산으로만 명단을 다룹니다. 다음 코드를 reading-lab/examples/ch4-3.mjs로 저장하고 node examples/ch4-3.mjs로 실행합니다. 이 예제는 core.mjs를 쓰지 않습니다.
function createRoster(initialIds = []) {
const valid = (id) => typeof id === "string" && /^[a-z][a-z0-9-]{0,23}$/.test(id);
let ids = [...initialIds];
if (!ids.every(valid) || new Set(ids).size !== ids.length) {
throw new Error("Invalid initial roster");
}
return {
list() {
return [...ids];
},
add(memberId) {
if (!valid(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 copy = roster.list();
copy.push("outside");
console.log("밖에서 바꾼 사본:", copy.join(","));
console.log("실제 명단:", roster.list().join(","));
console.log("추가 결과:", roster.add("member-b"), roster.add("member-b"));
console.log("삭제 결과:", roster.remove("member-a"), roster.remove("member-a"));
console.log("남은 명단:", roster.list().join(","));
try {
roster.add("Member-c");
} catch (error) {
console.log(error.message);
}밖에서 바꾼 사본: member-a,outside
실제 명단: member-a
추가 결과: true false
삭제 결과: true false
남은 명단: member-b
Invalid membercreateRoster가 돌려준 객체의 세 함수는, createRoster의 실행이 끝난 뒤에도 그 안의 변수 ids를 계속 쓸 수 있습니다. 이처럼 함수가 자신이 만들어질 때의 바깥 변수를 기억해 쓰는 것을 클로저(closure)라고 하며, 그 동작 원리는 클로저와 실행 컨텍스트에서 자세히 다룹니다. ids는 객체의 속성으로 드러나지 않으므로 바깥에서는 세 함수를 통해서만 명단을 다룰 수 있고, list도 내부 배열이 아니라 사본을 돌려줍니다. 그래서 사본에 outside를 더해도 실제 명단은 member-a 그대로이며, 같은 아이디를 두 번 더하거나 빼면 두 번째에는 false가 나오고 명단은 바뀌지 않습니다.
이 명단의 원소는 문자열이라 배열을 복사하는 것만으로 바깥의 수정을 막을 수 있습니다. 하지만 원소가 객체라면 [...ids]는 배열만 새로 만들고 안의 객체는 함께 쓰므로, 바깥에서 객체의 속성을 바꾸면 내부에도 반영됩니다. "복사했으니 안전하다"고 말하기 전에, 드러낸 값이 어느 깊이까지 복사되었는지와 내부를 바꿀 수 있는 통로가 남아 있지 않은지를 확인해야 합니다.
더 깊이: 클래스와 private 필드로 쓴 명단
클래스로 구현할 때는 이름 앞에 #을 붙인 private 필드로 내부 상태를 감출 수 있습니다. 아래 코드는 형식 검사를 줄인 짧은 예입니다.
class Roster {
#ids;
constructor(initialIds = []) {
this.#ids = [...initialIds];
}
list() {
return [...this.#ids];
}
add(memberId) {
if (this.#ids.includes(memberId)) return false;
this.#ids = [...this.#ids, memberId];
return true;
}
}
const roster = new Roster(["member-a"]);
console.log(roster.add("member-b"), roster.add("member-b"), roster.list().join(","));
console.log(Object.keys(roster).length, roster.ids);true false member-a,member-b
0 undefined#ids는 클래스 안에서만 쓸 수 있으므로 Object.keys로도 보이지 않고, roster.ids처럼 읽으려 해도 undefined가 나옵니다. 다만 모든 필드에 값을 읽고 쓰는 함수를 붙이는 것으로는 부족합니다. setParticipants(anyArray)처럼 아무 배열이나 받아 주는 함수를 공개하면 중복 검사와 정원 검사를 다시 건너뛸 수 있으므로, 공개하는 연산은 "추가한다", "뺀다"처럼 규칙을 지키는 행동의 단위로 정해야 합니다.
관계를 그릴 때는 대상과 방향을 적는다
구조를 나타내는 그림의 선은 실행 순서가 아닙니다. 회원과 모임의 참가 관계는 누가 어느 모임에 참가하는지를 나타내고, 신청 서비스가 저장소를 쓰는 의존 관계는 저장소의 인터페이스가 바뀌면 서비스가 영향을 받는다는 것을 나타냅니다.
| 관계 | 뜻 | 이 프로젝트의 예 |
|---|---|---|
| 연관(association) | 대상 사이의 지속적인 관련 | 회원은 여러 모임에 참가할 수 있다 |
| 의존(dependency) | 다른 인터페이스, 타입, 연산을 쓴다 | 신청 서비스가 저장소의 load, save를 쓴다 |
| 일반화(generalization) | 더 일반적인 타입의 행동을 구체화한다 | 같은 인터페이스를 지키는 하위 타입 |
| 실체화(realization) | 인터페이스를 실제로 구현한다 | 메모리 저장소가 저장소 인터페이스를 구현한다 |
| 합성(composition) | 전체가 부분의 소유와 수명을 맡는다 | 모임을 지우면 그 모임의 신청 기록도 함께 지워진다 |
"가지고 있다"는 관계마다 합성을 나타내는 검은 마름모를 붙이지 않습니다. 한 회원이 여러 모임과 연결된다고 해서, 모임을 지울 때 회원까지 지워야 하는 것은 아니기 때문입니다. 관계의 표기는 누가 무엇을 소유하고 언제 함께 사라지는지를 확인한 뒤에 고릅니다. 또 UML에서 의존 화살표는 쓰는 쪽에서 쓰이는 쪽을 향하며, 데이터가 흘러가는 방향과 늘 같지는 않습니다.
다형성과 상속을 행동으로 검토한다
다형성(polymorphism)은 같은 인터페이스를 쓰는 코드가 여러 구현을 똑같이 다룰 수 있는 성질입니다. 저장 방식 변경과 의존성에서는 저장소의 구현이 달라도 같은 load와 save를 부르는 예를 봅니다. 상속(inheritance)은 기존 클래스의 특성을 이어받아 확장하는 방법인데, 다형성을 얻는 유일한 방법은 아닙니다. 함수를 인수로 넘기거나 같은 모양의 객체를 쓰는 것으로도 다형성을 얻을 수 있습니다.
비슷한 이름의 두 용어도 구별해 둡니다. 오버라이딩(overriding)은 하위 클래스가 상위 클래스의 메서드를 같은 이름으로 다시 정의하는 일입니다. 오버로딩(overloading)은 같은 이름의 함수를 인수의 개수나 타입에 따라 여러 개 정의하는 기능으로, 지원하는 방식이 언어마다 다릅니다. JavaScript에서는 같은 이름의 메서드를 여러 번 적으면 마지막 정의만 남으므로, Java처럼 오버로딩을 하려면 기본값이 있는 인수나 명시적인 분기로 처리합니다.
연산자도 언어마다 다르게 동작합니다. JavaScript의 +는 숫자의 덧셈과 문자열의 연결에 모두 쓰이지만, C의 +는 산술 연산이나 정해진 포인터 연산에 쓰일 뿐 두 문자열을 잇지 않습니다. C의 피연산자 규칙은 WG14 C11 위원회 초안 6.5.6절에서 확인할 수 있으므로, 한 언어의 동작을 다형성의 일반적인 설명으로 옮기지 않습니다.
리스코프 치환 원칙(Liskov substitution principle, LSP)은 상위 타입을 기대하는 코드에 하위 타입을 넣어도, 하위 타입이 상위 타입의 행동을 지켜 그대로 동작해야 한다는 기준입니다. 이름과 메서드의 수가 같다고 이 원칙을 지킨 것은 아닙니다. 정상적인 추가도 늘 거절하는 읽기 전용 명단을 쓰기 가능한 명단 자리에 넣으면 이 원칙을 어기며, 메서드를 다시 정의하지 않았더라도 상태 규칙이 달라 행동이 깨질 수 있습니다.
설계 원칙을 이 사례에 적용한다
책임을 나누는 모듈에서 본 단일 책임 원칙(SRP)과 앞의 리스코프 치환 원칙에 세 원칙을 더해 흔히 SOLID라고 부르며, 다섯 원칙을 코드에 하나씩 적용해 보는 과정은 SOLID 원칙 적용에서 볼 수 있습니다. 개방 폐쇄 원칙(OCP)은 새 정책을 더할 때 이미 안정된 호출부는 고치지 않도록 나눌 곳을 찾으라는 방향이고, 인터페이스 분리 원칙(ISP)은 읽기만 하는 코드에 쓰기 기능까지 요구하지 말라는 기준입니다. 의존 역전 원칙(DIP)은 업무 흐름이 구체적인 저장 방식이 아니라 필요한 인터페이스에 기대게 하라는 방향입니다. 이 원칙들은 코드를 모두 상속 계층으로 만들라는 지시가 아니므로, 예상되는 변경과 나누는 비용을 함께 따져 적용합니다.
정리
- 객체는 속성과 동작을 가진 값이고, 클래스는 객체를 만드는 문법이며, 인터페이스는 호출하는 쪽이 알아야 할 연산과 그 행동입니다.
- 인터페이스에는 함수의 이름뿐 아니라 거절할 때의 결과, 상태의 변화, 부수 효과까지 적습니다.
- 클로저나 private 필드로 내부 상태를 감추고 사본을 돌려주더라도, 복사의 깊이와 내부를 바꿀 수 있는 통로를 확인해야 합니다.
- 연관, 의존, 일반화, 실체화, 합성은 서로 다른 관계이며, 합성은 소유와 수명을 확인한 뒤에만 씁니다.
- 다형성은 상속 말고도 얻을 수 있으며, 리스코프 치환 원칙은 이름이 아니라 행동이 같은지로 판단합니다.
연습 문제
1. 읽기 전용 목록으로 바꾸어도 될까
도서 목록에는 list()와 add(book)가 있습니다. 검색 화면은 목록을 읽기만 하고, 관리자 화면은 책을 추가도 합니다. 새로 만든 읽기 전용 구현은 add를 부르면 언제나 오류를 던집니다. 이 구현을 기존의 쓰기 가능한 목록 자리에 넣어도 되나요? 검색 화면에는 어떤 인터페이스를 주면 좋을까요?
해설 보기
넣으면 안 됩니다. 쓰기 가능한 목록을 기대하는 관리자 화면은 정상적인 책 추가가 성공하리라고 기대하므로, 늘 오류를 던지는 구현은 그 행동을 지키지 못해 리스코프 치환 원칙을 어깁니다. "읽기 전용이 더 엄격하니 안전하다"는 생각은 호출하는 쪽의 기대를 고려하지 않은 것입니다. 검색 화면에는 list만 가진 읽기용 인터페이스를 주고, 관리자 화면에는 읽기와 쓰기를 모두 지키는 인터페이스를 주면 됩니다. 이렇게 쓰는 쪽에 필요한 만큼만 나누는 것이 인터페이스 분리 원칙입니다.
2. 객체를 담은 목록의 사본
다음 코드를 실행하면 마지막 줄에 무엇이 출력되나요? 바깥의 수정이 내부로 새지 않도록 list를 고치세요.
function createBookList(initialBooks) {
let books = initialBooks.map((book) => ({ ...book }));
return {
list() {
return [...books];
},
};
}
const shelf = createBookList([{ title: "설계의 기초" }]);
const books = shelf.list();
books[0].title = "바뀜";
console.log(shelf.list()[0].title);해설 보기
바뀜이 출력됩니다. [...books]는 배열만 새로 만들고 안의 도서 객체는 내부 배열과 함께 쓰므로, 바깥에서 제목을 바꾸면 내부의 도서 객체가 바뀝니다. list가 return books.map((book) => ({ ...book }));처럼 도서 객체까지 복사해 돌려주면 마지막 줄은 설계의 기초가 됩니다. 도서 객체 안에 또 다른 객체가 있다면 그 깊이까지 복사하거나, 바꿀 수 없는 값으로 만들어 돌려주어야 합니다.
3. 회원과 모임의 관계
회원과 모임의 참가 관계를 클래스 그림으로 그리려 합니다. 누군가 "모임이 참가자를 가지고 있으니 합성의 검은 마름모를 그리자"고 했습니다. 이 의견을 평가하세요.
해설 보기
알맞지 않습니다. 합성은 전체가 부분의 수명을 맡아, 전체를 지우면 부분도 함께 사라지는 관계입니다. 회원은 여러 모임에 참가할 수 있고 모임을 지워도 회원은 남아야 하므로, 이 관계는 연관으로 그리고 양쪽의 다중성을 "0개 이상"으로 적는 것이 맞습니다. 반면 모임을 지울 때 함께 지워야 하는 그 모임의 신청 기록이라면 합성으로 그릴 수 있습니다.
객체는 나눈 곳을 표현하는 도구이고, 인터페이스는 그곳을 어떻게 쓸지에 대한 약속입니다. 다음 글 코딩 규칙과 코드 검토에서는 이름과 형식을 맞추고 코드를 함께 검토해, 다른 사람이 이 약속을 읽고 실제 결함을 찾아낼 수 있게 하는 방법을 다룹니다.
원자료와 더 읽기
장원사이버평생교육원 「소프트웨어공학」 PDF 물리 170–185쪽의 주제를 바탕으로 설명, 도표, 실습을 새로 구성했습니다. 원자료 176–177쪽에서 오버로딩과 오버라이딩을 섞어 설명한 부분과 C의 +로 문자열을 잇는다고 한 부분, 182–183쪽의 치환 설명은 행동을 기준으로 바로잡았습니다. 클래스 문법은 MDN JavaScript 클래스를, 관계의 표기는 OMG UML 2.5.1을 참고합니다.