개발자가 알아야 할 SOLID를 잘 지키는 방법 (Java)

 

 

 

SOLID는 여전히 중요하다

소프트웨어 설계의 다섯 가지 핵심 원칙인 SOLID는 명명된 지 20여 년이 지나가고 있지만, 여전히 개발자들에게 중요한 기준으로 자리잡고 있다. 최근 카카오뱅크의 안드로이드 개발자도 이를 언급하여, SOLID 원칙이 소프트웨어 설계에서 어떤 시사점을 제공하는지 강조한 바 있다. 

 

모든 개발자가 알아야 할 SOLID의 진실 혹은 거짓

기술 면접 자리에서 SOLID 5대 원칙에 대한 질문을 받아보신 분이라면 주목! 이 글에서는 SOLID 원칙의 역사와 장점, 그리고 각각의 원칙에서 중요한 점을 면접 상황 예시를 통해 가볍게 풀어보았습

tech.kakaobank.com

 

다만 SOLID가 설계 품질을 높이기 위한 지침일 뿐, 모든 프로젝트에서 반드시 지켜야할 필수 요건이라거나 법이란 건 아니다. 실제 프로젝트의 규모, 복잡성, 역량, 요구사항에 따라 다른 모든 것들이 그렇듯 유연하게 지켜나갈 수 있다. 과도한 설계는 오히려 피로하고 공수가 더 많이 드는 법이니까. 또한 빠르게 프로토타입을 만드려는데 이것부터 고려해버린다면 요구사항이 계속해서 변하는 와중에 걸림돌이 될테다.

 

그러하더라도 이 SOLID가 생각났을 때, 고려해야할 때 어떻게 잘 준수할 수 있을지에 대한 몇가지 방법을 더욱 탐색해봤다.

 

 

 

S: Single Responsibility Principle

첫 번째로, 단일책임 원칙이 존재한다. 이 원칙에 따라 하나의 클래스는 하나의 책임만 가져와야하며, 사실상 '클래스가 변경되어야 하는 이유는 단 하나여야 한다'라는 핵심 개념을 가진다. 즉, 자신이 가진 책임 외의 이유로 클래스가 변경될 순 없다.

이를 준수하면 코드를 봤을 때 의미가 분명하므로 가독성이 좋고 하나의 책임만 관리하니 유지하는 입장에서도 연계된 작업을 배제하고 유지보수성을 높일 수 있다.

 

SRT 예제

class UserService {
    public void registerUser(String user) {
        // 사용자 등록 로직
    }
}

class EmailService {
    public void sendWelcomeEmail(String email) {
        // 이메일 발송 로직
    }
}

이 예제는 'User'에 대해서만, 그리고 'Email'에 대해서만 세분화하여 책임을 갖도록 했다. 

사실 이 하나의 예제만으로는 이 '책임'이란 것을 명확히 규정하는 데에 도움이 크진 못하다. 예를 들어 '보고서'에 대한 책임을 생각해보자

 

SRT 예제 - ReportClass

class ReportManager {
    public void generateReport() {
        // 보고서 생성 로직
    }

    public void saveReportToFile() {
        // 파일 저장 로직
    }

    public void sendReportByEmail() {
        // 이메일 발송 로직
    }
}

 

최초에 이 ReportManager 클래스를 보고, '보고서'의 단일 책임을 지킨 거라고 생각하는 A가 있다. 그러나 B가 보기엔 보고서는 이상적인 단일책임이라고 보지 않을 수 있다. B의 입장에선 '저장', '발송', '생성' 메소드는 다 별도의 책임이란 것이다.

그 누군가의 시선으로 보아, 실제로 각 메소드의 로직이 얽혀있고 따라서 Email 발송 방식이 변경되는게 Report 저장 임무에 영향을 끼치는 연쇄 반응이 있을 수 있다. 즉, 한 부분의 수정이 다른 기능에 영향을 줄 가능성이 존재한다. 

이같은 점을 초기에 파악했다면 아래처럼 개선해볼 수 있다.

 

SRT 예제 - ReportClass의 개선

class ReportGenerator {
    public void generate() { /* 생성 */ }
}

class FileSaver {
    public void save(String data) { /* 저장 */ }
}

class EmailSender {
    public void send(String data) { /* 발송 */ }
}

 

보이다시피 각 역할에 따라 Class부터 나누었다.

 

어떻게 잘 실천할 수 있을까

우선 '책임'이라는 기준이 모호했던 것이 최초의 문제였다 

 

