소프트웨어 공학
수업을 저번 학기에 이수했지만, 여러 슬픈 사유로 강의의 품질이 썩 좋지 않았다. 내용은 중요해 보였지만, 학점만 남고 머리속 지식은 잃었다.
따라서 스프링을 통한 백엔드 개발을 공부중이기에 SOLID 원칙에 대해 다시 알아보기로 했다.
C 언어를 공부 할 때 함수의 프로토 타입을 따로 명시해둔 경험이 있을것이다. 사유는 다양하지만, 이와 조금 유사하게 객체지향에서도 인터페이스
를 통해 구현부와 분리 할 수 있다.
인터페이스에는 메서드의 프로토타입만을 정의해두고, 이를 구현하는 클래스를 별도로 정의한다. 이 원리가 다형성을 활용하는 객체 지향 설계에서 기초가 된다.
객체지향 설계 원리에서, 대표적으로 다섯가지 원리가 있다.
- 단일 책임의 원리 (Single Responsibility Principle)
- 개방 폐쇄의 원리(Open Close Principle)
- 리스코프 교체의 원리(Liskov Substitution Principle)
- 인터페이스 분리의 원리(Interface Segregation Principle)
- 의존관계 역전의 원리(Dependency Inversion Principle)
이 다섯가지 원리의 맨 앞글자를 따서, SOLID라고 부른다. 전형적인 공대식 작명이다.
하나 하나 알아보자.
홀서빙 알바생에게 갑자기 요리까지 시키면 아마 화낼것이다.
단일 책임의 원리는 클래스와 역할의 책임을 단일화해, 클래스를 변경할 이유를 하나로 제한 으로 요약 할 수 있다.
클래스의 역할과 책임을 단일화? 이게 무슨 말일까?
예시를 생각해보자. 당신은 친구를 새로 사귈 수 있는 만남 서비스를 개발하고자 한다.
그런데 만약 회원가입/로그인부터 시작해서, 친구 매칭, 친구와 채팅 등등의 모든 기능을 단 하나의 클래스 안에서 수십개의 메서드를 통해 구현했다고 하자.
이 과정에서 전역 변수를 돌려쓰는 행위 등이 일어난다면, "로그인" 기능에 문제가 생겨서 버그를 고치기 위해서는 클래스속 수많은 메서드를 뒤져서, 연관된 메서드를 모두 조금씩 수정해야 하는 일이 생길수도 있다.
회원가입/로그인, 친구 매칭, 친구와 채팅과 같은 각각의 기능은 별도의 클래스로 분리하여 각 클래스가 해당 기능에 대해서만 책임을 질 수 있도록 해야한다. 이렇게 함으로써 한 클래스가 변경되더라도 다른 기능과 클래스에 영향을 미치지 않아 유지보수가 편리해지며, 결합도를 낮출 수 있다.
결론적으로 결합도를 낮추고 응집도를 올리는 핵심 원리다.
스프링을 공부했다면 MVC 패턴을 생각해보자.
개방 폐쇄의 원리는 클래스는 확장에는 열려있고(개방) 수정에는 닫혀있어야 한다(폐쇄) 로 요약 할 수 있다.
코드로 예시를 살펴보자.
public class Animal {
public String Dog(){
return "멍멍";
}
public String Cat(){
return "야옹";
}
}
위와 같은 클래스가 있다고 해보자. 위 클래스를 잘 사용하다가, 내 프로그램에서 비둘기의 울음소리를 추가할 필요가 생겼다. 그렇다면 다음과 같이 코드를 작성 할 수 있다.
public class Animal {
public String Dog(){
return "멍멍";
}
public String Cat(){
return "야옹";
}
public String Pigeon(){
return "구구";
}
}
그러나, 이는 프로그램의 기능 추가를 위해 기존 클래스를 수정한것이다. 따라서 OCP를 위배하는 사례다.
그러면 어떻게 설계해야 할까?
public abstract class Animal{
abstract String sound();
}
public class Dog extends Animal{
String sound(){
return "멍멍";
}
}
public class Cat extends Animal{
String sound(){
return "야옹";
}
}
public class Pigeon extends Animal{
String sound(){
return "구구";
}
}
위와 같이, 기존 클래스를 변경 할 필요 없이 상속을 통해 기능을 확장 할 수 있다. 부모가 되는 Animal은 추상 클래스로 두었지만, Interface로 두어도 같은 효과가 날 것이다.
먼저 리스코프는 사람 이름이기에 리스코프에 뜻을 두지 않아도 된다.
요약하면 자식 클래스를 부모 타입으로 사용할때도, 기능이 정상 동작해야 한다 이다. 예시 코드를 보자.
class Rectangle {
protected int width;
protected int height;
public void setWidth(int width) {
this.width = width;
}
public void setHeight(int height) {
this.height = height;
}
public int calculateArea() {
return width * height;
}
}
class Square extends Rectangle {
@Override
public void setWidth(int width) {
this.width = width;
this.height = width;
}
@Override
public void setHeight(int height) {
this.width = height;
this.height = height;
}
}
위 클래스는 LSP를 만족할지 잠시 생각해보자.
답은 'X'다.
정사각형을 표현하는 Square 클래스는 정사각형의 특징때문에 setWidth 또는 setHeight 과정에서 다른 필드도 건들게 된다.
위 클래스를 기반으로 직사각형을 만드는 메서드를 정의해보자.
public void createRectangle(Rectangle rectangle, int width, int height) {
rectangle.setWidth(width);
rectangle.setHeight(height);
}
이 때, 파라미터로 Square 클래스를 넘기게 되면 문제가 발생 할 수 있다.
Rectangle rectangle = new Square();
createRectangle(rectangle, 4, 5); // 실제로는 정사각형이 만들어짐
부모 클래스로 대체하더라도 기능이 정상 작동하여야 하는데, 기능이 손상되어 버린다.
이는 다형성을 이용하는데 있어 큰 제약 사항과, 예상치 못한 결과를 초래하게 된다.
ISP는 안 쓰는 인터페이스는 강제로 구현하지 마라 정도로 요약 할 수 있다.
예시를 보자.
// 인터페이스를 하나로 합친 경우
interface Worker {
void work();
void eat();
void sleep();
}
class Engineer implements Worker {
@Override
public void work() {
// 일하는 코드
}
@Override
public void eat() {
// 밥먹는 코드
}
@Override
public void sleep() {
// 잠자는 코드
}
}
class Robot implements Worker {
@Override
public void work() {
// 일하는 코드
}
@Override
public void eat() {
// NOT USE
}
@Override
public void sleep() {
// NOT USE
}
}
Worker라는 인터페이스를 정의해두고, 이를 각각 엔지니어와 로봇 클래스가 구현한다.
엔지니어는 사람이기 때문에, work, eat, sleep 메서드를 모두 구현해서 사용한다.
그러나 Robot의 경우엔 eat, sleep 메서드가 필요 없다. 따라서 속이 텅 비어있는 메서드로 두게 된다. 이 경우 ISP
를 위반한 것이다.
이를 해결하기 위해서는, 더 작은 단위로 인터페이스를 쪼갠다.
interface Workable {
void work();
}
interface Feedable {
void eat();
}
interface Sleepable {
void sleep();
}
class Robot implements Workable {
@Override
public void work() {
}
}
class Engineer implements Workable, Feedable, Sleepable {
@Override
public void work() {
}
@Override
public void eat() {
}
@Override
public void sleep() {
}
}
인터페이스는 다중 상속이 가능하다. 이를 잘 이용해서, ISP를 지킬 수 있다.
DIP는 인터페이스에 의존하고, 구현체에 의존하지 마라 정도로 요약 할 수 있다.
스프링을 공부한 사람은 의존성 주입(DI)를 생각하면 쉽게 이해 할 수 있다.
자바 코드로 예시를 살펴보자.
class EmailService {
public void sendEmail(String message) {
// 이메일 보내는 로직
}
}
class NotificationService {
private EmailService emailService;
public NotificationService() {
this.emailService = new EmailService();
}
public void sendNotification(String message) {
emailService.sendEmail(message);
}
}
위 코드에서 NotificationService
클래스는 EmailService
에 직접적으로 의존하고 있다.
만약 추후에 알림 서비스를, Email이 아닌 다른 방법을 통해 제공하도록 하고싶다면
NotificationService
클래스를 수정해야 한다.
interface MessageSender {
void sendMessage(String message);
}
class EmailService implements MessageSender {
@Override
public void sendMessage(String message) {
// 이메일 보내는 로직
}
}
class NotificationService {
private MessageSender messageSender;
public NotificationService(MessageSender messageSender) {
this.messageSender = messageSender;
}
public void sendNotification(String message) {
messageSender.sendMessage(message);
}
}
하지만 다음과 같이 인터페이스를 의존하게 되면, Email이 아닌 다른 서비스로 알림을 제공하고자 하더라도 NotificationService
클래스를 단 한줄도 바꾸지 않아도 된다. 그저 NotificationService
의 생성자로 EmailService
가 아닌 다른 구현 클래스를 넘겨주면 된다.
주의할 점은 의존 자체가 나쁘다는 것이 아니고, 유연한 의존을 해야한다는 것이다.
여기까지가 SOLID
원칙이였다.
굉장히 이론적인 내용이지만, 머리속에 숙지해두고 객체지향 프로그래밍을 하다보면 꼭 한번쯤 지식이 스쳐 지나가는 순간이 올것이다.
이 외에도 번외로 난 공통 폐쇄의 원리(CCP)
도 지키는게 좋다고 생각하는데, 이는
같이 변하는 클래스는 같은 패키지에 있어야 한다는 원리다.
만약 회원과 관련된 특성이 바뀌어야 한다면, 회원 패키지 내부 클래스만이 바뀌어야 한다는 원리다.
결국 패키지를 잘 만들어서 묶어놓자다.
위 원칙들을 잘 지켜가며 설계한다면, 유지보수 관점에서 더 좋은 결과를 만들어 낼 수 있을 것이다.