Java 객체지향 설계의 정석: SOLID 원칙 5가지 완벽 해설 및 실무 예제

개발자로서 경력이 쌓일수록 “돌아가는 코드”를 만드는 것보다 “유지보수가 쉬운 코드”를 만드는 것이 훨씬 어렵고 중요하다는 사실을 깨닫게 됩니다.

기능 하나를 수정했더니 전혀 상관없는 다른 기능에서 에러가 터지거나, 새로운 기능을 추가하기 위해 기존 코드를 전부 뜯어고쳐야 하는 경험, 다들 한 번쯤 있으실 겁니다. 이러한 ‘나쁜 설계’의 늪에서 벗어나기 위해 반드시 알아야 할 것이 바로 SOLID 원칙입니다.

SOLID는 로버트 C. 마틴(Robert C. Martin)이 정립한 객체지향 프로그래밍 및 설계의 5가지 기본 원칙의 앞 글자를 딴 용어입니다. 이 원칙들은 소프트웨어를 더 이해하기 쉽고, 유연하며, 유지보수하기 쉽게 만듭니다.

지난 포스팅에서 다룬 객체지향의 4대 특징(캡슐화, 상속, 다형성, 추상화)이 건물을 짓기 위한 ‘재료’라면, SOLID 원칙은 그 재료를 이용해 튼튼한 건물을 짓는 ‘공학적 설계도’라고 할 수 있습니다. 오늘은 이 5가지 원칙이 각각 무엇을 의미하며, 자바(Java) 코드로 어떻게 적용할 수 있는지 상세하게 알아보겠습니다.


한눈에 보기

  • 다섯 원칙은 따로 노는 규칙이 아니라 변경이 번지지 않게 만드는 한 가지 목표를 공유합니다.
  • 실무에서 가장 먼저 효과를 보는 것은 SRP 와 DIP 입니다. 클래스를 쪼개고 인터페이스를 보게 하는 것만으로 테스트가 쉬워집니다.
  • 원칙을 외우기보다 위반 신호를 알아채는 편이 빠릅니다. 아래 표의 오른쪽 칸이 그 신호입니다.
원칙한 줄 정의위반 신호
SRP 단일 책임하나의 클래스는 하나의 일만 한다클래스 하나가 로그인·메일 발송·로깅을 모두 한다
OCP 개방-폐쇄기능 추가는 쉽게, 기존 코드 수정은 없게기능을 넣을 때마다 기존 if·switch 를 고친다
LSP 리스코프 치환자식은 부모의 역할을 그대로 대신할 수 있어야 한다자식이 부모가 약속한 동작이나 반환값을 어긴다
ISP 인터페이스 분리인터페이스는 작게 쪼개 필요한 것만 쓰게 한다구현 클래스에 빈 메서드가 늘어난다
DIP 의존 역전구체 클래스 대신 인터페이스를 바라본다서비스가 특정 구현체를 직접 new 한다

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

“한 클래스는 하나의 책임만 가져야 한다.”

SRP는 모든 클래스는 하나의 책임만 가지며, 그 클래스는 오직 하나의 이유로만 변경되어야 한다는 원칙입니다. 여기서 ‘책임’이란 ‘기능’ 정도로 이해할 수 있습니다. 만약 하나의 클래스가 너무 많은 일을 하고 있다면, 그중 하나만 변경되어도 해당 클래스 전체를 다시 테스트하고 배포해야 하는 위험이 생깁니다.

  • 나쁜 예시: User 클래스가 로그인 처리도 하고, 이메일도 보내고, 로그도 남기는 경우.
  • 좋은 예시: User 클래스는 사용자 정보만 관리하고, EmailSender, LoginHandler 등으로 클래스를 분리하는 것.

Java

// [Bad Practice] 하나의 클래스가 너무 많은 책임을 짐
class UserService {
    public void login(String id, String pw) {
        // 로그인 로직
    }
    public void sendEmail(String email) {
        // 이메일 전송 로직 (로그인 로직이 바뀌어도 이 코드가 영향을 받을 수 있음)
    }
}

// [Good Practice] 책임의 분리
class UserAuthenticator {
    public void login(String id, String pw) { ... }
}

class EmailSender {
    public void sendEmail(String email) { ... }
}

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

“확장에는 열려 있어야 하고, 변경에는 닫혀 있어야 한다.”

가장 중요한 원칙 중 하나입니다. 기존의 코드를 변경하지 않으면서(Closed), 기능을 추가(Open)할 수 있도록 설계해야 한다는 뜻입니다. 이를 가능하게 하는 핵심 메커니즘은 바로 ‘추상화‘와 ‘다형성‘입니다.

예를 들어 결제 시스템을 만들 때, 새로운 결제 수단(카카오페이)이 추가된다고 해서 기존의 결제 처리 코드를 뜯어고쳐야 한다면 OCP를 위반한 것입니다.

Java

// 인터페이스를 통한 추상화
interface Payment {
    void process();
}

class CreditCard implements Payment {
    @Override
    public void process() { System.out.println("신용카드 결제"); }
}

class KakaoPay implements Payment {
    @Override
    public void process() { System.out.println("카카오페이 결제"); }
}

// 결제 관리 클래스
class PaymentService {
    // 새로운 결제 수단이 추가되어도 이 코드는 변경할 필요가 없음 (변경에 닫힘)
    public void pay(Payment payment) {
        payment.process(); // 다형성 작동 (확장에 열림)
    }
}

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

“서브 타입은 언제나 자신의 기반 타입(부모)으로 교체할 수 있어야 한다.”

