티스토리 뷰

나의 공부방

[Architecture] 헥사고날 아키텍처를 통한 의미 수준과 구현 수준에 대한 이해(semantic and implementation level with hexagonal architecture)

망나니개발자 2024. 9. 24. 10:00
반응형

 

 

1. 헥사고날 아키텍처에 대하여


[ 헥사고날 아키텍처의 도메인 엔티티(Domain Entity) ]

우리는 소프트웨어를 개발할 때 어떠한 의미를 갖는 이론적 토대를 바탕으로 개발을 하게 된다. 예를 들어 우리가 헥사고날 아키텍처라는 아키텍처 패턴으로 시스템을 개발한다고 하자.

헥사고날 아키텍처(Hexagonal Architecture)는 소프트웨어 설계 패턴 중 하나로, 포트와 어댑터 아키텍처(Ports and Adapters Architecture)라고도 불린다. 이 아키텍처의 주요 목표는 애플리케이션의 비즈니스 로직(핵심 도메인 로직)을 외부 의존성으로부터 분리하여 애플리케이션의 유지보수성, 테스트 용이성, 유연성을 높이는 것이다. 해당 포스팅은 헥사고날 아키텍처를 다루는 것이 아니기에, 단순히 “도메인을 순수하게 만드는 아키텍처 패턴”이라고 이해해도 괜찮다.

 

 

 

위의 아키텍처 그림에서 보이듯 아키텍처의 의존성 방향은 안쪽으로 향해야 하며, 그 중심에는 도메인 엔티티(Domain Entity) 혹은 도메인 객체(Domain Object)가 존재해야 한다. 도메인 엔티티는 외부 시스템(데이터베이스, 캐시 등)과 완전히 독립적이여야 하며 핵심 비즈니스 로직을 표현하는 객체이다.

예를 들어 사용자가 프로모션에 참여할 수 있는 나이는 18세까지라고 하자. 이러한 비즈니스 정책을 우리는 다음과 같이 Member라는 클래스에 퍼블릭 메서드로 구현할 수 있다.

@Getter
@Builder
@NoArgsConstructor/
@AllArgsConstructor
public class Member {

    private Long id;
    private String name;
    private Integer age;

    private static int MAX_PROMOTION_AGE = 18
    
    public boolean canParticipatePromotion() {
        return age <= MAX_PROMOTION_AGE
    }
}

 

 

위의 그림에서 보이듯이 도메인 엔티티(Entity)가 갖는 의존성 방향은 외부로 향하지 않는다. 즉, Entity가 데이터베이스나 스프링 등과 같은 외부 영역을 참조하지 않는 것이다. 대신 UseCase라는 계층이 Entity를 참조하여 활용하고 있다. 이렇듯 도메인 엔티티가 갖는 참조를 제거하여 이를 비즈니스에 순수하게 만들 수 있는 것이다.

 

 

 

[ 헥사고날 아키텍처의 유스케이스(Usecase) ]

위와 같이 도메인 엔티티를 통해 비즈니스 정책을 표현하는 것은 아주 바람직하다고 볼 수 있다. 하지만 우리의 시스템은 외부로부터 데이터를 조회하거나 저장하는 등 필연적으로 인프라 영역과 협업이 필요하다.

위의 프로모션 참여를 위한 비즈니스 로직이 다음과 같은 구성을 따른다고 하자. (실제로는 기참여 및 동시성 처리 등 검사해야 하는 부분이 많지만, 해당 부분이 현재 알아보고자 하는 핵심이 아니므로 넘어가도록 하자.)

  1. 데이터베이스에서 memberId로 사용자를 조회함
  2. 프로모션 참여 가능 여부를 검사하여, 참여 불가능하면 예외를 발생함
  3. 프로모션 참여 이력을 저장함

 

 

해당 부분을 헥사고날 아키텍처 패턴에 맞게 구현한다고 하면 다음과 같이 작성할 수 있다.

@Service
class ParticipatePromotionService(
    private final LoadMemberPort loadMemberPort;
    private final SaveParticipatePromotionPort saveParticipatePromotionPort;
) implements ParticipatePromotionUsecase {

    static class Input(
        private final Long memberId;
    )

    static class Output(
        private final Long particiatePromotionId
    )

    @Override
    public Output execute(Input input) {
        Member member = loadMemberPort.findByIdOrThrow(input.memberId)
        if (!member.canParticipatePromotion()) {
            throw new IllegalStateException("프로모션에 참여할 수 없는 사용자입니다.");
        }

        ParticipatePromotion participatePromotion = ParticipatePromotion.builder()
            .memberId(member.id)
            .build();

        saveParticipatePromotionPort.save(participatePromotion);
    }
}

 

 

