들어가며: 좋은 코드란 무엇일까?
우리는 종종 “좋은 코드”에 대해 이야기합니다. 단순히 동작하는 코드를 넘어, 시간이 지나도 이해하기 쉽고, 새로운 기능을 추가하기 용이하며, 예기치 않은 버그가 적은 코드를 우리는 좋은 코드라고 부릅니다. 그렇다면 어떻게 그런 코드를 작성할 수 있을까요?
그 길잡이가 되어주는 것이 바로 SOLID라는 다섯 가지 객체 지향 설계 원칙입니다. SOLID 원칙을 따르는 코드는 결합도(Coupling)는 낮추고 응집도(Cohesion)는 높여, 변화에 유연하고 유지보수가 쉬운 구조를 갖게 됩니다.
이번 글에서는 객체 지향의 대표 언어인 Java의 클래스와 인터페이스를 활용하여 SOLID 원칙을 어떻게 적용할 수 있는지, 실제 예제와 함께 하나씩 살펴보겠습니다.
1. 단일 책임 원칙 (Single Responsibility Principle, SRP)
“하나의 클래스는 단 하나의 책임만 가져야 한다.”
가장 기본적이면서도 중요한 원칙입니다. 여기서 ‘책임’의 변화는 곧 ‘수정의 이유’가 됩니다. 만약 하나의 클래스가 여러 책임을 가지고 있다면, 한 책임의 변경이 다른 책임에 의도치 않은 영향을 미칠 수 있어 코드 관리가 어려워집니다.
예시: 보고서 생성과 저장
보고서를 생성하고, 그 내용을 파일로 저장하는 기능이 ReportManager
라는 하나의 클래스에 모두 포함된 경우를 생각해 봅시다.
나쁜 설계: 모든 것을 다 하려는 클래스
1 2 3 4 5 6 7 8 9 10 11 12
// SRP를 지키지 않은 코드 public class ReportManager { public String generateReport() { // 보고서 내용 생성 로직 return "Report Content"; } public void saveReportToFile(String content, String filePath) { // 내용을 파일에 저장하는 로직 System.out.println("Saving report to file: " + filePath); } }
위 코드는 보고서 내용 생성 방식이 바뀌어도, 저장 방식(예: 파일 -> 데이터베이스)이 바뀌어도
ReportManager
클래스를 수정해야 합니다. 즉, 두 가지 수정의 이유를 가집니다.좋은 설계: 책임을 명확히 분리
SRP를 적용하여 ‘보고서 생성’과 ‘저장’의 책임을 별도의 클래스로 분리합니다.
1 2 3 4 5 6 7 8 9 10 11 12 13
// 보고서 생성 책임을 갖는 클래스 public class ReportGenerator { public String generateReport() { return "Report Content"; } } // 보고서 저장 책임을 갖는 클래스 public class ReportSaver { public void saveReportToFile(String content, String filePath) { System.out.println("Saving report to file: " + filePath); } }
이제 보고서 생성 로직은
ReportGenerator
가, 저장 로직은ReportSaver
가 각각 책임집니다. 저장 방식에 새로운 기능(DB 저장 등)이 추가되어도ReportGenerator
는 전혀 영향을 받지 않습니다.
2. 개방-폐쇄 원칙 (Open-Closed Principle, OCP)
“소프트웨어 요소(클래스, 모듈, 함수 등)는 확장에 대해서는 열려 있어야 하지만, 수정에 대해서는 닫혀 있어야 한다.”
새로운 기능을 추가할 때 기존 코드를 변경하는 것이 아니라, 새로운 코드를 추가함으로써 기능을 확장해야 한다는 원칙입니다. 이는 Java의 인터페이스와 상속을 통해 우아하게 구현할 수 있습니다.
예시: 다양한 결제 방법 추가
결제 시스템에서 신용카드 결제만 지원하다가, 다른 결제 수단을 추가해야 하는 상황을 가정해 봅시다.
나쁜 설계: 새로운 기능마다 분기문 추가
1 2 3 4 5 6 7 8 9 10 11
public class PaymentProcessor { // 새로운 결제 수단이 추가될 때마다 이 함수의 코드를 수정해야 함 public void process(String method, double amount) { if (method.equals("CreditCard")) { System.out.printf("Processing %.2f via Credit Card\n", amount); } else if (method.equals("KakaoPay")) { System.out.printf("Processing %.2f via KakaoPay\n", amount); } // 새로운 결제 수단 추가 시 여기에 else if 구문이 계속 늘어남 } }
좋은 설계: 인터페이스를 통한 확장
PaymentMethod
라는 인터페이스를 정의하고, 각 결제 수단이 이 인터페이스를 구현하도록 설계합니다.1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
// 결제 방법 인터페이스 public interface PaymentMethod { void pay(double amount); } // 신용카드 결제 public class CreditCardPayment implements PaymentMethod { @Override public void pay(double amount) { System.out.printf("Processing %.2f via Credit Card\n", amount); } } // 카카오페이 결제 public class KakaoPayPayment implements PaymentMethod { @Override public void pay(double amount) { System.out.printf("Processing %.2f via KakaoPay\n", amount); } } // 결제 처리기는 PaymentMethod 인터페이스에만 의존 public class PaymentProcessor { public void process(PaymentMethod method, double amount) { method.pay(amount); } }
이제 새로운 결제 수단(예:
NaverPayPayment
)을 추가하고 싶을 때,PaymentProcessor
의 코드는 단 한 줄도 수정할 필요가 없습니다. 새로운 클래스를 만들어PaymentMethod
인터페이스를 구현하기만 하면 되므로, 시스템은 확장에는 열려있고, 수정에는 닫혀있게 됩니다.
3. 리스코프 치환 원칙 (Liskov Substitution Principle, LSP)
“서브 타입(자식 클래스)은 언제나 기반 타입(부모 클래스)으로 교체할 수 있어야 한다.”
자식 클래스가 부모 클래스의 역할을 완벽하게 대체할 수 있어야 한다는 의미입니다. 즉, 부모 클래스를 사용하는 코드에 자식 클래스 객체를 전달하더라도, 프로그램의 동작이 예상대로 흘러가야 합니다.
예시: 직사각형과 정사각형
고전적인 예시인 ‘직사각형’과 ‘정사각형’의 관계입니다. 수학적으로 정사각형은 직사각형이지만, 프로그래밍에서는 문제가 발생할 수 있습니다.
나쁜 설계: 잘못된 상속 관계
Rectangle
을 상속받은Square
의 너비(width)나 높이(height)를 개별적으로 바꾸면 정사각형의 정의(“모든 변의 길이는 같다”)가 깨집니다.1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
public class Rectangle { protected int width, height; public void setWidth(int width) { this.width = width; } public void setHeight(int height) { this.height = height; } public int getArea() { return this.width * this.height; } } public class Square extends Rectangle { @Override public void setWidth(int width) { this.width = width; this.height = width; } @Override public void setHeight(int height) { this.width = height; this.height = height; } } // Rectangle 타입을 사용하는 테스트 함수 public void testRectangle(Rectangle r) { r.setWidth(5); r.setHeight(4); // 너비 5, 높이 4이므로 넓이가 20이 나올 것을 기대함 assert r.getArea() == 20; } // Square 객체를 전달하면 테스트가 실패함 (4*4 = 16) testRectangle(new Square()); // LSP 위반
Rectangle
을 기대했던testRectangle
함수에Square
객체를 전달하자, 함수의 기대 결과(20)와 실제 결과(16)가 달라졌습니다. 이는 리스코프 치환 원칙을 위반한 것입니다.좋은 설계: 공통 인터페이스 사용
잘못된 상속 관계 대신, 공통의 동작을 인터페이스로 추출합니다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
public interface Shape { int getArea(); } public class Rectangle implements Shape { private int width, height; // 생성자... @Override public int getArea() { return this.width * this.height; } } public class Square implements Shape { private int side; // 생성자... @Override public int getArea() { return this.side * this.side; } }
이제 각 도형은
Shape
인터페이스를 통해 일관되게 다룰 수 있으며, 서로의 구현 세부사항에 영향을 주지 않아 LSP를 준수하게 됩니다.
4. 인터페이스 분리 원칙 (Interface Segregation Principle, ISP)
“클라이언트는 자신이 사용하지 않는 메서드에 의존하도록 강요받아서는 안 된다.”
“뚱뚱한” 인터페이스 하나를 만드는 대신, 기능별로 잘게 쪼개진 여러 개의 “작은” 인터페이스를 만들라는 원칙입니다. 클라이언트는 자신이 필요한 기능에 해당하는 인터페이스만 구현하면 됩니다.
예시: 일하는 사람과 로봇
일(work)과 식사(eat) 기능이 모두 포함된 Worker
인터페이스가 있다고 상상해 봅시다. 개발자(Developer
)는 두 기능을 모두 수행할 수 있지만, 로봇(Robot
)은 식사를 할 수 없습니다.
나쁜 설계: 뚱뚱한 인터페이스
1 2 3 4
public interface Worker { void work(); void eat(); // 로봇은 이 기능이 필요 없음에도 구현해야 함 }
좋은 설계: 기능별 인터페이스 분리
각 기능을 별도의 인터페이스로 분리합니다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
public interface Workable { void work(); } public interface Feedable { void eat(); } // 개발자는 두 인터페이스를 모두 구현 public class Developer implements Workable, Feedable { @Override public void work() { System.out.println("Coding..."); } @Override public void eat() { System.out.println("Having lunch"); } } // 로봇은 Workable 인터페이스만 구현 public class Robot implements Workable { @Override public void work() { System.out.println("Assembling parts"); } }
이제
Robot
클래스는 자신이 필요한Workable
인터페이스만 구현하면 되므로, 불필요한 기능(eat
)에 대한 의존성이 사라졌습니다.
5. 의존 역전 원칙 (Dependency Inversion Principle, DIP)
“고수준 모듈은 저수준 모듈에 의존해서는 안 된다. 둘 다 추상화(인터페이스)에 의존해야 한다.”
쉽게 말해, 세부적인 구현(저수준 모듈)이 변경되더라도, 전체적인 비즈니스 로직(고수준 모듈)은 영향을 받지 않아야 한다는 원칙입니다. 이는 인터페이스를 중간에 두어 의존성의 방향을 “역전”시킴으로써 달성됩니다.
예시: 컴퓨터와 주변기기
Computer
클래스가 구체적인 Keyboard
와 Monitor
클래스에 직접 의존하는 경우입니다.
나쁜 설계: 구체적인 구현에 직접 의존
1 2 3 4 5 6 7 8 9 10 11 12
public class Keyboard {} public class Monitor {} public class Computer { private Keyboard keyboard; // 구체적인 Keyboard 클래스에 의존 private Monitor monitor; // 구체적인 Monitor 클래스에 의존 public Computer() { this.keyboard = new Keyboard(); this.monitor = new Monitor(); } }
이 설계에서는 키보드를
MagicKeyboard
로 바꾸거나, 모니터를LgMonitor
로 바꾸려면Computer
클래스의 코드를 직접 수정해야 합니다.좋은 설계: 인터페이스에 의존
InputDevice
와DisplayDevice
라는 추상적인 인터페이스를 만들고,Computer
가 이 인터페이스에 의존하도록 설계합니다.1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
// 추상화 (인터페이스) public interface InputDevice {} public interface DisplayDevice {} // 저수준 모듈 (구체적인 구현) public class Keyboard implements InputDevice {} public class Monitor implements DisplayDevice {} // 고수준 모듈 public class Computer { private InputDevice input; // 추상적인 인터페이스에 의존 private DisplayDevice display; // 추상적인 인터페이스에 의존 // 의존성 주입(Dependency Injection)을 통해 외부에서 객체를 전달받음 public Computer(InputDevice input, DisplayDevice display) { this.input = input; this.display = display; } }
이제
Computer
는Keyboard
나Monitor
의 존재를 전혀 모릅니다. 오직InputDevice
와DisplayDevice
인터페이스만 알 뿐입니다. 이처럼 의존성의 방향을 구체적인 것에서 추상적인 것으로 “역전”시킴으로써, 시스템은 유연하고 확장 가능하며 테스트하기 쉬운 구조를 갖게 됩니다.
마무리하며
SOLID 원칙은 당장 코드를 작성하는 데 약간의 시간이 더 걸리는 것처럼 보일 수 있습니다. 하지만 장기적인 관점에서 보면, 이 원칙들은 미래의 ‘나’와 동료들을 위한 최고의 투자입니다.
Java의 강력한 객체 지향 특성과 인터페이스 시스템은 SOLID 원칙을 적용하기에 매우 훌륭한 환경을 제공합니다. 오늘 살펴본 다섯 가지 원칙을 의식하며 코드를 작성하는 습관을 들인다면, 분명 더 견고하고 유연하며 지속 가능한 소프트웨어를 만들어나갈 수 있을 것입니다.