정의 기준의 차이

  • ReportManager 클래스는 보고서과 관련된 모든 일을 처리한다고 볼 수 있다
  • '생성', '저장', '발송'은 서로 다른 맥락을 가진 별개 책임이라고 보는 입장도 있다

프로젝트 규모에 따른 판단 차이

  •  소규모의 프로젝트라면 Reposrt라는 단일 클래스가 모든 역할을 수행하는 것을 충분히 감당할 수 있다.
  •  대규모 프로젝트이고 변경가능성이 많다면 역할의 모호함에 의해 유지보수가 어려워진다

경험치과 설계 철학의 차이

  • (대체로) 경험이 많을수록 책임을 더 세분화하는 데에 익숙하고 그 이점을 이루고자 실천하는 경향이 있다

 

그러니 최소한 아래와 같이 생각해 볼 수 있다

 

변경가능성을 추측해보자

  • 단일책임원칙은 '변경되어야 할 이유'를 최소화하기 위함이다
  • 만약 보고서의 생성, 저장, 발송 로직이 서로 다른 이유로 변경될 가능성이 있다면 별도의 클래스로 분리하자
    • ex) 보고서의 Format은 자주 변하겠는데?
    • ex) 발송 로직은 변할 일이 없는데?

주워진 자원을 생각하자. 팀원도 포함해서

책임을 심하게 세분화하면 팀의 공감대가 떨어진다. 현실적인 필요성과 조직의 수용범위를 파악하자 

 

 

O: Open/Closed Principle

두 번째는 개방폐쇄 원칙이다. 확장엔 열려있어야 하고 수정에는 닫혀있어야 한다

즉, 클래스의 입장에서는 새로운 기능을 추가하는 데에 어려움이 없지만, 기존에 작성된 코드는 변경되지 않는 것이 바람직하다. 이를 통해 코드 수정으로 인한 사이드이펙트를 줄이는 데에 도움된다. 책을 보면 익숙할 도형을 가지고 예제를 만들어보자.

 

인터페이스를 통한 예제

interface Shape {
    double calculateArea();
}

class Circle implements Shape {
    private double radius;
    public Circle(double radius) {
        this.radius = radius;
    }
    public double calculateArea() {
        return Math.PI * radius * radius;
    }
}

class Rectangle implements Shape {
    private double width, height;
    public Rectangle(double width, double height) {
        this.width = width;
        this.height = height;
    }
    public double calculateArea() {
        return width * height;
    }
}

class AreaCalculator {
    public double calculateTotalArea(List<Shape> shapes) {
        return shapes.stream().mapToDouble(Shape::calculateArea).sum();
    }
}

위처럼 Shape란 인터페이스가 있고 그 아래의 여러 구현체를 만들 수 있다. 이제 Triangle를 추가하더라고 기존 구조, 코드를 건드릴 것 없이 구현해나가면 된다. 간단히 인터페이스를 예로 들었지만, 디자인패턴을 활용하는 것도 또 다른 예가 될 수 있다

먼저 전략패턴으로 예를 들어보자.

 

Strategy Pattern으로 개방폐쇄 원칙 준수해보기

strategy pattern은 여러 알고리즘을 캡슐화하고 상호 교환 가능하게 만들어준다.

새로운 알고리즘이 추가되더라도 기존 코드를 수정할 필요 없으니, OCP를 잘 만족시키게 된다.

 

예를들어, 결제 방식을 관리하는 클래스가 기존에 있었으며 새로운 결재방식을 넣어보려고 한다.

interface PaymentStrategy {
	void pay(int amount);
}

class CreditCard implements PaymentStrategy{
	@Override
	public void pay(int amouunt){
		// 신용카드 지불 로직
	}
}

class PayPal implements PaymentStrategy{	
	@Override
	public void pay(int amouunt){
		// 추가된 페이팔 지불 로직
	}
}
class PaymentProcessor {
	private PaymentStrategy strategy;
	
	public PaymentProcessor(PaymentStrategy strategy){
		this.strategy = strategy;
	}
	
	public void process(int amount){
		strategy.pay(amount);
	}
}

 

주석에도 달았듯이, PaymentStrategy라는 인터페이스가 최초에 존재했다. 그리고 이에 대한 구현체가 두 개 있으며, 실제로 결재처리를 수행하는 process는 그 구현체를 미리 정의하지 않았다. 생성자에서 주입받아 결정된다.

 

