개발일지

커플 재결합 관련 Code Refactoring

Tommy__Kim 2023. 11. 22. 13:58

들어가기 앞서

현재 커플 기반 다이어리 서비스를 제작하고 있습니다. 
커플 끊기와, 커플 재결합 기능을 맡아 개발을 진행했습니다. 
그와 관련되어 코드를 리팩터링 하게 된 일화에 대해서 다뤄보려고 합니다. 

 

처음에는 간단하게 API를 세개를 두고, 커플 끊기, 커플 재결합 로직을 구현했습니다. 

  • [DELETE] /v1/couples/{coupleId} => 커플 끊기 API
  • [POST] /v1/couples/recouple/{coupleId} => 커플 재결합 신청 API
  • [POST] /v1/couples/recouple-decide/{recoveryId} => 커플 재결합 결정 API 

각 API에 대한 처리 로직은 다음과 같았습니다. 

  • 커플 끊기
    • Couple Entity에 @SQLDelete 어노테이션을 사용해 Soft Delete를 할 수 있도록 기능을 구현했습니다. 
  • 커플 재결합 신청
    • 재결합 신청시 Recovery라는 Entity에 Recovery를 저장하도록 구현했습니다. 
  • 커플 재결합 수락 / 거절 
    • 우선적으로 Recovery 테이블에서 재결합 신청 여부를 검증한 후 결정 여부에 따라 수락 / 거절 로직을 구현했습니다. 

 

그런 와중 저희 백엔드 팀원 한 분이 이런 의견을 주셨습니다. 

로그인을 할 때 로그인이 성공한 후에
 커플의 상태를 확인해서 커플이 깨졌지만 상대가 재결합 요청을 하는 경우라면 
재결합을 할 수 있는 전용 응답을 내려서 재결합 요청을 할 수 있도록 하는 것은 어떨까요?

 

이런 피드백을 받고 이런 저런 생각을 해 본 결과 피드백을 반영하는 것이 보다 나은 설계가 될 것이라는 판단을 했습니다. 

판단 근거는 다음과 같습니다. 

  • 불필요한 Entity 제거 가능
    • 현재 
      • 커플 재결합 로직을 위해 단순하게 재결합 신청 여부에 대해서만 저장하는 Recovery Entity가 존재합니다. 
    • 피드백 반영 시 
      • 회원의 특이 조건일 경우에만 재결합 요청 API주소를 알려준다면, Recovery Entity가 필요 없습니다. 
  • end-point 축소화 가능
    • 현재 
      • 커플 재결합의 경우 신청, 신청에 대한 응답으로 두 가지 end-point가 존재합니다.
    • 피드백 반영 시 
      • 하나의 end-point를 통해서 재결합 신청, 응답을 하나의 end-point로 관리할 수 있습니다. 
  • 보다 나은 사용자 경험 
    • 현재
      • 클라이언트에서 사용자의 커플 상태 여부를 확인해 다시 서버에 재결합 요청을 해야 합니다. 
    • 피드백 반영시 
      • 로그인 시 특정 조건인 경우 관련 API link를 줌으로 인해 바로 재결합 요청을 할 수 있습니다. 

로그인 시스템

현재 저희 프로젝트의 경우 OAuth2를 적용해 카카오 혹은 네이버 로그인을 통해 서비스를 사용할 수 있습니다.

Spring Security를 적용하고 있으며, 회원의 정보를 저희의 서비스에 맞게 저장하기 위해 CustomOAuth2UserService를 사용하고 있었습니다.

 

OAuth의 전체적인 동작 과정은 아래와 같습니다. 

 

 

Client가 /login으로 로그인 요청을 하면 카카오, 네이버 로그인 서비스로 redirect 시켜줍니다. 

  • 로그인 거부 시 
    • AuthenticationFailureHandler를 통한 후 실패 응답을 내려줍니다. 
  • 로그인 성공 시 
    • OAuth2UserService 로직을 통해 필요한 정보들을 저희 프로젝트 DataBase에 저장을 합니다.
    • AuthenticationSuccessHandler를 거친 후 응답을 내려줍니다.

로그인 성공시에 AuthenticationSuccessHandler 단계에서 회원의 커플 정보를 통해 커플의 상태를 확인 후, 상대가 재결합을 원하는 상태일 경우 특정 응답을 내릴 수 있도록 로직을 변경하면 될 것이라고 판단했습니다. 

CustomAuthenticationSuccessHandler 구현 

