[말모] 헥사고날의 선택, 그리고 후기

Choi Wontak·2025년 9월 2일
0

말모

목록 보기
2/9

난이도 ⭐️⭐️⭐️
작성 날짜 2025.06.26

해당 포스팅은 헥사고날 아키텍처를 선택하고 구현하면서 고민했던 부분, 그리고 개발 이후 느낀점에 대한 내용을 담고 있습니다.
구현 과정의 경우 부족한 저의 개인적인 판단이 많이 들어가 있으니 더 나은 방식에 대한 조언을 댓글로 남겨주시면 경청하겠습니다!

고민 내용

말모는 AI챗봇 모모가 사용자의 애착유형을 바탕으로 연애 고민을 상담해주는 어플리케이션이다.

처음 기획에 대해 들었을 때 개발 구현을 어떻게 해야할지를 먼저 고민했다.

가장 중점적으로 고민했던 내용을 정리하자면,

  1. 두 달 안에 기능이 구현되어야 한다.
  2. 기획은 개발 중 변경 가능성이 있다.
  3. 외부 API에 대한 의존이 크다.
  4. 개발 이후 확장 가능성이 있다.
  5. 백엔드를 혼자 개발한다.

🤔 이 프로젝트에 가장 잘 맞는 구조는 뭘까?


찾아보기

Layered Architecture

내가 가장 익숙하다고 생각하는 아키텍처이다.
보통 컨트롤러, 서비스, 레포지토리 계층으로 나누어 코드를 작성한다.
많이 사용해본 아키텍처라 익숙하기도 하고,
패키지를 나누는 것에도 간단, 명확해서 빠른 개발이 가능하다는 장점이 있다.

하지만 그렇다보니 코드가 비대해지는 경향이 있다.
그말은 즉 하나의 계층에서 너무 많은 책임을 지는 경향이 있고,
유지 보수 시 서로 많이 얽혀 있어 변경과 확장에 어려움이 있다.

또, 테스트를 할 때 일부만 Mocking하기 어려워 이것도 단점이라고 말할 수 있을 것이다. 백엔드를 혼자 개발하기 때문에 디테일한 테스트 코드가 필요한 시점에서, 이 단점도 해당 아키텍처를 선택하지 않은 이유로 작동했다.

Hexagonal Architecture

헥사고날 아키텍처의 다른 이름은 Port & Adpater이다.
도메인 외부와의 커넥터는 Port(Interface)로 이루어지고, Adapter가 해당 포트에 결합(구현)되는 형태이다.
이런 방식의 장점은 계층 외부가 변경되더라도, 어댑터만 갈아끼면 해결이며, 도메인은 변경하지 않아도 된다.
도메인을 변경하지 않아도 되니 다른 기능으로의 변경 전파에서도 자유롭다.

레이어드 아키텍처의 투머치 책임 문제를 해결하고,
나의 고민이었던 변경에 크게 휘둘리지 않는다는 점이 매력적으로 보여 헥사고날 아키텍처를 선택하게 되었다!
(그리고 이 결정은 이후 살짝 후회하게 된다.)


적용해보기

이 파트는 헥사고날 아키텍처의 구현 과정을 담고 있습니다.
조금 길어지니까 후기만 궁금하신 분들은 다음 파트로 넘기셔도 좋습니다!

헥사고날 아키텍처는 기본적으로 IN과 OUT으로 구분된다.
가장 코어에는 도메인이 존재하는데,
도메인으로 들어오는 흐름(ex. 사용자 요청 -> 도메인)인 경우 IN
도메인에서 나가는 흐름(ex. 도메인 -> 외부 API)인 경우 OUT이다.

이에 따른 핵심 원칙은 다음과 같다.

  • 의존성은 항상 외부에서 내부(도메인)로 향한다.
  • 도메인은 인프라에 대해 전혀 몰라야 한다.
  • 인프라(어댑터)가 도메인 포트를 구현해서 연결된다.

그럼 이 원칙을 잘 지키면서 간단한 디데이 업데이트 기능을 구현해보자.
실제 말모에서 적용했던 코드이다.

편한 코드 이해를 위해 구현 순서가 아닌 의존 흐름의 방향으로 내용을 작성한 점 유의 부탁드립니다!

구현 요구사항

구현 요구사항은 다음과 같다.

  • 말모는 커플 앱으로, 다른 사용자와 연동이 가능하다.
  • 사용자는 해당 기능으로 연애 시작일을 업데이트 할 수 있다.
  • 사용자가 연동하지 않은 경우, 자신의 연애 시작일을 업데이트 한다.
  • 사용자가 연동된 경우, 사용자의 연애 시작일을 업데이트하고, 동시에 커플의 연애 시작일을 업데이트한다.

