스프링은 좋은 객체 지향 애플리케이션을 개발할 수 있게 도와주는 프레임워크다.
최근 본 스프링 강의에서 들은 내용입니다.
우리는 객체 지향 프로그래밍을 이해하고 실천하는 것은 코드의 재사용성과 유지보수성을 높이는 데 필수적이라는 사실을 알고 있습니다.
그런데, 여기서 말하는 "좋은" 객체 지향이란 무엇일까요?
"좋은" 객체 지향 프로그래밍을 하려면 어떻게 해야할까요?
좋은 객체 지향 프로그래밍에 대해 알아보기 전에, 먼저 객체 지향 프로그래밍이 무엇인지 되짚어 볼 필요가 있습니다.
객체 지향 프로그래밍이란, 실세계의 사물 또는 개념들을 객체로 바라보고, 상태와 행위를 가진 객체를 만들어 그 객체들 간 상호작용을 통해 프로그램을 만들어 나가는 방식이다.
객체 지향 이전의 프로그래밍에는 절차 지향 프로그래밍
과 구조적 프로그래밍
이 있었습니다.
절차 지향 프로그래밍은 코드를 위에서 순차적으로 내려오면서 실행되는 방식입니다. 프로그램을 단계별로 작성하고, 논리가 직선적이어서 이해하기 쉽다는 장점이 있지만, 기존 코드를 재사용하는게 비효율적이고 유지보수가 어렵다는 단점이 있죠.
구조적 프로그래밍은 프로그램을 작은 함수 단위로 나누고, 함수끼리 호출을 하는 방식입니다. 코드가 명확하고 체계적으로 작성되어 마찬가지로 이해하기 쉽다는 장점이 있지만, 한 문서 내에 메소드의 수가 많아질 경우 추후 유지 보수가 어렵다는 단점이 있습니다.
두 프로그래밍 패러다임의 단점들을 해결하기 위해 객체 지향 프로그래밍
이 등장하게 되었습니다.
이는 큰 문제를 쪼개는 것이 아니라, 작은 문제들을 먼저 해결할 수 있는 객체
들을 만들고, 이 객체
들을 조합해서 큰 문제를 해결하는 상향식(Bottom-up) 방식입니다.
객체 지향 패러다임의 등장 덕분에 코드의 재사용성과 유지보수성을 높일 수 있었고, 개발 기간과 비용 또한 줄일 수 있었습니다.
그렇다면, 도대체 어떤 특성을 가지고 있길래 그러한 장점을 발휘할 수 있는 걸까요?
객체 지향은 다음과 같은 특징이 존재합니다.
추상화
처음부터 객체의 구현체를 구현하는 것이 아니라,
객체의 역할과 구현을 분리할 때 역할(인터페이스)에 대한 설계이다.
캡슐화
객체와 관련된 기능, 데이터 등을 감싸주는 것이다.
만약 감싸주지 않는다면 객체에 대한 무분별한 접근이 가능하게 되므로, 안전에 취약하고 모듈이라 보기 어렵다.
(자바의 경우 접근 제어자를 통해 객체의 캡슐화를 구현한다.)
상속
기존 클래스를 재사용하여 새로운 클래스를 작성하는 것으로, 상위 클래스의 기능을 하위 클래스가 사용할 수 있다.
이를 통해 코드의 중복을 제거하여 코드의 재사용성을 높일 수 있다.
(객체의 역할에 대한 구현체를 설계하게 된다.)
다형성
말 그대로 여러가지 형태를 지닐 수 있게 해주는 것이다.
객체 설계 시 역할
과 구현
을 분리하여 설계하면, 변경이 용이해진다.
예를 들어, 운전자(역할)는 자동차(구현)의 내부 구조를 몰라도 운전자로써의 역할만 하면 된다.
차종이 변경되어도 운전자(역할)는 영향을 받지 않는다.
(자바에서 오버라이딩을 통해 인터페이스를 구현한 객체 인스턴스를 실행 시점에 유연하게 변경할 수 있다.)
이러한 객체 지향 프로그래밍의 특성과 장점을 최대한으로 끌어올리기 위해서는 어떻게 해야할까요?
즉, 어떻게 좋은 객체 지향 설계를 할 수 있을까요?
여기 SOLID
라는 좋은 객체 지향 설계의 5가지 원칙이 존재합니다!
미국의 소프트웨어 공학자이자, 클린 코드로 유명하신 로버트 마틴 형님께서 정의하셨죠.
5가지 원칙은 아래와 같습니다.
이제 각 원칙에 대해 알아보고, 실제로 어떻게 적용되는지 간단한 예시를 통해 살펴보겠습니다.
SRP(단일 책임 원칙)은 하나의 클래스는 하나의 책임만 가져야 한다는 원칙입니다.
여기서 하나의 책임이라는 것이 조금 모호하게 느껴지실 겁니다.
중요한 기준은 변경
입니다.
만약 변경
이 있을 때 파급 효과가 적다면, SRP를 잘 따른 것이라 할 수 있습니다. 이를 통해 코드의 응집성을 높이고, 클래스를 변경할 때 다른 클래스에 영향을 덜 주도록 합니다.
class Report {
void generateReport() {
// 보고서 생성 로직
}
void saveToFile() {
// 파일에 저장하는 로직
}
}
위 코드는 SRP를 위반한 경우로, 보고서 생성과 파일 저장이 같은 클래스에 존재합니다. SRP를 따르기 위해서는 이를 분리해야 합니다.
class ReportGenerator {
void generateReport() {
// 보고서 생성 로직
}
}
class ReportSaver {
void saveToFile() {
// 파일에 저장하는 로직
}
}
이제 각 클래스는 하나의 책임만을 가지게 되어 유지보수가 용이해집니다.
OCP(개방 폐쇄 원칙)는 확장에는 열려 있어야 하고, 수정(변경)에는 닫혀 있어야 한다는 원칙입니다.
즉, 기존의 코드를 변경하지 않고도 새로운 기능을 추가할 수 있도록 설계해야 합니다.
앞서 살펴본 객체 지향의 특징 중 다형성
과 연관이 깊습니다.
class Rectangle {
int width;
int height;
}
class AreaCalculator {
int calculateArea(Rectangle rectangle) {
return rectangle.width * rectangle.height;
}
}
위 코드에서, 만약 다른 도형의 넓이를 계산하는 기능이 추가 된다면,
Rectangle
클래스를 수정해야 합니다.
interface Shape {
int calculateArea();
}
class Rectangle implements Shape }
int width;
int height;
@Override
public int calculateArea() {
return width * height;
}
}
class Square implements Shape {
int side;
@Override
public int calculateArea() {
return side * side;
}
}
인터페이스를 도입하여 각 도형이 Shape
를 구현하도록 한다면,
새로운 도형이 추가되어도 기존 코드를 수정할 필요가 없게 됩니다.
LSP(리스코프 치환 원칙)은 자식 클래스는 언제나 부모 클래스를 대체할 수 있어야 한다는 원칙입니다.
프로그램의 객체는 프로그램의 정확성을 깨뜨리지 않으면서 하위 타입의 인스턴스로 바꿀 수 있어야 합니다. 즉, 상위 타입 객체를 하위 타입 객체로 대체하여도 정상적으로 동작해야 한다는 것이죠.
이는 상속 관계에서 하위 클래스는 상위 클래스와 호환성을 유지하면서 사용될 수 있어야 함을 의미합니다.
예를 들어, 자동차 인터페이스가 있다고 해봅시다.
자동차 인터페이스의 엑셀 기능은 자동차가 앞으로 가는 기능을 수행합니다.
그런데, 엑셀 기능을 실행했는데 자동차가 뒤로 간다고 생각해봅시다.
이는 상위 타입의 기대 동작(앞으로 가기)를 깨뜨리게 되고,명백한 LSP 위반입니다.
즉, 느리더라도 앞으로는 가야합니다.
단순히 컴파일에 성공하는 것을 넘어서는 이야기죠!
class Bird {
void fly() {
// 새가 날다
}
}
class Ostrich extends Bird {
void fly() {
throw new UnsupportedOperationException("타조는 날지 못합니다.");
}
}
타조는 날 지 못하는데, Bird
클래스에는 fly()
만 있습니다.
그래서 Ostrich
클래스는 부모 클래스 Bird
의 메소드를 대체할 수 없습니다.
interface Bird {
void fly();
}
class Sparrow implements Bird {
void fly() {
// 참새가 날다.
}
}
class Ostrich implements Bird {
void fly() {
// 타조는 날지 못한다.
}
}
이렇게 인터페이스를 사용하여 각각의 클래스가 Bird
인터페이스를 구현하도록 하면, 자신의 특성에 맞게 fly
메소드를 구현할 수 있습니다.
ISP(인터페이스 분리 원칙)은 클라이언트는 자신이 사용하지 않는 메소드에 의존 관계를 맺으면 안된다는 원칙입니다.
즉, 클라이언트는 자신이 사용하는 메소드에만 의존해야 하고, 그러기 위해 인터페이스는 그 인터페이스를 사용하는 클라이언트 기준으로 작게 분리되어야 합니다.
특정 클라이언트를 위한 인터페이스 여러 개가 범용 인터페이스 한 개보다 낫다는 것이죠.
예를 들어, "자동차"
라는 하나의 범용 인터페이스보다는
운전
, 정비
, 타이어
등의 세부적인 인터페이스로 나누는 것이 더 낫습니다.
타이어
를 교체할 때는 타이어
인터페이스만 확인하고 변경하면 되니까요..!
interface Worker {
void work();
void eat();
}
class Engineer implements Worker {
void work() {
// 엔지니어가 일한다.
}
void eat() {
// 엔지니어가 식사한다.
}
}
class Waiter implements Worker {
void work() {
// 웨이터가 일한다.
}
void eat() {
// 웨이터가 식사한다. (오류 발생)
}
}
엔지니어는 work
와 eat
모두 필요하므로 문제가 없습니다.
하지만, work
만 필요한 웨이터는 필요하지 않은 eat
메서드를 구현해야 하는 상황이기 때문에 ISP를 위반했다고 볼 수 있습니다.
interface Workable {
void work();
}
interface Eatable {
void eat();
}
class Engineer implements Workable, Etable {
void work() {
// 엔지니어가 일한다.
}
void eat() {
// 엔지니어가 식사한다.
}
}
class Waiter implements Workable {
void work() {
// 웨이터가 일한다.
}
}
이렇게 인터페이스를 분리하여 각 클래스가 필요한 인터페이스만 구현하도록 하면,
불필요한 의존 관계를 막고 클라이언틀가 필요한 메서드에만 의존하게 됩니다.
DIP(의존 관계 역전 원칙)은 고수준 모듈은 저수준 모듈에 의존해서는 안되며, 둘 모두 추상화에 의존해야 한다는 원칙입니다.
쉽게 이야기해서 프로그래머는 구현 클래스에 의존하지 말고, 인터페이스에 의존하라는 뜻입니다. 그래야 유연하게 구현체를 변경할 수 있기 때문입니다. 반대로 만약 구현체에 의존한다면 변경이 아주 어려워지죠.
class LightBuld {
void turnOn() {
// 전구를 켠다.
}
void turnOff() {
// 전구를 끈다.
}
}
class Switch {
LightBuld bulb;
Switch(LightBult bulb) {
this.bulb = bulb;
}
void operate() {
// 스위치를 조작한다.
if (bulb.isOn()) {
bulb.turnOff();
} else {
bulb.turnOn();
}
}
}
Switch
클래스가 LightBulb
클래스에 직접 의존하고 있어 DIP를 위반합니다.
interface Switchable {
void turnOn();
void turnOff();
}
class LightBuld implements Switchable {
void turnOn( {
// 전구를 켠다.
}
void turnOff() {
// 전구를 끈다.
}
}
class Switch {
Switchable device;
Switch(Switchable device) {
this.device = device;
}
void operate() {
// 스위치를 조작한다.
if (device.isOn()) {
device.turnOff();
} else {
device.turnOn();
}
}
}
Switch
클래스가 Switchable
인터페이스에 의존함으로써, 높은 수준의 모듈(Switch)이 저수준 모듈(LightBulb)에 의존하지 않도록 DIP를 준수하고 있습니다.
이렇게 객체 지향부터 시작해서 좋은 객체 지향 설계가 무엇인지, 어떤 원칙이 있는지 알아보았습니다.
이 중에서 좋은 객체 지향을 위한 핵심은 다형성
입니다.
그러나 다형성
만으로는 쉽게 부품을 갈아 끼우듯이 개발할 수 없습니다.
다형성
만으로는 구현 객체를 변경할 때 클라이언트 코드도 함께 변경되기 때문이죠.
그래서! 우리는 스프링을 사용하는 겁니다.
스프링은 DI(Dependency Injection)을 통해서 의존 관계를 주입해주고,
DI 컨테이너를 제공함으로써 다형성 + OCP, DIP
를 가능하게 지원해주죠.
결론적으로, 이러한 원칙과 구조들을 잘 고려하며 설계를 하는 것이 바로 "좋은 객체 지향 프로그래밍"입니다.
📚 Reference
SOLID, 좋은 객체지향 설계의 5가지 원칙
🫶오늘도 유익한 정보 잘 보고 갑니다! 화이팅><