
도메인과 애그리어트 루트, VO는 알겠는데....
application, infrastructure, presentation
얘네들은 뭐지...?
관심사를 분리시키고 의존도를 낮추는 것에 목적을 둔 아키텍처
로버트 C. 마틴(Robert C. Martin)이 제안한 아키텍처 원칙이다.
흔히 헥사고날(Hexagonal) 아키텍처 또는 포트와 어댑터(Ports and Adapters) 패턴 이라고 한다.
구성 요소는 크게 다음과 같다.
외부 세계(Web, DB, UI)와 애플리케이션 내부의 도메인 및 로직 사이를 연결
흔히 우리가 말하는 Controller 계층이 대표적 예시이다.
클린 아키텍처의 경계를 형성하며, 시스템의 독립성을 보장한다.
비즈니스 로직의 진입점
시스템의 usecase를 정의 및 구현하는 Service 계층이 위치한다.
Domain과 Adapter(Infrastructure)사이에 상호작용을 조정하고 흐름을 제어한다.
시스템의 핵심 비즈니스 로직을 구현
자세한건 요기에!
DDD의 주요 핵심이라 볼 수 있는 요소다.
비즈니스 객체, 도메인 서비스, 도메인 이벤트 등이 포함하고,
외부 요소에 의존하지 않으며 순수성을 보장한다.
클린 아키텍처의 원칙을 살펴보면, Entities를 향해 안쪽 방향으로 향한다.
즉, 안쪽 계층은 바깥 계층을 알지 못한다.
만약에 분리를 안하고 Domain에 @Entity, @Table 같은 JPA 애노테이션을 붙이면,
Entities(도메인)가 Frameworks(DB)에 의존하게 되고 이는 클린 아키텍처를 위반하게 된다.
안쪽에 있던 Entites가 DB를 의존하게 되는 현상이 되어버리기 때문이다.
DIP(의존성 역전 원칙)를 통해 의존성이 도메인에서 밖으로 나가는 부분이 없으므로,
외부 요소를 신경쓰며 개발 할 필요가 없기 때문이다.
public class User {
private final EntityManager em; // JPA 직접 의존 (외부 기술)
public void save() {
em.persist(this); // 비즈니스 로직 안에서 JPA 호출
}
}
User 라는 도메인이 JPA(EntityManager)를 의존하고 있다.
만약 MongoDB에 저장해야 된다면, 도메인 로직(User) 자체를 뜯어고쳐야 된다.
// 도메인 모델
public class User {
private final UserNo id;
private final Nickname nickname;
// ...
}
// Port (추상화)
public interface UserRepositoryPort {
void save(User user);
}
// Application Service (고수준)
public class CreateUserService {
private final UserRepositoryPort userRepository;
public CreateUserService(UserRepositoryPort userRepository) {
this.userRepository = userRepository;
}
public void execute(User user) {
userRepository.save(user); // 구현체는 모름
}
}
// Infrastructure (구현체)
public class UserJpaRepositoryAdapter implements UserRepositoryPort {
private final EntityManager em;;
public void save(User user) {
em.save(user); // JPA 세부 구현
}
}
한 회원이 "회원가입"을 하는데,
Controller에서 받아온 Dto를 User 도메인의 Entity로 변경까지 했다고 가정하자.
CreateUserService 라는 회원가입 기능의 Service 계층은 UserRepositoryPort 를 통해 도메인 Entity를 Adapter로 전달하고, Adapter는 이를 DB에 저장한다.
이렇게 되면,
도메인 계층이 외부 기술(JPA, Redis, Kafka, API 등)에 직접 의존하지 않게 된다.
Adapter 계층(Infrastructure)와 도메인 계층(비즈니스 로직)을 분리하고,
각 도메인별로 비즈니스 로직을 모듈화하면 서로 간 결합도가 낮아진다.
이로 인해 전체 시스템이 아니라, 특정 모듈만 선택적으로 빌드·배포하는 것이 용이해진다.
모듈화가 잘되어 있는 구조이다 보니, 기능 확장이 용이하다.
그냥 Port와 Adapter만 더 연결하면 된다.
본인의 역할을 수행하기 위해 필요한 Port만 사용하여, Mocking하면 된다.
나머지는 전부 다 "비즈니스 로직"이기 때문이다.
@RestController
@AllArgsConstructor
@RequestMapping("/api/user")
public class UserController {
private final CreateUserUseCase createUserUseCase;
@PostMapping("/signup")
public ResponseEntity<?> signUp(@Valid @RequestBody UserSignReqDto dto) {
var response = createUserUseCase.createUser(dto.toCommand());
return ResponseEntity.status(HttpStatus.CREATED).body(response);
}
}
클라이언트가 준 DTO를 Application의 Command로 변경 후,
UseCase의 createUser를 실행한다.
// Application의 DTO를 담당하는 Command
public record CreateUserCommand(
Nickname nickname,
SocialInfo socialInfo,
LoginInfo loginInfo,
Email email) {
}
// 반환값
public record CreateUserResponse() {
public static CreateUserResponse success() {
return new CreateUserResponse("Registration successful");
}
}
// UseCase
public interface CreateUserUseCase {
CreateUserResponse createUser(CreateUserCommand command);
}
// 회원가입 Service
@Transactional
@Service
public class CreateUserService implements CreateUserUseCase {
// private final UserRepositoryPort userRepositoryPort;
@Override
public CreateUserResponse createUser(CreateUserCommand command) {
User user = new User().register(
command.socialInfo(),
command.loginInfo(),
command.nickname(),
command.email()
);
// DB 저장
// userRepositoryPort.save(user);
// 응답 DTO 변환
return CreateUserResponse.success();
}
}
구현체를 통해 createUser로 들어와서 저장을 한다. (아직 DB 연결은 하지 않았다.)
그럼 적절하게 로직을 수행 후, 다시 클라이언트에게 반환한다.
더 자세한건, 깃허브 레포에 들어가보면 된다.
출처: [카카오페이] Hexagonal Architecture, 진짜 하실 건가요?
오 나도 몰랐던 새로운 단어를 배웠다.
Server Driven UI(SDU)
클라이언트(앱/웹)가 UI를 직접 구성하지 않고, 서버가 내려주는 UI 스펙(JSON 등)을 그대로 그림.
저 글에선, 카카오페이는 이 UI 방식을 사용한다고 한다.
예를 들면
{
"type": "button",
"label": "결제하기",
"color": "blue"
}
→ 클라이언트는 이 JSON을 해석해서 화면에 버튼을 그린다.
즉, 프런트엔드가 미리 UI를 다 만들어놓고, 데이터를 받는 형식이 아닌
백 서버에서 "야 여긴 결제 버튼 자리야!" 하고 보내주는 형식이다.
당연히, API 응답 스펙(필드 이름, 구조, 값 범위 등)이 변경되면
클라이언트 쪽에서 검증 로직 없이 그대로 UI에 반영되기에, 외부 의존성이 매우 높다.