Controller

adaptor/in/.../MemberController.java

@PatchMapping("/start-love-date")
public BaseResponse<UpdateStartLoveDateUseCase.UpdateStartLoveDateResponse> updateStartLoveDate(
            @AuthenticationPrincipal User user,
            @Valid @RequestBody UpdateStartLoveDateRequestDto requestDto
) {
        UpdateStartLoveDateUseCase.UpdateStartLoveDateCommand command = UpdateStartLoveDateUseCase.UpdateStartLoveDateCommand.builder()
                .memberId(Long.valueOf(user.getUsername()))
                .startLoveDate(requestDto.getStartLoveDate())
                .build();

        return BaseResponse.success(updateStartLoveDateUseCase.updateStartLoveDate(command));
}

컨트롤러는 외부에서 들어오는 흐름이므로 IN Adapter에 해당한다.
그리고 서비스를 직접 호출하면 각 계층 간의 결합도가 올라가기 때문에, UseCase라는 인터페이스를 통해 접근한다.

UseCase

application/port/in/.../UpdateStartLoveDateUseCase.java

public interface UpdateStartLoveDateUseCase {

    UpdateStartLoveDateResponse updateStartLoveDate(UpdateStartLoveDateCommand command);

    @Data
    @Builder
    class UpdateStartLoveDateCommand {
        private Long memberId;
        private LocalDate startLoveDate;
    }

    @Data
    @Builder
    class UpdateStartLoveDateResponse {
        private LocalDate startLoveDate;
    }
}

컨트롤러와 서비스를 연결해 줄 UseCase 계층이다.
여기서 Command는 컨트롤러가 UseCase에게 주입하는 POJO 클래스이다.
Command를 사용하면, 서비스의 행동은 컨트롤러가 받아온 값에 의해 결정되어야 하지만, 서비스는 컨트롤러(View)의 실제 구현에 의존하지 않도록 하기 위해 DTO인 Command를 사용하는 것이다.

Service

application/service/.../MemberCommandService.java

@Service
@RequiredArgsConstructor
public class MemberCommandService implements UpdateStartLoveDateUseCase {

	@Override
    @CheckValidMember
    @Transactional
    public UpdateStartLoveDateResponse updateStartLoveDate(UpdateStartLoveDateCommand command) {
        Member member = memberQueryHelper.getMemberByIdOrThrow(MemberId.of(command.getMemberId()));
        LocalDate startLoveDate = command.getStartLoveDate();

        member.updateStartLoveDate(startLoveDate);
        Member savedMember = memberCommandHelper.saveMember(member);

        if (member.isCoupleLinked()) {
            coupleQueryHelper.getCoupleById(member.getCoupleId())
                    .ifPresent(couple -> {
                        couple.updateStartLoveDate(startLoveDate);
                        coupleCommandHelper.saveCouple(couple);
                    });
        }

        return UpdateStartLoveDateResponse.builder()
                .startLoveDate(savedMember.getStartLoveDate())
                .build();
    }
 }

어플리케이션 서비스는 구현 요구사항을 만족시키기 위해 Port들을 지휘하는 역할을 맡아야 한다.
나의 경우에는 대부분의 Port 의존을 Helper 클래스를 통해 이루어지도록 했는데, 그 이유는 후술하겠다.
구체적인 구현은 다른 클래스에서 이루어지기 때문에, 어떤 흐름으로 진행되는지 명확하게 확인할 수 있다.
도메인과 엔티티는 명확하게 분리되어 있어, 수정은 더티체킹이 아닌 조회, 모델 변경, 저장의 순서로 이루어져 있다.

또한, 캡슐화를 지키기 위해 모델 내 필드 변경은 모델 내부의 메서드 호출, 또는 애그리거트 루트 도메인 서비스를 통해 진행하도록 한다. (DDD)

여기서 Member의 ID를 넘길 때, 단순한 Long이 아닌 MemberId 클래스를 이용해 넘기는 것을 볼 수 있다.
Long 타입을 활용했다면, 단순하게 같은 Long 타입이 들어와도 컴파일 에러가 발생하지 않는다.
이렇게 하면 Member의 ID를 담아야 할 자리에 Couple의 ID를 담는 것과 같은 사고를 미연에 방지할 수 있다.
그리고 행위의 의미가 명확해진다는 장점도 있다.

Helper