@Slf4j
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class CustomSuccessHandler implements AuthenticationSuccessHandler {

    private final CoupleRepository coupleRepository;

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) {
        MyOAuth2Member oAuth2Member = (MyOAuth2Member) authentication.getPrincipal();
        Long coupleId = oAuth2Member.getCoupleId();

        Optional<Couple> optionalCouple = coupleRepository.findDeletedById(coupleId);
        optionalCouple.ifPresentOrElse(
            couple -> {
                if (couple.isRecoupleReceiver(oAuth2Member.getMemberId())) {
                    log.debug("send code!!");
                    sendRecoupleCode(response, coupleId);
                } else {
                    sendCode(response);
                }
            }
            , () -> sendCode(response)
        );

    }

    private void sendRecoupleCode(HttpServletResponse response, Long coupleId) {
        response.setStatus(HttpServletResponse.SC_OK);
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        response.setCharacterEncoding("UTF-8");
        try {
            String jsonResponse = String.format("""
            {
                "code": 200,
                "message": "Recouple request is in progress. Do you want to recouple?",
                "recoupleUrl": "https://love-back.kro.kr/recouple/%d"
            }
            """, coupleId);
            response.getWriter().write(jsonResponse);
        } catch (IOException e) {
            throw new IllegalStateException("Something went wrong while generating response message", e);
        }
    }

    private void sendCode(HttpServletResponse response) {
        response.setStatus(HttpServletResponse.SC_OK);
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        response.setCharacterEncoding("UTF-8");
        try {
            String jsonResponse = """
            {
                "code": 200,
                "message": "Login success"
            }
            """;
            response.getWriter().write(jsonResponse);
        } catch (IOException e) {
            throw new IllegalStateException("Something went wrong while generating response message", e);
        }
    }

 

  • AuthenticationSuccessHandler 상속
    • Custom 한 SuccessHandler를 구현하기 위해서는 AuthenticationSuccessHandler를 상속해야 합니다. 
  • MyOAuth2 Member 조회 
    • 저희 서비스의 경우 DefaultOAuth2User가 아닌 저희 서비스만을 위한 MyOAuth2Member를 생성했습니다. 
    • MyOAuth2Member에는 coupleId가 존재합니다. 
  • 커플의 상태 조회
    • 커플이 아닌 경우 / 사귀고 있는 커플의 경우 / 깨진 커플이지만 상대가 재결합 요청을 하지 않은 경우 
      • 재결합 요청 url을 포함시키지 않고, 응답을 내려줍니다. 
    • 깨진 커플이면서 상대가 재결합 요청을 한 경우 
      • 재결합 요청 url을 포함시켜 응답을 내려줍니다. 

 

Security FilterChain 변경 

http
    .oauth2Login(
        loginConfigurer -> loginConfigurer
            .userInfoEndpoint(uI -> uI.userService(oAuth2UserService))
            .successHandler(new CustomSuccessHandler(coupleRepository))	// 해당 부분 추가
    );

 

http Security Filterchain에서 successHandler를 방금 구현한 Custom Handler를 생성해서 넣어주었습니다. 

 

이에 따라 OAuth의 동작과정은 다음과 같이 변경되었습니다. 

 

Controller Layer 변경

[리팩터링 전]

@DeleteMapping("/{coupleId}")
public ResponseEntity<Void> deleteCouple(
    @PathVariable Long coupleId,
    @RequestParam Long memberId) {
    coupleService.deleteCouple(coupleId, memberId);
    return ResponseEntity.noContent().build();
}

@SneakyThrows
@PostMapping("/recouple/{coupleId}")
public ResponseEntity<ApiResponse<Void>> reCouple(
    @PathVariable Long coupleId,
    @LoginUser SessionUser sessionUser
) {
    LocalDate requestedDate = LocalDate.now();
    coupleService.reCouple(requestedDate, coupleId, sessionUser.memberId());

    return ApiResponse.ok(
        linkTo(methodOn(CoupleController.class).reCouple(coupleId, sessionUser)).withSelfRel(),
        linkTo(CoupleController.class.getMethod("getCoupleProfile", SessionUser.class)).withRel("get couple profile"),
        linkTo(CoupleController.class.getMethod("editCoupleProfile", CoupleProfileEditRequest.class, SessionUser.class)).withRel("edit couple profile")
    );
}