이 원칙은 상속을 올바르게 사용하기 위한 지침입니다. 자식 클래스는 부모 클래스의 기능을 수행하는 데 있어 문제가 없어야 한다는 것입니다. 즉, 부모 클래스의 인스턴스를 자식 클래스의 인스턴스로 대체해도 프로그램의 정확성이 깨지지 않아야 합니다.

대표적인 위반 사례가 ‘직사각형과 정사각형’ 문제입니다. 수학적으로는 정사각형이 직사각형의 일종이지만, 상속 관계로 구현하면 setWidth와 setHeight가 다르게 동작하여 LSP를 위반하게 됩니다.

  • 핵심: 부모 클래스가 약속한 규약(메서드의 동작 방식, 반환 값 등)을 자식 클래스가 어기면 안 됩니다.

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

“하나의 일반적인 인터페이스보다 여러 개의 구체적인 인터페이스가 낫다.”

클라이언트는 자신이 사용하지 않는 메서드에 의존하도록 강요받으면 안 됩니다. 거대한 인터페이스 하나에 모든 기능을 몰아넣는 것보다, 기능별로 쪼개는 것이 좋습니다.

예를 들어 SmartPhone이라는 인터페이스에 call(), sms(), wirelessCharge()가 있다고 가정해 봅시다. 구형 2G 폰 클래스가 이를 구현해야 한다면, 기능에도 없는 wirelessCharge() 메서드를 억지로 구현(빈 껍데기)해야 합니다.

Java

// [Good Practice] 인터페이스 분리
interface CallAble {
    void call();
}

interface ChargeAble {
    void wirelessCharge();
}

// 최신 폰은 둘 다 구현
class GalaxyS24 implements CallAble, ChargeAble {
    public void call() { ... }
    public void wirelessCharge() { ... }
}

// 구형 폰은 통화 기능만 구현하면 됨
class OldPhone implements CallAble {
    public void call() { ... }
}

DIP (Dependency Inversion Principle) – 의존 역전 원칙

“구체적인 것에 의존하지 말고, 추상적인 것에 의존하라.”

이 원칙은 **의존성 주입(Dependency Injection, DI)의 핵심 기반이 됩니다. 상위 모듈(비즈니스 로직)이 하위 모듈(구체적인 구현 클래스)에 직접 의존하면 안 됩니다. 둘 다 추상화(인터페이스)에 의존해야 합니다.

쉽게 말해, 자동차(상위)가 스노우 타이어(하위 구체 클래스)에 직접 의존하면, 여름이 되어 일반 타이어로 바꿀 때 자동차 코드를 수정해야 합니다. 대신 ‘타이어’라는 인터페이스에 의존하면 어떤 타이어가 오든 상관없게 됩니다.

Java

// [Bad Practice] 구체적인 클래스(SamsungTV)에 직접 의존
class Viewer {
    private SamsungTV tv; // LG TV로 바꾸려면 코드를 수정해야 함
}

// [Good Practice] 추상화(TV 인터페이스)에 의존
class Viewer {
    private TV tv; // SamsungTV든 LGTV든 TV 인터페이스를 구현했다면 모두 사용 가능
    
    public Viewer(TV tv) { // 생성자를 통해 의존성 주입
        this.tv = tv; 
    }
}

정리

지금까지 객체지향 설계의 5가지 원칙인 SOLID에 대해 알아보았습니다. 내용을 요약하자면 다음과 같습니다.

  1. SRP: 하나의 클래스는 하나의 일만 하자.
  2. OCP: 기능 추가는 쉽게, 기존 코드 수정은 안 하게 만들자.
  3. LSP: 자식은 부모의 역할을 완벽히 대신할 수 있어야 한다.
  4. ISP: 인터페이스는 작게 쪼개서 필요한 것만 쓰게 하자.
  5. DIP: 구체적인 클래스보다 인터페이스를 바라보자.

이 원칙들은 처음에는 코드를 복잡하게 만드는 것처럼 느껴질 수 있습니다. 인터페이스도 늘어나고 파일 개수도 많아지기 때문입니다. 하지만 프로젝트의 규모가 커지고 협업하는 인원이 늘어날수록, SOLID 원칙을 지킨 코드의 진가가 발휘됩니다. 수정 사항이 발생했을 때 변경 범위를 최소화하고, 버그 발생 가능성을 획기적으로 낮춰주기 때문입니다.

오늘 소개한 예제 코드들을 직접 타이핑해 보며, 여러분의 프로젝트에 어떻게 적용할 수 있을지 고민해 보시기 바랍니다. 완벽한 설계는 하루아침에 이루어지지 않지만, SOLID를 의식하며 코딩하는 습관이 여러분을 ‘클린 코드’ 작성자로 이끌어줄 것입니다.


함께 읽으면 좋은 글

서울·경기 아파트 실거래 주간 브리핑

국토교통부 실거래가 공개 데이터를 모아 서울·경기 관심 지역 17곳의 아파트 매매 거래를 매주 정리합니다. 거래 건수와 평당가 추이, 신고가 갱신 단지, 거래 위치 지도를 한 편에 담습니다. 무료입니다.

공개 데이터를 정리한 통계 정보이며 특정 단지의 매수·매도를 권유하지 않습니다.

“Java 객체지향 설계의 정석: SOLID 원칙 5가지 완벽 해설 및 실무 예제”에 대한 2개의 생각

  1. 핑백: Java NullPointerException 원인 5가지와 Optional을 활용한 우아한 해결 방법 - loghubs

  2. 핑백: Java 객체지향 프로그래밍(OOP) 4대 특징 완벽 정리: 캡슐화, 상속, 다형성, 추상화 - loghubs

댓글 달기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다

위로 스크롤