또 뻔한 SOLID 글이 아닙니다!
최근 객체 지향과 소프트웨어의 품질을 높일 수 있는 방법들을 깊이 있게 공부하고 있어요. 지금은 OO 프로그래밍 세계에서 가장 대표적이고 잘 알려진 SOLID 법칙에 대해 자세히 배우고 있어요.
SOLID가 워낙 유명하지만, 전 실제로 사용해 보고 결과를 눈으로 확인해보기 전까진 잘 믿지 않는 성격이에요. 그래서 SOLID를 사용하면 정말 확장과 유지가 용이한 소프트웨어가 만들어지는지 직접 살펴볼 거예요. 이런 이유뿐만 아니라 모든 디자인 패턴들은 많은 경험을 통해 만들어진 정제된 법칙들이니 실생활 예제를 최대한 많이 접해봐야 더 잘 공감할 수 있다고 생각해요. 제가 발견한 디미페이에서 SOLID 법칙을 이용해 코드를 개선할 수 있는 두 가지 사례 공유해 볼게요.
이 글은 여러분이 SOLID 법칙과 OO에 대해 기초적인 지식이 있다는 가정 하에 작성되었기 때문에 법칙들에 대한 기초적인 설명은 생략됐어요. 익숙하지 않는 개념이 있다면 글 아래의 참고 서적을 참고해주세요.
QR 토큰 매니저 (SOLID + CCP)
사실 저희는 두 가지 종류의 QR 코드를 다루고 있어요. 하나는 "레거시 토큰"으로 예전부터 사용하던 인터넷을 통해 발급한 QR 코드이고, 다른 하나는 새로 도입한 인터넷 없이 발급할 수 있는 "로컬 토큰"이에요. 레거시 토큰을 지원해야 하는 이유는 구버전 앱에 대한 호환성 때문이에요. 그리고 로컬토큰은 비교적 복잡한 로직을 가지고 있기 때문에 언제든 새로운 사양으로 업데이트가 될 수 있어 더 많은 유형의 QR 코드도 유연하게 처리할 수 있어야 해요.
키오스크는 아무것도 모른다! (OCP)
서버가 QR 코드의 종류에 따라 각각의 API를 제공하고, 키오스크가 QR 코드의 종류 알아내어 적절한 API를 호출하도록 설계하면 안 되는 이유는 굳이 설명하지 않아도 될 것 같아요. 토큰의 접두사가 변경되거나 새로운 종류의 QR 코드를 다루는 등의 변경 사항이 발생하면 서버 코드뿐만 아니라 키오스크의 코드도 수정해야 해요. 이는 OCP를 위반하는데, OCP는 하나의 변경 사항으로 다른 컴포넌트를 연속적(cascading)으로 수정하지 말아야 한다고 말해요. 이러한 속성을 rigidity(융통성 없는/뻣뻣한)라고 불러요.
When a single change to a program results in a cascade of changes to dependent modules, the design smells of Rigidity. [Martin ASD]
애초에 QR에 대한 정보를 알아내는 건 키오스크의 책임도 아니죠. 키오스크는 그냥 QR 결제를 요청하는 하나의 API만 알면 돼요.
법칙들을 반드시 코드 수준에서만 지킬 필요는 없어요. 클린 아키텍처에서 그랬듯, 이들은 더 큰 아키텍처 단위로 확장해 적용할 수 있어요. [Martin CA]
전략 패턴 (DIP & LSP)
저는 여러 종류의 QR 코드를 지원하기 위해 전략 패턴을 사용했어요. 전략 인터페이스 이름은 QRTokenManager이고, 여기엔 토큰 생성과 파싱을 담당하는 메서드가 포함돼 있어요. 구체적인 구현 방법을 보기 전에 전략 패턴이 어떻게 SOLID를 준수하는지 살펴볼게요. Agile Software Development에선 전략 패턴을 다음과 같이 소개해요.
The STRATEGY pattern solves the problem of inverting the dependencies of the generic algorithm and the detailed implementation in a very different way.
전략 패턴은 클라이언트를 추상화된 알고리즘에 의존하게 하여 구체적인 구현들을 숨기는 방법이에요. DIP를 사용해요. 추상화에 의존하니 자연스럽게 의존성이 역전되고, QRTokenManager의 소유권도 코어 레이어가 가져가게 되었어요.
QRTokenManager 구현 (SRP, OCP & ISP + CCP)
이제 QRTokenManager를 구현해 볼게요. 위에서 잠깐 소개했는데, QRTokenManager는 토큰을 생성(generate)하고 파싱(parse)하는 메서드를 가지고 있다고 했어요. 여기에 추가로 토큰을 파싱 하기 전 접두사나 토큰의 길이를 통해 파싱이 가능한지 평가하는 canParse 메서드도 추가할게요.
interface QRTokenGenerator {
generate(paymentMethod: PaymentMethod): Promise<string>
}
interface QRTokenParser {
canParse(token: string): boolean
parse(token: string): Promise<PaymentMethod>
}
interface QRTokenManager extends QRTokenGenerator, QRTokenParser { }
주목할 점은 QRTokenGenerator와 QRTokenParser 인터페이스가 따로 존재한다는 거예요. 왜 분리했을까요?
토큰 생성과 파싱 로직은 매우 강한 응집력(cohesion)을 가지고 있어요. 만약 토큰을 만드는 방법이 수정된다면 파싱 메서드도 항상 함께 변경되어야 할 거예요. 이는 CCP(Common Closure Principle)와 OCP를 따라요. 그럼 당연히 모든 메서드를 한 곳에 모아야 하지 않을까요?
하지만 이는 SRP와 ISP를 위반하는 행동이에요. 생성과 파싱은 분명 다른 책임이고, 이 메서드를 사용하는 클라이언트도 분명 달라요. SRP를 지키려면 두 책임은 반드시 분리해야 하고, ISP를 따르려면 클라이언트를 사용하지 않는 것에 의존하도록 강제하면 안 돼요. 둘은 분리해야 하죠. 정말 진퇴양난이에요.

