클래스는 한 가지 일만 합시다.
카카오헤어샵에서는 SOLID 원칙을 적용하기 위해 다양한 노력을 했습니다.
SOLID는 로버트 C.마틴이 주장한 유지보수가 쉽고 확장이 용이한 코드를 만들 때 적용하면 좋은 객체 지향 프로그래밍 원칙입니다. SOLID에 대한 자세한 설명은 위키피디아를 참고해주세요.
한 클래스는 단 한 가지 역할을 가져야 한다.
SRP는 클래스는 한 가지 일만 하라는 원칙입니다. 한 가지 일은 책임(Responsibility)이라고 하고, 역할(Role)이라고도 합니다. 역할은 클래스에 해당하고 책임은 메쏘드에 해당한다고 보시면 될 것 같아요.
하지만 "한 가지 일을 한다."라는 것은 이해하기가 쉽지 않습니다. 예를 들면 리뷰에 답글을 남기는 것(ReplyReviewController), 사진의 썸네일 생성하는 것(PhotoThumbnailGenerator)이 한 가지 일을 하는 클래스입니다. 반면에 ReviewController를 만들어서 리뷰를 쓰고, 지우고, 수정하는 것을 한 클래스에서 구현했다면 이는 한 가지 일을 하는 것이 아닙니다.
DB에 매장 정보를 저장하고 관리자에게 SMS를 보내는 일을 구현한 서비스 클래스 같은 경우 두 가지 일을 하는 것이 아니냐고 생각할 수 있습니다. 이런 경우 아래와 같이 다른 클래스에 위임(delegate)하면 한 가지 일을 한 것입니다.
void registerShop(){
shopSaveService.saveOne();
smsSender.sendResultToDeskManager();
}
카카오헤어샵 프로젝트에서는 SRP를 지키려고 했습니다. 그렇기 위해 일관된 추상화 수준에 맞게 소스코드를 작성했습니다.
한 번은 특정 클래스가 SRP에 맞는지 뜨거운 논쟁이 벌어진 적도 있습니다. FavoriteAddService는 사용자가 매장에 관심을 추가하는 역할을 하는 클래스입니다. 처음에는 addShopByUser() 메쏘드만 있었습니다. 시간이 흘러 사용자가 사진에 관심을 추가하는 기능이 추가 요청으로 들어왔습니다. 담당 개발자는 FavoriteAddService.addPhotoByUser() 메쏘드를 추가했습니다. 그러다 보니 코드 리뷰 과정에서 클래스를 분리해야 한다는 의견과 모두 관심을 추가하는 일이기 때문에 한 클래스로 해도 된다는 의견이 대립했었습니다.
이처럼 SRP를 따르는 것은 쉽지 않은 일입니다. 그렇지만 따로 정답이 있는 건 아니라고 생각합니다. 팀원들 같이 고민하면서 지금 하고 있는 프로젝트에 맞는 범위를 정하시면 됩니다.
단, ReviewController, ShopService로는 만들지 마세요. WriteReviewController, ReadReviewController, RemoveReviewController 정도로는 나누세요.
동작하고 있던 코드를 변경하는 것이 아니라 새로운 코드를 덧붙임으로써 나중에 그런 변경을 할 수 있게 된다.
Open은 확장에 열려 있어야 한다는 의미이고 Close는 수정에 대해서 닫혀 있어야 한다는 말입니다.
하위 모듈의 기능이 확장될 경우 기존에 제공하던 클래스(또는 메쏘드)를 수정하는 것이 아니라 새로운 클래스를 추가해서 기능을 확장하는 것입니다. 이를 위해서는 Java interface를 이용해서 추상화하는 방법을 사용해야 합니다.
카카오헤어샵 프로젝트에서는 OCP를 엄격하게 지키지는 못하고 일부 모듈에 대해서 적용했습니다. 추상화를 하는 것은 OOP 경험과 노하우가 있어야 하는데 그 부분이 부족했습니다.
메시지 발송 모듈은 OCP를 적용한 예입니다.
interface MessageService {
void send();
}
class TalkMessageToUserService implements MessageService { ... }
class SmsToUserService implements MessageService { ... }
TalkMessageToUserService는 고객에게 카카오톡 메시지를 보내는 클래스이고, SmsToUserService는 문자메시지를 보내는 클래스입니다. 간단하게 추상화를 했습니다. 나중에 매장에 문자메시지를 보내는 기능으로 확장하려면 SmsToShopService()를 만들면 됩니다.
class SmsToShopService implements MessageService { ... }
자식 타입은 부모 타입으로 치환 가능해야 한다.
LSP란 상속 관계를 구현할 때 자식 타입은 부모 타입으로 치환 가능해야 한다는 것입니다. 이해를 돕기 위해 예제를 살펴보겠습니다.
직사각형을 표현한 Rectangle 클래스가 있고 이를 상속한 정사각형 Square 클래스가 있습니다. 둘 다 Shape interface를 상속했습니다. 사용하는 클라이언트 소스는 아래와 같습니다.
int area(Shape s){
s.setWidth(5);
s.setHeight(4);
return s.area();
}
s의 타입이 Square이라면 area는 16입니다. setHeight() 메쏘드 호출 시 width = height로 세팅이 되기 때문입니다. s 타입이 Rectangle이라면 area는 20입니다. 이런 식으로 부모 타입이 자식 타입을 대체하지 못하는 것이 LSP 위반 사례입니다.
카카오헤어샵 프로젝트에서는 LSP를 따르지 못했습니다. 우리가 했던 추상화 수준이 interface - abstract class - nested class 정도였기 때문입니다. interface - class - class로 추상화를 해야 LSP를 적용해볼 수 있을 텐데 그렇지 못했습니다. 저를 포함한 팀원들의 객체지향 설계 능력이 부족했던 것 같습니다.
클라이언트가 자신이 사용하지 않는 메쏘드에 의존하지 않아야 한다.
클라이언트가 자신이 사용하지 않는 메쏘드에 의존하도록 강제될 때 이 클라이언트는 이 메쏘드의 변경에 취약하게 됩니다.
예제를 통해서 살펴보겠습니다. 출입문을 Door라고 하겠습니다. 시간이 지나면 닫히는 문을 TimerDoor라고 하겠습니다. Java 코드로 구현하면 아래와 같습니다.
interface IDoor { }
class Door implements IDoor { ... }
class TimerDoor extends Door {... }
TimerDoor에는 제한시간이 초과되었을 때 호출하는 timeout() 메쏘드를 제공합니다. 이를 IDoor 인터페이스에 적용하면
interface IDoor {
void timeout();
}
가 됩니다. 하지만 이 경우 ISP를 위반하게 됩니다. 위 코드를 ISP에 맞게 수정하면
interface IDoor { }
interface ITimer {
void timeout();
}
class Door implements IDoor { ... }
class TimerDoor implements IDoor, ITimer { ... }
가 됩니다.
카카오헤어샵 프로젝트는 불행히도 ISP를 적용하지 못했습니다. interface를 implements 하는 경우 단일 interface만 상속했고 2개 이상을 implements 한 경우는 없습니다.
추상화에 의존하자.
DIP는 추상화를 통해 의존 관계를 느슨하게 한다는 원칙입니다. Inversion 이란 상위 모듈이 하위 모듈에 의존하는 전통적인 관계가 반전되었다는 뜻입니다. 이 원칙은 '상위와 하위 모듈 모두가 동일한 추상화에 의존해야 한다'는 객체 지향적 설계의 대원칙을 제공합니다.
DIP에서는 상위 모듈이 하위 모듈을 참조할 때 구현(Concrete) 클래스를 직접 사용하는 것이 아니라 interface를 참조해야 합니다. 일반적인 Web 프로젝트라면 interface -> application, application -> infrastructure를 사용할 때 interface를 참조하는 것입니다.
카카오헤어샵은 DIP를 많이 지키지 못했습니다. 1/5 정도의 하위 모듈만이 interface를 통한 추상화가 되어 있습니다. 이 부분은 제가 특히 잘못했습니다. 3년 전 카카오헤어샵 프로젝트를 착수할 때 막연히 "interface는 코드 길이만 길어지고 귀찮으니 강제하지 말자."라고 의견을 냈고 다들 그렇게 하기로 했습니다.
위 OCP 내용에서 예제로 든 MessageService는 리팩토링 과정에서 DIP도 같이 적용해 본 사례입니다. 여기에 더해서 MessageDelegator를 만들어서 비즈니스 로직을 캡슐화 했습니다.
class MessageDelegator {
void send(MessageOrder order){
// order의 type에 따라 talkMessage, SMS를 보낸다.
}
}
interface MessageOrder { }
class UserTalkMessageOrder implements MessageOrder {... }
class UserSMSOrder implements MessageOrder {... }
클라이언트에서는 MessageOrder를 생성 후 MessageDelegator에 요청하면 되어 메시지를 보내는 비즈니스 로직이 모두 추상화되었습니다.
class MessageClient {
void doThing(){
MessageOrder order = MessageOrderFactory.SMS;
messageDelegator.send(order);
}
}
카카오헤어샵 프로젝트는 SOLID 원칙 중에서 SRP는 70점, OCP는 30점, LSP은 0점, ISP는 0점, DIP는 20점 정도 줄 수 있을 것 같습니다. 좋은 점수를 주지(?) 못해서 아쉬움이 많이 남습니다. 특히 DIP는 쉽게 적용할 수 있었는데 그러지 못했습니다.
MessageService를 리팩토링 하면서 OCP와 DIP를 적용해보니 다음번 유지보수가 편해지겠다는 생각을 많이 했습니다.
혹시 유지보수가 편한 소스코드에 대한 고민이 있다면 SOLID 원칙에 대하여 자세히 공부하시기를 추천드립니다. 카카오헤어샵은 일부분만 적용했지만 그래도 좋은 성과를 얻었다고 생각하기 때문입니다.