CS&Network

더 좋은 설계를 위한 S.O.L.I.D 원칙 이해하기

Luti 2025. 3. 11. 02:23

 


우리는 좋은 소프트웨어 시스템을 개발하기 위해 좋은 설계를 하고자 합니다.

 

좋은 설계란 무엇일까요?

다양한 사람들의 의견을 모아본 바는 다음과 같습니다.

"낮은 결합도와 높은 응집도를 지향하는 것"

"유지보수가 쉬운 시스템을 만드는 것"

"변화를 손쉽게 받아들일 수 있도록 하는 것"

 

서비스를 지속하고 가치를 창출하기 위해서는 하나의 애플리케이션 개발을 마치고 방치하는 것이 아니라, 지속적으로 유지하고 확장해나가야 합니다.

이 과정에서 코드를 추가하거나 기존 코드를 수정해야하는 일이 종종 발생하곤 할겁니다.

하지만 유지보수 및 확장 단계에서 다른 사람이 작성한 코드가 이해가 안되거나, 너무 많은 기존의 코드를 건드려야하게 되는 상황을 맞게 된다면 어떨까요?

많은 사람들이 좋은 설계의  정의를 위와 같이 내린 이유를 이 상황에서 찾을수 있습니다.

 

변경에 유연하도록

이해하기 쉽도록

코드를 작성하고자 하는 목적이 바로 SOLID 원칙이 지향하는 바입니다.

 

이번 글에서는 좋은 설계를 위한 SOLID 원칙에 대해 설명을 드려보도록 하겠습니다.

 

 

# 00. About S.O.L.I.D

 

SOLID 원칙은 특정 언어나 특정 프레임워크에 국한되어있는 개념이 아닙니다.

1980년대 Robert C. Martin 이라는 개발자가 여러 사람들과 소프트웨어 설계 원칙에 대해 토론하는 과정에서 원칙을 모으기 시작했고, 2000년대 초반 안정화된 최종 버전에서 원칙들의 첫 글자들을 따 SOLID라는 이름을 붙이게 되었습니다.

 

SOLID를 이루고있는 각 원칙들에 대해 개략적으로 알아보자면 다음과 같습니다.

 

- SRP: 단일 책임 원칙  Single Responsibility Principle

- OCP: 개방-폐쇄 원칙  Open-Closed Principle

- LSP: 리스코프 치환 원칙  Liskov Substitution Principle

- ISP: 인터페이스 분리 원칙  Interface Segregation Principle

- DIP: 의존성 역전 원칙  Dependency Inversion Principle

 

SOLID 대해 공부하다 보면, 위 다섯가지 원칙이 서로 강하게 연관되어있음을 느낄수 있습니다.

각 원칙들이 서로 어떻게 유기적으로 연결되어있는지를 이해하며 글을 읽어보는 것도 의미가 있을 것입니다.

 

# 01. SRP - 단일 책임 원칙

 

SOLID에는 표준이나 공식 문서가 존재하진 않지만, SRP에 대해 역사적으로 다음과 같이 기술되어 왔습니다.

"단일 모듈은 변경의 이유가 하나, 오직 하나뿐이어야 한다."

 

즉, 코드에 대해 변경 요청을 하는 집단이 오직 하나 뿐이어야 한다고 이해할 수 있습니다.

또한, 이 원칙을 사람들은 일반적으로 다음과 같이 얘기해오고 있습니다.

"하나의 클래스는 하나의 기능을 담당하여 하나의 책임을 수행한다."

 

하나의 클래스에서 여러 책임을 맡고있다면 어떤 일이 일어날까요?

하나의 클래스에서 여러 책임을 맡게 되면, 해당 클래스가 변경되어야 할 이유가 여러 개가 되어버립니다. 즉, 한 가지 기능에만 집중하지 않고 다양한 책임을 포함하게 되면, 그 중 하나의 기능을 수정하려 할 때 다른 기능에도 의도치 않은 영향을 미칠 위험이 커집니다. 이로 인해 유지보수는 물론 확장에도 큰 어려움을 초래할 수 있습니다.

 

이러한 이유로 SRP에서는 각 클래스에 자신의 메서드에 반드시 필요한 소스 코드만을 포함하는 것을 권유합니다.

 

하지만, 이러한 방법은 개발자가 각각의 클래스를 인스턴스화하고 추적해야 하는 단점이 있습니다.

 

이러한 난관에서 빠져나올 때 흔히 쓰는 기법이 Facade 패턴입니다.

Facade 패턴은 복잡한 서브시스템을 단순화된 인터페이스로 감싸 사용자가 쉽게 접근할 수 있도록 하는 디자인 패턴입니다.

