1. 다시 객체지향 프로그래밍
시작하기에 앞서 여러분이 생각하는 좋은 객체지향 프로그래밍은 무엇인가요? 각 기능별로 구분해서 사용하는 것? 모든 것을 클래스안에 정의해서 필요에 따라 해당 클래스의 객체를 불러와 사용하는 것? 여러가지 생각이 들겠지만, 이를 위해서는 "객체지향 프로그래밍"이 무엇을 의미하는 지를 먼저 생각한 후에 어떻게 프로그래밍하는 것이 좋은 객체지향 프로그래밍인지를 고민하는 것이 순서일 것입니다.
그만큼 객체지향 프로그래밍의 정의가 중요하다는 것인데요. 잠시 예전에 배웠던 자바의 기억을 떠올려봅시다. 자바를 포함해 다른 언어라고 해도 "객체지향 프로그래밍"이 갖는 특징을 이야기해보자면 아래와 같은 내용들이 있었다는 것을 기억하게 될 겁니다.
1. 다형성
2. 추상화
3. 캡슐화
4. 상속
이 중에서 이번 장에서는 특히, 다형성에 대한 이야기를 많이 하게 될 것 같습니다. 우선 시작하기 전에 먼저 "객체지향 프로그래밍"의 정의를 한 번 읽어보도록 하죠.
객체지향 프로그래밍이란, 컴퓨터 프로그램을 명령어의 목록으로 보는 시각에서 벗어나 여러 개의 독립된 단위, 즉 "객체"들의 모임으로 파악하고자 하는 것이다. 각각의 객체는 메시지를 주고받고, 데이터를 처리할 수 있다. (협력)
...
객체지향 프로그래밍은 프로그램을 유연하고 변경이 용이하게 만들기 때문에 대규모 소프트웨어 개발에 많이 사용된다.
일상으로 비유해보자면, 컴퓨터를 업그레이드 하기 위해서 기존에 사용되는 부품을 성능이 좋은 부품으로 교체하는 것처럼 변경 전에도 컴퓨터를 키는 것에 문제는 없지만, 더 좋은 성능을 높히기 위해 메모리를 교체하거나, 키보드와 마우스를 교체하는 것처럼 사용자의 필요에 의해서 부품을 교체하는 것이지만, 정작 "컴퓨터를 실행하는 것"에는 문제가 없습니다.
이처럼 프로그램을 구성할 때도 전체적인 흐름은 유지를 하면서, 기능을 추가하거나, 제거하는 것에는 영향을 주지 않는 것을 "유연하고 변경이 용이하다" 라고 표현합니다. 결과적으로, 객체지향 프로그래밍은 결국 하나의 프로그램을 특정 역할을 수행하는 "객체"로 나누고, 프로그램을 구성하는 객체가 유연하고 변경이 용이하도록 프로그래밍을 하는 것이 목표인 프로그램 설계방법론이라고 할 수 있습니다.
그리고 이러한 객체지향 프로그래밍이 유연하고 변경이 쉬울 수 있는 이유는 바로 "다형성(Polymorphism)"을 지원하기 때문입니다.
2. 다형성이란?
객체지향을 공부하게 되면, 무조건 나오는 개념인 만큼 중요하고, 앞서 스프링은 다형성을 잘 활용하는 것이 중요하다고 말씀드렸기 때문에 다형성에 대해서도 한 번 다시 짚고 넘어가보겠습니다. 우선 개념을 설명하기에 앞서 객체지향의 세계관을 조금 설명해야될 것 같습니다. 물론 우리가 살고 있는 실세계와 1:1 매칭이 잘 안되지만, 객체지향의 세계관에는 "역할"과 역할을 행하는 "구현"으로 구성이 됩니다.
예를 들어, 우리가 뮤지컬 "웃는남자"을 연출하는 감독이라고 가정해봅시다. 이를 객체지향의 세계로 가져온다면, 우리의 목표는 성공적으로 공연을 구현하는 것이고, 이를 위해 각 배역에 맞는 배우들을 캐스팅할 것입니다. 여기서 우리가 집중할 부분은 "공연을 한다"(구현) 라는 것과 "배역"(역할)에 집중해보겠습니다. 웃는남자에는 그윈플렌, 우르수스, 데아, 조시아나 등 여러 인물들이 등장하고, 공연을 하기 위해서는 인물별로 연기할 배우분들이 필요합니다. 실제 공연에서도 아래 그림과 같이 등장인물별로 여러 배우분들이 캐스팅 되시죠.
다시 예시로 돌아와서, 앞서 말했듯이 우리의 목표는 "공연을 구현한다" 입니다. 그리고 객체지향의 세계에서는 누군가는 그 등장인물을 연기해야하고, 만약 특정 배역의 배우분에게 문제가 생긴다면, 다른 배우분이 연기를 해서 공연을 구현하는 데 문제가 생기면 안됩니다. 이렇게 역할과 구현으로 나누어진 객체지향의 세계에서는 대체 가능성이라는 것이 생깁니다. 즉, 공연을 구현함에 있어 배역을 연기하는 배우가 누가 되었든, 그 배역을 연기만 하면 되기 때문에 유연하고 변경이 용이합니다.
다음으로 객체지향의 세계에서, 각 등장인물을 담당하는 배우분은 상대방이 누구인지 신경을 쓰지 않아도 됩니다. 예를 들어 그윈플렌역에 이석훈님이 되었다고 해서 반드시 우르수스 역에는 서범석님이, 데아 역에는 이수빈님이 되어야한다는 규칙이 없습니다. 즉, 배우가 교체된다고 해서 공연에 영향을 주는 것은 아니라는 의미입니다.
정리해보자면, 특정 프로그램을 실행한다고 했을 때, 해당 프로그램을 수행하기 위한 기능들을 구현이라고 보고, 기능을 실행하기 위한 역할을 객체라고 했을 때, 기능을 실행하는 객체는 유연하고 변경이 용이하게 설계되어야 하며, 변경되더라도 프로그램의 기능에 영향이 없어야한다는 것입니다.
그리고 위와 같이 다형성을 적용하면 좋은 점들이 뭐가 있냐고 물어본다면, 아래와 같이 4가지를 장점으로 이야기할 수 있습니다.
1. 클라이언트는 대상의 역할(인터페이스)만 알면 된다.
2. 클라이언트는 구현 대상의 내부 구조를 몰라도 된다.
3. 클라이언트는 구현대상의 내부 구조가 변경되어도 영향 받지 않는다.
4. 클라이언트는 구현 대상 자체를 변경해도 영향 받지 않는다.
위의 내용에 대해 자바에서는 역할을 인터페이스로, 구현은 인터페이스를 구현한(상속받은) 클래스, 구현객체를 의미하며, 객체를 설계할 때는 역할과 구현을 명확히 분리해야합니다. 또한 객체 설계 시, 역할(인터페이스)을 먼저 부여하고, 그 역할을 수행하는 구현 객체를 만드는 순서로 진행하면 됩니다.
설계를 할 때 중요한 부분이 하나 있는데, 바로 객체 간의 관계입니다. 쉽게 예를 들자면, 클라이언트는 요청을 하고, 서버는 클라이언트의 요청에 응답을 하는 관계처럼, 혼자 모든 것을 수행하는 객체는 없으며, 수 많은 객체 클라이언트와 객체 서버는 서로 협력관계를 가진다는 것입니다. 객체지향을 공부하면서 많이 오해하는 부분 중 하나가, "클라이언트가 없다" 라는 것을 전제로 생각하게 되는 점인데, 앞서 말씀드린 것처럼, "클라이언트" 는 요청하는 쪽, "서버"는 요청을 받는 쪽이 되서, 객체 간에도 서버와 클라이언트가 존재를 하고, 서로 협력 관계를 맺는다라는 점도 같이 이해를 하면 좋겠습니다. 이에 해당하는 대표적인 예시가 오버라이드(Override) 입니다.
위의 그림에서처럼 MemberService 라는 객체 내에 있는 MemberRepository 인터페이스의 save() 메소드를 호출하게 되면, MemberRepository 인터페이스의 구현체인 MemoryMemberRepository 클래스나 JdbcMemberRepository 클래스 중 구현된 클래스의 객체에 있는 save() 메소드를 실행하는 것이 오버라이딩입니다.
이처럼 다형성으로 인터페이스를 구현한 객체를 실행시점에서 클라이언트는 변경하지 않고, 서버측 객체를 유연하게 변경할 수 있다는 것이 장점이자, 여러 객체가 관계를 맺는다는것을 보여주는 예시라고 할 수 있습니다.
3. SOLID 원칙
그렇다면 어떻게 좋은 객체지향적 프로그래밍을 할 수 있을까요? 이에 대해 로버트 C. 마틴 (Robert C. Martin) 은 2000년대 초반, 아래의 5가지 원칙을 준수하는 것이 좋은 객체지향 프로그래밍을 할 수 있다고 정의하며, 각 원칙을 앞 글자를 사용해 "SOLID 원칙"이라고 부르게 되었습니다.
S: 단일 책임 원칙 (SRP, Single Responsibility Principle)
O: 개방-폐쇄 원칙 (OCP, Open-Closed Principle)
L: 리스코프 치환 원칙 (LSP, Listov Substitution Principle)
I: 인터페이스 분리 원칙 (ISP, Interface Segregation Principle)
D: 의존관계 역전 원칙 (DIP, Dependencies Inversion Principle)
위의 5가지 원칙은 클린 코드와 더불어, 개발자 면접에까지 등장할 정도로 알아두면 좋은, 객체지향설계에서는 중요한 원칙이라고 볼 수 있습니다. 그러면 각 원칙별로 어떻게 설계를 하면 좋은지를 살펴보도록 하죠.
3.1 S: 단일 책임 원칙
첫 번째로 S 에 해당하는 "단일 책임 원칙"에 대해서 알아보도록 하겠습니다. 우선 개념을 설명하기 앞서, "책임" 이란게 무엇인지를 먼저 알아야 될 텐데요. 여기서 말하는 책임이란, 쉽게 말하면 해당 객체가 수행하는 일 혹은 행동을 의미합니다. 객체지향의 세계에서는 객체가 특정 요청에 대해 답해줄 수 있거나, 적절한 행동을 할 의무가 있는 경우에 "책임이 있다" 라고 합니다.
단일 책임 원칙(SRP, Single Responsibility Principle)은 하나의 객체가 반드시 하나의 책임을 가져야 한다는 것을 의미합니다. 즉, 하나의 객체가 수행하는 일 혹은 행동은 반드시 하나만 있어야 한다는 것이죠. 하지만 프로그래밍을 하다보면 알겠지만, 위의 정의에서 말한 "하나의 책임" 이라는 것이 매우 모호하다는 것도 알 수 있습니다. 수행해야하는 일의 규모가 작을 수도, 클 수도 있고, 문맥과 상황에 따라서 달라질 수 있기 때문입니다.
그렇다면 설계를 할 때, 단일 책임 원칙을 잘 지켰다고 판단하는 기준은 무엇일까요? 바로 코드가 변경에 민감한 지를 확인해보면 됩니다. 정확히는 우리가 어떤 코드를 수정한다고 할 때, 해당 코드의 변경으로 인해 파급력이 적다면 단일 책임 원칙을 잘 지킨 것으로 볼 수 있습니다. 때문에 앞서 책임을 정하는 것이 모호하다고 하였는데, 그만큼 설계 단계에서 수행해야하는 일의 규모를 적절하게 조절해서 분리하는 것이 중요합니다. 만약 책임의 규모를 너무 잘게 조절하면, 객체가 너무 많아져서 관리하기가 어렵고, 반대로 규모를 너무 크게 가져가면, 코드 수정 시 해당 객체의 파급력이 크기 때문에, 관련된 모든 코드에 영향을 주고, 코드를 대폭 수정해야할 수도 있기 때문입니다.
3.2 O: 개방-폐쇄 원칙
두 번째로 O에 해당하는 "개방-폐쇄 원칙"을 알아보겠습니다. 개방 폐쇄 원칙(OCP, Open-Closed Principle)이란, 소프트웨어 요소가 확장에는 "열려 있어야" 하고, 변경에는 "닫혀 있어야" 한다는 것입니다. 여기서 말하는 확장은 "기능에 대한 확장"을 의미하고, 변경은 "코드의 변경"을 의미합니다. 즉, 위의 정의를 풀어서 보면, 어떤 객체에 대해 기능의 확장은 가능해야하고, 코드의 변경은 불가능해야 된다는 것으로도 볼 수 있습니다. 여기서 이 말을 처음 들으시는 분이라면, 풀어서 설명한 내용이 모순이 있다고 들릴 겁니다.
이에 대해 앞서 우리가 살펴 본 다형성을 대표적인 예시라고 들 수 있습니다. 앞서 다형성을 설명할 때 객체지향의 세계에서는 역할을 수행할 수 있으면 된다고 이야기 했습니다. 즉, 새로운 기능이 필요하다면 이를 수행하기 위한 인터페이스를 생성하고, 해당 인터페이스를 구현한 새로운 클래스를 하나 만들어서 기능을 구현할 수 있습니다. 따라서 우리가 앞서 배운 다형성을 잘 활용하게되면 개방-폐쇄 원칙을 잘 준수하면서 설계를 할 수 있습니다.
하지만, 다형성을 사용한다고 해서 모든 것이 개방-폐쇄 원칙을 잘 지킬 수 있는 가에 대해서는 조금 더 생각해봐야 합니다. 이에 대해 이해를 돕고자 제가 아래 2개의 예제 코드를 준비했습니다.
우선 배경을 잠깐 설명하자면, MemberService를 구현하기 위해서는 MemberRepository 라는 인터페이스를 상속받는 객체를 통해 회원정보를 저장하는 save() 메소드를 사용해야합니다. 이를 위의 코드인 변경 전에는 Memory에 저장하는 MemoryMemberRepository 객체로 구현한 것입니다. 이 후 시간이 흘러, 멤버 정보를 메모리가 아닌 데이터베이스에 저장하는 방식으로 변경하려 합니다.
이를 위해서는JDBCMemberRepository 클래스를 사용해야하고, 이를 사용하려면 아래에 있는 코드처럼 memberRepository 객체를 MemoryMemberRepository 대신 JDBCMemberRepository 클래스로 변경해주면 되는 것입니다.
[예제 - 변경 전 코드]
public class MemberService {
private MemberRepository memberRepository = new MemoryMemberRepository();
}
[예제 - 변경 후 코드]
public class MemberService {
// private MemberRepository memberRepository = new MemoryMemberRepository();
private MemberRepository memberRepository = new JDBCMemberRepository();
}
위의 변경 후 코드는 분명히 다형성을 잘 지켰지만, 개방-폐쇄 원칙은 지키지 못했습니다. 왜냐면, 변경 후의 코드를 보면 알 수 있듯이, 기존에 있던 MemoryMemberRepository 클래스를 사용하는 코드는 주석처리하고, JDBCMemberRepository 클래스를 참조하도록 수정했기 때문이죠. 이 부분에서 확장에는 열려 있지만, 변경이 되었기 때문에 개방-폐쇄 원칙을 지키지 못한 것입니다.
그렇다면, 위의 경우에는 어떻게 코드를 구현해야 개방-폐쇄의 원칙까지 지킬 수 있을까요? 위의 문제를 해결하기 위한 방법은 객체를 생성하고, 연관관계를 맺는 별도의 설정 객체가 있으면 해결되는 문제입니다. 바로 이 문제를 스프링에서는 이 후에 다루게 되는 "스프링 컨테이너" 라는 객체가 관리합니다. 스프링 컨테이너에 관한 것은 차차 설명드릴 예정이니, 이번 장에서는 이런 게 있다, 한 번은 들어봤다는 느낌으로 알고 있으면 됩니다.
3.3 L: 리스코프 치환 원칙
다음으로 다룰 내용은 "리스코프 치환 원칙" 입니다. 이 원칙은 프로그램 객체는 프로그램의 정확성을 깨뜨리지 않으면서, 하위 타입의 인스턴스로 바꿀 수 있어야 한다는 내용인데, 얼핏 들으면 무슨 내용이지 싶으실 겁니다. 쉽게 말하면, "자동차" 라는 인터페이스에서 accel() 이라는 기능이 있고, 앞으로 가는 기능으로 정의했다고 가정해봅시다. 이 때 엑셀을 실행하면 앞으로 가는 기능인데, 구현을 뒤로 가게 하는 것이 아니라, 느리게라도 앞으로 가도록 구현해야 합니다.
이처럼 사전에 인터페이스를 만들 때, 추상적이더라도 정의한 규약에 대해서, 해당 인터페이스를 상속받는 클래스라면, 이미 정의된 규약을 지켜야한다는 의미입니다.
3.4 I: 인터페이스 분리 원칙
네 번째는 바로 "인터페이스 분리 원칙"입니다. 특정 클라이언트를 위한 인터페이스 여러 개가 범용 인터페이스 하나보다 낫다라는 내용인데, 예시로 앞서 본 자동차 인터페이스를 보겠습니다. 자동차 인터페이스에는 운전에 대한 기능, 각 부품들에 대해 정비하는 것에 대한 기능 등 이외에도 설명하지 못한 여러 가지 기능들이 있을 것입니다.
물론 자동차라고하는 인터페이스 하나에 위의 모든 기능을 전부 규약하고, 하위 클래스가 상속받도록 하는 것도 가능하겠지만, 굵직한 기능에 대해서는 분리시켜서 하나의 인터페이스로 만드는 것이 더 낫다는 것입니다. 왜냐면, 만약 정비에 대한 기능을 변경한다고 하면, 정비 인터페이스 자체만 변경하면 되고, 운전에 대한 인터페이스는 수정하지 않아도 되기 때문이죠. 뿐만 아니라, 기능을 하나의 덩어리보다 목적에 맞게 나눠지기 때문에, 인터페이스가 명확해지고, 대체 가능성도 높아지는 것도 장점입니다.
3.5 D: 의존관계 역전 원칙
마지막은 "의존관계 역전 원칙" 입니다. 프로그래머는 추상화에 의존해야지, 구체화에 의존하면 안된다는 내용인데, 쉽게 말해서 구현 클래스에 의존하지 말고, 인터페이스에 의존해서 설계하라는 의미입니다. 앞서 이야기한 역할에 의존하게 해야한다는 말과 같은데요. 객체지향의 세계에서도 클라이언트가 인터페이스에 의존해야 유연하게 구현체를 변경할 수 있으며, 구현체에 의존하게 되면 변경이 아주 어려워지기 때문에 위와 같은 원칙이 나오게 된 것입니다.
그렇지만, 앞에서 개방-폐쇄 원칙의 문제점을 설명할 때, 이야기 했던 MemberSerivce의 경우 인터페이스에 의존하지만, 구현 클래스도 동시에 의존합니다. 왜냐면 MemberService 클라이언트가 구현 클래스를 직접 선택하는 로직을 가지고 있기 때문입니다. 따라서 해당 코드는 개방-폐쇄 원칙 뿐만 아니라 의존관계 역전 원칙도 위배하는 코드인 것이죠.
그렇다면, 위와 같은 문제가 스프링에서도 있었을 텐데, 왜 스프링을 사용하면 객체 지향 설계가 가능해진 것인지 알아보겠습니다.
4. 객체 지향 설계와 스프링
다시 스프링으로 돌아와서, 왜 스프링에서 이렇게 객체지향에 대한 언급이 많은 가에 대한 답을 해보겠습니다. 우선 스프링은 의존성 주입(DI, Dependency Injection) 이라는 기술을 사용해 다형성과 개방-폐쇄 원칙, 의존관계 역전 원칙의 문제점을 모두 해결하도록 지원해주는 DI 컨테이너라는 것을 제공합니다.
DI 컨테이너는 자바 객체들을 컨테이너 안에 모아 넣고, 객체 간의 의존관계를 정리하여 제공해주는 것입니다. 이렇게 할 경우 클라이언트 코드의 변경 없이 개발이 가능해지며, 쉽게 부품을 변경하듯이 개발할 수 있게 됩니다.
지금까지의 내용을 정리를 해보자면, 객체지향의 핵심은 "다형성을 어떻게 잘 활용하는 가"이지만, 다형성 만으로는 쉽게 부품을 갈아 끼우듯이 개발을 할 수는 없습니다. 왜냐면, OCP와 DIP를 지킬 수 없기 때문입니다. 하지만, 스프링에서는 의존성 주입 기능을 사용해 문제들을 해결할 수 있어, 결과적으로는 객체 지향에 맞는 설계가 가능해진 것입니다.
위의 원칙들을 실제로 어떻게 설계하고, 구현할 수 있는지 살펴보기 위해 다음 장에서 Java 만을 사용해 회원별로 주문 및 할인 정책을 적용하는 예제를 만들어보도록 하겠습니다.
'BackEnd > Spring 🍃' 카테고리의 다른 글
[Spring] 5. 좋은 객체지향 프로그래밍이란 Ⅱ : 실습편 - 회원 도메인 (0) | 2025.03.12 |
---|---|
[Spring] 3. 스프링 빈과 의존관계 찍먹하기 (0) | 2025.01.11 |
[Spring] 2. 웹 개발 기초 (1) | 2024.12.31 |
[Spring] 1. 스프링을 시작하며... (0) | 2024.12.29 |