객체 지향 설계의 5가지 원칙

gorapaduckoo·2023년 6월 13일
0

스프링 기본편

목록 보기
2/10
post-thumbnail

인프런 김영한님의 스프링 핵심 원리-기본편 강의 내용을 바탕으로 작성한 글입니다.


이전 포스팅에서 객체 지향 설계를 실현하기 위해서는 다형성을 잘 지켜야 한다는 이야기를 했다. 하지만 객체 지향 설계에 대해 제대로 이해하려면, 다형성 외에도 앞글자를 따서 SOLID라 불리는 객체 지향 설계의 5가지 원칙에 대해 이해해야 한다.

SRP: 단일 책임 원칙 (Single Responsibility Principle)

하나의 클래스는 하나의 책임만 가져야 한다. 하나의 책임이라는 말은 모호하기 때문에, 이 클래스가 책임이 하나인지 여러개인지 헷갈릴 수 있다.

이런 경우, 클래스를 변경했을 때의 파급 효과를 보면 SRP를 잘 지켰는지 확인할 수 있다. 예를 들어, 재료 기반 레시피 검색 서비스에서 재료 클래스를 변경했을 뿐인데 회원 클래스까지 변경해야 한다면 이는 잘못된 설계이다.

class Employee {
	static int MIN_PAY= 9620;
	int overtimeHours; // 초과근무 시간
    
    // 초과근무 시간을 기반으로 초과근무 수당을 계산하는 함수
    int calculatePay() {
    	return overtimeHours * MIN_PAY;
    }
    
    // 초과근무 시간을 보고하는 함수
    int reportHours() {
    	return overtimeHours;
    }

위와 같은 코드를 생각해보자. 재무팀에서는 calculatePay()를 통해 초과근무 수당을 보고받고, 인사팀에서는 reportHours()를 통해 초과근무 시간을 보고받는다. 그러던 와중에, 주 52시간 근무를 철저히 지키기 위해 초과근무를 시간 단위가 아니라 분 단위로 체크하게 되었다고 가정하자.

인사팀에서는 Employee 클래스의 overtimeHoursovertimeMinutes로 변경했다. 그러면 재무팀에서는 또 난리가 날 것이다. 3시간 20분을 초과근무한 사람의 수당이 3*9620원이 아니라, 200*9620원으로 계산되기 때문이다. 결론적으로 인사팀의 요청 한번에 재무팀도 꼼짝없이 로직을 수정해야 하는 상황이 되었다.

특정 클라이언트(인사팀)에 맞게 클래스를 수정하면 다른 클라이언트(재무팀)도 영향을 받는 상황이다. 이는 Employee 클래스에 너무 많은 책임이 있기 때문에 발생한다. 인사팀과 재무팀은 둘 다 Employee 클래스에 의존하지만 서로 다른 함수를 사용하고 있다. 이 경우 인사팀이 사용하는 부분과 재무팀이 사용하는 부분을 별도의 인터페이스로 분리하여 SRP를 지킬 수 있다. SRP를 지키면, 변경 사항이 발생했을 때 수정해야 할 클래스가 명확해진다.


OCP: 개방-폐쇄 원칙 (Open-Closed Principle)

소프트웨어 요소는 확장에는 열려 있지만 변경에는 닫혀 있어야 한다. 쉽게 말해, 기존의 코드를 변경하지 않으면서 기능을 추가할 수 있어야 한다는 소리이다. 이게 무슨 소리일까? 기능을 추가하려면 당연히 코드를 변경해야 하는 게 아닌가? 싶을 수 있다. 하지만 다형성을 이용하면 부품 갈아끼우듯이 기능을 변경할 수 있고, 새 부품을 추가하는 것처럼 기능을 확장할 수 있다.

class Pokemon {
	String name;
    
