들어가기 앞서
현재 커플 기반 다이어리 서비스를 제작하고 있습니다.
커플 끊기와, 커플 재결합 기능을 맡아 개발을 진행했습니다.
그와 관련되어 코드를 리팩터링 하게 된 일화에 대해서 다뤄보려고 합니다.
처음에는 간단하게 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를 통해 유연한 응답을 내려줄 수 있다는 것을 깨달았습니다.
개인적으로 구현이 쉽지는 않았지만 이번 리팩터링을 통해 보다 많이 깨달을 수 있는 기회였습니다.
'개발일지' 카테고리의 다른 글
kafka 성능 개선기 (feat. 배치 리스너) (0) | 2024.09.02 |
---|---|
[Git] SubModule에 대해서 (0) | 2024.04.16 |
asciidoc를 통해 생성된 html이 jar에 포함되지 않은 이유는 무엇일까? (1) | 2023.11.03 |
[Spring] Github Actions 테스트 시 credential 값 설정하는 방법 (5) | 2023.10.26 |