헥사고날 아키텍처까지 필요했을까?

아래 예시 코드는 모두 실제 작성된 코드가 아닌 집에서 혼자 도메인을 대략적으로 설계하고 작성한 코드이며, 하나의 상황을 예시로 보여주는 코드입니다.

"도메인 모델이 명확한 서비스" 위 한 문장으로 헥사고날의 도입을 목표해볼 수 있을까요?

헥사고날 아키텍처의 본질적 가치는 다음과 같습니다.

  1. 도메인 로직의 순수성 보호이다.

만약 서비스가 외부 API를 호출하고 결과를 그대로 전달하는 pass-through 성격이라면 controller -> service -> port -> adapter(실제 로직) 이런 구조가 되어버리고 이건 의미없는 레이어 낭비인것은 분명합니다. 예를 들어 결제 시스템을 보면 PG사, 은행, 포인트 시스템 등 외부 연동이 많지만, 그 데이터를 조합하고 비즈니스 규칙을 적용하는 도메인 로직이 풍부할 수 있다. 이런 경우엔 외부 의존성이 많아도 헥사고날이 여전히 유효하게 적용될 수 있다고 생각합니다. 1번에 대한 주요 쟁점은 결과적으로 Domain 모듈의 imprt 문에 jpa나 외부 의존성을 띄지 않아야 한다. 이게 보호의 가시적 증거입니다.

광고를 예를 들었을때 도메인 로직이 많은 부분이 붙게 된다. 하나의 예시로 타겟광고, 예산 유효성체크, 빈도제어, 스코어링 등 순수 도메인 로직에 많은 adapter를 붙이게 된다. 이러한 경우에는 헥사고날로써 각각의 비즈니스 로직을 쪼개 usecase에서 port를 조립하여 사용한다면 재사용이 가능하다. 재사용이 어떻게 가능한가? port는 조립하여 사용하는것이 이와 같은 케이스이다. 이 하지만 이 조각을 너무 많이 쪼갤 경우에는 보일러 플레이트가 증가하기 때문에 개인적인 생각으로는 관심사-도메인(기능) 별로 묶거나 infra에서 기능 단위로 쪼개는게 좋다고 생각했습니다.

뿐만 아니라 service, usecase를 작성하기 위해서는 사실상 infra layer 개발이 마무리가 되어합니다. 하지만 시기상 모든 infra가 개발되어 있는 상태도 아니고 business와 presentation 1인, infra 부분 1인 이렇게 나눠서 작업을 하기에는 계층형 구조에는 한계가 있습니다. controller -> service -> mapper 순으로 작업이 마무리가 되어야 하기때문에 지정된 작업자 1명 만이 개발이 가능한겁니다. 이러한 부분은 추상화를 통해서 문제를 해결할 수 있다고 생각하실수도 있습니다. 하지만 앞서 언급했던 내용처럼 광고의 경우에는 그때 당시에 management와 serving이라는 도메인으로 두가지가 분리가 되어야만 햇고, servingAd와 Ad의 도메인간에 어느정도 격리가 필요성을 느꼈습니다.

실제 개발을 진행하며 TPS 변경에 따른 serving 전략이 수시로 바뀌었고, 계층형 구조일때의 경우 아래와 같이 비즈니스 로직이 지속적으로 변경될거 같습니다. 광고 도메인인의 경우는 실제로 사용자가 소비하는 매체로 광고 송툴이 멈춘다면 비용적인 측면에서도 큰 타격이 있습니다. 이를 위해 저는 안정적으로 설계하고 송출해야 한다고 생각을 했고, 이 판단에 대하여 동료와 신중하게 논의를 했던거 같습니다. 이렇게 요구사항이 빠르게 변한다면 개발을 하는것이 문제가 아닌 안정성의 측면에서 좀 더 고민을 했던거 같습니다.

만약 아래와 같은 계층형 코드라면,, 우리는 감히 어떻게 추가 개발한 기능만을 두고 테스트를 할 수 있을까요?

// 1) AdServingService에 LocalCache를 먼저 조회하고 읽어어고 miss가 나면 이후에 아래 그대로 코드가 동작해야하는 상황을 가정해보겠습니다. 
@Service
class AdServingService {
    // redis에 직접 의존 -> local cache로 변경할때 문제가 발생할 수 있다.
    private final RedisTemplate<String, ServableAd> redisTemplate;

    // JPA 직접 의존
    private final ServableAdRepository servableAdRepository;

    public ServableAd getServableAd(Long adId) {
        String key = "servable-ad:" + adId;

        // 캐시 로직이 서비스에 직접 존재
        ServableAd cached = redisTemplate.opsForValue().get(key);
        if (cached != null) {
            return cached;
        }

        // DB 조회
        ServableAdEntity entity = servableAdRepository.findById(adId)
                .orElseThrow(() -> new AdNotFoundException(adId));

        ServableAd ad = toServableAd(entity);

        // 캐시 저장
        redisTemplate.opsForValue().set(key, ad, Duration.ofMinutes(30));

        return ad;
    }
}

