| OOP의 핵심
[ 지난 번 OOP 관련 포스팅 내용 요약 ] - OOP는 객체 간 상호 관계에 포커스를 맞춘 방법론으로, 추상화, 상속, 다형성, 캡슐화 4가지의 주요 특징이 있다. - 추상화는 모델링이고, 상속은 확장과 재사용성이며, 다형성은 사용 편의, 캡슐화는 정보 은닉에 해당된다. - 추상화 단계에서는 클래스 내 응집도를 높이는 게 필요하고, 상속 시에는 클래스 간 결합도에 주의해야 한다. |
- OOP는 풀면 참 복잡했는데, 오늘 배운 수업에서는 분류와 교체라는 단어로 그 핵심을 단순화해 설명했다.
* 현업에 오래 종사했던 강사분은 스파게티 소스에 대해 언급하며,
소프트웨어를 유연하게 만들려면 객체지향 방식이 유용하다 얘기주셨다.
분류한다 | 코드를 적절히 잘 분류한다. -> 클래스 |
교체한다 | 필요에 따라서 특정 모듈을 통째로 교체하기도 한다. -> DBMS 업체나 라이브러리를 통째로 교체하기도 한다. |
※ 라이브러리와 프레임워크와 아키텍처 간의 상관관계 바로가기
| SOLID 원칙
- 로버트 C. 마틴에 의해 만들어진 원칙
SRP | 단일 책임 원칙(분류) | Simple Responsibility principle |
OCP | 개방 폐쇄 원칙(교체) | Open/Closed principle |
LSP | 리스코프 치환 법칙(교체) | Liskov subsituation principle |
ISP | 인터페이스 분리 원칙(분류) | Interface segregation principle |
DIP | 의존성 역전 원칙(교체) | Dependency inversion principle |
※ 참조 : 실제 현장에서 사용하는 코드는 아래와 달라 보입니다. 원리를 이해하기 위한 코드로 봐주시면 감사하겠습니다.
1. SRP : 한 클래스는 단일의 책임만 가져야 한다.
SRP가 지켜지지 않은 코드
class PaymentService{
public void pay(Customer customer, Product product) {
if (!isValidatedPrice(customer, product)) {
throw new IllegalArgumentException("charge your money");
}
customer.setMoney(customer.getMoney() - product.getPrice());
}
public boolean isValidatedPrice(Customer customer, Product product) {
if (customer.getMoney() < product.getPrice()) {
return false;
}
return true;
}
public void sendSMS(Customer customer, Product product) {
System.out.println("phone number : " + customer.getPhone());
System.out.println("상품 " + product.getName() + "("
+ product.getProduct_no() + ")이 결제되었습니다.");
}
}
- 고객이 상품을 주문할 때, PaymentService라는 클래스에서 아래와 같이 세가지의 기능을 제공한다고 할 때에
(1) 결제하기 - pay()
(2) 결제가능 여부 확인하기 - isValidatedPrice()
(3) 결제완료 SMS(메세지) 전송하기 - sendTMS()
- 이 세가지 기능은 엄밀히 말하면 서로 다른 기능이라 볼 수 있다.
- 전체 scope이 작은 경우에는 세가지 기능이 묶일 수도 있겠지만, 만약 기능이 더 복잡해지고 확장된다면 세가지를 분리할 수 있다.
클래스를 분리
class PaymentService{
public void pay(Customer customer, Product product) {
if (!PaymentValidation.isValidatedPrice(customer, product)) {
throw new IllegalArgumentException("charge your money");
}
customer.setMoney(customer.getMoney() - product.getPrice());
}
}
class PaymentValidation{
public static boolean isValidatedPrice(Customer customer, Product product) {
if (customer.getMoney() < product.getPrice()) {
return false;
}
return true;
}
}
class SMSService{
public void sendSMS(Customer customer, Product product) {
System.out.println("phone number : " + customer.getPhone());
System.out.println("상품 " + product.getName() + "("
+ product.getProduct_no() + ")이 결제되었습니다.");
}
}
2. OCP : 확장에는 열려있고, 변경에는 닫혀있다.
* if-else의 반복적인 케이스가 보일 때에는 클래스를 분리하는 것이 효과적일 수 있다.
OCP가 지켜지지 않은 코드
class PointService {
public double getPointRate(String category) {
// 구매 카테고리별로 포인트 비율을 다르게 지급
if (category.equals("옷")){
return 0.05;
} else if (category.equals("잡화")) {
return 0.02;
} else if (category.equals("기타")) {
return 0.01;
}
return 0;
}
public double getVIPRate(String category){
// 구매 카테고리 별로 VIP 여부에 따라서 비율을 다르게 지급
if (category.equals("잡화")){
return 0.12;
} else if (category.equals("도서")) {
return 0.08;
} else if (category.equals("기타")) {
return 0.01;
}
return 0;
}
}
- 만약에 포인트 제도가 추가되어, 구매 카테고리 별로 서로 다르게 포인트를 지정한다고 하자.
- 이럴때 카테고리가 위와 같이 한정적이라면 상관이 없겠지만 카테고리가 계속 늘어난다면 문제가 발생할 수 있다.
- 더불어 getPoint()와 getVIPRate()의 카테고리명과 순서가 다른 걸 볼 수 있다.
- 이런 경우 로직이 통일적이지 않아지기 때문에 결과적으로 문제가 발생할 수 있다.
인터페이스를 통한 OCP 확보
interface PointServiceIF{
public abstract double getPointRate(String category);
public abstract double getVIPRate(String category);
}
class Clothe implements PointServiceIF{
@Override
public double getPointRate(String category) {
return 0.05;
}
@Override
public double getVIPRate(String category) {
return 0.00;
}
}
class Book implements PointServiceIF{
@Override
public double getPointRate(String category) {
return 0.12;
}
@Override
public double getVIPRate(String category) {
return 0.25;
}
}
- 인터페이스를 통해서 메소드를 프로토타입으로 만들어 준 후에, 각각의 카테고리를 클래스로 만들어 메소드를 구현하면 OCP를 확보할 수 있다.
3. LSP : 서브타입은 언제나 기반타입으로 교체할 수 있어야 한다.
- 이 부분은 상위 클래스(또는 인터페이스)의 기능과 동일한 기능을 하위 클래스가 상속(또는 구현)하는 가를 말한다.
상속 | is kind of | 하위 클래스는 상위 클래스의 한 분류이다. |
구현 | is able to | 하위 클래스는 상위 인터페이스를 구현할 수 있다. |
- 여기서는 예제를 만들지 않고 대신 예제 링크만 가져왔다.
https://blog.itcode.dev/posts/2021/08/15/liskov-subsitution-principle
- 위 예제에서는, 직사각형 > 정사각형으로 LSP를 설명하는데, 정사각형은 직사각형의 메소드를 모두 동일하게 가져가지 않기 때문에, 사각형 클래스를 만들고 직사각형과 정사각형이 그것을 상속받게 했다.
- 실무에서는 상속을 많이 사용하지 않는다고 한다.
왜냐하면, 오버라이딩을 했는지 안 했는지 매번 확인하기가 어렵고,
오버라이딩 잘못하면 로직 충돌 (Fragile base class문제)이 일어나기도 하며,
기능을 너무 확장하거나 변경하면 재활용성이 낮아지기 때문이라고 한다.
- 그래서 상속의 대안 및 상속을 잘하기 위해서는
(1) 상위 클래스를 한 클래스만 상속하거나
(2) 클래스 상속이 아닌, 인터페이스로 대체하거나
(3) 상위 클래스와 상호 치환(=동일 기능을 갖도록)이 가능하도록 상속을 해야한다.
4. ISP : 인터페이스도 단일 책임을 갖도록 분리해야 한다.
* 인터페이스를 너무 크게 만들면, 사용하지 않는 메소드들도 구현하도록 해야 한다.
* 실무에서 보면 단일 메소드를 가진 인터페이스들이 있다. > 세분화된 인터페이스로 쪼개서 단일 책임을 갖게 한 것
- 이 부분은 별도로 내용을 추가하지 않고, 바로 앞의 예시로 이야기하겠다.
interface PointServiceIF{
public abstract double getPointRate(String category);
public abstract double getVIPRate(String category);
}
class Clothe implements PointServiceIF{
@Override
public double getPointRate(String category) {
return 0.05;
}
@Override
public double getVIPRate(String category) {
return 0.00;
}
}
class Book implements PointServiceIF{
@Override
public double getPointRate(String category) {
return 0.12;
}
@Override
public double getVIPRate(String category) {
return 0.25;
}
}
- 바로 위의 코드를 보면 "옷"의 경우 안 쓰는 메소드인 getVIPRate()를 구태어 어거지로 구현한 걸 볼 수 있는데,
이유는 인터페이스 안에 메소드를 두 개를 넣어 두어서 두 가지를 모두 구현해야 하기 때문이다.
- 이렇듯 상속은 주의해서 사용하는 게 좋다. 필요하지 않은 메소드까지 구현해야할 수 있고,
너무 많이 확장되고 재사용되다보면 최초에 목적하고 있던 기능에서 많이 동떨어진 메소드가 만들어질 수 있기 때문이다.
- 이러한 문제를 해결하기 위해서는 단일 인터페이스, 곧 단일 메소드를 가진 인터페이스를 만드는 것이 좋다.
interface GeneralPointServiceIF{
public abstract double getPointRate(String category);
}
interface VIPPointServiceIF{
public abstract double getVIPRate(String category);
}
class Clothe implements GeneralPointServiceIF{
@Override
public double getPointRate(String category) {
return 0.05;
}
}
class Book implements GeneralPointServiceIF, VIPPointServiceIF{
@Override
public double getPointRate(String category) {
return 0.12;
}
@Override
public double getVIPRate(String category) {
return 0.25;
}
}
5. DIP : 하위 모듈의 변경이 상위 모듈의 변경을 요구하는 의존성을 끊어내야 한다.
- 개발을 하다보면 보안상의 이슈가 발생한 라이브러리를 다른 라이브러리로 교체해야할 때가 있다고 한다.
- 또는 외주업체에 맡겨 서비스를 추가하는 경우도 있는데, 업체를 변경할 때에도 기존 서비스를 변경해야할 때가 있다고 한다.
- 만약 호텔에 Deposit 금액으로 20만원을 넣어두고, 호텔의 서비스를 사용할 때마다 deposit 금액을 차감시킨다고 하자.
[1] 호텔 비용 정산 클래스 - HotelPaymentService
[2] 고객 deposit에 연결하는 클래스 - HotelDepositAdapter
class HotelPaymentService{
String pay(Customer customer, ServiceRequest serviceRequest){
HotelDepositAdapter hotelDepositAdapter = new HotelDepositAdapter();
int payResult = hotelDepositAdapter.useService(customer, serviceRequest);
if (payResult == -1){
return "Fail";
}
return "Success : left deposit - " + payResult + "(원)";
}
}
class HotelDepositAdapter{
int useService(Customer customer, ServiceRequest serviceRequest){
int possibleDeposit = customer.getDeposit() - serviceRequest.getServicePrice();
if (possibleDeposit < 0) {
return -1;
}
return possibleDeposit;
}
}
- 이 상태에서, 만약 호텔측에서 Deposit 금액 외에 카드를 맡기면 추가적으로 결제가 가능하게 해달라는 요청을 한다면
[1] 호텔 비용 정산 클래스 - HotelPaymentService
[2] 고객 deposit에 연결하는 클래스 - HotelDepositAdapter
[3] 고객 card에 연결하는 클래스 - HotelCardAdapter
- 위와 같이 card를 연결하는 어뎁터 클래스를 하나 더 추가하게 될 수 있다.
- 이런 경우 HotelPaymentService이 이미 HotelDepositAdapter에 의존적이라 코드를 전반적으로 수정해주어야 한다.
- 앞으로 비용 정산 방식이 추가적으로 바뀌게 되면 이 방식은 비효율적이다
class HotelPaymentService{
String pay(Customer customer, ServiceRequest serviceRequest, PaymentType type){
if (type.getType() < 0 || type.getType() >= 2) {
return "PaymentRequest Error : write correct PaymentType";
}
int payResult = -1;
if (type.getType() == 0){
HotelDepositAdapter hotelDepositAdapter = new HotelDepositAdapter();
payResult = hotelDepositAdapter.useService(customer, serviceRequest);
} else if (type.getType() == 1){
HotelCardAdapter hotelCardAdapter = new HotelCardAdapter();
payResult = hotelCardAdapter.useService(customer, serviceRequest);
}
if (payResult == -1){
return "Fail";
}
return "Success : left deposit - " + payResult + "(원)";
}
}
- 이런 경우 아래와 같이 어뎁터 자체가 인터페이스를 구현하도록 하고,
HotelPaymentService가 인터페이스를 바라보도록 하면 해결된다.
enum HotelPaymentType{
DEPOSIT, CARD
}
class HotelPaymentService{
private final HotelDepositAdapter hotelDepositAdapter = new HotelDepositAdapter();
private final HotelCardAdapter hotelCardAdapter = new HotelCardAdapter();
String pay(Customer customer, ServiceRequest serviceRequest){
HotelPaymentIF hotelPaymentIF;
if (serviceRequest.getPaymentType() == HotelPaymentType.DEPOSIT){
hotelPaymentIF = hotelDepositAdapter;
} else {
hotelPaymentIF = hotelCardAdapter;
}
int payResult = hotelPaymentIF.useService(customer, serviceRequest);
if (payResult == -1){
return "Fail";
}
return "Success : left deposit - " + payResult + "(원)";
}
}
interface HotelPaymentIF{
int useService(Customer customer, ServiceRequest serviceRequest);
}
class HotelDepositAdapter implements HotelPaymentIF{
int useService(Customer customer, ServiceRequest serviceRequest){
int possibleDeposit = customer.getDeposit() - serviceRequest.getServicePrice();
if (possibleDeposit < 0) {
return -1;
}
return possibleDeposit;
}
}
class HotelCardAdapter implements HotelPaymentIF{
int useService(Customer customer, ServiceRequest serviceRequest){
if (CardService.useCard(customer, serviceRequest) < 0) {
return -1;
}
return serviceRequest.getServicePrice();
}
}
[ 출처 및 참조 ]
부트캠프 강의를 들은 후 정리한 내용입니다.
[스프링 입문을 위한 자바 객체지향의 원리와 이해]
SOLID https://bottom-to-top.tistory.com/27
위키백과 https://ko.wikipedia.org/wiki/SOLID_(%EA%B0%9D%EC%B2%B4_%EC%A7%80%ED%96%A5_%EC%84%A4%EA%B3%84)
'Language > Java' 카테고리의 다른 글
JAVA 유용한 타입 - Enum (0) | 2022.09.15 |
---|---|
JAVA 라이브러리 - Optional<T> 클래스 (0) | 2022.09.15 |
JAVA 라이브러리 - 컬렉션 (0) | 2022.08.11 |
JAVA 라이브러리 - 제네릭 클래스 (0) | 2022.08.10 |
JAVA 라이브러리 - 날짜, 시간 관련 클래스(java.util/java.time..) (0) | 2022.08.10 |