여기서 ParticipatePromotionService는 프로모션 참여라는 비즈니스 로직을 담당하는 유스케이스 구현체라는 점이다. 따라서 해당 클래스는 MySQL, JPA 또는 Redis와 같은 특정 인프라스트럭처의 세부 기술에 의존해서는 안된다. 따라서 세부 구현 기술로부터 비즈니스 로직을 보호하기 위해 인터페이스(포트, Port)로 감싸진 것이고, 유스케이스에서 이를 활용한다.

public interface LoadMemberPort {
    Member findByIdOrThrow(Long id);
}

 

 

 

[ 유스케이스와 도메인 사이의 영속성 계층 ]

위의 유스케이스 로직을 보았을 때 중간에 무언가 빠진듯한 생각이 든다면 매우 정상이다. 일반적으로 데이터는 데이터베이스에 저장되고 관리되는데, JPA를 사용한다는 가정 하에 Member 클래스 어디에도 영속성 관련된 애노테이션이 보이지 않기 때문이다.

헥사고날 아키텍처에서 JPA와 같이 영속성과 관련된 정보들은 핵심 비즈니스 정보들이 아니다. 대신 외부 시스템에 해당하는 정보이다. 따라서 이러한 간극을 매워주기 위해 Member에 대한 영속성 정보들을 표현하는 MemberEntity 클래스와 이를 변환해주는 과정이 필요하다.

@Getter
@NoArgsConstructor
@AllArgsConstructor
public class MemberEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String name;
    private Integer age;

    public Member toDomain() {
        return Member.builder()
            .id(id)
            .name(name)
            .age(age)
            .build();
    }
}

@Repository
@RequiredArgsConstructor
public class LoadMemberAdapter implements LoadMemberPort {
    private final MemberRepository memberRepository;

    public Member findByIdOrThrow(Long id) {
        MemberEntity memberEntity = memberRepository
            .findById()
            .orElseThrow(); 
            
        return memberEntity.toDomain();
    }
}

interface MemberRepository extends JpaRepisotry<Member, Long> {

}

 

 

즉, 비즈니스를 표현하는 정보와 데이터를 조회하고 저장하기 위한 정보는 의미론적으로 서로 다르다. 따라서 이를 위한 각각의 클래스들이 필요하며, 둘 사이의 변환이 필요한 것이다. Member라는 도메인 엔티티에는 영속성 관련 정보들이 존재하지 않고, MemberEntity라는 영속성 엔티티에는 프로모션 참여와 같은 핵심 비즈니스 로직이 존재하지 않는다.

 

 

 

 



2. 헥사고날 아키텍처를 통한 의미 수준과 구현 수준에 대한 이해
(semantic and implementation level with hexagonal architecture)


[ 의미 수준과 구현 수준에 대한 이해 ]

이렇듯 헥사고날 아키텍처에서 의미 수준(semantic level)을 따르면 영속성 엔티티와 도메인 엔티티를 구분해야 하며, 계층 사이의 변환을 위한 추가 작업이 필요하다.

하지만 위의 짧은 코드에서 직감할 수 있듯이, 모든 구현을 의미 수준에 맞게 끌어올리는 것은 상당한 부가 작업을 필요로 한다. 이러한 분리를 통해서 얻을 수 있는 이점은 도메인 정책들만을 갖도록 클래스가 순수해지며, 영속성 저장소가 변경되어도 영향을 받지 않는다는 점 등이 있다.

하지만 실용적이지 않다. 데이터베이스 저장소를 바꾼다는 것은 비즈니스가 엄청나게 성장해야 하는데, 현실적으로 거의 모든 케이스는 관계형 데이터베이스로 충분하다. 또한 결국 저장소로부터 조회한 데이터로부터 도메인 엔티티로의 변환이 필요한데, 저장소에서 조회하는 부분에 문제가 있다면 비즈니스 영역도 동일하게 영향을 받게 된다. 즉, 해당 분리를 통해서 실질적으로 얻을 수 있는 이점이 없는 것이다.

