본문 바로가기

기타

OCP(Open Closed Principle) 원칙

728x90
반응형

* 해당 글은 회사 스택 게시판에 올린 글로, 사내망이기 때문에 링크 대신 전문을 복사해서 기록합니다.

 

 안녕하세요 OOOO OO팀 백성규선임입니다. 저는 현대큐밍 홈페이지 웹을 담당하면서 기존의 코드를 유지보수, 개발하는 업무를 하고 있습니다. 최근에 좋은 유지보수성을 가지기 위해서 다양한 방법들을 공부하고 있는데 그중에 한가지를 공유합니다.

오늘 소개드릴 내용은 OCP(Open Closed Principle) 개방 폐쇄 원칙입니다.

OCP는 SOLID 원칙 중 하나로, SOLID 원칙은 자기 자신의 클래스의 응집도는 높이고 클래스 간의 결합도는 낮추기 위한 객체지향 설계 기법입니다. 궁극적으로 유지보수와 재사용성을 높이기 위함입니다. OCP는 다형성에 힘입어 재사용, 관리 가능한 코드를 만드는 기반이 되고 있습니다. 

OCP의 정의는 다음과 같습니다.

 

"클래스나 모듈은 확장에는 열려있으나, 변경에는 닫혀있어야한다."

 

 풀어본다면, 요구사항에 따라 기능이 추가되는 경우 기존 요소를 재사용하여 쉽게 추가할 수 있어야하고(확장에 열려있다) 기존의 클래스나 모듈에는 변경이 있으면 안됩니다(변경에 닫혀있다)

 이는 객체지향 언어인 JAVA에서 동작의 묶음을 추상화하여 표현하는 interface를 통해 가능합니다. 직접적으로 참조하거나 구현하는게 아닌 interface 참조를 통해 확장에 열리고, 변경에 닫혀있도록 유연함을 더해줍니다.

OCP를 적용하기 위해서 유의할 점은, 변경될 사항과 아닌 사항 잘 구분하기, 구분되는 지점에 인터페이스 정의하기, 실제 코드에서 직접 구현하는 것이 아닌 인터페이스에 의존하기(핵심!!!) 입니다.

결제 예시를 통해서 알아보겠습니다.

 


1. 개별적으로 요청 만들기


 카카오페이, PG카드사 결제 모듈에 네이버페이를 새로 만든다고 가정합니다.
가장 손쉬운 방법은 결제 요청에 따라 Controller와 Service를 만들어서 처리하는 방법입니다.

 

 

Diagram

 

 

 가장 직관적으로 이해가 가지만,  NaverController, NaverPayService 및 naverPay메서드를 만드므로 중복코드들이 발생합니다. 요청 클래스 또한 각각의 Request가 다르기 때문에 개별적으로 만들어주어야 합니다. 

 

 

@Controller
publi class KakaoPayController {
  @Autowired
  private KakaPayService kakaoPayService;

  @PostMapping("/pay/kakao")
  public void kakaoPay(@RequestBody KakaoPayRequest request) {
    kakaoPayService.kakaoPay(request);
    ...
  }
}

@Controller
public class PgCardPayController {
  @Autowired
  private PgCardPayService pgCardPayService;

  @PostMapping("/pay/pgcard")
  public void pgCardPay(@RequestBody PgCardPayRequest request) {
    pgCardPayService.pgCardPay(request);
    ...
  }
}

@Controller
public class NaverPayController {
  @Autowired
  private NaverPayService naverPayService;

  @PostMapping("/pay/naver")
  public void naverPay@RequestBody NaverPayRequest request) {
    naverPayServaice.naverPay(request);
    ...
  }
}

 


2. 하나의 요청에서 처리하기


모든 결제 요청을 하나의 Controller로 요청하도록 개선합니다. 

 

Diagram

 

 기존과 달리 요청이 공통으로 처리된다는 점에서 추상화가 이루어져 중복되는 코드가 한결 줄어들고 가독성이 올라갔습니다.  요청 방식이 제각각이었지만, 공통된 PayRequest를 사용하므로 하나의 통일된 구조로 개선됩니다. 하지만 여전히 새로운 결제모듈 추가에 if문과 service들이 늘어나게 되어 확장이 어렵고 변경에는 취약합니다.

 

@Getter
@NoArgsConstructor(access = PROTECTED)
@Builder
publi class PayRequest {
  private PayMethod paymethod;
  private Long amount;
  private Long orderId;
}

public enum PayMethod {
  KAKAO, PG_CARD, NAVER
}

@Controller
public class PayController {
  @Autowired
 private KakaoPayService kakaoPayService;
  
  @Autowired
  private PgCardPayService pgCardPayService;

  @Autowired
  private NaverPayService naverPayService;

@PostMapping("/pay")
public void pay(@RequestBody PayRequest request) {
  if(request.getPayMethod == PayMethod.KAKAO) {
    kakaoPayService.pay(request);
  }

  if(request.getPayMethod == PayMethod.PG_CARD) {
    pgCardService.pay(request);
  }

  if(request.getPayMethod == PayMethod.NAVER) {
    naverPayService.pay(request);
  }
}

 


3. 인터페이스를 활용해 확장하기


최종적으로 결제하는 메서드를 PaymentApiCaller 인터페이스로 추상화합니다.

 

Diagram

 

