문제 상황
- 사용자 정보를 변경한 후 커밋되었지만 메시지가 발행되지 않는 경우
- 메시지를 발행했지만 사용자명 변경 내역이 롤백된 경우
다음과 같이 컨트롤러에서 userService를 호출해서 사용자 정보를 변경하여 DB에 커밋한 후 kafkaService를 호출해서 변경된 사용자 정보를 카프카 메시지로 발행한다고 가정하자.
@RestController
@RequiredArgsConstructor
public class UserController {
private final JwtUtils jwtUtils;
private final UserService userService;
private final KafkaService kafkaService;
@PutMapping("/edit")
public ResponseEntity editUserInfo(@RequestBody EditUserInfoRequest editUserInfoRequest, HttpServletRequest request) {
ReadUserResponse readUserResponse = userService.editUserInfo(editUserInfoRequest, jwtUtils.getEmailFromHeader(request));
kafkaService.publishUserInfoUpdated(readUserResponse.getEmail());
return ResponseEntity.status(HttpStatus.OK)
.body(new SuccessResponse("회원 정보 수정 성공", readUserResponse));
}
}
혹은 다음과 같이 @Transactional이 붙은 UserSerivce에서 KafkaService를 호출하는 방식으로 하나의 트랜잭션으로 묶을 수도 있다.
@RestController
@RequiredArgsConstructor
public class UserController {
private final JwtUtils jwtUtils;
private final UserService userService;
@PutMapping("/edit")
public ResponseEntity editUserInfo(@RequestBody EditUserInfoRequest editUserInfoRequest, HttpServletRequest request) {
ReadUserResponse readUserResponse = userService.editUserInfo(editUserInfoRequest, jwtUtils.getEmailFromHeader(request));
return ResponseEntity.status(HttpStatus.OK)
.body(new SuccessResponse("회원 정보 수정 성공", readUserResponse));
}
}
@Service
@Transactional
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository;
private final KafkaService kafkaService;
public ReadUserResponse editUserInfo(EditUserInfoRequest dto, String email) {
User user = findUser(email);
user.editUserInfo(dto.getName());
kafkaService.publishUserInfoUpdated(user.getEmail()); // 카프카 메시지 발행
return new ReadUserResponse(user);
}
}
그런데 사용자 정보를 업데이트하고 카프카 메시지를 발행하는 작업을 하나의 트랜잭션으로 묶더라도 카프카 메시지를 발행하는 것은 외부의 카프카 서버로 발행하는 분산 환경이므로 다음과 같은 문제가 발생할 수 있다.
- 사용자 정보를 변경한 후 커밋되었지만 메시지가 발행되지 않는 경우
- 일시적인 네트워크 문제
- 카프카 서버의 문제
- 커밋 완료 후 메시지 발행 직전 해당 애플리케이션 서버의 장애
- ...
- 메시지를 발행했지만 사용자명 변경 내역이 롤백된 경우
- DB 연결 끊김
- ...
위와 같은 문제들이 있을 수 있는데, 이때 일시적인 네트워크 문제 또는 카프카 서버의 문제로 카프카 서버와의 연결이 끊긴 경우에는 retries 설정을 하면, 설정된 횟수만큼 메시지 발행을 재시도한다. 따라서 카프카 서버와 재연결이 되었을 때 메시지 발행에 성공할 수 있다.
그러나 커밋이 완료되어 변경된 사용자 정보가 DB에 업데이트되었지만, 메시지 발행 직전 애플리케이션 서버가 중단된다면, 메시지는 영영 발행되지 않는다.
Transactional Messaging
이처럼 비동기 메시징 시스템을 활용한 분산 환경에서는 비즈니스 로직의 실행에 따라 이를 표현하는 이벤트도 온전히 발행되는 것이 중요하다. 보통 애플리케이션 로직상 트랜잭션이 완료되기 전에 이벤트 메시지를 발행하는데, 이때 메시지가 발행되었으나 특정 이유로 예외가 발생해 롤백이 될 수 있고, 또는 메시지 발행에 실패했으나 커밋할 수 있다. 즉, DB 커밋과 메시지 발행이 원자적으로 수행되지 않는 문제가 발생할 수 있다.
이와 같이 서비스 로직의 실행과 그 이후의 이벤트 발행을 원자적으로 함께 실행하는 것을 트랜잭셔널 메시징(Transactional Messaging)이라고 한다. 트랜잭션이 커밋되면 메시지도 정상 발행되어야 하고, 트랜잭션이 롤백되면 메시지는 발행되면 안된다.
Transactional Outbox Pattern
위와 같은 문제를 해결하기 위한 방법으로 DB를 업데이트하는 트랜잭션의 일부로 데이터베이스에 메시지를 저장하는 방법이 있다. 그런 다음 별도의 프로세스가 저장된 이벤트를 읽어 메시지 브로커에 전송하는 것이다. 이 방법이 Outbox Pattern이다.
애플리케이션은 데이터베이스의 outbox 테이블에 메시지를 저장한다. 그리고 별도의 프로세스가 outbox 테이블에서 데이터를 읽고 해당 데이터를 사용해 작업을 수행한다. 실패 시 완료될 때까지 재시도한다. 따라서 Outbox Pattern은 메시지 발행에 시차가 생길 수 있지만 결과적 일관성(Eventual Consistency)을 보장하며 적어도 한 번 이상(at-least once) 메시지가 전송됨을 보장한다.
@Service
@RequiredArgsConstructor
@Transactional
public class UserService {
private final UserRepository userRepository;
private final OutboxRepository outboxRepository;
public ReadUserResponse editUserInfo(EditUserInfoRequest dto, String email) {
User user = findUser(email);
user.editUserInfo(dto.getName());
Outbox outbox = Outbox.create(user.getEmail());
outboxRepository.save(outbox);
return new ReadUserResponse(user);
}
@Service
@RequiredArgsConstructor
public class OutboxProcessor {
private final OutboxRepository outboxRepository;
private final KafkaService kafkaService;
@Scheduled(fixedRate = 5000)
public void publishOutboxMessage() {
List<Outbox> outboxes = outboxRepository.findAll();
for (Outbox outbox : outboxes) {
kafkaService.publishUserInfoUpdated(outbox.getPayload());
outboxRepository.delete(outbox); // 메시지 전송 후 삭제
}
}
}
이때 메시지 전송 후 outbox 메시지를 데이터베이스에서 삭제한다. 그런데 일시적인 네트워크 문제 등으로 메시지 전송에 실패할 수 있으므로 카프카 프로듀서의 retries를 설정하는 것이 좋다.
Reference
- https://medium.com/@greg.shiny82/%ED%8A%B8%EB%9E%9C%EC%9E%AD%EC%85%94%EB%84%90-%EC%95%84%EC%9B%83%EB%B0%95%EC%8A%A4-%ED%8C%A8%ED%84%B4%EC%9D%98-%EC%8B%A4%EC%A0%9C-%EA%B5%AC%ED%98%84-%EC%82%AC%EB%A1%80-29cm-0f822fc23edb
- https://blog.gangnamunni.com/post/transactional-outbox/
- https://velog.io/@eastperson/Transaction-Outbox-Pattern-%EC%95%8C%EC%95%84%EB%B3%B4%EA%B8%B0