application/helper/.../MemberQueryHelper.java

@Component
@RequiredArgsConstructor
public class MemberQueryHelper {

    private final LoadMemberPort loadMemberPort;
    
    public Member getMemberByIdOrThrow(MemberId memberId) {
        return loadMemberPort.loadMemberById(MemberId.of(memberId.getValue()))
                .orElseThrow(MemberNotFoundException::new);
    }
}

application/helper/.../MemberCommandHelper.java

@Component
@RequiredArgsConstructor
public class MemberCommandHelper {

    private final SaveMemberPort saveMemberPort;

    public Member saveMember(Member member) {
        return saveMemberPort.saveMember(member);
    }
}

멤버의 조회와 저장을 위한 Helper 클래스이다.
조회 메서드가 너무 비대해지는 것을 막기 위해 CQRS를 통해 책임을 분리했다.
성능보다는 역할 분담과 서비스에서의 명확한 사용을 위해 CQRS를 사용하였다.

Helper 클래스를 사용하면 여러 장점이 있다.

  • 코드 재활용이 편하다.
    Member 모델을 조회하는 것이지만, Couple 관련 로직에서도 이를 사용할 수도 있고 그외 다른 로직에서도 Helper 클래스만 주입하면 사용할 수 있다.

  • 서비스 코드가 깔끔해진다.
    서비스는 외부에 의한 복잡한 예외 처리에 의존하지 않고, 필요한 흐름에만 집중해서 코드를 작성할 수 있다.

  • 예외를 Helper로 집중시킬 수 있다.
    개발을 하다보면 Optional로 조회해서 orElseThrow 처리하는 경우가 많은데, 그럴 때 마다 예외를 던지는 것은 불필요한 반복이고, 이 예외를 수정하려면 모든 것을 수정해야하는 문제가 있었다.
    Helper에서 예외 처리를 집중시키면, DB 조회 오류를 한 곳에서 처리할 수 있었다.

그럼 Optional이 필요한 경우는?
서비스에서 Optional이 필요한 경우에는 Optional을 반환하는 메서드를 만들었다.
메서드명을 보면 알겠지만, getMemberByIdOrThrow가 있고, getMemberById 처럼 예외 처리가 없다는 것을 함수명에 명시하여 분리하였다..!

다른 예외가 필요한 경우는?
getMember의 경우 DB에 멤버가 없는 경우 모두 같은 예외를 터뜨리면 되지만, 다른 비즈니스 로직에 의해 다른 예외를 터뜨려야 하는 경우, 다른 Helper에서 이를 구현하거나, valid를 위한 메서드를 Helper에 구현하여 처리하였다.

도메인 서비스에서 이루어지면 되는 거 아닌가...? 라고 생각했었는데,
도메인 서비스는 외부의 의존 없이 순수한 자바 코드로 이루어져야 한다.
여기선 사용하지 않았지만, 도메인 서비스는 도메인 모델의 생성에 다른 모델이 필요한 복잡한 경우나 모델 내의 필드로 확인할 수 있는 검증 조건과 같은 경우 사용한다.

Port

application/port/out/.../LoadMemberPort.java

public interface LoadMemberPort {
    Optional<Member> loadMemberById(MemberId memberId);
}

도메인을 지나 이제 외부를 호출하는 방향으로 흘러가기 때문에 OUT port에 해당한다.
포트는 어댑터의 역할을 단순히 규정만 하고, 서비스에서 구체적인 구현을 알 필요가 없게 도와준다.
이제 어댑터는 memberId를 받으면, Optional에 조회한 Member를 담아 넘기는 역할을 가지면 된다.

Adapter

adaptor/out/persistence/adapter/MemberPersistenceAdapter.java

@Component
@RequiredArgsConstructor
public class MemberPersistenceAdapter implements
        LoadMemberPort, SaveMemberPort, LoadPartnerPort, LoadInviteCodePort, LoadChatRoomMetadataPort {

    private final MemberRepository memberRepository;
    private final MemberMapper memberMapper;
    
    @Override
    public Optional<Member> loadMemberById(MemberId memberId) {
        return memberRepository.findById(memberId.getValue())
                .map(memberMapper::toDomain);
    }
    
    @Override
    public Member saveMember(Member member) {
        MemberEntity memberEntity = memberMapper.toEntity(member);
        MemberEntity savedEntity = memberRepository.save(memberEntity);
        return memberMapper.toDomain(savedEntity);
    }
}

