헥사고날 아키텍처
헥사고날 아키텍처라고 이름이 어려운 한 설계구조가 있다.
헥사고날 아키텍처(Hexagonal Architecture)는 2005년 Alistair Cockburn이 제안한 아키텍처 패턴이다. 포트와 어댑터 패턴(Ports and Adapters Pattern) 이라고도 부른다. 이 아키텍처의 핵심 목적은 애플리케이션을 외부 요소로부터 격리하여 도메인 로직을 보호하는 것이다.
Port and Adapter Pattern
포트와 어댑터 패턴은 무엇일까?
쉽게 설명하면 포트는 추상화 어댑터는 구현체 라고 생각하면 이해하기 쉽다.
예를 들어, 나의 핸드폰이 C타입 충전기를 사용한다고 생각해보자.
그렇다면 나의 핸드폰은 C타입만 호환되는 핸드폰이다. 여기서 C타입이 바로 포트와 어댑터 패턴에서 이야기하는 포트이다.
포트란, 어플리케이션 코어, 즉 어플리케이션의 핵심로직을 사용하기 위한 input을 추상화 시켜서 이렇게 사용해주세요 라는뜻이다.
C타입 충전을 지원하는 핸드폰, 하지만 케이블은 어떠한 제조사, 어떠한 성능을 가지던 간에 C타입만 구현되어 있으면 꽂을 수 있고 충전이 가능하다는 뜻이다.
그렇다면 여기서 어댑터(Adapter) 가 바로 C타입의 규격에 맞게 구현해서 삼성, 애플, 샤오미.. 등등 회사에서 C타입의 규격에 맞게 만들어낸 실제 제품, 즉 구현체 라고 할 수 있다.
이제 다음사진을 보면 충분히 이해될 것이다.
생각보다 그림이 복잡할 수 있지만 간단하게 생각하면 오히려 잘 보이는데, 중간에 희미한 원으로 둘러싸인 곳이 어플리케이션 코어 이다.
이런식으로 구현하게 되면 Adapter만 변경하고 내부 비즈니스 로직과의 연관 관계가 느슨하게 잡히기 때문에 변경에 용이하게 된다.
그림에서 좌측이 User Interface 계층, 우측이 Infrastructure 계층이다. 좌측은 사용자의 요청을 받아오는 곳이라고 생각하면 된다. 간단한 예를 들면 사용자가 회원가입을 요청한다고 가정하자. 그렇다면 회원가입 이라는게 하나의 유스케이스로 작용하게 된다.
유스케이스란?
유스케이스는 사용자가 시스템을 통해 달성하고자 하는 목표를 의미한다. 쉽게 말해 "사용자가 시스템에게 요청하는 작업"이라고 볼 수 있다.
회원가입을 유스케이스로 본다면, 사용자는 시스템을 통해 회원가입이라는 목표를 달성하려 하는 것이다.
그렇다면 Port는 어떻게 구현되어야 하고 Adapter는 어떻게 구현되어야 하고 애플리케이션 코어에서 어떻게 영속 계층에 저장하는지 지금부터 예제를 통해 알아본다.
예제를 통해 헥사고날 아키텍처와 포트와 어댑터 패턴을 이해하기
1. 도메인 객체 생성
우리에게 주어진 요구사항은 다음과 같다
- 유저는 id, password, username을 입력하고 회원가입한다.
- 간단한 예제이기 때문에 중복을 허용한다.
- 회원가입한 정보는 어플리케이션이 종료되기 전까지 유지된다.
그렇다면 여기서 유스케이스를 회원가입 으로 볼 수 있다.
그렇다면 먼저 도메인 패키지를 만들고 도메인 객체를 구현하자. 도메인은 User가 될것이다.
User 도메인은 매우 심플하다 id, password, username을 가진다.
public class User {
private String id;
private String password;
private String username;
public User(final String id, final String password, final String username) {
this.id = id;
this.password = password;
this.username = username;
}
public String getId() {
return id;
}
public String getPassword() {
return password;
}
public String getUsername() {
return username;
}
}
생성자를 통해 객체를 생성하고 get을 통해 내부 값을 가져올 수 있는 정도면 객체의 설계는 충분하다. validation과 exception처리는 생략한다.
2. 도메인 객체를 토대로 Port 추상화 설계
회원가입을 할때 유저는 id, password, username의 값을 입력할 것이다.
여기서 방법은 전혀 상관이 없다. 콘솔 입력, 웹 요청.. 등등 이런 방법은 전혀 상관없다 그것은 나중에 한번에 구현을 할 것이다.
유저로 부터 값을 입력받고 가져오는 것을 추상화시켜보자
여기서 그림을 한번 참고해보자 Port는 어디의 소속인가? 바로 어플리케이션 코어 소속이다. 어플리케이션 코어의 스펙대로 포트는 만들어 져야되고 우리는 회원가입을 구현하기 위해 유저로 부터 id, password, username을 넘겨줘야 하는 것이다.
그렇다면 먼저 유저로 부터 받을 값을 dto로 구현해보자. 패키지 구조는 다음과 같이 설계한다.
그리고 클래스를 다음과 같이 작성한다.
// SignupCommand
public record SignupCommand(String id, String password, String username) {}
// SignupResponse
public record SignupResponse(String id, String username) {}
// SignupUseCase
public interface SignupUseCase {
SignupResponse signup(SignupCommand signupCommand);
}
SignupUseCase는 매개변수로 SignupCommand를 받고 SignupResponse로 돌려주는 아주 쉬운 추상화된 인터페이스이다.
이게 User Interface 부분 Port의 전부이다.
핸드폰으로 비유해서 우리는 C타입으로 충전하기 때문에 C타입 규격으로 만든거 가져오세요~ 까지 된것이다.
이제 내부 비즈니스 로직을 만들어보자
3. 애플리케이션 코어 로직 구현
애플리케이션 코어도 간단하다. 우리에게 주어진 요구사항 중 애플리케이션이 종료되기 전까지 데이터 유지
이걸 하려면 자연스럽게 Repository가 생각날 것이다. 유저의 정보를 어느곳에서든지 저장을 시켜야 유지가 되기 때문이다.
여기서 어디에저장할 지는 절대 고민할 필요가 없다 그건 Adapter라는 구현체에서 할 일이지 Port 즉, 애플리케이션 코어를 구현하는데는 전혀 생각할 필요가 없다.
그렇다면 넘겨받은 SignupCommand를 비즈니스로직을 거쳐 저장을 시키도록 Repository 계층에 보내야하는 것이다. 패키지 구조는 다음과 같다.
생각보다 매우 간단하다. UserRepository의 로직은 다음과 같다.
public interface UserRepository {
User save(User user);
}
Repository를 거쳐 영속계층을 통과하려면 save 메서드를 통해서 데이터를 저장해야 되는 것이다.
이제 어느정도 흐름이 만들어졌다.
- SignupUseCase를 통해 입력을 받음
- 애플리케이션 코어 로직을 처리
- UserRepository에 저장
이제 마지막 2번이 남았다. 비즈니스 로직을 처리하는 부분이 필요하다. 입력받은 부분을 도메인 객체로 생성하고 그걸 저장하는 것이다.
4. 비즈니스 로직 처리하고 도메인을 저장하기
이제 실제로 로직을 처리하고 Repository에 저장하고 그 결과를 반환하는 비즈니스 로직 처리, 즉 애플리케이션의 코어를 담당하는 부분을 만들어보자.
@Service
public class UserService implements SignupUseCase {
private final UserRepository userRepository;
public UserService(final UserRepository userRepository) {
this.userRepository = userRepository;
}
@Override
public SignupResponse signup(final SignupCommand command) {
final User user = new User(command.id(), command.password(), command.username());
final User saveUser = userRepository.save(user);
return new SignupResponse(saveUser.getId(), saveUser.getUsername());
}
}
애플리케이션의 비즈니스 로직을 담당하는 UserService를 생성하고 signup메서드를 통해 비즈니스 로직 처리후 영속계층에서 유저의 정보를 유지 및 저장하는 것 까지해준다음 User Interface에 SignupResponse를 반환해준다.
UserService는 핵심 비즈니스 로직을 처리함과 동시에, User Interface와 영속계층, Infrastructure들을 연결해주는 역할을 한다고 생각하면 좋다.
이렇게 하면 port -> 비즈니스 로직 -> port 로 이어지는 추상화된 하나의 유스 케이스를 처리했다. 이를 바탕으로 이제 구현체를 만들고 연결해주기만 하면 끝난다.
5. 어댑터 구현
아까 C타입 충전기를 바탕으로 설명했을때 다음과 같이 설명했다.
C타입이라는 규격만 맞추면 어떤 회사에서 만들던 간에 다 같은 C타입 충전기이다
이 개념을 어댑터에 적용시키는 것이다.
어댑터는 추상화된 유스케이스와 Repository를 상속받아 만들면 그 규격에 맞게 만들어지는 것이 되는 것이다.
실제로 어댑터를 구현하는 패키지를 설계하고 만들어보자
5-1. Input 어댑터 구현
우리가 크게 이 상황을 두가지로 나눌 수 있다. 계속 얘기하고 있는 User Interface와, 영속계층
먼저 User Interface를 통해 구현해보자.
구현 방식은 여러가지가 있다. 웹 요청을 통해 받는다던가, 혹은 콘솔을 통해 받는다던가.. 결론적으로 크게 의미가 없다 우리는 정해진 Port의 규격에 맞추어서 구현하면 되기 때문이다.
이것이 헥사고날 패턴이 가진 가장 강력한 강점이다. 포트의 규칙만 지켜서 구현체만 바꿔껴주면 비즈니스의 핵심 로직은 변하지 않는다. 이것이 헥사고날 패턴, 포트 어댑터 패턴이 존재하는 이유라고 봐도 무방할 것이다.
User Interface부분은 웹에서 입력받은 값을 가지고 사용할 것이다. Port는 값만 받으면 되고 어디서 어떻게 가져오든지 간에 관심이 없다. 그냥 SignupCommand라는 매개변수의 타입에 맞도록 값만 가지고 가면 되기 때문이다.
@Controller("/user")
public class UserController {
private final SignupUseCase signupUseCase;
public UserController(final SignupUseCase signupUseCase) {
this.signupUseCase = signupUseCase;
}
@PostMapping
public SignupResponse signup(final SignupCommand signupCommand) {
return signupUseCase.signup(signupCommand);
}
}
웹 요청을 처리하는 controller를 구현하고 SignupUseCase를 생성자로 주입받아준다.
UserService 를 받아야 되는거 아닌가요? 할 수 있다.
하지만 이렇게 해야 의미가 있다고 볼 수 있는게, UserService에서 결론적으로 SignupUseCase를 구현해서 만들어 졌기 때문에 결국 UserService가 스프링 DI 컨테이너에 의해 들어올 것이다. 우리의 포트 규칙은 SignupUseCase이기 때문이다.
5-2. Output 어댑터 구현
Output 어댑터가 곧 영속계층을 의미한다. 어딘가에 저장되고 애플리케이션이 종료될 때까지 유지하라는 요구사항에 맞추려면 우리는 Inmemory에 저장되는 Inmemory Database를 스프링 프로젝트 내부에 구현해도 정상적으로 요구사항을 만족할 수 있다.
만약 실제 데이터 베이스에 저장하라고한다면, 실제 DBMS에 저장되도록 Repository를 구현하면 될것이고, 혹은 ORM을 사용하라고 한다면 JPA를 사용하면 될것이다. 하지만 상관없다 포트 어댑터 패턴은 그냥 Output에 알맞게 추상화 되어있는 포트를 구현하면 끝이다.
인메모리든, redis, jpa 등등.. 포트와 어댑터 패턴으로 구현하면 신경쓰지 않아도 된다. 그냥 구현만 하면된다.
우리는 간단하게 인메모리로 구현해보겠다.
@Repository
public class InMemoryUserRepository implements UserRepository {
private final Map<String, User> store = new HashMap<>();
@Override
public User save(User user) {
store.put(user.getId(), user);
return user;
}
}
이렇게하면 회원가입 유스케이스를 다 작성한 것이다. 물론 길게 느낄 수 있지만, 맨 처음부터 작성했기 때문에 길게 느낄 수 있다.
여기서 다른 validate처리 혹은 exception처리가 들어가면 더 복잡하겠지만 우리는 헥사고날을 이해하기 위한 코드를 작성했기 때문에 생략한다.
이제 헥사고날, 포트 어댑터 패턴이 왜 좋은지 알 수 있을 것이다.
포트 어댑터 패턴이 왜 좋나요
포트 어댑터 패턴은 결론적으로 애플리케이션 핵심 로직의 변화를 없앨 수 있다.
객체지향 설계의 SOLID 원칙을 기억한다면 2번째, Open Closed Principle을 잘 지킨 것이라고 할 수 있다.
확장에는 매우 열려있지만 변화에는 닫혀있는 매우 좋은 설계라고 할 수 있는 것이다.
'BackEnd' 카테고리의 다른 글
[CS] 웹 서버(WS)와 웹 어플리케이션 서버(WAS) (0) | 2025.02.17 |
---|---|
[CS] 모놀리식 아키텍처와 마이크로서비스 아키텍처 (0) | 2025.02.17 |
백엔드 면접 질문 정리하기 - 1 (1) | 2025.01.24 |
FCM(Firebase Cloud Messaging)을 Spring boot에서 테스트 하기 좋은 코드로 만들어보기 (0) | 2025.01.13 |
[Spring] FCM + 스프링 부트로 푸시 알림 구현 고민하기 (0) | 2025.01.07 |