- Published on
객체지향 설계 원칙(SOLID)을 각각 구현한 개인적인 생각
- Authors
- Name
- 김민석
Introduction
동기
프로그래밍을 하다 보면 코드가 점점 복잡해지고 어려워지는 순간이 온다. 기능이 추가될 때마다 기존 코드를 수정해야 하거나, 여러 클래스가 서로 밀접하게 얽혀 있어서 변경이 어려운 경험한 적이 있다.
이런 문제들은 대부분 객체지향 설계 원칙(SOLID)
을 제대로 적용하지 않았기 때문이라고 생각한다. 객체지향 프로그래밍에서 코드의 핵심 가이드라인이라고 생각 든다.
이 글에서는 각 원칙을 실무 코드에 어떻게 적용할 수 있는지 자바로 구현 하며, 적용 전후의 차이를 기록으로 남기려고 한다.
"내가 왜 이렇게 코드를 짜야 하는지 몰랐다면?"
"내 코드가 더 깔끔하고 유지보수하기 쉬워지려면 어떻게 해야 할까?"
이 글을 쓰고 나면 위 질문에 대한 명확한 답을 얻을 수 있기를 목표로 하고 있다.
SRP - 단일 책임 원칙
(위반하는 경우)
- 민석 클래스 같은 경우 많은 역할과 책임을 지니게 된다
- 민석 클래스는 여자친구의 역할과 책임을 하는 동시에, 아들, 사원 역할과 책임까지 한 번에 지니게 된다.
- 이런 경우 역할과 책임을 분리하라는 것이
단일 책임 원칙
이다.

(준수하는 경우)
- 역할에 따라 클래스 이름이 정해져 이해하기 좋아졌다.
- 사원은 회사를 나오더라도 여자친구 - 남자친구 관계, 어머니 - 아들 관계, 직장 상사 - 사원 관계는 아무런 영향도 받지 않게 된다.

또 다른 상황
- 고객
주문
을 처리하는 상황이다. - 현재 주문을 저장하고 고객에게
이메일 알림
을 보내는 기능이 있다.
시간이 지나면서 시스템이 확장되었고, 이메일 전송 로직에 변경이 생기거나 주문 저장 방식이 바뀔 때마다 서비스 코드를 수정해야 하는 상황이 빈번해졌다. 이런 문제를 해결하기 위해 단일 책임 원칙
을 적용해보자.
문제
OrderService
클래스가 두 가지 책임(주문 저장
, 이메일 전송
)을 가지고 있다.
기능이 변경되면 여러 메서드에 영향을 미치므로 유지보수가 어렵다.
구현
- 기존 코드.
public class OrderService {
public void saveOrder(Order order) {
System.out.println("주문이 저장되었습니다: " + order);
}
public void sendEmail(Order order) {
System.out.println("고객에게 이메일이 전송되었습니다: " + order.getCustomerEmail());
}
}
- 개선 코드.
public class OrderService {
public void saveOrder(Order order) {
System.out.println("주문이 저장되었습니다: " + order);
}
}
public class EmailService {
public void sendEmail(Order order) {
System.out.println("고객에게 이메일이 전송되었습니다: " + order.getCustomerEmail());
}
}
해결
OrderService
와 EmailService
가 각각 하나의 책임만 가지게 되었다.
변경 사항이 생기더라도 각 클래스의 변경 범위가 줄어들어 유지보수가 쉬워졌다.
정리
SRP를 적용함으로써 코드의 응집도가 높아
지고, 변경의 영향을 줄일 수 있었다.
각 클래스가 하나의 책임
만 가지기 때문에, 변경 사항이 발생해도 관련된 부분만 수정하면 된다.- ex) 이메일 전송 로직이 변경되더라도
OrderService
는 영향을 받지 않는다.
- ex) 이메일 전송 로직이 변경되더라도
- 새로운 기능을
추가
하거나 기존 기능을수정
할 때, 기존 클래스를 변경하지 않고 새로운 클래스를 추가하는 방식으로 작업할 수 있다.
OCP - 개방/폐쇄 원칙
(위반하는 경우)
- 호랑이는 먹이를 던져주면 끝이고, 코끼리는 직접 먹여줘야 한다.
- 새로운 동물이 추가되니 사육사의 행동에 변화가 오는 것이다.

(준수하는 경우)
- 사육사와 동물 사이에
인터페이스
를 둠으로써 다양한 동물이 생긴다고 해도 객체 지향 세계의 사육사는 동물 습관에 영향을 받지 않게 된다. - 다양한 동물이 생긴다고 하는 것은
확장에는 개방
되어 있는 것이고, 사육사 입장에서는 주변의변화에 폐쇄
되어 있는 것이다.