이제 어댑터를 붙여주자.
Persistence 계층의 어댑터는 Repository를 통해 DB와 관련된 모든 행위를 명령할 책임을 갖는다.
Repository에서 가져온 엔티티를 서비스 계층에서 사용할 수 있도록 도메인으로 변경한다.
이때, 도메인과 엔티티 간의 변환은 Mapper 클래스라는 도구를 이용한다.

Mapper

adaptor/out/persistence/mapper/MemberMapper.java

@RequiredArgsConstructor
@Component
public class MemberMapper {

    public Member toDomain(MemberEntity entity) {
        return Member.from(
                entity.getId(),
                entity.getNickname(),
                entity.getEmail(),
                entity.getStartLoveDate(),
                // ...생략
        );
    }

    public MemberEntity toEntity(Member domain) {
        return MemberEntity.builder()
                .id(domain.getId())
                .email(domain.getEmail())
                .nickname(domain.getNickname())
                .startLoveDate(domain.getStartLoveDate())
                // ...생략
                .build();
    }
}

Mapper 클래스는 단순히 엔티티 - 도메인 간 변환을 도와준다.
이를 어댑터에서 직접 처리하지 않고 책임 분리하여 깔끔한 코드를 작성할 수 있다.

Repository

adaptor/out/persistence/repository/member/MemberRepository.java

public interface MemberRepository extends JpaRepository<MemberEntity, Long>, MemberRepositoryCustom {

    @Query("select m from MemberEntity m where m.id = ?1 and m.memberState != 'DELETED'")
    Optional<MemberEntity> findById(Long memberId);
}

대망의 마지막 코드인 Repository이다.
Repository는 Persistence Adapter의 명령을 바탕으로 DB에 직접적인 쿼리를 던지는 역할을 갖고 있다.
좀 복잡한 쿼리가 필요한 경우 RepositoryCustom 인터페이스를 통해 구현된 Querydsl 코드를 이용하였다.

실제 구현 순서는 서비스에 필요한 로직을 먼저 작성하고, Port와 UseCase를 작성하는 내부 -> 외부 방식으로 개발하였다.


결론

후회

MVP 개발을 마치고 돌아보자면, 결론적으론 헥사고날 아키텍처를 사용한 것을 후회한다..ㅎㅎ

개발 속도가 너무 느리다

위의 개발 내용만 봐도 알겠지만, 간단한 기능 하나를 추가하는 것에도 파일이 10 - 12개 추가되는 것을 알 수 있다.
아마 레이어드로 개발했다면, 파일 수가 5개 정도 추가되었을 것이니 단순 파일 수만 생각해도 200% 이상...

물론 파일 수 == 코드 수는 아니다.
그러나 새로운 파일을 생성하고, 책임을 분리하기 위한 고민을 하고, 적합한 패키지를 찾아 코드를 작성하기 위해 소요된 시간을 무시하긴 어려울 것이다. (이후에는 조금 익숙해져서 빨라지긴 했지만...)

심지어 도메인과 엔티티를 명확하게 분리했어야 하기 때문에, JPA의 편리성 중 하나인 더티체킹과 Lazy Loading은 포기해야 한다.

두 달 안에 개발해야 하는 상황에서 이 구조의 사용은 너무 많은 시간을 잡아먹는다는 문제가 있었다.

팀을 고려한 선택이었을까?

너무 빠른 시기에 확장성을 고려했으니, 오버엔지니어링이라는 표현이 적합한 것 같다.
구현부터 빠르게 진행해야 했고, 유지보수를 고려한 코드의 퀄리티는 이후 리팩토링을 통해서 해결할 수 있는 부분이었다.
백엔드 내부적으로 이쁜 코드를 만드는 것도 중요하지만, 빠르게 MVP가 나와야 하는 시점에서 프론트엔드 팀은 백엔드의 API 구현을 기다린다.
휴학생이라 시간이 많아서 다행히 시간에 맞출 수는 있었지만 내가 좀더 익숙한 구조를 통해 빨리 개발해서 API부터 내놓고 이후 남는 시간에 확장을 고려했다면 어땠을까?
아마 익숙한 기술을 선택하는 것이 팀을 위한 선택이었을지도 모르겠다.


하지만...

그렇다고 후회만 하였는가? 그건 또 아니다.
배우는 것이 정말 많았던 소중한 기회였다.
만약 헥사고날을 선택하지 않았다면 장단점 조차 느끼지 못했을 것이다.

첫째, 명확한 책임

헥사고날 아키텍처를 구현하면서 가장 고민이 많았던 부분이 책임에 대한 분리였다.
헥사고날을 처음 사용해보아서 개발하면서 GPT랑 참 많은 이야기를 나누었다.
이 코드는 어느 패키지에 넣으면 될까? 하는 방식으로...