Facade 패턴에 대해서는 다음에 기회가 있을때 글을 다시 작성해보는 것으로 하고, 제가 프로젝트에서 Facade 패턴을 적용한 코드를 간소화해서 보여드리고 OCP로 넘어가겠습니다.

 

@Component
@RequiredArgsConstructor
public class PostFacade {
	private final MemberReader memberReader;
	private final PostService postService;
    private final PostReader postReader;
    
	public Long registerPost(CustomOAuth2User token, PostCommand.RegisterPostRequest request,
		List<MultipartFile> fileList) {
		checkToken(token);
		String memberId = token.getId();
		Member member = memberReader.getMember(memberId);

		Long postId = postService.register(member, request, fileList);
		return postId;
	}

	public void deletePost(CustomOAuth2User token, Long postId) {
		checkToken(token);
		String memberId = token.getId();
		Member member = memberReader.getMember(memberId);
		Post post = postReader.getPost(postId);

		postService.checkPostOwner(member, post);
		postService.delete(post);
	}
    
    ...
    
}

 

 

# 02. OCP - 개방-폐쇄 원칙

 

Bertrand Meyer가 만든 OCP의 용어 정의는 다음과 같습니다.

"소프트웨어 개체는 확장에는 열려 있어야 하고, 변경에는 닫혀 있어야 한다."

 

OCP는 위 정의 한 줄로 명확하게 정의되는 것 같습니다.

새로운 기능에 대해 요구사항을 받았을때, 기존 코드를 수정하는 일을 최소화하고 코드를 추가하는 것만으로 요구사항을 충족하도록 설계를 하자는 의미입니다.

OCP 원칙을 훌륭하게 충족한 아키텍처에서 새로운 기능에 대해 코드의 이상적인 변경량은 0입니다.

 

그럼 어떻게 OCP 원칙을 충족할 수 있을까요?

SRP 원칙을 충족함으로써 서로 다른 목적으로 변경되는 요소를 적절하게 분리하고, 하나에서 변경이 발생하더라도 나머지 부분에서는 변경되지 않도록 소스 코드 의존성도 확실히 조직화하는 것입니다.

 

또한 이때 관계의 의존성 방향은 변경으로부터 보호하려는 컴포넌트를 향하도록 단방향으로 지정하는 것입니다.

A 컴포넌트에서 발생한 변경으로부터 B 컴포넌트를 보호하려면 A 컴포넌트가 B 컴포넌트에 의존해야 합니다.

이러한 이유로 비즈니스 로직을 포함하는 컴포넌트 (애플리케이션에서 가장 높은 수준의 정책을 포함하는) 를 가장 OCP를 잘 준수할 수 있는 곳에 위치하도록 합니다.

이는 DDD 아키텍처 구조에서 도메인 레이어가 다른 레이어에 의존하지 않고 의존 받기만 하는 이유와도 크게 상관이 있을거라고 봅니다.

 

결국 OCP의 목적은 시스템을 확장하기 쉬운 동시에 변경으로부터 시스템이 너무 많은 영향을 받지 않도록 하는 것입니다.

이를 위해 저수준 컴포넌트에서 발생한 변경으로부터 고수준 컴포넌트를 보호할 수 있는 형태의 의존성 계층 구조를 설계하도록 합시다.

 

 

# 03. LSP - 리스코프 치환 원칙

 

1988년 Barbara Liskov가 정의한 리스코프 치환 원칙을 요약하자면 다음과 같습니다.

"상호 대체 가능한 구성요소를 이용해 소프트웨어 시스템을 만들 수 있으려면, 이들 구성요소 서로 치환 가능해야 한다는 계약을 반드시 지켜야 한다."

 

문장 그대로 받아들이기 어려울수도 있다고 생각되는데, 이를 쉽게 풀어서 얘기해보자면 다음과 같이 이해할 수 있습니다.

"서브 타입은 언제나 부모 타입으로 교체할 수 있어야 한다."

 

LSP의 경우는 위배하는 상황을 보면 더 와닿는 경우가 있었습니다.

LSP를 위배하는 상황을 알아보겠습니다.

// 부모 클래스: 모든 새는 날 수 있다고 가정
class Bird {
    public void fly() {
        System.out.println("Bird is flying.");
    }
}

// 자식 클래스 1: 오리(duck)는 날 수 있다.
class Duck extends Bird {
    @Override
    public void fly() {
        System.out.println("Duck is flying.");
    }
}

// 자식 클래스 2: 타조(ostrich)는 날 수 없다.
class Ostrich extends Bird {
    @Override
    public void fly() {
        // 부모의 계약(날 수 있음)을 위배함
        throw new UnsupportedOperationException("Ostrich cannot fly.");
    }
}

// 클라이언트 코드: Bird 타입의 객체를 받아 fly() 메서드를 호출함
public class BirdTest {
    public static void makeBirdFly(Bird bird) {
        bird.fly();
    }
    
