Search

리스코프 치환 원칙과 공변성, 반공변성에 대해 알아보자

subtitle
SOLID 원칙 중 하나인 리스코프 치환 원칙에 대해 알아보자. (그만 알아보자)
Tags
디자인패턴
Created
2021/07/17
2 more properties
generated by meme generator

유지보수가 용이한 프로젝트 만들기

규모에 상관 없이 유지보수가 용이한 프로젝트를 만들려면 '격리'의 방식을 취해야 한다. 격리란 어떤 것을 변경해도 다른 것에는 영향이 없는 상태를 말한다.
기획자가 로그인 방식을 카카오에서 네이버로 교체해달라고 했을 때, 개발자가 OAuth 로그인을 어떤 방식으로 교체하든 간에 프로그램은 잘 돌아가야 한다. 만약 기존에 이메일을 처리하는 로직이 있다면 네이버 로그인 방식에도 이메일을 처리하는 로직이 들어가야 한다. 그래야 네이버 로그인으로 교체해도 기존과 동일하게 돌아갈 것이다.
A를 B로 교체해도 서비스에 아무 문제가 없도록 만드는 원칙을 리스코프 치환 원칙(Liskov Substitution Principle)이라고 한다.

리스코프 치환 원칙

서브타입, 슈퍼타입이 잘 와닿지 않는다면 자식 클래스, 부모 클래스로 이해해도 좋다.
B가 A의 서브타입일 때, A를 B로 대체해도 프로그램이 작동하는 데 문제가 없어야 한다.
자동차에 파이프를 끼우건 빨대를 끼우건 배기가스만 잘 배출되면 자동차가 돌아가는 데에는 문제 없다.
말은 쉬워보이지만 원칙을 지키기 위해서는 까다로운 조건들이 수반된다. 이 글에서는 변성을 중심으로 설명하기 위해 다른 규칙에 대해서는 간략하게 설명해놓았다.
서브타입에서 함수 타입을 정의할 때
자식에서 함수 파라미터의 반공변성
자식에서 리턴값의 공변성
서브 타입에서 함수를 구현할 때
자식에서 메서드는 부모 메서드에서 던진 에러의 서브타입을 제외하고 새로운 에러를 던지면 안 된다.
자식에서 선행 조건은 강화될 수 없다
자식에서 후행 조건은 약화될 수 없다
자식에서 부모의 불변 조건은 반드시 유지되어야 한다

변성(variance)과 변성을 지키지 않을 때의 문제점