예를 들어, 레이어드를 구현할 때는 사용하지 않았던 Mapper 클래스가 있다.
도메인 - 엔티티 간 변환 방법은 Mapper에서만 제시한다.
그리고 이 Mapper를 Persistence Adapter 계층에서만 사용하여 도메인 - 엔티티 간 변환 책임을 몰아주었다.
Adapter는 Repository 인터페이스를 호출한다.
그리고 DB에 직접적인 쿼리를 던지는 책임은 Repository 계층에 집중하였다.
그리고 Adapter는 Port를 통해 어플리케이션 서비스에서 사용한다.
도메인 모델에 대해 도메인 서비스와 도메인 모델 자체만이 변경할 수 있는 책임을 갖는다.
그리고 어플리케이션 서비스는 다양한 Port와 도메인 서비스(모델)를 이용해 서비스의 흐름을 지휘(orchestration)하는 책임을 갖는다.

헥사고날 아키텍처에서 각 계층의 외부에 해당하는 의존은 인터페이스로만 접근하기 때문에, 애초에 책임 분리를 강제하여 코드를 작성하게 된다.

이런 식으로 작성된 코드는 계층 간의 역전이나 얽힘 없이 코드의 재사용성도 높일 수 있었다.

책임이 명확하니, 디버깅도 빠르게 가능했다.
어떤 문제가 생겼을 때 문제의 원인을 빠르게 찾아갈 수 있었으며, 복잡하게 얽혀있지 않다보니 해당 클래스만 수정해주면 문제가 해결되었다.

후술할 장점들도 크게 보면 이 장점에 속할 정도로 명확한 책임 분리는 헥사고날의 큰 장점에 속하는 것 같다.

둘째, 장기적인 시간 단축

명확하게 분리해두었기 때문에, 외부 API에 대한 변경이 이루어질 경우 초기 구현과는 달리 오히려 시간적 비용이 절감된다.
실제로 구현 이후에 사용자의 탈퇴 처리를 위한 요구 사항이 하나 추가되었는데, 서비스 코드는 한 줄 정도로 크게 수정이 없었으며 카카오와 애플 API에 대한 코드만 추가하는 방식으로 해결할 수 있었다.
아마 현재 사용 중인 Redis Stream을 확장하여 이후 Kafka나 RabbitMQ로 변경한다면 헥사고날 아키텍처는 빛을 발할 것이다.

셋째, 편리한 테스트 Mocking

책임에 따라 잘게 나누어져 있기 때문에,
외부 의존 클래스에 대해서만 콕 집어서 모킹할 수 있다는 장점이 있었다.
개발하면서 Stream을 통해 Consumer가 API 요청을 제대로 보내는지 확인하고 싶었는데, 테스트 코드로 확인하기 어려운 부분이었다. (레디스를 사용하여 컨슈머의 동작을 확인해야 했기 때문)
그래서 @Profile("dev")를 통해 테스트만을 위한 클래스를 새롭게 만들고, 개발 환경에서 API 요청에 대한 부분만 가짜 객체를 이용하는 방식으로 기능을 검증할 수도 있었다.

넷째, 개인적인 성장

어떤 것이 이쁜 코드인가?에 대한 판단은 주관적인 부분인 것 같다.
그러나 헥사고날 아키텍처를 통해 '이쁜 코드를 작성하는 것'에 대해 강제로 고민할 수 있었던 부분이 나를 성장시켰을 것이라고 생각한다!
특히 내가 Java라는 언어에서 매력을 느끼는 SOLID 원칙과, 스프링의 핵심 기술을 극대화시켜 내가 사용하는 기술이 어떤 것인지 돌아볼 수 있었다.
앞으로 레이어드, 클린 등 다른 아키텍처를 사용하더라도 깔끔하고, 이쁜 코드를 작성하는 것에 큰 도움이 될 것 같다.

결론
절대 헥사고날 아키텍처를 비추하는 것은 아니다.
헥사고날을 경험하면서 정말 많은 것을 배웠고 이후의 확장에 있어서 해당 구조가 큰 도움이 될 것으로 생각한다.
구조, 기술, 방법론 등 무엇이든 상황에 맞는 선택이 가장 중요함을 알 수 있었던 좋은 기회였다!

개발 이후 뒤늦게 보게된 내용인데 공감하는 부분이 많아 남겨둡니다!


profile
백엔드 주니어 주니어 개발자

0개의 댓글