따라서 실제 개발을 할 때에는 현실적으로 의미 수준을 타협함으로써 의미 수준과 구현 수준을 다르게 가져갈 수 있다. 대표적으로 Member라는 클래스에서 영속성에 대한 정보와 비즈니스 정책을 함께 표현하는 방법이 있다.

@Getter
@NoArgsConstructor
@AllArgsConstructor
public class Member {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String name;
    private Integer age;

    private static int MAX_PROMOTION_AGE = 18
    
    public boolean canParticipatePromotion() {
        return age <= MAX_PROMOTION_AGE
    }
}

interface MemberRepository extends JpaRepisotry<Member, Long> {

}

 

 

 

[ 의미 수준과 구현 수준에 대한 의사 결정 ]

이렇듯 소프트웨어 설계 관점에서 의미 수준과 구현 수준 사이에는 언제나 간극이 존재할 수 있다. 따라서 우리가 구현을 할 때에도 이에 대한 고민을 항상 함께 하고, 올바른 가치 판단을 내릴 수 있어야 한다.

소프트웨어 개발은 의사 결정의 과정이며, 의미 수준과 구현 수준 사이의 타협을 어느 정도까지 가져갈지 결정해야 한다. 모든 구현을 의미 수준까지 끌어올리면 당장 필요하지도 않은 작업들로 인해 리소스를 낭비할 수 있으며, 의미 수준까지 분리된 코드들로 인해 코드의 가독성 및 추적성을 떨어뜨릴 수 있다. 반면에 지나치게 구현 수준을 의미 수준으로부터 외면해버리면 추후에 시스템의 확장이 매우 어려워질 수 있다. 따라서 코드를 읽고 쓸 때에 이러한 부분에 대해 반드시 유의해야 하며, 두 수준 사이에서 어느 정도로 실제 구현을 가져갈 것인지 가치 판단이 필요하다. 의미 수준에 가까워짐에도 불구하고 얻을 수 있는 명확한 이점이 없다면 의미 수준으로부터 멀게 구현 수준을 가져가도 괜찮다. 이러한 부분들로 인해 소프트웨어 설계에는 정답이 없고 맞고 틀림이 없다. 대신 중요한 것은 왜 이러한 의사 결정과 가치 판단을 내렸는지에 대한 명확한 근거이다.

참고로 개인적으로는 실용주의적 사고로 가치 판단을 내리는 경우가 많은데, 이러한 생각의 기저에는 “개발자라면 비즈니스에 기여해야 한다”는 관점이 있기 때문이다. 개발자라는 직업의 전문가로서 우리는 좋은 코드를 작성해야 하기도 하지만, 결국 비즈니스적인 가치가 없다면 무의미한 경우가 많다. 예를 들어 사업이 확장되어야 하는 상황에서 영속성 엔티티와 도메인 엔티리를 분리하는 것과 같은 코드 작업으로 인해 사업 확장에 실패한다면, 그 코드는 이제 존재 의미 자체가 없어지게 된다. 마찬가지로 이러한 작업의 결과로 코드를 읽고 이해하는데 어려움을 겪어 당장의 비즈니스를 빠르게 구현하지 못하는 상황도 결국 동일한 결말을 초래할 것이다. 결국 우리의 코드는 비즈니스적인 가치를 줄 수 있어야 하고, 그러려면 가급적 실용적인 방향으로 선택해야 한다고 생각한다.

진정한 추상화는 발견하는 것이지 발명하는 것이 아니라고 한다. 개발을 하다가 추상화가 필요한 시점에 도입을 해야지, 필요하지도 않은 시점과 지점에 추상화를 도입해야 하는 것이 과연 합리적인 것인가 생각해 볼 필요가 있다.

 

 

 

위의 포스팅은 조영호님이 값 객체에 대하여 남긴 포스팅을 읽고 작성하게 되었습니다. 개인적으로도 이러한 부분에 대한 생각을 갖고 있었는데, 영호님이 좋은 포스팅을 작성해주어서 이에 대한 내용을 작성하게 되었습니다.

 

 

 

 

 

 

 

 

반응형
댓글
반응형
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
TAG more
«   2024/11   »
1 2
3 4 5 6 7 8 9
10 11 12 13 14 15 16
17 18 19 20 21 22 23
24 25 26 27 28 29 30
글 보관함