이와 같은 현상을 해결하기 위해, 카카오페이는
"외부의 변화가 우리 서버의 핵심 로직(도메인)에 침투하지 못하도록 막는 구조적 안전장치"
가 필요하다고 판단해서 '핵사고날 아키텍처'를 일단 적용했다.
하지만... '핵사고날 아키텍처'가 완벽하지는 않는거 같다.
'핵사고날 아키텍처'가 만능은 아니다.
카카오페이는 '연동 API 인터페이스'라는 별도의 프로세스로 방파재 역할을 하고 있다.
근데 또 '핵사고날 아키텍처'로 방파재가 필요가 없단 뜻이다.
게다가, '핵사고날 아키텍처'의 도메인 계층은 외부 의존성과 완전히 분리된 핵심 비즈니스 로직을 담는다.
근데... 카카오페이의 저 서비스는,
외부 API에서 받아온 데이터들를 조합해서, 클라이언트가 그릴 수 있는 형태로 JSON을 내보내는 형태다
???
그러니까 "진짜 도메인"이 연동된 부서의 시스템 안에 있었던 거다.
보호해야 할 핵심 도메인이 명확하지 않으니, 사실상 필요가 없는 아키텍처가 되어 버렸다..
게다가 '핵사고날 아키텍처'라는 설명으로는 부족할 정도로...
기능에 비해 코드의 사이즈가 비대해졌다.
당연히 새로운 팀원이 들어올 때마다,
어떻게 동작하는지 설명하기 위해 많은 비용을 들여야 한다고 한다.
게다가 사람마다 또 '핵사고날 아키텍처'가 사람마다 생각하는게 조금씩 달라서,
결과적으로 안 그래도 많아진 파일에 산발적으로 로직이 다 흩어지며 관리 비용이 증가했다고 한다.
너무 많은 구현체, 클래스, 객체, 도메인, 목적, 사람마다 조금씩 다른 설명 등등...
이 아키텍처를 이해하는 시간만 3~4일 정도 소요되었다.
물론 도메인을 보호하고, 기능 확장이 용이하고, 테스트가 간단하다는 등등...
많은 장점이 있지만 "적절한 상황에 알맞게 쓰는게" 가장 중요하다.
"어? 이거 요즘 다 하자나. 이거 무조건 해야 돼!!" 라고 아무거나 막 하면 안된다.
'Over-Engineering(오버엔지니어링)'이 되어 버린다.
근데... 학습 목적으로 할려면 어카지...?
일단 정확하게 기술의 개념, 장점, 단점, 구조를 정확히 이해하는 것이 중요하다고 생각한다.
단순하게 "우리 서비스에 필요할까?" 라는 생각보단,
일단 "이런 기술이 왜 생겼을까?"로 생각해보자.
예로 들면,
필자같은 경우, 일부러 기술의 장점이 드러나는 상황을 설계해봤다.
예) 일부러 기술의 장점이 드러나는 상황을 설계해봄
→ DB를 JPA에서 MongoDB로 쉽게 교체 가능하게 만들기
"이 기술이 이러한 문제에 왜 필요한지?"
를 설명할 수 있어야 한다고 생각한다.
장점이 실질적으로 발휘되지 않는다면, 과감히 안써야 한다고 생각한다.
부캠하면서도 그렇고, 인스타 광고 등등 많은 글에서 이러한 문구를 봤다.
기술은 사용자의 편의 및 이익을 실현시키기 위한 수단일 뿐, 목적이 될 수 없다.
어떠한 기술을 사용하든 사용하지 않든 사용자에게 이익이 되는 것이 우선이다.
아무리 멋지고 유행하는 기술들과 아키텍처라도,
서비스에 맞지 않으면 오히려 개발 속도를 떨어뜨리고 복잡성을 높일 수 있다.
앞으로도 "기술을 쓰는 이유"를 먼저 고민하고,
그 목적과 상황이 맞을 때만 적용하려 한다.