변성은 AB의 서브타입일 때와 C<A>C<B>일 때의 관계 변화를 의미한다. (C<A>는 base 타입 C<T>에 타입 A를 대입한 것을 의미한다.)
함수 파라미터에서의 반공변성
반공변성은 A가 B의 서브타입일 때 C<A>는 C<B>와 같거나 C<B>의 슈퍼타입이 되는 것이다.
웹 개발자, 프론트 개발자 타입을 WebDeveloper<WorkType, Output>, WebDeveloper<'frontend', 'website'> 라고 생각해보자. 함수 파라미터 workType 타입은 각각 WorkType'frontend'가 된다.
type WorkType = "frontend" | "backend" | "others"; type Output = "website" | "webserver"; interface WebDeveloper<T, R> { work: (workType: T) => R; } const startProject = ( developer: WebDeveloper<WorkType, Output>, workType: WorkType ) => { const output = developer.work(workType); deploy(output) };
TypeScript
startProject에 웹 개발자 대신 프론트엔드 개발자를 넣으면 어떻게 될까?
startProject(frontendDeveloper, 'frontend') startProject(frontendDeveloper, 'backend')
TypeScript
프론트엔드 프로젝트는 성공하겠지만 백엔드 프로젝트는 프론트엔드 개발자가 처리하지 못한다. 'frontend'만 올 것이라고 가정하기 때문이다.
웹 개발자를 프론트엔드 개발자로 대체할 때 프론트엔드 개발자의 workType이 웹 개발자 workType을 모두 커버할 수 있어야 한다. 프론트엔드 개발자는 웹 개발자의 서브타입이지만, work 함수 파라미터 타입에 있어서는 마치 웹개발자 일이 프론트엔드개발 일의 서브타입인 것처럼 되므로 반공변성을 띄게 된다.
따라서 프로젝트가 정상적으로 돌아가게 하려면 프론트엔드 개발자의 workTypeWorkType이 되어야 한다. 그리고 work 함수에 frontend 외에 다른 타입의 일을 처리하는 로직을 구현해야 한다. (단, 에러를 발생시킬 때는 부모에서 던지는 에러만을 허용한다는 점에 유의해야 한다. 아래에서는 startProject에서 에러 핸들링을 하고 있다는 것을 가정한다.)
interface FrontendDeveloper extends WebDeveloper<WorkType, Output> { work: (workType: WorkType) => "website"; } const webDeveloper: WebDeveloper<WorkType, Output> = { work(workType: WorkType) { switch(workType) { case 'frontend': return 'website'; case 'backend': return 'webserver'; case 'others': throw new Error('이런거 시키지 마세요') } }, }; const frontendDeveloper: FrontendDeveloper = { work(workType: WorkType) { if (workType !== 'frontend') { throw new Error('이런거 시키지 마세요') } return "website"; }, };
TypeScript
리턴 값의 공변성
공변성은 A가 B의 서브타입일 때 C<A>는 C<B>의 서브타입이 되는 것이다. base 타입에 A나 B를 대입해도 그 관계는 변하지 않는다.
웹 프론트엔드 개발자가 웹 개발자의 서브타입일 때 프론트 개발자의 결과물 타입 'website'는 웹 개발자의 결과물 타입인 'website' | 'webserver'의 서브타입이므로 공변성을 띈다.

타입스크립트에서 반공변성

리턴값에서의 공변성을 지키려면 서브타입은 슈퍼타입이 반환하는 것 내에서 반환하면 된다. 반면 함수 파라미터에서의 반공변성은 직관적으로 봤을 때는 잘 이해되지 않는 개념이라 놓치고 넘어가기 쉽다.
타입스크립트에서도 함수 파라미터의 반공변성을 지키도록 강제할 수 있다. tsconfig.json 에서 "strictFunctionTypes": true 를 추가하자. 혹은 "strict": true 로 설정해서 자동으로 strict* 타입들을 모두 true로 바꿀 수 있다. 그 다음 메서드 시그니처를 arrow function으로 바꾸면 함수 파라미터에 반공변성이 강제된다.
위에서 문제가 됐던 코드는 에러가 발생할 것이다.
interface WebDeveloper<T, R> { work: (workType: T) => R; } // 에러! // Interface 'FrontendDeveloper' incorrectly extends interface 'WebDeveloper'. interface FrontendDeveloper extends WebDeveloper<WorkType, Output> { work: (workType: "frontend") => "website"; }
TypeScript
그동안 strict 모드를 사용했음에도 불구하고 이런 에러가 발생하지 않았다면 shorthand 방식 ( work(workType: T): R )으로 메서드 타입을 정의하고 있었을 것이다. 타입스크립트에서 함수 파라미터는 기본적으로 이변성(bivariant)을 띈다. 이변성이란 공변성, 반공변성 모두 띌 수 있는 것이다. 그래서 FrontendDeveloper의 workType에 WorkType 또는 'frontend'를 자유롭게 넣어도 문제가 생기지 않았다.

그 이유