public static void main(String[] args) {
    PaymentProcessor processor = new PaymentProcessor(new CreditCardPayment());
    processor.processPayment(100);

    processor = new PaymentProcessor(new PayPalPayment());
    processor.processPayment(200);
}

보이다시피 런타임 시점에 생성자에 다른 전략을 주입함으로써 자유롭고도 기존 코드에 영향력을 가하지 않는 변경이 가능해졌다.

 

Factory Method로 개방폐쇄 원칙 준수해보기

이번엔 로그관리에 대해 Factory Method를 이용해보자

interface Logger{
	void log(String message);
}

class ConsoleLogger implements Logger{
	@Override
	void log(String message){
		System.out.println("console" + "message");
	}
}

class FileLogger implements Logger{
	@Override
	void log(String message){
		System.out.println("File" + "message");
	}
}

전략패턴과 유사하게, 하나의 인터페이스에 대한 구현체 두 개가 존재한다.

 

class LoggerFactory{
	private final static String console = "console";
	private final static String file = "file";

	public static Logger getLogger(String type){
		if (type.equalsIgnoreCase(console)){
			return new ConsoleLogger();
		}else if (type.equalsIgnoreCase(file){
			return new FileLogger();
		}
		
		throw new IllegalArgumentException("not exist");
	}
}

그리고 Factory를 통해서 특정 타입(=sign)를 받아 생성자를 만드는 static Method가 정의되어있다.

 

이제 실제 사용하는 main에서는 console, file을 자유롭게 런타임 시점에 결정하여 생성시킬 수 있다

public static void main(String[] args) {
    Logger logger = LoggerFactory.getLogger("console");
    logger.log("Logging to console");

    Logger fileLogger = LoggerFactory.getLogger("file");
    fileLogger.log("Logging to file");
}

 

Factory나 Strategy가 다소 유사해보일 수 있다. 하나의 인터페이스를 각자가 구현하고 실제 호출 시에 달리 만들어두니까. 그러나 한 가지 짚자면, Strategy는 행동(알고리즘, 로직)을 동적으로 변경해야할 때이며, Factory는 이보다는 객체 자체의 종류를 동적으로 생성하여 생성 로직 자체는 알 필요 없도록 설계할 수 있게 해준다.

 

L:Listkov Substitution Principle

세 번째는 리스코프 치환 원칙. SubClass는 ParentClass를 대체할 수 있어야 한다. 즉, 부모가 가진 특성은 자식에게 그대로 유전되는 다형성을 올바르게 사용한다

 

상속 예제

class Bird {
    public void fly() {
        System.out.println("Flying");
    }
}

class Penguin extends Bird {
    @Override
    public void fly() {
        throw new UnsupportedOperationException("펭귄은 날 수 없습니다!");
    }
}

 

위처럼 펭귄을 날 수 있는 새 종류로 상속시키면 "날 수 있다"라는 특성은 변이된 것도 아니고 일반적으론 오류인 상황이다. 

 

상속 예제 개선

interface Flyable {
    void fly();
}

class Bird {
...
}

/* 날 수 있는 애*/
class Sparrow extends Bird implements Flyable {
    public void fly() {
        System.out.println("Flying");
    }
}

/* 펭귄은 그냥 새 */
class Penguin extends Bird {
...
}

이처럼 '날 수 있다' 라는 속성을 interface로 따로 만들어 선택적으로 구현한다면 위의 오류를 개선시킬 수 있다.

사실 이 코드는 ISP측면에서도 평가해 볼 수 있다.

 

I: Interface Segregation Principle

인터페이스 분리 원칙, "Client는 자신이 쓰지 않을 Method에 의존하지 않는다"

Interface는 Client가 필요한 기능만 제공해야 한다. 그러니 interface에 너무 많은 역할을 주려고 하지 말자. 그럴 수록 불필요한, 쓰지 않을 Method가 늘어날 수 있으니까. 그러니 Interface가 어느정도 규모가 크다는 점을 인지했다면 이를 다시 작은 단위로 분할시키는 것을 고려할 수 있다.

 

ISP 위반 예제

interface Animal{
	void fly();
	void swim();
	void walk();
}

class Dog implements Animal {
	@Override
	public void fly(){
		throw new UnsupportedOperationException("개는 못 나는데요")
	}
	
	@Override
	public void swim(){
		log.info("can swim!")
	}
	