요구 사항은 아래와 같은 흐름으로 변경했다고 가정하겠습니다.

  1. persistence에서 직접 조회
  2. local cache -> redis -> persistence 순서대로 조회
  3. redis -> persistence 조회

그렇다면 service 코드 내부를 수정하여 처리하는 방법 뿐입니다. 근데 infraStructure와 같은 내용이 비즈니스 로직이라고 할수 있을까요,, 이부분에도 고민이 많았으나, 결국 스타일 차이라는 생각이 들었습니다.

  1. 인프라 교체의 용이성

광고 트래킹의 경우 이러한 이유에서 헥사고날이 빛을 볼 수 있다고 생각했다. 트래킹의 경우 사실 pass-through의 성향이 매우 강하다.. 따라서 이를 도메인의 순수성을 지키는 측면보다는 infra adapter를 교체하기 용이함과 동시에 테스트코드를 작성할때 실제로 adapter layer를 단위 테스트 시에 이러한 부분이 효과적인 부분이 있었다. 실제 sqsPort를 stubbing을 통해 messageSendAdapter의 인프라 없이 테스트가 가능했다. 인프라가 교체될 시점에 어떻게 코드의 구현이 바뀌게 될지에 대해 예시로 살펴보자.

헥사고날이 아니어도 인프라는 쉽게 교체가 가능하지 않을까?

그래서 헥사고날을 도입한 뒤에 고민을 해보았다. 이거 헥사고날이 아니었어도 괜찮을수도 있었겠다. 기존 계층형에서 DIP를 잘 적용하여 동일한 효과를 어떻게 낼 수 있을까? 예시 코드로 살펴볼까요.


@RequiredArgsConstructor
@RestController
public class ServableAdController {

    private final ServableAdService servableAdService;

    @PostMapping("/api/v1/serve/ad")
    public ResponseEntity<List<ServableAd>> serveAd(@RequestBody ServableAdsRequest request) {
        return ResponseEntity.ok(servableAdService.getServableAds(request));

    }
}


@Service
@RequiredArgsConstructor
public class ServableAdService {
    // redis에 직접 의존 -> local cache로 변경할때 문제가 발생할 수 있다.  
    private final RedisTemplate<String, ServableAd> redisTemplate;

    // JPA 직접 의존 x DIP를 통해 Impl을 주입받아 Service와 동일한 레벨에서 servableAdJpaRepository에 접근한다.  
    private final ServableAdRepository servableAdRepository;

    public List<ServableAd> getServableAds(ServableAdsRequest request) {
        String key = "servable-ad";

        // local cache 조회 - Caffeine  
        // 캐시 로직이 서비스에 직접 존재  
        List<ServableAd> cached = redisTemplate.opsForSet().pop(key, request.size());
        if (cached != null) {
            return cached;
        }
        // DB 조회  
        List<ServableAd> servableAds = servableAdRepository.findAll()
                .stream()
                .map(ServableAd::of)
                .toList();

        // 캐시 저장 - 하나의 예시일뿐  
        redisTemplate.opsForSet().add(key, servableAds.toArray(new ServableAd[0]));

        return servableAds;
    }
}

public interface ServableAdRepository {

    List<ServableAdEntity> findAll();
}

@RequiredArgsConstructor
@Repository
public class ServableAdRepositoryImpl implements ServableAdRepository {

    private final ServableAdJpaRepository repository;

    @Override
    public List<ServableAdEntity> findAll() {
        return repository.findAll();
    }
}

public interface ServableAdJpaRepository extends JpaRepository<ServableAdEntity, Long> {
}

위 계층을 대략 도식으로 그려보면 아래와 같습니다.

Controller
    |
Service   <-  Repository
                  |
            RepositoryImpl <- JpaRepository  

이는 결국 DIP를 통해 Repository를 추상화하고 구현체에서 JPA 기술에 의존하게 한것입니다. 이 부분을 잘 살펴보면 결국 Hexagonal에서 보여주는 구조와 비슷하다는 생각이 들었는데요. 저 또한 이러한 부분이 너무 혼동이 되었고, 다만 차이점이라고 하면 service 계층도 추상화를 통해 controller 계층에서 약하게 결합하는 점만이 다르다는 생각이 들었습니다. 과도하게 service 계층까지 추상화 하는 것이 좋은 방법일까? 라는 고민이 들기도 했지만, 만약 usecase로 service 계층을 (작성중)

계층형 + DIP 헥사고날
의존성 역전 개발자가 "의도적으로" 적용해야 함 구조가 "강제"함
경계 암묵적 (컨벤션 의존) 명시적 (Port/Adapter)
신규 인원 온보딩 "여기선 이렇게 해요" 설명 필요 폴더 구조만 봐도 어디에 뭘 넣을지 명확

헥사고날로 구현하였을때,