OOP 설계 원칙 - KimGyuBek/Threadly GitHub Wiki
Threadly는 단순히 객체지향의 핵심 원칙(SOLID)을 표면적으로 따라가는 수준이 아니라,
프로젝트 전반에서 객체지향 설계가 실제 구조와 동작에 반영되도록 설계했다.
도메인 중심 구조(DDD), 헥사고날 아키텍쳐, 멀티 모듈 분리와 같은 아키텍처적 기반이 서로 맞물리며,
프로젝트 전체가 OOP 철학을 중심으로 동작하도록 구성되어 있다고 자신한다.
그 결과, 단순 CRUD 기반 프로젝트와 달리, 객체 지향 설계 원칙이 자연스럽게 코드 구조에 녹아 있는 아키텍쳐를 갖추게 되었다.
"하나의 클래스는 하나의 책임만 가진다."
Command(쓰기)와 Query(읽기)를 명확히 분리하여 각 기능이 맡아야 할 책임만 가지도록 구성되어 있다.
예시:
PostCommandService, FollowQueryUseCase, UserCommandPort
이 구조 덕분에
조회용 객체가 불필요한 저장 로직을 알 필요가 없고,
쓰기 로직은 조회 책임과 섞이지 않아 유지보수가 훨씬 쉬워진다.
"확장에는 열려있고 수정에는 닫혀있어야 한다."
Threadly의 도메인 계층은 어떤 외부 기술도 모르며, 오직 Port 인터페이스만 바라본다.
즉, 새로운 인프라가 필요해도 Service, UseCase, 도메인 로직은 수정할 필요가 없다.
확장이 필요한 경우, 새로울 기술을 구현하는 Adapter 모듈만 추가하면 된다.
예시:
Kafka를 추가해야하는 경우
threadly-adapters/
├── adapter-persistence/
├── adapter-redis/
├── adapter-kafka/ <추가됨>
Kafka를 도입할 때 필요한 변경은 다음과 같다.
-
core-port.out에Kafka관련Port구현체 정의 -
adapter-kafka모듈 추가 후 구현- 이 과정에서 도메인, 서비스, 유즈케이스 계층의 코드는 단 한줄도 수정되지 않는다.
따라서 새로운 외부 기술 추가에는 열려있고, 핵심 로직은 수정할 필요가 없어 닫혀있는 OCP를 정확히 만족한다.
모든 알림 메타데이터를 sealed interface로 묶어 관리하며,
새로운 알림 타입을 추가하더라도 기존 코드를 수정할 필요 없이 확장 가능하도록 설계했다.
예시:
//NotificationMetaData.java
public sealed interface NotificationMetaData
permits PostLikeMeta, PostCommentMeta, CommentLikeMeta, FollowRequestMeta, FollowMeta,
FollowAcceptMeta {
NotificationType notificationType();
}다음과 같은 이유로 이 구조는 OCP를 만족한다.
-
타입 안정성 확보
-
sealed interface는 어떤 구현체가 허용되는지 컴파일 타임에 강제한다. - 허용되지 않은 타입을 사용하면 컴파일 단계에서 바로 예외가 발생하여 런타임의 오류를 방지한다.
-
-
확장에는 열려있음
- 새로운 알림 타입 추가 시 새 클래스만 생성하면 되어, 확장에 열려있다.
- 기존 메타데이터 구현체는 수정이 불필요하므로 닫혀있다.
-
패턴 매칭에서 모든 타입을 강제 처리
-
Java 17의 패턴 매칭switch는sealed interface의 모든 구현 타입을 처리하도록 강제한다. - 만약 누락 타입이 있으면 컴파일 에러가 발생해 안정성을 높인다.
-
예시:
// MetaDataMapper.java
public NotificationMetaData toTypeMeta(NotificationType type, Map<String, Object> raw) {
Class<? extends NotificationMetaData> clazz = switch (type) {
case POST_LIKE -> PostLikeMeta.class;
case COMMENT_ADDED -> PostCommentMeta.class;
case COMMENT_LIKE -> CommentLikeMeta.class;
case FOLLOW_REQUEST -> FollowRequestMeta.class;
case FOLLOW_ACCEPT -> FollowAcceptMeta.class;
case FOLLOW -> FollowMeta.class;
};
return objectMapper.convertValue(raw, clazz);
}하위 타입은 상위 타입을 대체하더라도 동작이 변하지 않아야한다.
즉, 같은 인터페이스를 구현하는 여러 구현체는 서로 교체되어도 시스템의 의미와 동작이 동일해야 한다.
Threadly에서는 이 원칙이 Service - Port - Adapter 구조에서 자연스럽게 충족된다.
-
Service계층은 외부 기술을 전혀 모르고, 오직Port인터페이스에만 의존한다. -
Port는 각각의Adapter에서 구현하며, 어떤Adapter를 사용하더라도 동일한 방식으로 동작한다. - 따라서
Adapter를 변경해도Service의 코드는 단 한줄의 수정되지 않는다.
그 결과, 구현체 변경(Local -> S3, PostgreSQL -> MongoDB 등 외부 인프라 교체)이 발생해도 Service는 동일하게 동작한다.
이것이 바로 LSP의 핵심인 치환 가능성이다.
예시:
기존에는 PostCommandPort를 PostgreSQL 기반 Adapter가 구현하고 있다.
public class PostPersistenceAdapter implements PostCommandPort {...} DB를 MongoDB로 교체해야한다면, 그냥 새로운 Adapter를 추가 후 PostCommandPort를 구현하기만 하면 된다.
public class MongoPostPersistenceAdapter implements PostCommandPort {...} Service 코드는 변경 없음.
postCommandPort.save(post);
상위 타입(PostCommandPort)은 동일하게 사용되므로
구현체(PostgreSQL -> MongoDB)가 바뀌더라도 서비스의 동작은 동일하다.
따라서 LSP가 추구하는 "하위 타입의 완전한 대체 가능성"을 충족하는 구조다.
한 인터페이스가 너무 많은 책임을 가지면 안 되며, 클라이언트는 자신이 사용하지 않는 메서드에 의존해서는 안 된다.
하나의 범용 인터페이스보다 특화된 여러 인터페이스가 낫다.
즉, 하나의 인터페이스가 너무 많은 역할을 가지면 안 되고, 클라이언트는 사용하지도 않는 메서드에 의존해서는 안 된다.
Threadly에서는 USeCase와 Port 모두 Command, Query로 분리해서 설계했다.
UseCase 계층에서 쓰기와 읽기를 분리했다.
예시:
PostCommandUseCase
PostQueryUseCase
FollowCommandUseCase
FollowQueryUseCase
이렇게 해두면
- 작성용
Controller는PostCommandUseCase만 의존 - 쓰기용
Controller는PostQueryUseCase만 의존
따라서, 조회만 하는 Controller가 불필요하게 "생성/수정/삭제" 메서드가 들어있는 인터페이스를 의존할 필요가 없다.
Controller는 자기 역할에 맞는UseCase만 의존
외부 인프라와 연동으르 위한 Port도 역할별로 분리했다.
얘시:
PostCommandPort
PostQueryPort
FollowCommandPort
FollowQueryPort
그 결과
- 조회 관련
PostCommandService에서는PostCommandPort만 의존 - 쓰기 관련
PostQueryService에서는PostQueryPort만 의존
Service 입장에서도
- 쓰기 로직은 "쓰기용
Port만" - 조회 로직은 "조회용
Port만"
알면 되기 때문에, Service가 사용하지 않는 메서드를 가진 거대한 Port를 의존하지 않는다.
이게 바로 ISP에서 말하는
"불필요한 메서드에 보다 특화된 여러 인터페이스가 낫다"
를 그대로 구현한 구조다.
Command / Query 분리 구조는 SRP와 ISP가 동시에 충족되는 형태지만,
두 원칙이 바라보는 관점은 다르다.
SRP에서 중요한 것은
"하나의 클래스는 하나의 책임만 가진다."
Threadly에서 Command와 Query를 나눈 이유 중 하나는 쓰기/읽기의 책임을 명확하게 나누기 위함이다.
즉, 책임을 단일화하기 위함이다.
ISP의 핵심은
"불필요한 메서드를 가진 인터페이스에 의존하지 말아라"
Threadly에서 Command / Query 기준의 인터페이스 분리는
Controller 및 Service가 자신에게 필요한 인터페이스만 의존하도록 하기 위한 목적이 더 크다.
즉, 인터페이스가 불필요하게 비대해지는 것을 막기 위한 관점에서의 분리다.
CQRS(
Command/Query기준 분리 전략)는 여러 분리 전략의 하나일 뿐
현재는 Command/Query로 나누는 CQRS 방식을 사용하지만 ISP 관점에서 더 다양한 분리 방법도 가능하다.
예를 들어, 행위(Action) 단위의 분리, 도메인 기능별 분리 등 다양한 방식이 있다.
관련 문서: CQRS 설계
고수준 모듈은 저수준 모듈에 의존하지 않아야 한다.
Threadly는 이 원칙을 구조 전체에 실제로 구현하고 있는 형태다.
Service -> Port -> Adapter
의존의 흐름은 철저히 이 방향만 허용된다.
-
Service는Port(추상화)에만 의존 -
Adapter는Port의 구현체일 뿐 - 도메인/서비스 계층은 어떤 외부 기술도 모름
- 고수준 모듈이 저수준을 모르니 기술 교체에도 영향이 없음
즉, Service는 DB가 PostGreSQL인지 MongoDB인지, 파일 업로드가 Local인지 S3인지 전혀 알 필요가 없다.
그저 Port로 정의된 추상 메서드만 호출하면 된다.
Threadly는 모든 인프라 기술을 Adapter로 캡슐화했다.
덕분에 환경별로 구현체를 선택하는것도 아주 간단하다.
@Profile만 바꾸면 끝이다.
@Repository
@Profile("local")
class LocalImageUploadAdapter implements UploadImagePort { ... }
@Repository
@Profile("prod")
class S3ImageStorageAdapter implements UploadImagePort { ... }
Service는 여전히 동일한 Port만 바라보므로 코드는 그대로 유지된다.
- 기술 독립성
- 서비스/도메인 계층은 외부 기술을 모름
- 어댑터를 교체해도 코드 수정이 없음
- 테스트 용이성
-
Port는 인터페이스이므로 단위 테스트에서 쉽게mock으로 대체 가능 - DB 없는 테스트 가능
-
- 확장성
- 새로운 외부 기술이 필요하면
Adapter만 추가하면 된다. - 기존 코드에는 단 한 줄도 변화 없음
- 새로운 외부 기술이 필요하면
- 유지보수성
- 변경 포인트가 항상
Adapter에만 집중됨 - 버그가 새겨도 추적 범위가 좁아짐
- 변경 포인트가 항상
Threadly에서는 핵심 도메인부터 Adapter까지, 모든 계층이 의존성 방향을 일관되게 유지하도록 설계했다.
그 결과 외부 기술 교체/확정/테스트/운영 등 어떤 상황에서도 안정성과 유연성을 잃지 않는 구조를 갖추었다고 자신한다.