	@Override
	public void walk(){
		log.info("can walk!")
	}
}

위의 경우 강아지는 날지도 못하지만 Interface로 인해 억지로 구현하고 있다.

 

개선된 예제

interface Walkable{
	void walk();
}

interface Swimmable{
	void swim();
}

interface Flyable{
	void fly();
}

class Dog implements Walkable, Swimmable {
	@Override
	public void walk(){
		System.out.println("Can Walk!");
	}
	
	@Override
	public void swim(){
		System.out.println("Can Swaim!");
	}
}

좀 전에 봤던 리스코프 치환 원칙과 같은 해결 방식이다. 분리될 수 있는 특성은 별도의 interface로 선언하였고, 이를 선택적으로 구현하는 Dog Class를 만듦으로써 더욱 깨끗한 구현이 가능해졌다.

 

 

D: Dependency Inversion Principle

마지막 원칙은 '의존 역전 원칙'이다. 고수준 모듈은 저수준 모듈에 의존하면 안 되며 둘 다 추상화에 의존해야 된다는 목적을 갖고 있다. 코드가 특정 구현체제 강하게 결합되지 않도록 설계하는 것이 중요한데, 예제를 보는 것이 더욱 와닿는다.

 

DIP 위반 예제

class Keyboard{
	public void type(){
		System.out.println("Typing keyboard!")
	}
}

class Computer{
	private Keyboard keyboard = new Keyboard(); // 의존중
	
	public void use(){
		keyboard.type();
	}
}

위의 경우 Computer 클래스는 Keyboard를 직접 의존하고 있는 형태다. 따라서 설계상 Computer 클래스는 반드시 Keyboard 클래스와 함께 동작하게 되고, 다른 입력장치(ex.마우스)를 사용할 수 없다. 혹은 필요 시 새로운 입력장치(ex. 음석인식)이 추가되면 Computer 클래스의 코드를 직접 수정해야 할 것이다. (결합도 증가, 확정성 감소)

하지만 우리는 앞서서 배웠듯이, 기존의 코드는 가급적 수정하고 싶지 않다.

 

개선 예제

Interface InputDevice{
	void input();
}

class Keyboard implemtns InputDevice{
	@Override
	public void input(){
		System.out.println("Typing keyboard!");
	}
}

class Mouse implmenets InputDevice{
	@Override
	public void input(){
		System.out.println("Clicking a mouse");
	}
}

class Computer {
	private InputDevice inputDevice;
	
	public Computer(InputDevice inputDevice){
		this.inputDevice = inputDevice;
	}
	
	public void use(){
		inputDevice.input();
	}
}

입력장치라는 공통 특성을 갖춘 interface를 별도로 구성했다. 이를 통해서 메소드를 별도로 구현한 mouse, Keyboard클래스가 존재한다.

이제 Computer 클래스는 자신의 InputDevice의 종류를 직접적으로 선언하여 가질 필요가 없다. 입력장치를 받아둘 Interface만 갖고 있으면 된다. 이제 실제로 생성 시에 지정해주면 내부 클래스를 직접 수정할 뻔했던 상황은 오지 않는다.

 

public static void main (String [] args){
	Computer keyboardCompuiter = new Computer(new Keyboard());
	keyboardComputer.use();
	
	Computer mouseComputer = new Computer(new Mouse());
	mouseComputer.use();
}

이처럼 결과적으로 다음과 같은 이점을 얻었다

  • 결합도 감소: Computer는 InputDevice라는 추상화에 의존한다. 구체적인 구현체가 변경되어도 영향을 받지 않게 되었다.
  • 확장성 증가: 새로운 입력장치 (음성장치)를 추가하려면 inputDevice를 구현하는 클래스를 추가하면 된다. 기존 코드를 건드리지 않는다
  • 유연성 증가: 객체 생성 시 외부에서 주입(DI, 의존성 주입_을 시키고 있으니 더 유연한 설계가 가능해졌다

 

 

 

SOLID 원칙은 단순한 교과서적인 규칙이 아니라, 소프트웨어 설계 품질을 높이고 확정성을 확보하기 위한 지침이라 볼 수 있다. 말했듯이 이 지침을 모든 프로젝트에서 반드시 엄격히 준수할 필요는 없다. 현재의 자원과 역량과 규모와 요구사항을 모두 통틀어 적용을 시도해 볼 수 있으며, 다만 SOLID 원칙을 이해하고 있는 상황에서 이를 적절히 적용하려는 노력이 더해지면 좋다. 그래야 코드의 복잡성을 높이고 유지보수성을 향상하는 데에 중요 역할을 할테니까.