현재 프로젝트에서 관리자 권한을 가진 유저가 다수의 유저에게 초대 이메일을 보내는 비즈니스 로직이 존재합니다.
해당 로직을 수행하는 메서드에 트랜잭션이 걸리고, 메일 서버에 메일 전송 요청도 보내고 있습니다.
트랜잭션을 수행하는 중에 네트워크 통신을 진행하면 굉장히 안 좋다는 글을 본 적이 있습니다.(Real MySQL)
따라서 트랜잭션 수행 코드와 네트워크 통신 코드가 결합된 비즈니스 로직 코드를 개선한 과정을 포스팅으로 작성하려고 합니다.
시작하겠습니다.
비즈니스 로직 정리
멤버와 <-> 프로젝트 멤버 <-> 프로젝트는 M:N 관계이다.
비즈니스 로직을 수행하는 메서드의 로직은 다음과 같다.
- 로그인에서 사용하는 JWT 토큰에서 멤버 id를 얻는다.
- 트랜잭션 시작
- 멤버 id를 사용해 데이터베이스에서 멤버 엔티티를 조회한다.
- 멤버 엔티티의 권한이 관리자인지 조회한다. 관리자라면 계속 로직이 수행된다.
- 추후 검증을 위해 HTTP 요청 바디에 담긴 초대 멤버들의 id를 이용해 초대 받은 멤버 데이터를 프로젝트 멤버 테이블에 저장한다.
- 마지막으로 여러 유저에게 초대 이메일을 발송한다.
- 트랜잭션 종료
개선 과정
현재 코드
초대 메일을 발송하는 로직의 서비스 메서드의 처음 코드는 다음과 같다.
invitationProjectMemberService는 도메인 서비스다.
@Transactional(readOnly = false) //트랜잭션이 걸림
public void inviteProjectMembers(InviteProjectMemberRequestDto dto) {
//멤버 조회
ProjectMember projectAdmin = projectMemberRepository.findByMemberIdAndProjectId(dto.getProjectAdminId(), dto.getProjectId()).orElseThrow(NotExistsProjectMemberException::new);
//관리자인지 검증
projectValidator.validAdmin(projectAdmin);
//프로젝트 조회
Project project = projectRepository.findProjectById(dto.getProjectId()).orElseThrow(NotExistsProjectException::new);
//초대된 멤버를 초대된 프로젝트 멤버로 저장함. 그리고 초대된 멤버들의 이메일을 List<String>으로 반환함.
List<String> invitedEmails = saveInvitedPersons(dto, project);
//이메일 발송 -> 여기가 네트워크 통신 코드
invitationProjectMemberService.sendInvitationMails(invitedEmails, project);
}
첫 번째 개선
현재 프로젝트는 계층 아키텍처를 바탕으로 진행하고 있다.
표현 계층(ex: 컨트롤러) -> 애플리케이션(ex: 서비스) -> 인프라스트럭처(ex: 레포지토리, 메일 서버 요청 클래스)
첫번째로 생각한 대안은 단순히 마지막 이메일 발송 코드를 표현 계층으로 내려버리는 것이다.
//컨트롤러 메서드 코드
@PostMapping("/projects/{projectId}/invitation")
public ResponseEntity inviteProjectMember(@AuthenticationPrincipal Long projectAdminId,
@PathVariable Long projectId,
@RequestBody InviteProjectMemberRequest request){
List<String> invitedEmails = projectMemberService.inviteProjectMembers(request.toServiceDto(projectId, projectAdminId));
//이 코드가 컨트롤러로 옮겨짐.
invitationProjectMemberService.sendInvitationMails(invitedEmails, project);
return new ResponseEntity(CommandSuccessResponse.from("프로젝트 초대 이메일 발송에 성공했습니다."), HttpStatus.OK);
}
@Transactional(readOnly = false)
public List<String> inviteProjectMembers(InviteProjectMemberRequestDto dto) {
//멤버 조회
ProjectMember projectAdmin = projectMemberRepository.findByMemberIdAndProjectId(dto.getProjectAdminId(), dto.getProjectId()).orElseThrow(NotExistsProjectMemberException::new);
//관리자인지 검증
projectValidator.validAdmin(projectAdmin);
//프로젝트 조회
Project project = projectRepository.findProjectById(dto.getProjectId()).orElseThrow(NotExistsProjectException::new);
//초대된 멤버를 초대된 프로젝트 멤버로 저장함. 그리고 초대된 멤버들의 이메일을 List<String>으로 반환함.
return saveInvitedPersons(dto, project);
}
그러나 표현 계층이 도메인 서비스인 invitationProjectMemberService를 직접적으로 의존하게 되고,
이메일 발송은 비즈니스 로직이라고 생각하므로 이메일 발송 코드가 컨트롤러에 존재하는 것이 표현 계층의 책임을 벗어난다는 생각이 들었다.
또한 inviteProjectMembers의 리턴 값이 List<String>인 것도 맘에 들지 않았다.
따라서 첫 번째 방법을 선택하지는 않았다.
두 번째 개선
결국 문제는 이메일 전송 코드와 트랜잭션 코드가 강하게 의존하고 있는 것이다.
이를 위해 강한 의존성을 해결하려고 고민해보았다.
본인이 아는 의존성을 약화시키는 방법은 3가지다.
- 중간 클래스
- 인터페이스
- 이벤트
필자는 도메인 이벤트를 적극적으로 사용해 의존성을 약화시키기로 결정했다.
수정한 로직은 다음과 같다.
- 로그인에서 사용하는 JWT 토큰에서 멤버 id를 얻는다.
- 트랜잭션 시작
- 멤버 id를 얻으면 데이터베이스에서 멤버 엔티티를 조회한다.
- 멤버 엔티티의 권한이 관리자인지 조회한다. 관리자라면 계속 로직이 수행된다.
- 추후 검증을 위해 HTTP 요청 바디에 담긴 초대 멤버들의 id를 이용해 초대 받은 멤버 데이터를 프로젝트 멤버 테이블에 저장한다.
- 여기서 도메인 이벤트를 발행한다.
- 트랜잭션 종료
- 마지막으로 도메인 이벤트를 기반으로 여러 유저에게 초대 이메일을 발송한다.
트랜잭션이 종료되면 도메인 이벤트를 기반으로 유저에게 초대 이메일을 발송하도록 수정했다.
현재 상황에서 도메인 이벤트를 사용해 문제를 해결할 수 있는 풀이 방식이 2가지가 있다.
- @TrnasactionalEventListener를 사용해 이벤트 처리하기
- 직접 코드로 작성해서 이벤트를 다루기
결론부터 말하자면 현재 프로젝트 코드상 직접 코드로 작성해 이벤트를 다루는게 테스트 작성이 편리하므로 2번을 택했다.
자세히 설명하면 Events.raise()라는 정적 메서드를 사용해 이벤트를 발행하고 있다.
raise() 내부에는 이벤트 퍼블리셔 코드가 있으므로 이벤트를 발행할 수 있다. 코드는 아래와 같다.
public class Events {
private static ApplicationEventPublisher eventPublisher;
public static void setPublisher(ApplicationEventPublisher publisher){
Events.eventPublisher = publisher;
}
public static void raise(Event event){
if(eventPublisher != null){
eventPublisher.publishEvent(event);
}
}
}
1번을 선택하지 않은 이유
- 우선 static 메서드를 모킹하고 단위 테스트에서 검증하기가 불편하다. (귀찮다)
- 필자는 보통 도메인간의 의존성을 약화시키기 위해 이벤트 리스너를 사용했다.
- 이메일 발송도 프로젝트 도메인이 수행해야할 로직이라고 생각하므로 이벤트 리스너를 사용하면서 의존성을 약화시킬 필요가 없다.
그러므로 2번을 선택했다.
2번은 사실 유닛 테스트라는 책에서 본 도메인 이벤트를 사용해 인프라스트럭쳐와 관련된 단위 테스트 작성에서 영감을 받았다.
2번을 선택하면서 퍼사드 패턴을 도입했다.
public class ProjectMemberFacade {
private final ProjectMemberService projectMemberService;
private final InvitationProjectMemberService invitationProjectMemberService;
//..생략
public void inviteProjectMembers(InviteProjectMemberRequestDto dto){
//트랜잭션이 걸리는 프로젝트 서비스 코드
List<ProjectMemberInvitedEvent> events = projectMemberService.inviteProjectMembers(dto);
//이메일을 발생하는 코드
sendInvitationProjectEmails(events);
}
private void sendInvitationProjectEmails(List<ProjectMemberInvitedEvent> events) {
//발행된 도메인 이벤트만큼 메일을 보낸다.
for (ProjectMemberInvitedEvent event : events) {
invitationProjectMemberService.sendInvitationMail(event.getInvitedMemberEmail(), event.getProject());
}
}
}
수정한 서비스 코드다. 도메인 이벤트를 리턴하고 있다.
@Transactional(readOnly = false)
public List<ProjectMemberInvitedEvent> inviteProjectMembers(InviteProjectMemberRequestDto dto) {
ProjectMember projectAdmin = projectMemberRepository.findByMemberIdAndProjectId(dto.getProjectAdminId(), dto.getProjectId()).orElseThrow(NotExistsProjectMemberException::new);
projectValidator.validAdmin(projectAdmin);
Project project = projectRepository.findProjectById(dto.getProjectId()).orElseThrow(NotExistsProjectException::new);
List<String> invitedEmails = saveInvitedPersons(dto, project);
return project.createInvitationProjectMemberEvents(invitedEmails); //도메인 이벤트를 리턴
}
퍼사드 패턴과 도메인 이벤트를 사용하면서 트랜잭션 코드와 네트워크 통신 코드의 의존성을 약화시켰다.
이것으로 성능 이슈를 미리 사전에 예방했다고 생각한다.
부가적으로 생긴 장점은 비즈니스 핵심 로직(서비스 클래스의 메서드) 테스트가 깔끔해졌다.
단위 테스트, 통합 테스트(DB와 연결 테스트)는 이메일 발송과는 크게 연관이 없는 테스트다.
통합 테스트를 수행하면서 초대 이메일을 보내는 것도 본인은 원하지 않는다.
이제는 서비스에서 리턴한 도메인 이벤트의 갯수가 초대 이메일 전송 갯수이므로 이를 검증하면 충분한 테스트라고 생각한다.
개인적으로 늘 고민하던 주제를 날을 잡아 재밌게 해결했다. 프로젝트 구조를 좀 더 좋게 개선한 것 같아 뿌듯하다.
이상으로 포스팅을 마칩니다. 감사합니다.
댓글