    String growl() {
    	if(name.equals("pikachu") {
        	return "pika!";
        else if (name.equals("lizard") {
        	return "liza!";
        }
    }

위와 같은 코드가 있다고 가정해보자. 새로운 포켓몬이 발견될 때마다 growl()에 else-if문을 추가해주어야 하는 매우 번거로운 코드이다. 기능이 확장될때마다 기존의 코드가 변경되므로 OCP 위반이다.

interface Pokemon {
    String growl();
}

class Pikachu implements Pokemon {
	String growl() {
    	return "pika!";
    }
}

class Lizard implements Pokemon {
	String growl() {
    	return "liza!";
    }
}

위와 같이 추상화를 통해 역할과 구현을 분리하면, 새로운 포켓몬이 추가되더라도 기존의 코드를 수정할 필요 없이 새로운 클래스를 추가해주기만 하면 된다.

그렇다면 다형성만으로 정말 OCP를 지킬 수 있을까?

public class MemberService {
	
//	private MemberRepository memberRepository = new MemoryMemberRepository(); // 기존 코드
		private MemberRepository memberRepository = new JdbcMemberRepository(); // 변경된 코드
}

그렇지 않다. 위의 코드는 분명 다형성을 이용해 역할 MemberRepository와 구현 MemoryMemberRepository, JdbcMemberRepository를 잘 분리했다. 하지만 MemberRepository의 구현체가 변경되자 클라이언트인 MemberService의 코드도 함께 수정되었다.

문제는 바로 MemberService가 직접 구현체를 선택하고 있다는 점이다. 그렇다고 new~~ 부분을 지우면 인터페이스만으로는 아무것도 할 수 없다. 따라서 new를 통해 구현체를 생성하고 연관관계를 맺어주는 별도의 조립 담당이 필요하다. 이처럼 다형성만으로는 OCP를 지킬 수 없기 때문에, 스프링에서는 추가적으로 DI와 IoC를 통해 OCP를 지킨다.


LSP: 리스코프 치환 원칙 (Liskov Substitution Principle)

프로그램의 객체는 프로그램의 정확성을 깨뜨리지 않으면서 하위 타입의 인스턴스로 바꿀 수 있어야 한다. 쉽게 말하면 구현체는 인터페이스의 규약을 지켜야 한다는 이야기이다. LSP를 지켜야 클라이언트는 구현체를 믿고 사용할 수 있다.

마우스를 예시로 들어보자. 모든 마우스는 왼쪽 버튼을 누르면 클릭이 되고, 오른쪽 버튼을 누르면 컨텍스트 메뉴가 뜬다. 이게 바로 마우스 인터페이스의 규약이다.

그런데 어느 날, 왼쪽 버튼을 누르면 컨텍스트 메뉴가 뜨고 오른쪽 버튼을 눌러야 클릭이 되는 마우스가 출시됐다고 해보자. 이 마우스는 사용자들에게 혼란을 불러올 것이고, 사용자들은 더 이상 마우스를 믿고 사용할 수 없을 것이다. 이게 바로 LSP 위반이다.


ISP: 인터페이스 분리 원칙 (Interface Segregation Principle)

특정 클라이언트를 위한 인터페이스 여러 개가 범용 인터페이스보다 낫다. 인터페이스는 클라이언트가 사용하는 기능만 제공해야 한다는 의미이다.

interface Player {
	void playAudio();
    void playVideo();
}

class MediaPlayer implements Player {
	@Override
    void playAudio() {
    	...
    }
    
    @Override
    void playVideo() {
    	...
    }
 }
 
 class MP3Player implements Player {
	@Override
    void playAudio() {
    	...
    }
    
    @Override
    void playVideo() {
    	System.out.println("지원하지 않는 기능입니다.");
    	return;
    }
 }

위와 같은 코드가 있다고 가정해보자. MP3Player에 필요한 기능은 playAudio() 뿐이지만 playVideo() 까지 함께 구현해주어야 한다는 문제가 생긴다. 만약 playVideo() 메서드에 매개변수가 추가되거나, 반환타입이 변경된다면 MP3Player 함수는 사용하지도 않는 메서드 때문에 코드를 수정해야 하는 것이다.

이처럼 ISP를 지키지 않는 경우, 기능 변경의 여파가 커진다는 것을 알 수 있다. 그렇다면 Player 인터페이스를 AudioPlayerVideoPlayer로 분리하는건 어떨까?

interface AudioPlayer {
    void playVideo();
}
interface VideoPlayer {
	void playVideo();
}

class MediaPlayer implements AudioPlayer, VideoPlayer {
	@Override
    void playAudio() {
    	...
    }
    
    @Override
    void playVideo() {
    	...
    }
 }
 
 class MP3Player implements AudioPlayer {
	@Override
    void playAudio() {
    	...
    }
 }

이제는 playVideo() 함수의 선언부를 변경해도 MP3Player 클래스에는 영향이 가지 않는다.
이처럼 ISP를 잘 지키면 인터페이스가 명확해지고, 대체 가능성이 높아진다. 그리고 특정 인터페이스를 변경해도 다른 인터페이스에는 영향을 주지 않는다는 장점이 있다.


DIP: 의존관계 역전 원칙(Dependency Inversion Principle)

프로그래머는 추상화에 의존해야지, 구체화에 의존해서는 안된다. 쉽게 말해, 구현 클래스에 의존하지 말고 인터페이스에 의존해야 한다는 뜻이다.

예를 들어, 운전자는 자동차에 대해 알고 있으면(=운전면허만 갖고 있으면) K3든 제네시스든 운전할 수 있다. 연비는 얼마인지, 최고속도는 얼마인지 등에 대해서는 몰라도 된다. 클라이언트(운전자)가 구현체(K3)가 아닌, 인터페이스(자동차)에 의존하기 때문에 K3를 제네시스로 바꿔도 운전할 수 있는 것처럼, 클라이언트는 인터페이스에 의존해야 구현체를 유연하게 변경할 수 있다.

public class MemberService {
	
//	private MemberRepository memberRepository = new MemoryMemberRepository(); // 기존 코드
		private MemberRepository memberRepository = new JdbcMemberRepository(); // 변경된 코드
}

위의 코드에서, MemberServiceMemberRepository라는 인터페이스에 의존하는 동시에 MemoryMemberRepository라는 구현체에도 의존하고 있다. 클라이언트가 구현체에 의존하기 때문에 구현체를 JdbcMemberRepository로 변경하려면 클라이언트의 코드도 수정해 주어야 한다. 이와 같은 코드는 DIP 위반이다.

MemberService가 직접 구현체를 선택하고 있기 때문에, DIP를 지킬 수 없게 되었다. 이처럼 다형성만으로는 OCP, DIP를 지킬 수 없다. 그렇다면 어떻게 해야 할까? MemberServiceMemberRepository만 알게 만들면 된다. 그럼 new MemoryMemberRepository()는 누가 해줄까?

스프링을 개발한 사람들도 똑같은 고민을 했다. 그 결과, 스프링은 객체들을 컨테이너 안에 넣은 뒤에 객체 간의 의존관계를 설정해주는 DI 컨테이너를 제공하게 되었다. 스프링이 제공하는 컨테이너 덕분에 개발자들은 다형성, OCP, DIP를 모두 지킨 코드를 짤 수 있게 된 것이다.

0개의 댓글