또 다른 상황
- 상품의 가격을 계산하는 기능이 있다.
- 기본 가격에서
할인 정책
을 적용하는 기능이 포함되어 있는데,새로운 할인 정책이 추가될 때
마다 기존 코드를 수정해야 하는 문제가 발생한다.
문제
- 새로운 할인 정책을 추가할 때마다
PriceCalculator
클래스를 수정해야 하므로 코드가 복잡해지고 오류 발생 가능성이 높아진다.
구현
- 기존 코드.
public class PriceCalculator {
public double calculatePrice(Product product, String discountType) {
double price = product.getPrice();
if ("seasonal".equals(discountType)) {
price *= 0.9;
} else if ("clearance".equals(discountType)) {
price *= 0.7;
}
return price;
}
}
- 개선 코드.
public interface DiscountPolicy {
double applyDiscount(double price);
}
public class SeasonalDiscount implements DiscountPolicy {
@Override
public double applyDiscount(double price) {
return price * 0.9;
}
}
public class ClearanceDiscount implements DiscountPolicy {
@Override
public double applyDiscount(double price) {
return price * 0.7;
}
}
public class PriceCalculator {
public double calculatePrice(Product product, DiscountPolicy discountPolicy) {
return discountPolicy.applyDiscount(product.getPrice());
}
}
해결 + 정리
- 새로운 할인 정책을 추가할 때 기존 코드를 수정할 필요 없이
DiscountPolicy 인터페이스
를 구현하는 클래스만 추가하면 된다. - OCP를 적용함으로써 코드의 확장성이 높아지고, 변경이 필요한 범위가 최소화되었다.
LSP - 리스코프 치환 원칙
(위반하는 경우)
Father 아들 = new Son();
Father 딸 = new Daughter();
- 상위 클래스의 객체 참조 변수에 하위 클래스의 인스턴스를 할당한다.
- 아들과 딸이
아버지의 역할을 하고 있다는 의미
기도 하다.
- 아들과 딸이
- 아들과 딸은
Father 타입
의 객체이기 때문에 Father 객체가 가진행위(메서드)
를 할 수 있어야 한다. - 아버지와 아들 딸의 관계는 리스코프 치환 원칙을
위배
하고 있는 것이다.

(준수하는 경우)
Animal 핑구 = new Penguin()
- 귄 한 마리가 태어나 핑구라는 이름을 갖고
Animal
타입으로 동물의 행위를 할 수 있다. - 동물과 펭귄 구조는 리스코프 치환 원칙을
만족
하고 있는 것이다. - 하위 클래스의 인스턴스는 상위 타입 객체 참조 변수에 대입해
상위 클래스의 인스턴스 역할
을 하는 데 문제가 없어야 한다.

List<Integer> list1 = new ArrayList<Integer>();
List<Integer> list2 = new LinkedList<Integer>();
List<Integer> list3 = new Vector<Integer>();
List
타입으로 생성된ArrayList
,LinkedList
등은 List 인터페이스의add()
,remove()
메서드 등을 공통적으로 사용할 수 있다.
또 다른 상황
- 회사의 직원 관리 시스템에서
정규직 직원
과계약직 직원
을 관리하고 있다. - 직원의 급여를 계산하는 메서드를 구현하는 과정에서
계약직 직원은 급여 계산 방식이 다르다는 문제에 직면
했다.
문제
ContractEmployee
클래스가 부모 클래스의 기능을 제대로 대체하지 못하고, calculateSalary
메서드를 호출할 때 예외가 발생한다.
구현
- 기존 코드.
public class Employee {
public double calculateSalary() {
return 5000;
}
}
public class ContractEmployee extends Employee {
@Override
public double calculateSalary() {
throw new UnsupportedOperationException("계약직 직원은 다른 방식으로 급여가 지급됩니다.");
}
}
- 개선 코드.
public abstract class Employee {
public abstract double calculateSalary();
}
public class FullTimeEmployee extends Employee {
@Override
public double calculateSalary() {
return 5000;
}
}
public class ContractEmployee extends Employee {
@Override
public double calculateSalary() {
return 3000;
}
}
해결 + 정리
- 부모 클래스인
Employee
를 대체해도 문제없이 동작하며, 메서드 호출 시 예외가 발생하지 않는다. LSP
를 적용하여서브 클래스
가부모 클래스
를 완전히 대체할 수 있도록 개선하였다.
ISP - 인터페이스 분리 원칙
(위반하는 경우)
- 운송 수단 인터페이스와 구현하는 자동차, 비행기 클래스가 있다.

