헥사고날 아키텍처 알아보기 (아키텍처 2)

이월(0216tw)·2024년 5월 25일

이전 이야기 : 👉 클린 아키텍처 알아보기 (아키텍처 1)


헥사고날 아키텍처란?

헥사고날 아키텍처는 앱의 핵심 비즈니스 로직을 외부 인터페이스와 명확히 분리하는 것을 목표로 한다. 다른 용어로는 "Ports and Adapters" 아키텍처 라고도 하며, 이 개념을 이용해 외부시스템과 내부 로직 간의 계층을 독립시켜 의존성을 제거해준다.

의존성이 제거되면 각 계층간의 테스트나 유지보수 및 확장이 용이해지는 장점이 생긴다.

Ports : 내부 로직에 대한 추상화된 인터페이스를 정의

port는 내부로직과 외부시스템간의 의존성을 분리하는 층(layer) 역할을 한다.

Inbound (입력) Port : 외부요청을 받는 인터페이스 ex) API호출 등
Outbound (출력) Port : 외부시스템에 요청을 보내는 인터페이스

Adapter : 포트를 구현한 구체적인 클래스/모듈

port가 추상화된 인터페이스라면 Adapter는 이를 실제로 구현해 처리하는 구현체이다.

Inbound Adapter : 사용자 요청이나 이벤트를 받아 inbound port에 전달
Outbound Adapter : Outbound port가 정의한 요청을 외부 시스템에 전달

Core Domain (핵심 도메인) : 내부 로직과 도메인 모델

내부 로직에서 사용하는 객체와 이를 처리하는 서비스(메서드) 등의 모임이다.

외부와 독립적으로 설계되므로, 외부가 변경된다고 해서 이 부분이 변경되지는 않는다.

헥사고날 아키텍처 구조도

위 그림에서 보듯 내부 로직인(엔터티 , 유스케이스) 가 외부 시스템 (Web , DB 등) 과
통신하기 위해서는 Port를 활용하고 있고, adapter로 실제 구현체를 만들고 있다


계층형 아키텍처

전통적으로 사용되어 오던 계층형 아키텍처는 의존성이 위에서 아래로 연결되어 있어 상위 계층의 변경이 하위 계층의 로직 변경에 영향을 준다. 즉, 계층형 아키텍처는 계층간의 의존성이 강하게 나타난다.

[ 전통적인 계층형 아키텍처 예시 ]

br>

계층형과 헥사고날의 차이점 정리

(1) 의존성의 방향

계층형 아키텍처 :
의존성이 위에서 아래로 흐름( Controller -> Service -> Dao 등)
그래서 데이터 접근 계층의 변경이 상위 계층에 영향을 미칠 수 있음

헥사고날 아키텍처 :
의존성이 핵심 비즈니스 로직으로 향함. (안쪽으로)
외부의 DB나 Web 부분은 내부 로직이 몰라도 되고 오직 포트로 외부와 상호 작용함

(2) 테스트 용이성

계층형 아키텍처 :
의존성이 강함. 즉 계층 간 강한 결합으로 인해 독립적인 테스트가 어려움
특정 컨트롤러 테스트 하려면 해당 서비스 , DAO 를 모두 주입해주어야 함
(물론 mock 나 TDD 기반의 원리를 사용해 독립 테스트를 할 수는 있음)

헥사고날 아키텍처 :
어댑터만 교체하면 핵심 비즈니스를 쉽게 테스트할 수 있음
예를 들어 기존 DB와 연동하는 어댑터가 아니라, 임시 메모리 어댑터를 사용하면
DB와 직접 연결하지 않아도 테스트를 할 수 있음


계층형 아키텍처의 문제 (코드 예시)

아래는 Oracle 을 연동해 데이터를 주고받는 소스코드 예시이다. 현재 개발자는 테스트를 위해서 Oracle 연결이 아닌 메모리 상의 HashMap 등으로 테스트를 할 필요가 생긴 상황이다.

//UserController 
@RestController
@RequestMapping("/users")
public class UserController {

    @Autowired
    private UserService userService;

    @GetMapping
    public List<User> getAllUsers() {
        return userService.getAllUsers();
    }
}

//UserService
@Service
public class UserService {