 요청된 결제유형에 따라 따라서 결제수단이 결정되고, 그에 해당하는 실제 구현체 PaymentApiCaller의 pay가 실행됩니다. 이제 확장에는 열리고 변경에는 닫혀있는 구조가 되었습니다. 새로운 결제수단을 추가한다면, 결제를 구현하는 구현체만 만들면 되며, 기존에 PaymentService 어느 곳에서도 코드를 변경할 필요가 없습니다.

 

먼저 새롭게 개선된 Caller 부분을 확인해보겠습니다.

 

public interface PaymentApiCaller {
 public void pay(PayRequest request);
}

@Component
public class KakaoPayCaller implements PaymentApiCaller {
  public void pay(PayReuest request) {
  //Todo : 카카오페이 결제 구현 
  }
}

@Component
public class PgCardPayCaller implements PaymentApiCaller {
  public void pay(PayRequest request) {
  //Todo : PG사 카드결제 구현
  }
}

@Component
public class NaverPayCaller implements PaymentApiCaller {
  public void pay(PayRequest request) {
  //Todo : Naver페이 결제 구현
  }
}

 

 PaymentApiCaller 인터페이스에 pay() 메서드를 만들어 실제 구현체가 없는 껍데기로 추상화시킵니다.  PaymentService에서는 이 pay를 사용하여 확장성에 열리도록 합니다. 결제 유형에 따라서 KakaoPayCaller, PgCardPayCaller, NaverPayCaller 클래스가 pay를 구현해서 실제 결제를 합니다. 이 구현체들은 Spring Bean으로 관리 할 예정이므로 @Component를 붙여줍니다.

 

@Service
public class PaymentService {
  @Autowired
  List<PaymentApiCaller> paymentApiCallerList;
  ...

  //실제 결제 구현이 되는 부분
  public void pay(@RequestBody PayRequest request) {
    PaymentApiCaller paymentApiCaller = PaymentCheck(request);
    paymentApiCaller.pay(request);
  }

  //어떤 결제요청인지에 검사하여 반환
  public PaymentApiCaller paymentCheck(PayRequest request) {
     //Java 8 이상
     PaymentApiCaller paymentApiCaller = paymentApiCallerList.stream()
                  .filter(caller -> caller.support(request.getPayMethod())
                  .findFirst()
                  .orElseThrow(InvalidParamExcpetion::new);
     
    //Java 7 이하
    PaymentApiCaller paymentApicaller = null;
    for(PaymentApiCaller caller : paymentApiCallerList) {
     if(caller.getMethod() == request.getMethod()) {
       paymentApiCaller = caller;
       break;
    }

   if(paymentApiCaller == null) throw new InvalidParamExcpetion();
   }

     return paymentApiCaller;
  }
}

 

 일반적으로 Spring에서 @Autowired로 Bean을 주입할 때, 단일 클래스를 주입시키지만, List<PaymentApiCaller> 처럼 여러 클래스의 Bean들을 한번에 주입할 수 있습니다. PaymentApiCaller를 구현하는 NaverPayCaller, KakaoPayCaller, PgCardPayCaller 3개의 Bean이 동시에 주입됩니다. 이 점을 이용하면, 이후에 새로운 결제가 추가될 때 PaymentApiCaller를 구현하는 새로운 PaycoPayCaller, HPointPayCaller 등등만 생성하면 됩니다. PaymentService에는 어떠한 수정도 필요하지 않습니다. paymentCheck에서 요청된 결제 유형을 검사하고 인터페이스가 그에 맞는 구현체를 찾아서 pay메서드를 실행할 것이기 때문입니다. 이제 확장에는 열려있고 변경에는 닫힌 구조가 되었습니다.

 

번외로 확장에 열려있다는 뜻은, 새로운 기능 추가 시에 유용하지만 반대로 삭제에도 유용합니다. 갑자기 네이버페이와 계약이 종료된다면? 단순히 네이버 페이를 Bean 관리에서 제거해버리면 됩니다. NaverPayController, NaverService를 모두 삭제하거나 주석처리하거나... 등등의 번거로운 작업을 하지 않아도 됩니다.

 

//@Component <- 주석처리하면 끝!
public class NaverPayCaller implements PaymentApiCaller {
  public void pay(PayRequest request) {
  //Todo : Naver페이 결제 구현
  }
}

 

