https://github.com/f-lab-edu/used-trading-market
중고 거래 API 서버에 Kafka를 적용하고 이벤트를 통해 비동기적으로 로직을 처리하려고 한다.
아래의 사진은 프로젝트 RDB의 일부이다. Member 테이블은 Post, Region 테이블을 참조하고 있다. Post는 Member를 통해 작성자의 Region을 간접적으로 참조할 수 있지만, 인덱스와 효율 문제로 인해 반정규화를 진행하여 자체적으로 regionId를 가지고 있게 설계했다.
멘토님의 말씀에 의하면 검색 성능 개선을 위해 반정규화 진행을 고려하고 있다면, 해당 데이터가 변경될 가능성이 존재하는지 따져봐야 한다고 한다. 만약 반정규화의 타겟이 되는 데이터가 변경될 가능성이 존재한다면 트레이드-오프가 큰 것이라고 한다. 따라서 내가 진행한 반정규화는 트레이드-오프가 컸고 해당 방식은 사용하지 않는 것이 좋다고 인지했다. 하지만 지금은 학습을 위해 이벤트를 사용해보려 하기에 반정규화를 유지하고 이벤트를 적용해보려 한다.
post는 작성자의 region을 참조할 수 있어야 한다. post를 작성할 때는 member의 regionId를 넣어주기 때문에 아무런 문제가 발생하지 않는다. 하지만 member의 region이 변경된다면, member가 작성한 모든 post를 select한 뒤 regionId를 변경시켜야 하는 오버헤드가 발생한다.
member의 region 정보를 변경하는 것은 빈번하게 발생하지 않기에 위 처럼 처리하는 방식도 UX 관점에서 큰 문제가 발생하진 않을 것이라 생각한다. 하지만 member 서비스에서 post의 처리 과정 까지 직접 핸들링 해야한다는 점에서 서비스의 의존성이 추가된다. 만약 회원의 region 정보를 변경하는 작업이 추후 또 다른 처리 로직을 처리해야 한다면 코드를 수정하지 않고는 리팩터링할 수 없을 것이다.
현재 프로젝트에서는 큰 문제가 발생하진 않지만 실제로 문제가 발생할 가능성이 있는 유사한 문제를 마주쳤을 때, 해결할 수 있는 능력을 기르기 위해 학습 차원에서 카프카를 적용시켜 이벤트를 처리하도록 구현해보려 한다.
유의할 점
아파치 카프카에서 메시지를 처리하는 데 있어 유의해야 할 점이 있다. 메시지 전달 시맨틱을 기본 옵션으로 사용하는 경우 메시지는 ‘적어도 한 번’ 발행 된다는 점이다. 즉, 동일한 메시지가 한 번 이상 발행될 수 있기에 멱등한 핸들러를 생성하거나 중복을 솎아내는 로직을 구현해야 한다.
두 번째 유의할 점은 도메인의 영속화와 이벤트 발행을 하나의 트랜잭션으로 묶어야 한다는 것이다. 해당 부분을 애플리케이션 레벨에서 처리하려면 Kafka를 동기 호출하여 이벤트를 발행해야 한다. 하지만 이런 처리 방식에서 Kafka 인스턴스가 하나라면 SPOF(Single Point of Failure)가 될 수 있다.
<마이크로서비스 패턴>을 참고하면 이벤트를 저장하는 아웃박스 테이블을 두고, 해당 테이블을 주기적으로 폴링하거나 트랜잭션의 로그를 테일링하는 방법으로 Kafka로의 이벤트 발행을 비동기처리할 수 있다.
위의 문제를 고려하여 생각했을 때, 해당 작업은 멱등하다. 따라서 중복을 솎아 내는 로직은 구현하지 않는다.
아웃박스 테이블을 통핸 이벤트 비동기 발행은 구현하지 않는다. 현재 프로젝트는 학습 목적이고 해당 방법을 적용하기엔 너무 많은 공수가 들 것으로 예상되어 해결 방법을 한 번 더 상기하고 넘어가려 한다.
회원 지역 정보 변경 프로세스
API는 PATCH api/v1/members/regions HTTP1.1 로 지정한다.
PUT 메서드는 ‘리소스를 완전히 대체한다’ 라는 의미를 가지기에 리소스가 존재하지 않는다면 생성해야 한다.
따라서 PATCH 메서드를 사용한다.
먼저 회원의 지역 변경 프로세스를 진행한다. Access Token의 memberId와 변경할 regionId를 받아 회원 정보를 변경하고 UserRegionUpdateEvent를 Kafka에 발행한다. Event는 지역정보를 변경한 memberId와 변경된 regionId를 포함한다.
해당 Event를 구독하는 구독기는 Service 영역에서도 구현할 수 있지만, 현재 프로젝트에서 비즈니스 로직(appliction) 모듈과 도메인 모듈은 POJO로 개발되고 있기에 UI를 처리하는 bootstrap 모듈에 InBoundAdapter를 두어 이벤트를 구독하고 핸들링하도록 구현한다.
회원 지역 정보 변경 시퀀스 다이어그램 (Event Produce)
회원의 지역을 변경하고 도메인 이벤트를 Kafka에 발행하는 시퀀스 다이어그램이다. 변경할 지역 정보의 유효성 검사를 수행한 뒤, 회원의 지역 정보를 변경하고 이벤트를 Kafka에 발행한다.
게시글 지역 정보 변경 시퀀스 다이어그램 (Event Consume)
회원 지역 변경 이벤트를 소비하여 회원이 작성한 모든 상품(게시글)의 지역 정보를 변경하는 시퀀스 다이어그램이다. 이벤트에 포함되어 있는 memberId와 regionId(변경되어야 할 지역 정보)를 참고하여 회원이 작성한 모든 상품을 조회하고 수정하여 영속화한다.
Code
이벤트의 타입 추상화 구조이다. Kafka에서 다룰 이벤트들의 최상위 타입인 DomainEvent를 선언하고 각 세부 타입으로 구분하여 사용하기 편리하게 구성한다. 이벤트를 발행할 때는 DomainEvent 클래스를 사용하여 추상화된 타입을 사용하고, 이벤트를 구독할 때는 세부 타입으로 타입을 구분하여 받을 수 있다.
public interface DomainEvent {}
public interface MemberDomainEvent extends DomainEvent {}
public record MemberRegionUpdatedEvent(Long memberId, Long updatedRegionId)
implements MemberDomainEvent {}
Member 도메인에서 Region 업데이트 메서드가 호출되면 작업을 처리하고 Event를 반환한다.
public class Member {
...
public MemberRegionUpdatedEvent changeRegion(Long regionId) {
this.regionId = regionId;
return new MemberRegionUpdatedEvent(id, this.regionId);
}
}
Service에서는 파라매터를 검증하고, 회원 도메인의 변경 작업을 수행한 뒤 메서드를 호출하여 회원 영속화와 이벤트 발행의 책임을 각 Outbound Adapter에 위임한다.
@RequiredArgsConstructor
@Service
public class MemberService implements CreateMemberUseCase, LoginUseCase, MemberUpdateUseCase {
private final MemberOutputPort memberOutputPort;
private final RegionValidator regionValidator;
private final MemberEventOutputPort memberEventOutputPort;
...
@Transactional
@Override
public Member regionUpdate(Long memberId, Long regionId) {
regionValidator.validateRegionId(regionId);
Member member = memberOutputPort.findById(memberId)
.orElseThrow(MemberNotFoundException::new);
MemberRegionUpdatedEvent memberRegionUpdatedEvent = member.changeRegion(regionId);
memberOutputPort.updateMember(member);
memberEventOutputPort.publish(memberRegionUpdatedEvent);
return member;
}
}
나머지는 Kafka를 통해 메시지 발행과 소비를 구현하는 코드이기에 생략한다.
결론
이벤트를 사용하면 서비스의 응집도를 높이고, 결합도를 낮출 수 있다.
예를 들어 위의 회원 지역 변경 로직을 하나의 서비스로 구현한다면, 회원 정보를 변경하는 로직과 회원이 작성한 상품 정보를 변경하는 로직이 하나의 메서드에서 처리되어야 한다.
이는 하나의 서비스가 두개 이상의 도메인과 의존하고 있다는 의미이기에 처리 로직이 복잡해짐에 따라 복잡도는 기하급수적으로 증가할 것이다. 실제로 이전 프로젝트에서 작성한 코드는 하나의 서비스에서 총 5개의 테이블을 사용해야 했으며, 주석을 달아놓지 않는 이상 다른 개
발자가 이해하기 어려운 코드가 되어 버렸다.
지금은 너무나 작은 모놀리식 애플리케이션이기에 메시지 브로커를 통한 이벤트 처리는 큰 빛을 발하진 않는 것 같다. 하지만 분산 서비스의 경우에는 비동기로 통신하고 의존성을 낮춤으로써 서로의 가용성에 영향을 끼치지 않게 되고, 독립적으로 개발, 배포될 수 있게 되어 생산성이 크게 증가할 것으로 예상한다.
물론 의존성이 아예 사라지는 것은 아니다. 다른 서비스의 이벤트를 소모하려면 이벤트를 소모하는 서비스에서 해당 이벤트를 파싱할 객체를 가지고 있어야 하고, 이 또한 의존성이다. 또한, 비동기적으로 로직을 처리함에 있어 개발자 입장에서는 이벤트가 제대로 처리되었는지, 언제 처리될 것인지 확인하는 데 조금 어렵다는 단점도 존재한다.
Kafka를 실무에서 사용하려면 많은 학습이 필요하겠지만, 이벤트 기반 로직 처리와 간단한 카프카 사용으로 인해 추후 학습하는 데 있어 조금 더 수월할 것 같다.
Reference
'중고 거래 플랫폼 API 서버 개발' 카테고리의 다른 글
MySQL Full-Text Search를 통한 쿼리 개선 (0) | 2024.05.07 |
---|---|
프로젝트 아키텍처와 의존성 방향 다이어그램 (0) | 2024.02.24 |
헥사고날 아키텍처: 싱글 모듈 → 멀티 모듈 (1) | 2024.02.09 |
헥사고날 아키텍처: MVC 구조에서 헥사고날 아키텍처로 (0) | 2024.02.08 |