    @Autowired
    private UserDAO userDAO;

    public List<User> getAllUsers() {
        return userDAO.findAll();
    }
}

//UserDAO 
@Repository
public class UserDAO {

    @Autowired
    private JdbcTemplate jdbcTemplate;

    private static final class UserMapper implements RowMapper<User> {
        public User mapRow(ResultSet rs, int rowNum) throws SQLException {
            User user = new User();
            user.setId(rs.getLong("id"));
            user.setName(rs.getString("name"));
            user.setEmail(rs.getString("email"));
            return user;
        }
    }

    public List<User> findAll() {
        String sql = "SELECT * FROM users";
        return jdbcTemplate.query(sql, new UserMapper());
    }
}

이 상태에서 In-memory 테스트를 하고 싶으면 해당 Dao 와 Service를 알맞게 수정해야 한다.
즉, 계층 간의 의존성이 강해서 변경시 다른 코드도 변경을 해야한다는 문제가 발생한다.
(TDD 등의 방법도 있지만 일단 논외로 한다.)


헥사고날 아키텍처의 대안 (코드 예시)

Service 와 DB(외부시스템) 사이에 계층을 하나 더 주면 어떨까?
port 라는 계층과 이를 실제로 구현한 adapter를 사용해보자.

//Controller
@RestController
@RequestMapping("/users")
public class UserController {

    @Autowired
    private UserService userService;

    @GetMapping
    public List<User> getAllUsers() {
        return userService.getAllUsers();
    }
}

//Service **여기서 DB접근을 직접하는게 아니라 Port (Repository가 port역할) 를 적용
@Service
public class UserService {

    private final UserRepository userRepository;

    @Autowired
    public UserService(UserRepository userRepository) {
        this.userRepository = userRepository; 
    }

	public List<User> getAllUsers() {
        return userRepository.findAll();
    }
}

//Repository ( Port 역할 ) 
public interface UserRepository {
    List<User> findAll();
}

//Repository (실제 Adapter 역할) 
@Repository
public class UserDAO implements UserRepository {

    @Autowired
    private JdbcTemplate jdbcTemplate;

    private static final class UserMapper implements RowMapper<User> {
        public User mapRow(ResultSet rs, int rowNum) throws SQLException {
            User user = new User();
            user.setId(rs.getLong("id"));
            user.setName(rs.getString("name"));
            user.setEmail(rs.getString("email"));
            return user;
        }
    }

    @Override
    public List<User> findAll() {
        String sql = "SELECT * FROM users";
        return jdbcTemplate.query(sql, new UserMapper());
    }
}

언뜻보기에는 차이는 없고 소스만 늘어난 거 아닌가? 라는 생각을 할 수 있다.

하지만 이 상황에서 만약 In-memory 방식의 테스트를 하고 싶다면,

추가로 UserRepository를 구현한 InMemoryDao 같은 클래스를 만들어 어댑터로 사용하면 된다.

//Repository (실제 Adapter 역할) 

@Repository
public class ImMemoryDAO implements UserRepository {

	HashMap<Integer , User> map = new HashMap<>() ; 

	User user = new User(); 
    user.setId("TEST"); 
    ...
    map.put(user); 

    @Override
    public List<User> findAll() {
    	//map의 내용을 모두 출력 및 리턴 
    }
}

//환경 설정을 통해서 Repository를 구현한 구현체가 2개 이상일 경우 나눌 수 있음
//이 외에도 Qualifer 어노테이션 사용도 가능 
@Configuration
public class MainConfiguration {

    @Bean
    @Profile("production")
    public UserRepository userDAO() {
        return new UserDAO();
    }

    @Bean
    @Profile("test")
    public UserRepository testUserDAO() {
        return new TestUserDAO();
    }
}

즉 Service 나 Domain 같은 내부 로직은 변경하지 않고 유지할 수 있게 된다.
외부 시스템의 변경에는 내부 로직이 영향을 받지 않고,
내부 로직을 확장하고 싶을때도 외부 시스템이 영향을 주지 않는다.


자료 참고

https://reflectoring.io/spring-hexagonal/
https://tech.osci.kr/hexagonal-architecture/

profile
개발도 하고 강의도 하고 고민을 제일 많이 합니다

0개의 댓글