객체지향 설계 원칙 (SOLID)란?
객체지향 설계 원칙 (SOLID)란? — #객체지향 #객체지향설계 #객체지향원칙 #객체지향SOLID #개발자의도구들 AI스쿨 msa기반 java 백엔...
#객체지향 #객체지향설계 #객체지향원칙 #객체지향SOLID #개발자의도구들
AI스쿨 msa기반 java 백엔드 코스 중에 공부한 내용을 작성하였습니다
\* 현재 소켓통신 프로젝트를 진행중입니다. 전체적인 목차를 보시려면 여기에서 참고하세요
코딩의 벽: 객체지향 - 아키텍쳐설계
개인적으로 생각하는 코딩의 벽이 몇 가지가 있는데, 그 중에 하나가 객체지향과 아키텍쳐 설계입니다. 개념적으로 보면 크게 어렵지는 않지만, 전혀 와닿지가 않아서 공부하는데 어려움이 있었습니다.
이는 공부법을 잘못 적용해서라고 생각되는데, 그 이유는 이해하고 암기하는 것에 익숙해져 있어서, 먼저 글로 "이해"만 하려고 했기 때문에 발생한 문제였습니다.
아키텍쳐의 경우에는 글로서만 이해하려다 보면, 절대 와닿지가 않기 때문에, 반드시 실전에서 적용해보면서 개념이해를 해야한다고 생각됩니다. 개념을 보고 바로 코드에 적용하여 설계를 직접 해봐야지 아키텍쳐를 만든 의미에 대해 깊게 이해하고 제대로된 공부가 가능하다고 생각됩니다.
이 설계 부분은 정말로 중요한데, 직접 현업에서 일을 해보지 않는 학생들에게는 전혀 와닿지 않기에 괴리감이 더욱 커지는 것 같습니다. 설계없이 작성한 코드는 빠르게 작성되는 것처럼 보여도, 유지, 보수에 큰 시간이 들어갑니다. 기능 하나만 확장하려고 해도 너무나 많은 코드를 고쳐야하고, 또 어디에서 무엇을 담당하는지 까먹어서 내가 만든 코드를 찾는데에만 계속 시간을 허비하게 됩니다.
아키텍쳐야 말로 프로그래머의 진정한 능력이라고 생각되며, 앞으로는 좋은 설계를 위해 많은 시간을 투자하여 최대한 많은 경험을 쌓아보고 싶습니다. 결국 빠르고 정교한 설계는 오랜 경험을 통해 나오는 것일 테닌깐요.
오늘은 이런 아키텍처들의 뿌리에 깔려있는 설계 원칙에 대해 이야기해보고자 합니다.
S.O.L.I.D
오늘은 객체지향 프로그래밍에서 가장 많이 활용되는 SOLID 원칙에 대해 소개하고자 합니다. 이 원칙들을 공부하기전과 후에 코드를 바라보는 시각이 많이 달라졌습니다. 본격적으로 설계를 시작한다는 느낌도 들었습니다.
여러분들도 SOLID를 공부하시고나면, 이전까지 본인이 잤던 객체지향 코드에 문제가 많았음을 발견하시리라 생각합니다. 각설하고 바로 시작할게요!
S: The Single Responsibilty Principle
단일 책임원칙(일명 SRP)는 핵심 설계 원칙입니다. SRP만 제대로 갖춰줘도 clean coding의 절반 이상을 잡고 들어가는 것이라고 생각됩니다.
단일책임 원칙은 *하나의 클래스는 반드시 하나의 행위를 해야한다. 그렇기에 **하나의 이유에서만 변경이 되어야 한다* 입니다.
Exapmle
이해를 돕기 위한 예시 코드입니다.
| public class Invoice { private Book book; private int qquantity; private double discountRate; private double taxRate; private double total; public Invoice(Book book, int quantity, double discountRate, double taxRate) { this.book = book; this.quantity = quantity; this.discountRate = discountRate; this.taxRate = taxRate; this.total = this.calculateTotal(); } public double calculateTotal() { } public void printInvoice() { } public void saveToFile(String filename) { }} |
|---|
설명에 들어가기에 앞서 먼저 해당 코드에서 하는 역할을 구분하여 봅시다.
- 총 구매 가격 계산(calcluateTotal)
- 송장 출력
- 파일로 저장
이렇게 역할만 나눠도 SRP를 위배했는지 안했는지 바로 알 수 있습니다. 해당 클래스는 하나보다 많은 이유에서 클래스가 변경될 수 있습니다. 출력에 대한 로직이나,파일 저장과 같은 로직에서 말이죠.
그렇기 때문에 해당 로직을 책임지는 클래스를 따로 만들어 관리해줘야합니다.
| public class Invoice { private Book book; private int qquantity; private double discountRate; private double taxRate; private double total; public Invoice(Book book, int quantity, double discountRate, double taxRate) { this.book = book; this.quantity = quantity; this.discountRate = discountRate; this.taxRate = taxRate; this.total = this.calculateTotal(); } public double calculateTotal() { } } |
|---|
| pulbic class InvoicePrinter { private Invocie invoice; public InvoicePrinter(Invoice invoice) { this.invoice = invoice; } public void print() { \] } |
|---|
| public class InvoicePersistence { Invoice invoice; public InvoicePersistence(Invoice invoice) { this.invoice = invoice; } public void saveToFile(String fileName) { // Create a file with given name and wirtes the invoice. }} |
|---|
이렇게 세부분으로 나눠서 관리할 수 있겠습니다. 매우 심플하면서도 중요한 SRP원칙, 특히나 유지보수에서 여러분들의 시간을 매우 아껴줄 것 입니다.
O: Open-Closed Principle
개방 폐쇄 원칙이라고 불리는 OCP는 *클래스는 반드시 **확장에는 열려이썽야하고. 수정에는 닫혀있어야한다*를 명시하고 있습니다.
여기서 수정은 말그대로 현재 코드를 수정하는 것을 의미하고, 확장은 새로운 기능을 추가함을 의미합니다.
이는 주로 추상화를 통해서 일어나며, 인터페이스로 구현됩니다. 예시를 보면서 이해를 해보겠습니다.
Exapmle
위에 언급한 예제에서 파일을 저장하는 클래스를 따로 만들어 관리했습니다. 하지만, 파일에 저장하지 않고 데이터 베이스에 저장을 하려면 어떻게 해야할까요?
| public class InvoicePersistence { Invoice invoice; public InvoicePersistence(Invoice invoice) { thils.invoice = invoice; } public void saveToFile(String fileName) { } public void saveToDataBase() { }} |
|---|
이렇게 코드를 작성해버리면 생기는 문제가 하나 있습니다. 🧠어떤 문제가 생길지 스스로 생각해보세요.
문제는 이렇습니다. 매번 새로운 저장방식이 생겨버린다면, 기존 코드(클래스)를 계속해서 수정해야합니다. 이는 OCP원칙의 수정에는 닫혀있어야한다를 위배하게 됩니다.
OCP원칙을 적용하기 위해 먼저 추상화를 생각해 봅시다. InvoicePersistence를 나타내는 기능을 꼽아보자면, 바로 저장이라고 할 수 있습니다. DB든, 파일로든 어쨌거나 저장을 하는 행위를 하는 클래스입니다.
그렇기에 InvoicePersistence를 인터페이스로 만들어 새로운 저장 방식이 생긴다면 확장해서 사용하면 OCP원칙을 지킬 수 있게됩니다.
| interface InvoicePersistence { public void save(Invoice invoice) { }} |
|---|
인터페이스를 만들고 저장방식이 변경되면 확장하여 사용합니다.
in DB
| public class DatabasePersistence implements InvoicePersistence { @Override public void save(Invoice invoice) { // save to DB }} |
|---|
In FILE
| public class FilePersistence implements InvoicePersistence { @Override public void save(Invoice invoice) { // save to File }} |
|---|
OCP원칙을 쉽게 적용하기 위해서 추상화가 필요함을 잊지 마세요!
L : Liskov Substituon Principle
다음은 리스코프 치환 원칙이라고 불리는 LSP입니다. 이 원칙은 *자식 클래스는** 반드시 부모 클래스를 대체할 수 있어야한다**. *를 말하고 있습니다.
이를 쉽게 설명하자면, 부모 클래스에서 만들어준 메서드나, 결과값, 심지어 예외처리도 자식클래스에서 달라지지 않아야 합니다.
가장 이해하기 쉬운 예시로 (직)사각형과, 정사각형 예시가 있습니다.
| class Rectangle { protected int width, height; public Rectangle(int width, int height) { this.width = width; this.height = height; } public void setWidth(int width) { this.width = width; } public int getHeight() { return height; } public void setHeight(int height) { this.height = height; } public int getArea() { return width \* height; }} |
|---|
우선 기본적은 사각형 클래스입니다. 가로,세로 길이를 셋팅하고 넓이 값을 얻을 수 있습니다.
| class Square extends Rectangle { public Square(int size) { width = height = size; } @Overide public void setWidth(int width) { super.setWidth(width); super.setHeight(widht); } @Overide public void setHeight(int height) { super.setHeight(height); super.setWidth(height); } } |
|---|
여기서 정사각형이 등장합니다. 정사각현은 가로, 세로의 길이가 같기 때문에 setWitdh를 구현할 때 한번에 처리하도록 변경하였습니다. 이는 Square의 특징 위배를 방지하기 위해 만든 코드입니다.
작성한 코드를 특정 클라이언트가 가로, 세로 길이를 다르게 설정하여 Square의 특징을 위배할 수 있기 때문입니다. 이를 사전에 방지하기 위해 @Override로 기존의 코드와 다르게 구현한 것이죠.
| Class Test { static void getAreaTest(Rectangle r) { int widrh = r.getWidth(); r.setHeight(10); System.out.println("Expected area of " + (width \* 10) + ", got " + r.getArea()); } public static void main(String\[\] args) { Rectangle rc = new Rectangle(2, 3); // width = 2 height = 10 getAreaTest(rc); // width = 5 height = 10 width = 10 Rectangle sq = new Square(); sq.setWidth(5); getAreaTest(sq); }} |
|---|
하지만 문제가 발생합니다. 해당 테스트 코드를 한번 살펴볼게요. Rectangle로 구현된 rc는 우리가 예산한대로 잘 동작을 합니다.
하지만, Square의 경우에는 다릅니다. 처음에 5x5로 셋팅을하고 Test에서 다시 10x10으로 셋칭을 했기 때문에 기대값은 50, 실제값은 100이 출력됩니다.
이는 자식 클래스인 Square에서 부모인 Rectangle을 대체할 수 없음을 시사하는 예제입니다.
I: Interface Segregation Priniple
인터페이스 분리 원칙이라 불리우는 ISP는* **범용적인 하나의 인터페이스 보다는 **클라이언트가 실제 사용하는 세부적인 여러개의 인터페이스가 낫다*고 말하고 있습니다.
바로 예시코드를 보겠습니다.
Exapmle
| public interface ParkingLot { void parkCar(); // Decrease empty spot count by 1 void unparkCar(); // Increase empty spots by 1 void getCapacity(); // Returns car capacity double calculateFee(Car car); // returns the price based on number of hours. void doPayment(Car car);}class Car {} |
|---|
현재 ParkingLot 인터페이스가 구현되어 있습니다. 꽤나 많은 기능이 있습니다. 주차, 출차, 용량반환, 요금 계산, 지불 수행 등
실제세계와 대비해서 보면, 이 인터페이스에는 문제가 있습니다. 🧠어떤 문제가 발생할 수 있을까요?
아래 보시기 전에 반드시 한번씩은 고민을 해주세요!
| public class FreeParking implements ParkingLot { @Override public void ParkCar() { } @Override public void unparkCar() { } @Override public void getCapacity() { } @Override public double calculateFree(Car car) { return 0; } @Override public void doPaymend(Car car) { throw new Exception("Parking lot is free"); }} |
|---|
문제는 무료 주차장에서 발생합니다. 인터페이스는 상속받은 클래스에서 반드시 해야할 행동에 대해 명시하고 있습니다.
하지만, 무료 주차장은 calculateFee 메서드가 사실상 필요없지만, 어쩔 수 없이 상속 받아서 구현을 하게 됩니다.
이는 ISP원칙에 위배됩니다. 실제로 클래스가 사용하지도 않을 기능을 어쩔 수 없이 상속 받아야 하기 때문이지요.
이렇게 구현하는 것보다는 차라리 ParkingLot인터페이스를 PaidParkingLot과 FreeParkingLot으로 나누는 것을 권유하고 있습니다.
D: Dependency Inversion Principle
의존성 역전원칙 DIP는 *클래스들은 반드시 직접적인 클래스를 의존하는게 아닌 **인터페이스나 추상 클래스들에 의존해야 함*을 말하고 있습니다.
객체들이 서로 정보를 주고 받으면 의존 관계가 형성됩니다. 이때 보통 추상성이 낮은 클래스가 아닌 추상성이 높은 클래스와 통신하게끔 하는것이 DIP의 핵심입니다.
Exapmle
| public class EmailSender { public void send(String message) { System.out.println("Sending email: " + message); }}publoc class MessageService { private EmailSender sender = new EmailSender(); public void sendMessage(String message) { sender.send(message); } } |
|---|
지금은 MessageService가 EmailSender에 직접적으로 의존하고 있습니다. 이런 방식은 MessageService에서 메세지 전달 방식이 변경될때마다, MessageService를 매번 수정해줘야 합니다.
| interface MessageSender { void send(String message);} |
|---|
| public class EmailSender implements MessageSender { @Override public void send(String message) { System.out.println("Sending email: " + message); }}public class SMSSender implements MessageSender { @Override public void send(String message) { System.out.println("Sending SMS: " + message); }} |
|---|
인터페이스를 통해 MessageSender를 추상화하고, 방법에따라 클래스를 구분하도록 만듭니다.
| public class MessageService { private MessageSender sender; public MessageService(MessageSender sender) { this.sender = sender; } public void sendMessage(String message) { sender.send(message); } } |
|---|
MessageService내에서는 인터페이스인 MessageSender를 의존하게 함으로 DIP 원칙을 지키게 구현할 수 있습니다.
| public static void main(String\[\] args) { MessageSender emailSender = new EmailSender(); MessageService emailService = new MessageService(emailSender); emailService.sendMessage("Hello via Email!"); MessageSender smsSender = new SMSSender(); MessageService smsService = new MessageService(smsSender); smsService.sendMessage("Hello via SMS!"); } |
|---|
MessageService를 사용할 때, 원하는 sender를 생성하여 보내어주면 되므로, send기능이 변경되어도 MessageService 코드를 수정할 필요가 없어집니다.
이렇게 보면 DIP는 OCP와도 매우 밀접한 관련이 있음을 알 수 있습니다.