어떤 법칙을 더 중요하게 여기느냐에 따라 구현이 달라지겠지만, 전 SRP와 CCP를 동시에 만족하는 방향을 원했어요. 그래서 QRTokenGenerator와 QRTokenParser 인터페이스를 따로 만들고, QRTokenManager가 이 둘을 확장(extend)하게 했어요. 인터페이스가 하나 더 생기는 건 전혀 문제 되지 않을 거예요. 실제 파생(derivative) 객체는 QRTokenManager를 구현하고, 토큰 매니저를 사용하는 클라이언트는 QRTokenGenerator와 QRTokenParser 인터페이스를 이용해요.
QRTokenParser (composite pattern)
이 글의 주제를 조금 벗어나는 내용이지만, 마지막에 꼭 하고 싶은 말이 있어 QRTokenGenerator와 QRTokenParser를 사용하는 방법도 함께 적어볼게요.
QRTokenGenerator의 클라이언트는 QRTokenGenerator의 구현체 당 하나라고 생각해도 괜찮을 것 같아요. 조건에 따라 적절한 Generator를 선출할 필요가 없으니 QRTokenGenerator를 사용하는 데엔 흥미로운 부분이 없어요.
하지만 QRTokenParser의 클라이언트는 반드시 하나예요(그림 1 참고). 문자열 형태의 토큰만으론 어떤 토큰 파서를 써야 할지 당장 알 수 없기 때문이에요. 모든 QRTokenParser를 순회(iterate)하며 canParse 메서드를 호출해 적절한 파서를 선출(delegate) 해야 하죠.
registry나 펙토리 패턴을 사용할 수 있을 것 같지만, 전 composite 패턴을 사용해 봤어요. CompositeQRTokenParser는 QRTokenParser 인터페이스를 구현하지만, QRTokenParser의 모든 파생 객체의 참조를 가지고 있어요. 파서를 사용하는 클라이언트는 composite 객체의 parse 메서드를 호출하기만 하면 돼요.
class CompositQRTokenParser implements QRTokenParser {
private parsers: QRTokenParser[] = []
public addParser(parser: QRParser): this {
this.parsers.push(parser)
return this
}
public canParse(token: string): boolean {
return this.parsers.some(parser => parser.canParse(token))
}
public parse(token: string): Promise<PaymentMethod> {
const parser = this.parser.find(parser => parser.canParse(token))
if(parser === undefined) {
throw new Error('Unsupported QR')
}
return parser.parse(token)
}
}
Nest.js에선 Composite 객체를 다음과 같이 초기화할 수 있어요.
@Module({
providers: [
LegacyQRTokenManager,
V1LocalQRTokenManager,
{
provide: CompositeQRTokenManager,
inject[LegacyQRTokenManager, V1LocalQRTokenManager],
useFactory: (legacy, v1local) =>
new CompositeQRTokenParser()
.addParser(legacy)
.addParser(v1local)
}
]
})
class TransactionModule {}
어떤 사람은 이 방법이 마음에 들지 않을 수도 있어요. composite 객체를 등록하는 방법이 OCP를 준수하지 않기 때문이에요. 새로운 QRTokenManager가 추가되면 CompositeQRTokenParser도 수정되어야 해요. Agile Software Development에서도 이 점을 언급했는데, 그 부분을 인용해볼게요.
In general, no matter how “closed” a module is, there will always be some kind of change against which it is not closed. There is no model that is natural to all contexts!
이 문제는 오래전부터 잘 알려진 문제라 Single Choice 라는 이름의 패턴으로 만들어 지기도 했어요. 파생(derivative) 객체에 대한 모든 리스트(exhaustive list)를 최대 하나의 컴포넌트만 알고 있어야 한다는 내용이에요.
Whenever a software system must support a set of alternatives, one and only one module in the system should know their exhaustive list. [Meyer OOSC2]
법칙은 언제까지나 법칙일 뿐 꼭 모든 부분에서 의무적으로 따라야 할 필요는 없고, 그럴 수도 없어요. 중요한 건 "열린" 곳이 핵심 로직을 담당하는 부분이 아니라 의존성 주입 부분으로 미뤘다는 거라고 생각해요.
만약 더 좋은 방법이나 새로운 접근 방법이 있다면 댓글로 알려주세요:)
2. 포트-어댑터 아키텍처 (DIP)
의존성 역전의 법칙은 아마 가장 많은 정의를 가지고 있는 법칙일 거예요.
a. High-level modules should not depend on low-level modules. Both should depend on abstractions.
b. Abstractions should not depend on details. Details should depend on abstractions.
[Martin ASD]
고수준(상위) 모듈은 저수준(하위) 모듈에 의존하면 안 되고, 둘은 추상화에만 의존해야 하는 게 핵심이에요. 여기서 고수준이라는 건, 상대적인 개념이긴 하지만, 보통 중요한 비즈니스 로직이 포함된 내부 계층이고, 저수준 모델은 프레젠테이션 계층 같은 외부 계층을 뜻해요.
포트-어댑터 아키텍처는 이러한 DIP의 이러한 속성을 잘 보여주고 있어요. 이 아키텍처는 헥사고날 아키텍처라는 이름으로도 잘 알려져 있는데, 도메인 로직에 필요한 외부 기능을 도메인 계층에 포트라는 인터페이스로 만들어 코어 계층에서 안정적으로 외부 서비스를 사용하도록 하고, 어댑터라는 구현체는 애플리케이션의 바깥에(인프라 계층) 두는 방법이에요. 이 아키텍처에 대한 자세한 내용은 콕번의 글을 참고해주세요.
예를 들어 푸시 알림을 보내는 경우를 생각해볼게요. 저희는 결제가 완료됐거나 새로운 기기에 로그인되는 경우 푸시 알림을 보내고 있어요. 이렇게 언제 알림을 보낼지 결정하는 건 중요한 비즈니스 로직에 속해요. 하지만 푸시 알림을 직접 보내기 위해선 서드파티 API(FCM)를 사용해 보내야 해요. 비즈니스 계층에서 필수적으로 사용되지만, 구체적인 구현은 관심이 없기 때문에 구현에 관한 부분은 외부로 격리해요.
만약 DIP를 사용하지 않았다면 도메인 계층은 인프라 계층에 의존해야 해요. 하지만 DIP 덕분에 푸시 알림 인터페이스의 소유권을 도메인 계층이 가질 수 있게 되었고, 구체적인 구현은 여전히 인프라 계층에서 관리할 수 있죠.
결론
이제 SOLID가 확장에 용이하고 유지 보수가 쉬운 소프트웨어를 만들 수 있는지 확인해볼 차례예요.
- 쉽게 새로운 QR 토큰 매니저를 추가할 수 있는가? ✅
- QR 토큰 매니저를 수정해도 잠재적으로 영향을 받는 컴포넌트가 없는가? ✅ (단 한 곳, composit 객체만 영향 받음)
- 도메인 로직이 외부로 유출되지 않았는가? ✅
- 도메인 계층이 외부 기술적 구현에 의존하지 않는가? ✅
소프트웨어 개발에서 가장 무서운 게 내가 알지 못하는 의존성이라고 생각해요. 그럼 코드를 조금만 수정하더라고 내가 모르는 곳에 부작용(side effect)이 생길 수 있고 이건 정말 끔찍한 일이에요. 테스트를 꾸준히 작성했다면 초기에 원인을 쉽게 찾을 수 있겠지만, 테스트가 근본적인 해결책은 아니에요. 저는 이런 부분에서 특히 설계 원칙 중요성을 강조하고 싶어요.
참고서적
[Martin CA]
- Martin, Robert C. Clean Architecture: A Craftsman’s Guide to Software Structure and Design. Pearson Professional, 2018.
- 한글 번역판: 『클린 아키텍처: 소프트웨어 구조와 설계의 원칙』, 인사이트, 2019
[Martin ASD]
- Martin, Robert C. Agile Software Development: Principles, Patterns, and Practices. Pearson, 2003.
- 한글 번역판: 『클린 소프트웨어』, 제이펍, 2017
[Meyer OOSC2]
- Meyer, Bertrand. Object-oriented Software Construction. Prentice Hall, 1997.
'디미페이' 카테고리의 다른 글
Local Token 0.4 Release Note (1) | 2024.11.24 |
---|---|
QR 코드에 최대한 많이 때려넣기 (0) | 2024.10.15 |
🤫오프라인이지만 온라인이어야 해요 - 로컬 생성 결제 토큰 (5) | 2024.10.05 |
🔩Nest.js로 견고한 백엔드 만들기 (0) | 2024.10.01 |
🐣백지에서 시작하는 디미페이 v2 백엔드 리팩터링 (3) | 2024.09.28 |