소프트웨어는 계속해서 변화하고 수정된다.
그 때문에 주먹구구식의 수정을 반복하다 보면 코드는 결국 이런 모양이 된다.
다 때려부수고 새로 만드는 게 나을지도
심정이 어떻든 간에 동작은 하는 애플리케이션이라면 현실적으로 다 뜯어고치기는 힘들다.
이를 한 번에 해결하는 방법은 존재하지 않고,
해결을 위해 들이는 모든 시간과 노력이 결국 코스트로 귀결되기 때문이다.
그렇기에 결국 OOP이다.
OOP는 소프트웨어의 복잡성을 해결하고 관리할 수 있는 방법으로 제시된 것 중 가장 대중적이고 좋다고 알려진 방법이다.
그저 클래스를 만들고 멤버변수와 메소드를 넣고, 그걸 이용해 프로그래밍을 한다고 완전한 OOP인 것은 아니다.
제대로 된 OOP는 분류에 철저하고 교체에 유연해야 한다.
예를 들어, 클라이언트 A가 있고 가게인 Z가 있다.
현금으로 물건을 계산한다고 하면 A - cash - Z와 같은 도식이 성립할 것이다.
이때 결제 방법을 카드로 바꾼다면?
A - card - Z의 도식이 성립해야 한다.
이를 정말 단순하게 구성해보자.
if(결제수단 = 현금){
A.현금결제() // or 현금결제.결제(A)
} else if(결제 수단 = 카드){
A.카드결제() // or 카드결제.결제(A)
}
결제의 주체는 A이고, A가 결제수단을 이용하는 것이고, 결제수단에 따라 결제방식이 다르니 메소드가 달라야 한다.
그러면 대강 위의 구조가 나올 것이다.
결제 메소드를 각 결제수단 클래스가 가진다면 주석 쪽의 형태가 되겠다.
그런데 알다시피 요즘은 결제 수단이 정말 많다.
휴대폰 결제, 티머니, 페이팔 등
이런 게 하나 추가될 때마다 저기에 다 추가해줘야 하나?
동작은 할 거다. 하지만 일단 코드가 더럽고, 휴먼에러의 발생 가능성이 높아진다.
이런 구조는 어떤가?
A.결제어답터.결제()
interface 결제어답터{
...
}
class 현금결제 implements 결제어답터{
...
}
class 카드결제 implements 결제어답터{
...
}
같은 인터페이스를 구현하고 있으니 업캐스팅이 가능하다.
해당 인터페이스에 결제 메소드가 있고, 각 클래스에서 그걸 구현했다면,
클라이언트 쪽의 코드는 위의 1줄로 끝난다.
물론 결제방식에 따라 어떤 구현체를 사용할 것인지에 대한 처리가 남아있긴 하다.
허나, 달리 말하면 그 부분에 대한 일괄적인 처리만 하면 끝이다.
무언가가 추가되거나 수정되어도 클라이언트 부분은 신경끄고 해당 부분에 집중해서 작업할 수 있다.
이렇게 캡슐화, 상속, 다형성의 특징을 최대한 활용해 어떤 작업에 대한 책임을 여러 곳에서 나눠가지지 않고, 업무를 확실하게 분담하며 추가/수정에 유연한 모습을 보이는 것이 제대로 된 OOP이다.
SOLID를 지키는 것은 제대로 된 OOP를 돕는다.
의 앞글자를 따서 SOLID이다.
한 클래스는 단일의 책임만 가져야 한다.
어느 사이트에 회원클래스가 있다고 가정해보자.
이 사이트는 개인 컨텐츠(글, 그림, 수공예 등)을 거래하는 사이트이고,
댓글, 리뷰, 평점, 즐겨찾기 등의 커뮤니티 기능도 있다고 가정하자.
그럼 회원클래스가 가져야 하는 기능은 대강 3분류로 나뉜다.
1) 회원 정보 관리 기능 (개인정보, 비밀번호 등)
2) 거래 기능
3) 커뮤니티 기능
이것을 한 클래스에 전부 쑤셔박으면 아래와 같은 모양이 된다.
class MemberService{
register();
changeInfo();
withdraw();
...
addCart();
removeCart();
pay();
...
like()
writeComment();
writeReview();
rate();
...
}
위에는 예시로 9개의 메소드 밖에 없지만, 실제로는 훨씬 더 많을 것이다.
게다가 위는 선언형식으로 짧게 되어있지만, 모두가 알다시피 메소드를 실제로 구현하면 길어진다.
그러면 일단 가독성이 떨어진다. 그리고 직관성도 떨어진다.
예를 들어, 평점 같은 경우에는 회원이 부여하는 거니까 회원 클래스에 있다고 할 수 있지만, 동시에 어느 제품에 관한 평점이니까 제품 클래스에 있다고 해도 말이 된다.
여러 명이 협업하는 경우 이런 단점은 더욱 부각된다.
나중에 가면 PR 생성 시 과도한 컨플릭트가 발생할 수 있고, 개발자가 교체되는 경우도 고려하면 어디에 뭐가 있는지 찾는데 쓸데 없이 시간을 소모하게 된다.
이는 한 클래스에 너무 많은 책임을 부여했기 때문에 일어나는 일이다.
이걸 이렇게 바꿔보자.
class MemberService{
register();
changeInfo();
withdraw();
...
}
class PurchaseService{
addCart();
removeCart();
pay();
...
}
class CommunityService{
like()
writeComment();
writeReview();
rate();
...
}
기능들에 대한 각 클래스의 책임이 보다 명확해졌다.
개발자들은 자기 담당 분야의 클래스만을 신경쓰면 되기 때문에 컨플릭트 발생도 일어나지 않는다.
확장에는 열려있고, 변경에는 닫혀있다.
위에서 예시로 들었던 사이트가 꽤 성공을 했다. 그래서 이번에 미국과 일본, 러시아에도 진출한다고 한다.
이 경우 국가마다 결제 수단과 세금, 가격 표기 등이 상이할 수 있으므로 이에 대한 구분을 해줘야 한다.
위의 클래스에서 PurchaseService의 일부를 가지고 와 보자.
class PurchaseService{
pay(){
if(country == KR)
else if(country == US)
else if(country == JP)
else if(country == RU)
}
getTaxRate(){
if(country == KR)
else if(country == US){
// 여기는 또 주마다 세금이 다르다 ㅋㅋ!
if(state == NY)
else if(state == CA)
...
}
else if(country == JP)
else if(country == RU)
}
...
}
이런 식의 코드는 다음과 같은 문제가 있다.
1) 테스트 시에 조건식마다의 테스트 필요.
2) 휴먼 에러 발생 가능성 up (국가별 로직 하나 빼먹는다던지).
3) 메소드 내부에서 국가 순서가 바뀌면서 찾는데 쓸데 없이 시간 소모.
이렇게 바꿔보자
interface PurchaseService{
pay();
getTaxRate();
}
class PurchaseServiceKR implements PurchaseService{
pay();
getTaxRate();
}
abstract class PurchaseServiceUS implements PurchaseService{
pay();
abstract getTaxRate();
}
class PurchaseServiceUSNY extends PurchaseServiceUS{
getTaxRate();
}
class PurchaseServiceUSCA extends PurchaseServiceUS{
getTaxRate();
}
class PurchaseServiceJP implements PurchaseService{
pay();
getTaxRate();
}
class PurchaseServiceRU implements PurchaseService{
pay();
getTaxRate();
}
테스트하기가 상대적으로 쉬워졌고,
국가별로 클래스가 나뉘니 실수로 로직이 빠지는 경우도 드물게 될 것이다. 이 구조에서 로직이 빠지면 빈 클래스가 되게 될 테니까
국가의 추가/삭제도 보다 쉬워졌다.
조건문으로 꾸역꾸역 끼워넣으며 기존 코드 수정하지 말고,
애초에 확장 가능한 설계를 한 후에 추가 파트는 추가적인 클래스로 집어넣으라는 게 이 원칙의 핵심이다.
서브타입은 언제나 기반타입으로 교체할 수 있어야 한다.
자식클래스는 부모클래스와 동일한 동작을 해야 한다는 소리다.
그래야 재활용 가능성이 높아지기 때문이다.
그러니까 이런 식의 설계는 별로라는 소리다
class Vehicle{
create();
destroy();
}
class Car extends Vehicle{
drive();
}
class Plane extends Vehicle{
fly();
}
상위 클래스로 업캐스팅이 되겠지만 제대로 된 호환이 되지 않는다.
받은 게 Car인지 Plane인지 어떻게 안단 말인가?
어느 쪽에는 drive()가 있고, 어느쪽에는 fly()가 있다.
단순히 이름의 문제가 아니다. 비행기는 상하로도 움직인다.
fly를 move따위의 이름으로 바꾼다고 해도 여전히 moveUp, moveDown 등의 메소드가 필요해진다.
그 외에도 아래와 같은 문제 때문에 상속은 그리 추천되는 항목이 아니다.
1) 상속 시 메소드 오버라이드를 한 것과 아닌 것의 혼란
2) 오버라이드를 잘못하면 로직 충돌이 발생 (fragile base class problem)
3) 기능을 너무 확장하거나 변경하면 재활용성이 저하
그렇기에 굳이 상속을 하고 싶거든 다음과 같은 원칙을 생각하며 해야 한다.
1) 상속을 위한 설계를 한 클래스만 상속
2) 상속 대신 인터페이스를 활용
3) 상속을 피할 수 없는 경우 부모와 동일한 기능을 제공해 상호 치환이 가능하도록 설계
이 원칙을 지켜서 위 예제를 바꿔보자.
interface Drivable{
drive();
}
abstract class Car implements Drivable{
abstract create();
abstract destroy();
}
class Bus extends Car{
create();
destroy();
drive();
}
interface Flyable{
fly();
}
abstract class Aircraft implements Flyable{
abstract create();
abstract destroy();
}
class Plane extends Aircraft{
create();
destroy();
fly();
}
대강 이런 느낌이 되겠다.
인터페이스도 단일 책임을 갖도록 분리해야 한다.
SRP의 Interface 버전이다.
개인적으론 SOLD라고 하면 이상하니까 구색맞추기 식으로 끼운 게 아닌가 생각한다.
예의 그 사이트! 또 가져오자.
interface PurchaseService{
pay();
getTaxRate();
sendTip(); // 추가된 녀석!
}
인터페이스던 클래스던 간에 너무 많은 책임을 한 몸에 가지고 있으면 좋지 않다는 것은 SRP를 통해 이미 배웠다.
거기에 인터페이스를 구현하는 식이라면, 해당 인터페이스가 구현된 클래스들 중 어느 하나에만 필요한 메소드라도 인터페이스에는 선언이 되어있어야 한다.
sendTip은 과연 어느 나라용일까? 당연히 미국이다.
그 외의 나라들: KR, JP, RU는 팁이 필요 없다.
그러니까 미국 클래스 하나 때문에 나머지 세 클래스는 아무짝에도 쓸모없는 빈 메소드를 가지고 있게 된다 이 말이다.
그러니까 나누자.
interface PurchaseService{
pay();
getTaxRate();
}
interface TipService{
sendTip();
}
이렇게 나누면 미국 클래스에만 추가적인 구현을 해주면 된다.
인터페이스는 다중 부여가 가능하기에 기능별로 작게 구현하는 것이 좋다!
하위 모듈의 변경이 상위 모듈의 변경을 요구하는 의존성을 끊어야 한다.
무슨 소리냐? 그러니까 이런 거다.
또 그 사이트로 예를 들자.
실제로 결제를 하면 결제용 서비스가 따로 필요해질 거다.
카드로 결제를 한다 치면, 카드를 조회하고 인증하고 승인하고 매출내역을 전송하고 매출금액을 입금하고 하는 등의 복잡한 작업들이 필요하다.
이걸 가게마다 자기만의 시스템을 만들어서 운영해야 할까?
그러면 사업을 위해서는 일단 소프트웨어 개발자를 고용하는 것부터 출발해야 할 것이다. 개이득인데?
이 틈새를 노린 기업들이 있다. 결제 대행 서비스 제공 업체다.
이 사람들이 잘 만든 모듈을 그냥 가져다 쓰면 모든 것이 편하다.
이들의 서비스가 API형태로 제공이 되고 있고, 그걸 가져다 쓴다고 생각해보자.
import AbcPaymentAPI; // ABC 업체에서 만든 모듈
class PurchaseServiceKR implements PurchaseService{
pay(){
AbcPayment.pay();
}
getPaymentAmount();
}
class PurchaseServiceUS implements PurchaseService{
pay(){
AbcPayment.pay();
}
getPaymentAmount();
}
class PurchaseServiceJP implements PurchaseService{
pay(){
AbcPayment.pay();
}
getPaymentAmount();
}
... // 대충 비슷한 클래스 한 20개 정도
대충 이런 형태가 될 거다.
그런데,
평화롭게 판매를 하던 어느 날, XYZ 업체에서 자기들의 서비스를 이용하면 가맹점에 10% 페이백을 해주겠다고 한다.
참을 수가 없어서 바꾸기로 했다.
그런데 바꾸려고 보니, 무려 몇십 개나 되는 클래스를 하나하나 수정해줘야 한다는 걸 깨달았다.
업무량도 업무량이지만 휴먼 에러의 발생 가능성도 높아졌다.
상위 모듈이 하위 모듈에 의존하기 때문에 이런 일이 발생한 거다.
좀 더 쉽게 설명하자면, 결제의 도구로 쓰이는 결제 모듈의 변경이 일어나자 그 도구를 직접적으로 사용하던 클래스들이 전부 변해야만 하는 일이 일어났다 이거다.
이때 일부러 의존성을 역전시켜 하위 모듈이 상위 모듈에 의존하게 하는 것이 DI이다.
interface PaymentService{
pay();
}
class AbcPaymentService implements PaymentService{
pay();
}
class XyzPaymentService implements PaymentService{
pay();
}
interface PurchaseService{
pay(PaymentService p);
getPaymentAmount();
}
class PurchaseServiceKR implements PurchaseService{
pay(PaymentService p){
p.pay();
}
getPaymentAmount();
}
class PurchaseServiceUS implements PurchaseService{
pay(PaymentService p){
p.pay();
}
getPaymentAmount();
}
class PurchaseServiceJP implements PurchaseService{
pay(PaymentService p){
p.pay();
}
getPaymentAmount();
}
DIP의 핵심은 상위 모듈과 하위 모듈이 둘 다 interface를 의존하게끔 하여,
변경 시 구현체 중 어떤 것을 선택하게 할까 하는 부분만을 수정하게끔 하는 것이다.
둘 다 같은 거다.
초기에는 IoC (Inversion of Control)이라는 이름으로 의존성 역전 설계가 소개되었다.
그런데 뭔가랑 이름이 겹친다나 어쨌다나 하는 이유로
보다 구체적인 방법을 나타내는 느낌의 DI (Dependency Injection)으로 바뀌었다고 한다.
Spring을 쓰면 최소한의 SRP, ISP가 저절로 지켜진다.
아래가 Spring Web MVC에서 가장 대중적으로 활용되는 역할 분리 방법이다.
표준적인 레이어 별 역할을 나눠주기 때문에 SRP, ISP에 도움이 된다.
그렇기에 각 클래스의 역할이 보다 명확해지고, 코드의 분석 및 파악이 보다 쉬워진다.
Spring을 쓰면 OCP, LSP, DIP도 지키기도 쉬워진다.
를 이용해 외부에서 의존성을 관리한다.
즉, 코드 상에서의 의존성 주입 없이 DI Config에서 수정만하면, 모든 객체는 DI container에 의해 관리되므로 알아서 의존성을 주입해준다.
꼭 Spring을 써야만 코드가 잘 짜여지는 것은 아니다.
하지만 SOLID를 잘 지키면서, 교체가 자유롭고, 단일 책임을 잘 지키도록 어찌어찌 코드를 잘 짜놓고 보면 Spring와 유사한 형태가 나오게 된다.
그렇기에 효율성 측면에서 봤을 때, 굳이 고생해가며 맨땅에서 헤딩할 필요 없이 그저 잘 만들어져 있는 것을 가져다 쓰는 게 낫다는 결론이 나오게 된다.