MyB

이메일 초대를 위한 내부 로직 개선

2024.09.24

기존 상황

외부 인원을 팀원으로써 초대하기 위해서 현재 팀에 초대하고자 하는 사람의 이메일이 존재하는지 확인해야 하는 상황, 그런데 여러명의 사람들을 초대할 때 시간이 너무 오래 걸리는 상황이 발생. 현재 동아리에 가입된 회원수가 200명정도의 규모일때 한 명 초대할 때 마다 가입되어 있는지 단순 조회에만 최대 1.5초 정도가 걸리는 상황이라 다수의 사람들을 실제 초대 로직까지 실행시 너무 큰 시간이 소요가 되는 상황.

15000 →500

기존 로직

상황

팀에 속한 멤버들을 관리하는 M : N 을 관리하는 테이블에 따로 이메일 정보가 존재하지 않고 관계만 나와 있음. 따라서 현재 로직에서 멤버의 이메일을 알기 위해서 팀의 아이디를 활용하여 관계 테이블 → 멤버 테이블로 접근이 한번 더 필요한 상황.

코드

@Transactional
    public void createTeamInvitation(final  TeamInvitationCreateServiceRequest request){
		    if (!TeamRepository.existsByTeamId(request.teamId())) throw new TeamNotFoundException();
        List<MemberTeamManager> memberTeamManagers = memberTeamManagerFinder.findAllByTeamId(request.teamId());
        boolean flag = false;
        for(int i =0; i<memberTeamManagers.size();i++){
            long memberId = memberTeamManagers.get(i).getMemberId();
            String email = memberFinder.findEmailById(memberId);
            if(Objects.equals(email, request.targetEmail())){
                flag = true;
                break;
            }
        }
        if(flag){
            throw new TeamInvitationException(ALREADY_INVITED);
        }
        TeamInvitation teaminvitaion = new TeamInvitation(request.teamId(), request.email(), InvitationStatus.PENDING))
    } 
  1. 초대할 팀의 아이디를 활용하여 팀원 목록을 관리하고 있는 테이블에서 해당 팀에 속한 멤버 아이디 리스트를 가져온다.
  2. 반환된 리스트를 순회하면서 각각 멤버 테이블에 접근하며 해당 멤버가 요청한 이메일과 동일한 지 비교한다.

문제 상황

  1. JPA에서 많이 언급되는 N+1 문제가 코드상으로 구현되어 있음.
  2. 멤버 이메일 하나 찾을때마다 라운드 트립 시간 발생으로 인해 전체 로직 처리 시간이 기하급수적으로 증가

해결 방안

일단 첫번째로 발생하고 있는 쿼리의 갯수를 줄여야 할 것 같다고 판단. join을 사용해서 발생되는 쿼리를 줄이고 단계를 간소화함.

두번째로 적은 수의 팀원을 초대하기 위해 만들어진 로직 자체를 여러명을 초대해도 문제 없게 수정하기 위해서 set자료형을 통해 팀에 가입되어있는지 한번에 확인하고 초대 또한 한번에 생성하도록 수정

코드

@Transactional
    public void createTeamInvitation(final  TeamInvitationCreateServiceRequest request){
		    
		    //존재하는 팀인지 확인
		    if (!TeamRepository.existsByTeamId(teamId)) throw new TeamNotFoundException();
	      
	      Set<String> targets = new HashSet<>(request.targetEmails());
	      
	      //팀에 이미 가입된 이메일인지 확인
	      List<String> alreadyMembers = memberTeamManagerRepository
            .findMemberEmailsInTeam(teamId, targets);
        
        //초대에 제외될 목록들, 즉 이미 가입된 이메일들 
        Set<String> exclude = new HashSet<>(alreadyMembers);
        
        List<TeamInvitation> newInvitation = targets.stream()
            .filter(email -> !exclude.contains(email))
            .map(email -> new TeamInvitation(teamId, email, InvitationStatus.PENDING))
            .toList();
            
    }
public interface MemberTeamManagerRepository extends JpaRepository<MemberTeamManager, Long> {

    // targets중에서 team속한 이메일 찾
    @Query("""
        select m.email
        from MemberTeamManager mtm
        join Member m on m.id = mtm.memberId
        where mtm.teamId = :teamId
          and m.email in :targets
    """)
    List<String> findMemberEmailsInTeam(@Param("teamId") Long teamId,
                                        @Param("targets") Collection<String> targets);
}

개선 결과

기존의 코드가 매우 비효율적으로 짜여져 있어서 그런지 200명의 팀원이 있고 1명을 초대한다고 했을때 약 3배 정도의 시간을 단축하지만 초대 인원이 늘어날 경우 수십배까지도 단축.

고민해볼 지점

  • 언제 쿼리를 쪼개서 보내고 언제 join을 하는게 좋은지?
    • to-many관계가 아니고 join할 컬럼의 크기가 작거나 간단한 필터링일 경우에는 웬만해선 join이 유리하지만 그 외에 상황에서는 상황을 고려하여 사용
  • findMemberEmailsInTeam ← 이 부분보고 고민이 드는게 각종 로직에서 join이 많이 필요할 것 같은데 근데 그럼 이때마다 JPA를 불필요하게 의존하고 있는게 아닌가?
    • Command + Query 패턴으로 읽기와 쓰기에 대해서 분리하여 사용
    • 아키텍쳐에 대한 수정과 각 기술이 왜 쓰이는지 어디에 쓰이는지에 대해 자세히 알아야 할 것 같음