그렇다면 타입스크립트에서 함수 파라미터가 기본적으로 이변성이었던 이유는 무엇일까? 대답은 타입스크립트 레파지토리 FAQ에 나와있다.
function trainDog(d: Dog) { ... } function cloneAnimal(source: Animal, done: (result: Animal) => void): void { ... } let cat = new Cat(); // Runtime error here occurs because we end up invoking 'trainDog' with a 'Cat' cloneAnimal(cat, trainDog);
TypeScript
source: microsoft/TypeScript
타입스크립트는 이변성을 허용하므로 위 코드는 컴파일 에러는 나지 않지만 cloneAnimal을 실행하면 런타임 에러가 발생할 것이다. trainDog는 파라미터로 Dog를 받고 있기 때문이다.
이런 문제는 현재 타입 시스템에 공변성, 반공변성을 명시하는 *어노테이션이 없기때문에 발생한다. 따라서 타입스크립트에서는 (x: Animal) => void(x: Dog) => void 를 할당할 수 있는가라는 질문에 좀 더 관대해질 수 밖에 없다.
*모든 타입 시스템이 그런 것은 아니다. facebook의 flow에는 변성을 명시하는 어노테이션이 있다.
두 가지 질문을 생각해보자. Dog[]Animal[]의 서브타입인가? 그리고 타입스크립트에서도 Dog[]Animal[]의 서브타입인가?
Dog[]Animal[] 서브타입이 아니라고 해보자. checkIfAnimalsAreAwake에서 배열을 변경하지 않는 한 코드는 문제가 없을 것이다. 이 코드에서 Dog[]Animal[] 그룹에 속한다는 것은 명확한 사실이므로 Dog[]Animal[]을 대체하지 못하도록 강제할만 한 이유가 없다.
function checkIfAnimalsAreAwake(arr: Animal[]) { ... } let myPets: Dog[] = [spot, fido]; // Error? Can't substitute Dog[] for Animal[]? checkIfAnimalsAreAwake(myPets);
TypeScript
만약 타입 시스템이 Dog[]Animal[]의 서브타입인지 아닌지를 판별하게 만든다면 어떻게 될까? 타입 시스템은 아래와 같은 질문들을 해야한다.
Is Dog[] assignable to Animal[]?
Is each member of Dog[] assignable to Animal[]?
Is Dog[].push assignable to Animal[].push?
Is the type (x: Dog) => number assignable to (x: Animal) => number?
Is the first parameter type in (x: Dog) => number assignable to or from first parameter type in (x: Animal) => number?
Is Dog assignable to or from Animal?
Yes.
결국 타입시스템에서 Dog[]Animal[]의 서브타입인지 판단하려면 (x: Dog) => number assignable to (x: Animal) => number 질문에 대한 답을 해야한다. 만약 아니라고 하면 (함수 파라미터의 반공변성을 강제하면) Dog[]Animal[]에 할당될 수 없을 것이다. 즉, 함수 파라미터에서 반공변성을 강제하면 일반 array도 할당할 수 없다는 의미가 된다. 이런 부분을 감당하기 위해 함수 파라미터 타입에 대한 정확성을 트레이드오프할 수 밖에 없었다.

리스코프 치환 원칙 적용

interface IOAuthLoginService { login: (token: string) => Promise<Session> } export class LoginService { constructor( private naver: NaverLoginService, // OAuthLoginService 상속한 서비스 클래스 private kakao: KakaoLoginService // OAuthLoginService 상속한 서비스 클래스 ) {} async login(provider: 'kakao' | 'naver') { await this.#login<IOAuthLoginService>(provider === 'kakao' ? kakao : naver); } async #login<T>(loginService: T) { // ... 생략 const session = await loginService.login(token); res.setCookies('session', session.cookies); } }
TypeScript
NaverLoginService와 KakaoLoginService에서의 login 함수 파라미터는 string타입이 강제된다.
NaverLoginService와 KakaoLoginService에서의 login 함수 반환값은 Promise<Session>을 반환해야한다.
NaverLoginService와 KakaoLoginService에서 에러를 던질 때 OAuthLoginService에서 던지는 에러만 사용할 수 있다.
login 함수의 파라미터인 token을 validation하는 과정을 부모보다 더 추가해서는 안된다. 예를 들어 OAuthLoginService 서비스에서는 만료된 토큰인지만 확인한다면 자식 클래스는 그 이상을 구현해서는 안된다.

References