interface 운송수단 {
void go();
void fly();
}
class 자동차 implements 운송수단 {
@Override
public void go() {
// ...
}
@Override
public void fly() {
// ...
}
}
class 비행기 implements 운송수단 {
@Override
public void go() {
// ...
}
@Override
public void fly() {
// ...
}
}
운송수단
이라는 추상 클래스 또는 인터페이스에는go()
와fly()
라는 추상 메서드가 존재한다.- 자동차 클래스와 비행기 클래스가 운송수단의 구현체로 구현한다면,
Car는 날지 못함에도 불구하고 fly() 메서드를 구현하게 된다.
- 자동차 클래스의
목적에 맞지 않는 fly() 메서드
로 인해 인터페이스 분리 원칙을위반
한다.
(준수하는 경우)

interface 이동 {
void go();
}
interface 날다 extends 이동 {
void fly();
}
class 자동차 implements 이동 {
@Override
public void go() {
// ...
}
}
class 비행기 implements 날다 {
@Override
public void go() {
// ...
}
@Override
public void fly() {
// ...
}
}
운송수단
을‘이동'
과‘날다'
인터페이스로 분리하여go()
메서드와fly()
메서드를 별도로 구현하도록 한다.- 자동차 클래스는
‘이동’
인터페이스를 구현하여go()
메서드만 사용할 수 있도록 한다. ‘날다’
인터페이스는‘이동’
인터페이스를 상속하고,‘비행기’
클래스는‘날다’
인터페이스를 구현하여go()
와fly()
모두를 구현할 수 있도록 한다.
또 다른 상황
- 멀티 기능 프린터를 개발하고 있는데,
인쇄
,스캔
,팩스
기능을 제공해야 한다. \ - 그러나 모든 클라이언트가 모든 기능을 필요로 하지는 않는다.
- 한 가지 인터페이스에 모든 기능을 포함시키면 사용하지 않는 메서드를 구현해야 하는 문제가 발생한다.
문제
Printer
인터페이스가 너무 크고,BasicPrinter
클래스가 사용하지 않는 메서드를 구현해야 한다.
구현
- 기존 코드.
public interface Printer {
void printDocument(String document);
void scanDocument(String document);
void faxDocument(String document);
}
public class BasicPrinter implements Printer {
@Override
public void printDocument(String document) {
System.out.println("문서를 출력 중입니다: " + document);
}
@Override
public void scanDocument(String document) {
throw new UnsupportedOperationException("스캔 기능을 지원하지 않습니다.");
}
@Override
public void faxDocument(String document) {
throw new UnsupportedOperationException("팩스 기능을 지원하지 않습니다.");
}
}
- 개선 코드.
public interface Printer {
void printDocument(String document);
}
public interface Scanner {
void scanDocument(String document);
}
public interface Fax {
void faxDocument(String document);
}
public class BasicPrinter implements Printer {
@Override
public void printDocument(String document) {
System.out.println("문서를 출력 중입니다: " + document);
}
}
해결 + 정리
- 인터페이스가 분리되어 각 클래스가 필요한 기능만 구현할 수 있다.
BasicPrinter
클래스는 인쇄 기능만 구현하고,스캔이나 팩스 기능을 지원하지 않도록
할 수 있다.
ISP
를 적용하여 인터페이스가 더 작고, 사용하지 않는 기능을 구현할 필요가 없어진 것이다.
DIP - 의존성 역전 원칙
(위반하는 경우)

- 자동차는 현재 스노우 타이어에 의존하고 있다.
- 스노우 타이어는
계절이 바뀌면
일반 타이어로교체
해야 할 것이다.
class SnowTire {
public void print() {
System.out.println("스노우 타이어");
}
}
class Car {
private SnowTire snowTire;
public Car() {
this.snowTire = new SnowTire();
}
public void printTire() {
snowTire.print();
}
}
- Car는 SnowTire에 의존하고 있다.
- 만약, 계절이 바뀌어 스노우 타이어를 일반 타이어로 교체할 경우 자동차는 영향을 받게 된다.
계절이 바뀐 경우
class RegularTire {
public void print() {
System.out.println("일반 타이어");
}
}
class SnowTire {
public void print() {
System.out.println("스노우 타이어");
}
}
class Car {
private RegularTire regularTire; // 수정
public Car() {
this.regularTire = new RegularTire(); // 수정
}
public void printTire() {
regularTire.print(); // 수정
}
}
타이어만 교체했을 뿐인데
Car 클래스에서 수정이 일어난다.- Car 클래스에서 구체적인
Tire 클래스를 의존
하고 있기 때문이다. - 두 클래스 간의
결합도가 높은
것이다.
(준수하는 경우)

