객체 지향 프로그래밍을 하면서 지켜야 하는 5대 원칙이 존재한다.
이것을 SOLID원칙이라고 하는데 지키면서 코드를 짜려 하지만 경험만이 답인 것 같다.
이번 글에서는 SOLID원칙 중 OCP원칙을 위반한 객체를 발견해 한번 수정해보려 한다.
그전에 SOLID원칙에 대해서 좋은 글이 많지만 간단하게 정리해보자.
잘못된 내용이 있다면 바로 수정하겠습니다!
단일 책임 원칙이라고 부르는 SPR는 이름에서 알 수 있듯이 하나의 책임만을 가져야 한다는 원칙이다.
객체에게 해당하는 이야기인데 객체라 하면 대부분 클래스를 칭하는 것 같다.
필자는 단일 책임 원칙을 하나의 클래스를 변경하는 이유는 해당 클래스가 담당하고 있는 기능이 변경되었을 때만 가능해야 한다라고 이해했다.
예로는 인증을 관리하는 AuthService가 있고 인증으로 사용하고 있는 토큰을 관리하는 TokenService가 있다고 치자.
단일 책임 원칙에 맞게 설계했다면 인증방법을 토큰에서 세션으로 변경했다고 해서 코드의 수정 범위가 TokenService까지 영향이 가면 안된다.
반대도 마찬가지고, 토큰을 발급해주는 로직에 Payload를 변경했는데 이것이 AuthService까지 영향이 가면 안되고 TokenService만 수정하면 된다.
이렇게 설계하게 된다면 무엇보다 유지보수를 하기 편할 것 같다.
리스코프 치환원칙이라고 부르는 LSP다.
이 원칙은 S 타입이 T 타입의 하위 타입이라면 T의 객체를 S 타입으로 교체하여도 정상적으로 프로그램이 동작해야 한다는 원칙이다.
이 말은 부모, 자식 관계가 있으면 부모 타입으로 선언한 자리에 자식의 인스턴스가 와도 정상 동작해야 한다는 말로 이해했다.
예시로는 흔히 나오는 Animal 타입이 부모 타입이고 Hipo가 상속받은 자식 타입이라고 한다면 Animal 타입으로 선언된 곳에 Hipo의 인스턴스가 와도 문제 없이 동작해야 된다.
인터페이스 분리원칙이라고 부르는 ISP다.
이 원칙은 클라이언트가 자신이 사용하지 않는 메서드에 의존하면 안된다는 원칙으로 작은 단위로 인터페이스를 분리시켜 클라이언트가 꼭 필요한 메서드만 사용할 수 있게 해준다.
필자는 큰 덩어리의 인터페이스가 있다면 클라이언트들은 인터페이스의 모든 것을 구현해야 하니 필요없는 기능까지 강제로 구현해야 되기 때문에 이런 경우 인터페이스를 분리해서 여러 개의 인터페이스를 만들어 자신에게 필요한 기능들만 구현할 수 있도록 해라는 말로 이해했다.
의존 역전 원칙이라고 불리는 DIP이다.
이 원칙은 구현체에 의존하지 말고 인터페이스에 의존하라는 말이다.
가장 쉬운 예시로는 Repository를 구현해야 한다면 필요한 메서드를 인터페이스로 만들고 인터페이스에 의존하도록 하는 것이다.
이러면 Repository 내부가 MongoDB를 쓰든 MySQL을 쓰든 메서드 내부 로직이 변경되기 때문에 Repository만 수정할 수 있고 Service에서 의존하고 있다면 Service는 인터페이스에 의존하기에 수정하지 않아도 된다.
그럼 이제 개방 폐쇄 원칙이라고 불리는 OCP에 대해서 코드와 함께 알아보자.
이 원칙은 확장에는 열려있어야 하고 수정에는 닫혀 있어야 한다는 원칙인데 이는 곧 기능을 확장해야 한다면 클래스 내부를 수정하지 않고 쉽게 클래스를 추가해 확장할 수 있어야 한다는 말이다.
예시로는 프로필을 정렬 객체에 대해 들어볼 것이다.
export class ProfileSort {
private by?: Sort;
hasOption(): boolean { return this.by ? true : false; }; // 다른 정렬기준이 있는지
getOption(): Sort { return this.by; };
otherField(): string { return this.by === Sort.LowPay ? 'pay' : 'possibleDate'; }; // 다른 정렬 기준 필드
otherFieldBy(): "DESC" | "ASC" { return "ASC"; }; // 다른 정렬 기준
defaultField(): string { return '_id' }; // 기본 _id값 기준(생성일)
defaultFieldBy(): "DESC" { return "DESC"; }; // 기본 최신순
constructor(by?: Sort) { this.by = by; };
}
만약 기본으로 들어오는 정렬 조건 이외에 다른 정렬 조건을 가져와야 한다고 해보자.
otherField() 메서드는 DB에서 해당 정렬 조건의 필드를 조회하기 위해 반환하는 메서드이다.
위의 코드에서 만약 '찜 많은 순', '후기 많은 순'과 같은 정렬 조건이 포함된다고 해보자.
그럼 otherField() 메서드에 삼항 연산자 대신 계속해서 아래와 같이 if문을 탈 것이다.
export class ProfileSort {
private by?: Sort;
hasOption(): boolean { return this.by ? true : false; }; // 다른 정렬기준이 있는지
getOption(): Sort { return this.by; };
otherField(): string {
if( this.by === '일당' )
else if( this.by === '빠른순')
else if( this.by === '찜' )
else if( this.by === '후기')
....
}; // 다른 정렬 기준 필드
otherFieldBy(): "DESC" | "ASC" { return "ASC"; }; // 다른 정렬 기준
defaultField(): string { return '_id' }; // 기본 _id값 기준(생성일)
defaultFieldBy(): "DESC" { return "DESC"; }; // 기본 최신순
constructor(by?: Sort) { this.by = by; };
}
이는 기능이 확장될 때마다 클래스를 추가하여 확장하는 것이 아닌 기존에 만들어 놓은 ProfileSort 객체를 계속해서 수정해야 하므로 OCP 원칙에 어긋난다고 생각했다.
그래서 ProfileSort 객체를 아래와 같이 변경했다.
export class ProfileSort {
private field: ProfileSortField;
private orderBy: OrderBy;
constructor(field: ProfileSortField, orderBy: OrderBy) {
this.field = field;
this.orderBy = orderBy;
};
/* DB 필드에 해당하는 이름 */
public getField(): ProfileSortField { return this.field; };
/* 정렬 기준 */
public getOrderBy(): OrderBy { return this.orderBy; };
};
그리고 필요한 정렬 기준들은 아래와 같이 상속받아 사용하였다.
export class ProfileIdSort extends ProfileSort {
constructor(orderBy: OrderBy) { super( ProfileSortField.ID, orderBy) }
};
/* 프로필 일당으로 정렬 */
export class ProfilePaySort extends ProfileSort {
constructor(orderBy: OrderBy) { super(ProfileSortField.PAY, orderBy) }
};
/* 프로필의 시작 가능일로 정렬 */
export class ProfileStartDateSort extends ProfileSort {
constructor(orderBy: OrderBy) { super(ProfileSortField.STARTDATE, orderBy) };
};
만약 다른 정렬 조건이 필요하다면 해당 객체를 하나 만들어 주면 된다.
물론 들어오는 요청에 대해 알맞은 타입을 생성해주는 Factory 성질의 클래스는 필요하다.
만들 때 필드와 메서드 모두 공유해서 상속으로 만들었는데 객체 지향에서는 부모와의 강한 결합도, 수정의 공유 때문에 상속보다는 합성을 이용하는 것을 권장하는 것 같다.
다음에는 이 점을 생각해서 만들어보자...