 PaymentService에서 List<PaymentApiCaller>를 활용해 한번에 여러개의 Bean을 주입할 수 있다고 하였습니다. NaverPayController의 @Component를 제거해버린다면 더이상 Spring에서 Bean으로 관리되지 않고 따라서 PaymentService도 더이상  NaverPayCaller를 의존하지 않습니다. 결국 네이버 페이를 제외한 다른 결제 구현체들만 주입됩니다.

 

이 예시를 로그인 검사에서 적용해보도록 하겠습니다. LoginController에는 다음과 같이 로그인 예외 경우를 검사합니다.

 

String retCode = StringUtil.convNull(request.getParameter("retCode"),"");
      
if(retCode.equals("0")) {
  // 로그인 패스워드 오류(0)
  String loginPwdErrCnt = StringUtil.convNull(request.getParameter("loginPwdErrCnt"),"0");
  model.addAttribute("loginPwdErrCnt", loginPwdErrCnt);   
  model.addAttribute("resultMsg", message.getMessage("login.password.invalid"));
  model.addAttribute("resultStatus", "permission");
} else if(retCode.equals("-11")) {
  // 로그인 아이디 없음(-11)
  model.addAttribute("resultMsg", message.getMessage("login.id.invalid"));
  model.addAttribute("resultStatus", "permission");
} else if(retCode.equals("-12")) {
  // 계정삭제상태 (-12)
  model.addAttribute("resultMsg", message.getMessage("login.id.drop"));
  model.addAttribute("resultStatus", "permission");
}

...

return "index/index";

 

로그인 결과를 리턴받는 retCode를 if/else문으로 하나씩 검사해서 결과를 담고 있습니다. 특히 resultMsg와 resultStatus는 공통으로 중복되며, 또다른 로그인 오류 예외가 추가된다면 else if를 계속 늘려가야합니다. 따라서 이를 개선하기 위해서, 로그인을 처리하는 인터페이스 LoginErrorCaller를 만들고 각 구현체를 만들도록 합니다.

 

public interface LoginErrorCaller {
  public boolean isLoginWith(String retCode);
   
  public String message();
}

 

isLoginWith : 넘겨받은 retCode에 따라서 자신의 구현체에 해당하는 오류코드인지 검사합니다

message : 리턴할 오류메세지를 정의합니다.

 

@Component
public class LoginIdInvalid extends LoginErrorCaller {
   @Autowired
   private MessageUtil message;

   @Override
   public boolean isLoginWith(String retCode) {
     return retCode.equals("-11");
   }

   @Override
   public String message() {
      return message.getMessage("login.id.invalid");
   }
}

 

 예를 들어, 로그인 Id가 올바르지 않을 때 처리하기 위한 LoginInvalid 클래스를 만들었습니다. LoginErrorCaller를 구현하기 때문에 필수적으로 isLoginWith()와 message()를 재정의해야 합니다. isLoginWith()는 로그인 id가 올바르지 않는 규칙으로 만든 -11인지 검사하고, message()는 그에따른 오류 메세지를 리턴합니다. 

 

@Autowired
List<LoginErrorCaller> loginErrorCallerList;

...

for(LoginErrorCaller loginErrorCaller : loginErrorCallerList) {
  if(loginErrorCaller.isLoginWith(retCote)) {
    model.Attribute("resultMsg" , loginErrorCaller.mssage);
    return "index/index";
  }
}

...

 

 실제 구현에서는 각각의 로그인 오류를 검사하는 것이 아닌, LoginErrorCaller 인터페이스를 통해서 처리하도록 하고, 구현체들을 만들도록 개선했습니다. 이제 수십줄의 검사코드가 단지 6줄로 줄어들었으며 이후 확장에는 열려있고, 변경에는 닫혀있는 구조가 되었습니다. 실제로 30일 이상 미접속 계정에 대해 로그인 오류를 내는 추가 로직은 LoginErrorCaller를 구현하는 새로운 클래스를 만들어서 손쉽게 해결했습니다. 현재 예시에서는 retCode만으로 분기하는 굉장히 간단한 경우였음에도 큰 효과를 봤습니다.

 


 인터페이스를 활용해 응집도는 높게, 결합도는 낮게 가져가는 방법을 살펴보았습니다. 설계 시, 다양한 구현체가 추가 될 것으로 예상하는 부분은 인터페이스를 사용해 느슨한 설계를 가져가는 것이 효율적입니다. 혹은 계속 비슷한 유형의 기능들이 추가될 것으로 예상된다면 인터페이스를 사용하도록 개선할 수 있습니다. 만약에 추가 될 예정은 없지만 여러 분기문을 인터페이스로 개선하고 싶다면 리팩토링 관점에서 살펴봐야 합니다.

 실용주의 프로그래머에 따르면, 리팩토링을 위해서 사전에 든든한 테스트를 준비하고 자주 테스트해봐야 합니다. 각 환경에 맞게 안전한 테스트를 준비하고 진행하신다면, 좋은 설계와 구조의 안목을 키우는 기회가 될 것입니다. 

 

무엇보다 가장 중요한 것은 좋은 설계와 구조보다도 실제 동작이 되는 것입니다. 처음부터 완벽한 설계보다 빠르게 구현을하고 여건이 있을 때, 점진적으로 개선해 나가는 것이 좋습니다.

 

728x90
반응형

'기타' 카테고리의 다른 글

네이버 EP 작업기록  (0) 2022.06.12
Spring에서 예외 처리하기  (0) 2022.06.11
현업들과 협력/의사소통하기  (0) 2022.06.10
lombok local에서 실행하기  (0) 2022.05.24
EC2 & RDS 연동하기  (0) 2022.05.17