- 자동차가 구체적인 타이어들이 아닌
추상화
된 타이어인터페이스에만 의존
하게 변경한다. - 자동차는 사용하고 있는 타이어를
변경하더라도
자동차는 그영향을 받지 않는 형태로 구성
된다. - 또한, 구체적인 타이어 클래스들은 타이어
인터페이스에 의존
하게 되었다. 의존 관계의 방향이 역전된 것이다.
interface Tire {
void print();
}
class RegularTire implements Tire {
@Override
public void print() {
System.out.println("일반 타이어");
}
}
class SnowTire implements Tire {
@Override
public void print() {
System.out.println("스노우 타이어");
}
}
class WideTire implements Tire {
@Override
public void print() {
System.out.println("광폭 타이어");
}
}
class Car {
private Tire tire;
public Car(Tire tire) {
this.tire = tire;
}
public void printTire() {
tire.print();
}
}
- 구체적인 Tire 클래스(
Regular, Snow, Wide
)는Tire 인터페이스를 구현
하고 있다. - Car 클래스는 인스턴스를 생성할 때
Tire 타입의 객체를 매개변수로 받는다.
public class Main {
public static void main(String[] args) {
Car regularTireCar = new Car(new RegularTire());
Car snowTireCar = new Car(new SnowTire());
Car wideTireCar = new Car(new WideTire());
regularTireCar.printTire();
snowTireCar.printTire();
wideTireCar.printTire();
}
}
- 타이어를 교체하더라도
Car 클래스
에는 어떠한 영향도 가지 않게 되었다.
또 다른 상황
- 고객 정보를
데이터베이스에 저장하는 시스템
을 개발하고 있다. - 기존 코드에서는
CustomerService
클래스가Database
클래스에 직접 의존하고 있어, 데이터 저장소가 변경되면CustomerService
코드를 수정해야 하는 문제가 있다.
문제
CustomerService
클래스가Database
클래스에 직접 의존하고 있는 것이다.
구현
- 기존 코드
public class CustomerService {
private Database database = new Database(); // 직접 Database에 의존하고 있음
public void saveCustomer(Customer customer) {
database.save(customer);
}
}
public class Database {
public void save(Customer customer) {
System.out.println("고객 정보를 데이터베이스에 저장 중입니다: " + customer);
}
}
CustomerService
클래스가Database
클래스에 직접 의존하고 있다.CustomerService
클래스는Database
객체를 직접생성
하고 사용한다.- 만약
Database
클래스가 변경되거나 새로운 저장소(MySQL, MongoDB 등)로 바꿔야 한다면,CustomerService
클래스의 코드를 직접 수정해야 하는 것이다.
결과적으로, 새로운 기능 추가 시 기존 코드를 수정해야 하므로
OCP도 위반
하게 된다.개선 코드
public interface CustomerRepository {
void save(Customer customer);
}
public class DatabaseRepository implements CustomerRepository { // 새로운 클래스
@Override
public void save(Customer customer) {
System.out.println("고객 정보를 데이터베이스에 저장 중입니다: " + customer);
}
}
public class CustomerService {
private final CustomerRepository repository;
public CustomerService(CustomerRepository repository) {
this.repository = repository;
}
public void saveCustomer(Customer customer) {
repository.save(customer);
}
}
public class Main {
public static void main(String[] args) {
// 기존 DatabaseRepository 사용
CustomerService customerService1 = new CustomerService(new DatabaseRepository());
customerService1.saveCustomer(new Customer("짱구"));
// 새로운 FileRepository 사용
CustomerService customerService2 = new CustomerService(new FileRepository());
customerService2.saveCustomer(new Customer("철수"));
// 새로운 MongoRepository 사용
CustomerService customerService3 = new CustomerService(new MongoRepository());
customerService3.saveCustomer(new Customer("맹구"));
}
}
해결 + 정리
- 이제
CustomerService
는 구체적인 구현체(DatabaseRepository)가 아니라 인터페이스(CustomerRepository)에 의존한다. - 새로운 데이터 저장소를 추가하고 싶다면,
CustomerRepository
인터페이스를 구현하는 새로운 클래스를 만들기만 하면 되는 것이다. - 기존의
CustomerService
코드는 수정할 필요가 없다.