    public static void main(String[] args) {
        Bird duck = new Duck();
        Bird ostrich = new Ostrich();
        
        makeBirdFly(duck);      // 정상: "Duck is flying." 출력
        makeBirdFly(ostrich);   // 예외 발생: Ostrich cannot fly.
    }
}

 

부모 클래스인 Bird에서 fly() 메서드를 통해 "Bird is flying." 메세지를 출력합니다.

이는 모든 새가 날 수 있다는 사실을 Bird 클래스에서 계약 해둔 것이며, 부모 타입으로 기대하는 동작은 새는 항상 날 수 있음이어야 합니다.

하지만 타조(Ostrich)는 날 수 없기 때문에 타조 객체에서 fly() 메서드를 호출하면 예외가 발생합니다.

이는 부모 타입으로 기대하는 동작을 만족시키지 못해 LSP를 위배한다고 볼 수 있습니다.

 

 

# 04. ISP - 인터페이스 분리 원칙

 

소프트웨어 설계자는 사용하지 않는 것에 의존하지 않아야 한다.

 

즉, 한 인터페이스에 너무 많은 기능을 몰아넣기보다는, 역할에 따라 보다 작은 단위의 인터페이스로 분리하여 설계해야 한다는 의미입니다.

 

ISP를 위반하는 상황의 예시 코드를 보겠습니다.

 

// ISP 위반: 여러 기능을 포함한 인터페이스
interface MultiFunctionDevice {
    void print(Document doc);
    void scan(Document doc);
    void fax(Document doc);
}

// 일부 기기는 인쇄 기능만 필요하지만, 위 인터페이스를 구현할 경우 사용하지 않는 메서드를 강제로 구현해야 합니다.
class OldFashionedPrinter implements MultiFunctionDevice {
    @Override
    public void print(Document doc) {
        // 인쇄 기능 구현
    }
    
    @Override
    public void scan(Document doc) {
        throw new UnsupportedOperationException("Scan 기능 미지원");
    }
    
    @Override
    public void fax(Document doc) {
        throw new UnsupportedOperationException("Fax 기능 미지원");
    }
}

 

 위 코드에서 OldFashionedPrinter는 인쇄 기능만 필요하지만, MultiFunctionDevice 인터페이스 때문에 scan과 fax 메서드를 구현해야 하며, 이는 불필요한 의존성을 초래합니다.

 

이 문제를 해결하기 위해 인터페이스를 기능별로 분리할 수 있습니다.

// 기능별로 인터페이스를 분리
interface Printer {
    void print(Document doc);
}

interface Scanner {
    void scan(Document doc);
}

interface Fax {
    void fax(Document doc);
}

// 인쇄만 필요한 기기는 Printer 인터페이스만 구현하면 됩니다.
class SimplePrinter implements Printer {
    @Override
    public void print(Document doc) {
        // 인쇄 기능 구현
    }
}

 

이처럼 ISP를 적용하면, 클라이언트는 자신이 필요로 하는 기능만 구현된 인터페이스에 의존하게 되고, 불필요한 메서드로 인해 발생하는 문제를 미연에 방지할 수 있습니다. 이는 전체 시스템의 유연성과 유지보수성을 크게 향상시키는 중요한 설계 원칙입니다.

 

 

# 05. DIP - 의존성 역전 원칙

 

"DIP에서 말하는 '유연성이 극대화된 시스템'이란 소스 코드 의존성이 추상(abstraction)에 의존하며 구체(concretion)에는 의존하지 않는 시스템이다."

 

위 문장에서 추상(abstraction)은 추상클래스나 인터페이스를, 구체(concretion)은 클래스를 의미합니다.

즉 어떤 Class를 참조해서 사용해야하는 상황이 생긴다면, 그 Class를 직접 참조하는 것이 아니라 그 대상의 상위 요소(인터페이스)로 참조하라는 원칙입니다.

구체의 경우 변화하기 쉽고, 추상의 경우 변화가 거의 없습니다.

변화가 없는 것에 의존을 해야 시스템이 외부의 변화나 세부 구현의 변경에 영향을 덜 받게 됩니다.

 

다음은 클린 아키텍처에서 전달하는 DIP 코딩 실천법입니다.

1. 변동성이 큰 구체 클래스를 참조하지 말라.
2. 변동성이 큰 구체 클래스로부터 파생하지 말라.
3. 구체 함수를 오버라이드 하지 말라.

 

 

 


 

항상 머리로는 알고 있다고 생각하지만, 말로 설명이 잘 되지 않는 개념이 SOLID 원칙이었습니다.

자바 개발자로써 당연히 100% 이해하고 실천해야하는 원칙이라 생각해서 블로그 첫 포스트의 주제를 SOLID 원칙으로 정했습니다.