개요
Hexagonal Architecture의 아이디어는 입력과 출력을 시스템 아키텍처의 가장자리에 두는 것이다. 또한 비즈니스 로직은 어떤 형태의 API를(REST, GraphQL API,...) 제공하는지 여부에 따라 달라져서는 안 되며, 변경이 가능한 경우는 비즈니스 로직이 변경된 경우에만 변경이 가능하다. 또한 서비스에서 필요한 데이터를 Database, 마이크로 서비스 API 또는 CSV 파일 등 어디서 가져오는지에 따라 비즈니스 로직이 변경되지 않아야 한다.
위 패턴을 사용하면 애플리케이션의 핵심 로직을 외부 환경으로부터 격리할 수 있고, 핵심 로직을 격리하면 코드 베이스에 큰 영향을 주거나 주요 코드를 다시 작성하지 않고도 Data Source를 쉽게 변경할 수 있다.
경계를 명확히 구분하면 주요 비즈니스 로직은 외부 환경에 영향을 받지 않기 때문에 쉽게 테스트를 할 수 있다.
개념 정의
Hexagonal Architecture의 비즈니스 로직을 정의하는 세 가지 중요한 개념은 Entities, Repositories, Interactors로 구성되어 있다.
- Entities : 도메인 객체로 특정 환경에 종석적이지 않다. (Java Persistence API와 다르다.)
- Repositories : Entities를 관리하는 인터페이스다. Data Sources와 통신하는데 필요한 메서드 목록을 가지고 있다. (ex UserRepository)
- Interators : 도메인 작업을 조율하고 수행하는 클래스다. (Service 객체 또는 UserCase Objects) 복잡한 비즈니스 규직과 검증 논리를 구현한다.
위 세 가지 주요 유형의 객체를 사용하면 데이터가 어디에 저장되고 비즈니스 로직이 어떻게 트리거 되는지에 대한 지식이나 관심 없이 비즈니스 로직을 정의할 수 있다. 비즈니스 로직 외부에는 데이터 소스와 전송 계층이 있다.
- Data Sources : 다양한 저장소 구현에 대한 어댑터. (JPA, Elasticsearch, MongoDB, REST API,...)
- Transport Layer : 외부에서 비즈니스 로직을 수행하도록 트리거할 수 있다. HTTP API, Event, Cron 또는 cli 등으로 트리거 될 수 있다.
Hexagonal Architecture에서는 모든 종속성을 내부를 가리킨다. 핵심 비즈니스 로직은 Transport Layer나 Data Sources에 대하여 모른다. 그래도 Transport Layer는 Interactors를 사용하는 방법을 알고 있으며 Data Sources는 Repository Interface를 따르도록 되어있다.
Hexagonal Architecture Template
사용자 정보를 DB로(MySQL) 관리하고, 사용자 정보가 변경되면 Kafka를 통해 이벤트를 발행하는 프로젝트를 Spring을 사용하여 Hexagonal Architecture로 구성해 본다.
프로젝트 구조
프로젝트의 패키지 구조는 크게 domain, input, output으로 구성되어 있고 domain 패키지에는 애플리케이션의 비즈니스 로직을 가지고 있고 input, output 패키지에는 외부와 연동이 필요한 클래스들이 포함되어 있다.
Domain Package
User : 사용자 정보를 표현하는 객체
public record User(Long id, String name) { }
UserService : 비즈니스 로직을 가지고 있는 객체 (UserCase)
@Service
public class UserService {
private final UserRepository userRepository;
private final UserNotifier userNotifier;
public UserService(UserRepository userRepository, UserNotifier userNotifier) {
this.userRepository = userRepository;
this.userNotifier = userNotifier;
}
public User create(User user) {
var created = this.userRepository.insert(user);
if (created) {
this.userNotifier.notify(Event.CREATED, user);
}
return user;
}
public boolean delete(Long id) {
var user = this.userRepository.findById(id);
if (user == null) {
return false;
}
var deleted = this.userRepository.deleteBy(id);
if (deleted) {
this.userNotifier.notify(Event.DELETED, user);
}
return deleted;
}
public User findById(Long id) {
return this.userRepository.findById(id);
}
public List<User> findAll() {
return this.userRepository.findAll();
}
}
UserRepository, UserNotifier : Database, Message Queue와 같이 외부에서 필요한 것이 있는 경우 UseCase에서 호출할 수 있는 인터베이스 (Ouput Port)
public interface UserRepository {
boolean insert(User user);
User findById(Long id);
boolean deleteBy(Long id);
List<User> findAll();
}
public interface UserNotifier {
void notify(Event created, User user);
}
Input Package
UsersController : 애플리케이션 코어를 노출하는 어댑터. (Input Adapter)
Domain 패키지에 User 객체를 외부로 노출하지 않기 때문에 UserMapper를 사용하여 Controller에서 필요한 객체를 별도로 관리한다.
@RestController
@RequestMapping("/v1.0/users")
public class UsersController {
private final UserService userService;
public UsersController(UserService userService) {
this.userService = userService;
}
@PutMapping
public UserResponse createUser(String name) {
var user = UserMapper.user(name);
var createdUSer = this.userService.create(user);
return UserMapper.userResponse(createdUSer);
}
@DeleteMapping("/{id}")
public Map<String, Object> deleteUser(@PathVariable("id") Long id) {
var result = this.userService.delete(id);
return Map.of("id", id, "result", result);
}
@GetMapping("/{id}")
public UserResponse findById(@PathVariable("id") Long id) {
var user = this.userService.findById(id);
return UserMapper.userResponse(user);
}
@GetMapping
public List<UserResponse> findAll() {
return this.userService.findAll().stream().map(UserMapper::userResponse).toList();
}
}
class UserMapper {
public static User user(String name) {
return new User(null, name);
}
public static UserResponse userResponse(User createdUSer) {
return new UserResponse(createdUSer.name());
}
}
record UserResponse(String name) {
}
Output Package
JdbcUserRepository : 출력 어댑터로 MySQL을 사용하여 데이터를 관리하는 기능을 제공한다. (Output Adapter)
@Repository
public class JdbcUserRepository implements UserRepository {
private final JdbcClient jdbcClient;
public JdbcUserRepository(JdbcClient jdbcClient) {
this.jdbcClient = jdbcClient;
}
@Override
public boolean insert(User user) {
return this.jdbcClient.sql("INSERT INTO user(name) VALUES(:name)")
.param("name", user.name())
.update() == 1;
}
@Override
public User findById(Long id) {
return this.jdbcClient.sql("SELECT * FROM user WHERE id = :id")
.param("id", id)
.query(User.class)
.optional()
.orElse(null);
}
@Override
public boolean deleteBy(Long id) {
return this.jdbcClient.sql("DELETE FROM user WHERE id = :id")
.param("id", id)
.update() == 1;
}
@Override
public List<User> findAll() {
return this.jdbcClient.sql("SELECT * FROM user")
.query(User.class)
.list();
}
}
KafkaUserNotifier : 출력 어댑터로 Kafka를 통해 이벤트를 발행한다. (Output Adapter)
@Component
public class KafkaUserNotifier implements UserNotifier {
private static final Logger logger = LoggerFactory.getLogger(KafkaUserNotifier.class);
private final KafkaTemplate<String, Object> kafkaTemplate;
public KafkaUserNotifier(KafkaTemplate<String, Object> kafkaTemplate) {
this.kafkaTemplate = kafkaTemplate;
}
@Override
public void notify(Event event, User user) {
this.kafkaTemplate.send("topic_name", Map.of("event", event, "user", user))
.whenComplete((result, e) -> {
if(e == null) {
logger.info("Message offset : {}", result.getRecordMetadata().offset());
}
});
}
}
참고
'dev > spring' 카테고리의 다른 글
Spring AI를 사용한 AI 어시스턴트 구현 RAG - Part 2 (with OpenAI) (1) | 2024.10.03 |
---|---|
Spring AI를 사용한 AI 어시스턴트 구현 - Part 1 (with OpenAI) (1) | 2024.10.01 |
[Spring] @Async와 Virtual Thread (1) | 2024.09.14 |
[Spring Batch] On K8S with Jenkins (0) | 2024.09.12 |
[Spring Boot] Virtual Threads vs Reactive vs Kotlin Coroutines 성능 비교 (0) | 2024.09.10 |