@SneakyThrows
@PostMapping(value = "/recouple-decide/{recoveryId}", consumes = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<ApiResponse<Void>> decideReCoupleApproval(
    @PathVariable Long recoveryId,
    @LoginUser SessionUser sessionUser,
    @RequestBody @Valid DecideReCoupleRequest request
) {
    coupleService.decideReCoupleApproval(recoveryId, sessionUser.memberId(), request.toServiceRequest());
    return ApiResponse.ok(
        linkTo(methodOn(CoupleController.class).decideReCoupleApproval(recoveryId, sessionUser, request)).withSelfRel(),
        linkTo(CoupleController.class.getMethod("getCoupleProfile", SessionUser.class)).withRel("get couple profile"),
        linkTo(CoupleController.class.getMethod("editCoupleProfile", CoupleProfileEditRequest.class, SessionUser.class)).withRel("edit couple profile")
    );
}

 

[리팩터링 후]

@DeleteMapping("/{coupleId}")
public ResponseEntity<Void> deleteCouple(
    @PathVariable Long coupleId,
    @RequestParam Long memberId) {
    coupleService.deleteCouple(coupleId, memberId);
    return ResponseEntity.noContent().build();
}

@SneakyThrows
@PostMapping("/recouple/{coupleId}")
public ResponseEntity<ApiResponse<Void>> reCouple(
    @PathVariable Long coupleId,
    @LoginUser SessionUser sessionUser
) {
    LocalDate requestedDate = LocalDate.now();
    coupleService.reCouple(requestedDate, coupleId, sessionUser.memberId());

    return ApiResponse.ok(
        linkTo(methodOn(CoupleController.class).reCouple(coupleId, sessionUser)).withSelfRel(),
        linkTo(CoupleController.class.getMethod("getCoupleProfile", SessionUser.class)).withRel("get couple profile"),
        linkTo(CoupleController.class.getMethod("editCoupleProfile", CoupleProfileEditRequest.class, SessionUser.class)).withRel("edit couple profile")
    );
}

 

 

기존에는 두개의 end-point를 통해 재결합 로직을 구현했지만, 리팩터링 후 하나의 end-point로 재결합 로직을 구현할 수 있었습니다. 

 

Service Layer 변경 

[리팩터링 전] 

@Transactional
public void deleteCouple(Long coupleId, Long memberId) {
    Couple couple = findCouple(coupleId);
    checkAuthority(memberId, couple);
    coupleRepository.delete(couple);
}

@Transactional
public void reCouple(LocalDate requestedDate, Long coupleId, Long memberId) {
    Couple couple = findDeletedCouple(coupleId);
    checkAuthority(memberId, couple);
    checkExpiration(requestedDate, couple);
    recoveryRepository.save(Recovery.of(coupleId, requestedDate));
}

@Transactional
public void decideReCoupleApproval(Long recoveryId, Long memberId, DecideReCoupleServiceRequest serviceRequest) {
    Recovery recovery = findRecovery(recoveryId);
    Couple couple = findDeletedCouple(recovery.getCoupleId());
    checkAuthority(memberId, couple);
    recoupleOnCondition(serviceRequest, couple);
    recoveryRepository.delete(recovery);
}

x

[리팩터링 후]

@Transactional
public void deleteCouple(Long coupleId, Long memberId) {
    Couple couple = findCouple(coupleId);
    couple.checkAuthority(memberId);
    coupleRepository.delete(couple);
}

@Transactional
public void reCouple(LocalDate requestedDate, Long coupleId, Long memberId) {
    Couple couple = findDeletedCouple(coupleId);
    couple.recouple(memberId, requestedDate);
}

우선적으로 Couple에서 책임을 질 수 있는 부분들은 Couple 클래스 안으로 역할을 다 넣어주었습니다.

이에 따라 Service Layer에서 있던 코드들이 상당 부분 줄었습니다. 

추가적으로 재결합 로직의 경우 기존에는 재결합 신청 / 재결합 요청에 대한 응답으로 두 가지 메서드가 존재했으나,

리팩터링 후 하나의 메서드로 줄었습니다.

 

Entity Layer 변경

[리팩터링 전]

public void recouple() {
    this.deleted = false;
    this.deletedDate = null;
}

 

[리팩터링 후]

public void recouple(Long memberId, LocalDate requestedDate) {
    checkCoupleStatus();
    checkAuthority(memberId);

    if (this.coupleStatus == CoupleStatus.BREAKUP) {  // 요청을 하는 경우
        checkExpired(requestedDate);
        this.coupleStatus = CoupleStatus.RECOUPLE;
        this.reCoupleRequesterId = memberId;
    } else if (coupleStatus == CoupleStatus.RECOUPLE){    // 받은 요청을 수락하는 경우
        checkInvalidAccess(memberId);
        this.deleted = false;
        this.deletedDate = null;
        this.coupleStatus = CoupleStatus.RELATIONSHIP;
        this.reCoupleRequesterId = null;
    }
}

기존에는 Couple Entity의 recouple 메서드는 단순하게 삭제되었던 부분을 복구하는 로직만 들고 있었습니다. 

리팩터링 후 Couple Entity는 보다 많은 책임을 지니게 되었습니다. 

  • 요청을 하는 경우 
    • 커플 상태 변경 
    • 요청자 기입
  • 받은 요청을 수락하는 경우 
    • 커플 복구 기능 

마무리 

OAuth를 통한 로그인 과정 중간에 AuthenticationSuccessHandler를 사용해 특정 조건일 경우 추가 응답을 보내주는 방식을 통해 end-point가 감소했고, Entity에 책임을 조금 더 부여해 줌으로 인해 Service 로직에서의 책임이 줄었습니다. 

 이번 리팩터링을 통해 Entity가 책임을 질 수 있는 부분은 Entity에 책임을 부가하는 것이 객체지향에 조금 더 올바른 방식임을 다시 한번 깨달았고, handler를 통해 유연한 응답을 내려줄 수 있다는 것을 깨달았습니다. 

 개인적으로 구현이 쉽지는 않았지만 이번 리팩터링을 통해 보다 많이 깨달을 수 있는 기회였습니다.