본문 바로가기

프로젝트/Techfork

[26/01/27] 오늘의 개발 일지 - 검색 API 엔드포인트 통합 & 회원탈퇴 API 구현 & Activity 도메인 테스트 코드 작성

1. 검색 API 엔드포인트 통합

@Operation(summary = "1단계 검색(BM25 + 시맨틱)", description = "검색어를 기반으로 BM25 + k-NN 하이브리드 검색을 수행하고 합산하여 상위 결과를 반환합니다. (개인화 미적용)")
@GetMapping("/general")
public BaseResponse<List<SearchResult>> searchGeneral(
        @RequestParam @Parameter(description = "검색어", required = true) String query
) {
    List<SearchResult> results = searchService.searchGeneral(query);
    return BaseResponse.of(SuccessCode.OK, results).getBody();
}

@Operation(summary = "2단계 검색(1단계 검색 후보군 추출 -> 순위 조정", description = "검색어를 기반으로 1차 검색(BM25 + k-NN)을 수행한 후보군을 개인화 리랭킹 적용합니다.")
@GetMapping("/personalized")
public BaseResponse<List<SearchResult>> searchPersonalized(
        @RequestParam @Parameter(description = "검색어", required = true) String query,
        @AuthenticationPrincipal UserPrincipal userPrincipal
) {
   List<SearchResult> results = searchService.searchPersonalized(query, userPrincipal.getId()) ;
   return BaseResponse.of(SuccessCode.OK, results).getBody();

기존에는 다음과 같이 검색 API가 두 개로 분리되어 있었습니다.

분리가 되어있던 이유는 사용자가 아닌 유저의 검색에서는 2차 리랭킹을 진행할 수 없기 때문입니다.

 

하지만 프론트 측에서 API 연동에 번거로움이 있다고 생각하여

두 개의 API의 엔드포인트를 하나로 합치고 UserPrincipal의 유무로 분기 로직을 처리하였습니다.

또한 합친 api 엔드포인트를 스프링 시큐리티의 permitAll()로 설정하였습니다.

@Operation(
        summary = "통합 검색",
        description = "검색어를 기반으로 하이브리드 검색(BM25 + k-NN)을 수행합니다. " +
                "인증된 사용자의 경우 개인화 리랭킹이 적용되고, 비인증 사용자는 일반 검색 결과를 반환합니다."
)
@GetMapping
public BaseResponse<List<SearchResult>> search(
        @RequestParam @Parameter(description = "검색어", required = true) String query,
        @Parameter(hidden = true) @AuthenticationPrincipal UserPrincipal userPrincipal
) {
    List<SearchResult> results;

    if (userPrincipal != null) {
        results = searchService.searchPersonalized(query, userPrincipal.getId());
    } else {
        results = searchService.searchGeneral(query);
    }

    return BaseResponse.of(SuccessCode.OK, results).getBody();
}

 


2. 회원탈퇴 API 구현

회원탈퇴 API를 구현했습니다.

이 API는 소셜 ID를 제외한 사용자 정보를 null값으로 채우고, 사용자의 상태를 WITHDRAWN(탈퇴) 상태로 변경합니다.

소셜 ID로는 사용자를 특정할 수 없고, 사용자를 식별할 수 있는 데이터는 모두 null 처리하므로 괜찮을 것이라 판단했습니다.

 

사용자의 활동 데이터는 삭제하지 않고 통계용으로 수집할 계획입니다.

 

탈퇴한 유저의 JWT 인증은 막아야 하므로 JWT 토큰을 건드리는 대신 

JWT 필터에서 사용자의 상태를 체크하는 방식으로 구현했고,

@Slf4j
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtUtil jwtUtil;
    private final UserRepository userRepository;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
            throws ServletException, IOException {

        try {
            String jwt = HeaderUtil.refineHeader(request, Constants.AUTHORIZATION_HEADER, Constants.BEARER_PREFIX)
                    .orElse(null);

            if (jwt != null) {
                jwtUtil.validateToken(jwt);
                jwtUtil.validateTokenType(jwt, TOKEN_TYPE_ACCESS);

                Long userId = jwtUtil.getUserIdFromToken(jwt);
                User user = userRepository.findById(userId)
                        .orElseThrow(() -> new GeneralException(AuthErrorCode.USER_NOT_FOUND));

                if (user.isWithdrawn()) {
                    throw new GeneralException(AuthErrorCode.WITHDRAWN_USER);
                }
                
    // ...

 

소셜 로그인 성공 시 사용자가 탈퇴한 상태라면 재가입을 가능하게 하도록 처리하였습니다.

private User getOrCreateUser(SocialType socialType, String socialId, String email, String profileImage) {
        return userRepository.findBySocialTypeAndSocialId(socialType, socialId)
                .map(user -> {
                    if (user.isWithdrawn()) {
                        log.info("Withdrawn user re-registering - userId: {}, email: {}", user.getId(), email);
                        user.reactivate(email, profileImage);
                        return user;
                    }
                    return user;
                })
                .orElseGet(() -> {
                    User newUser = User.createSocialUser(socialType, socialId, email, profileImage);
                    User savedUser = userRepository.save(newUser);
                    log.info("New user created - id: {}, socialType: {}, socialId: {}, email: {}, profileImage: {}",
                            savedUser.getId(), socialType, socialId, email, profileImage);
                    return savedUser;
                });
    }
}

 


3. Activity 도메인 테스트 코드 작성

기존에 학기 중에 바빠서 작성하지 못했던 테스트 코드들을 작성하였습니다.

JUnit과 AssertJ, Mockito 라이브러리의 공식 문서를 뜯어보며 Mock과 Stub 검증 로직등에 대해 제대로 공부해보았습니다.

 

간단하게 정리해보면 아래와 같은 것 같습니다.

 

  • JUnit 5로 테스트 판을 깔고 (@Test)
  • Mockito로 복잡한 의존성(DB 등)을 가짜로 대체한 뒤 (@Mock, given)
  • AssertJ로 결과를 검증합니다. (assertThat)

 

 

또한 통합 테스트에서 기존의 MockMvc를 통한 가짜 서버를 띄우는 방식과

@SpringBootTest(webEnvironment=RANDOM_PORT)WebTestClient를 통한 실제 톰캣 컨테이너를 띄우는 방식을

고민하였는데,

실제 컨테이너를 띄울경우 E2E이므로 내부 로직을 제대로 검증할 수 없고 속도가 굉장히 저하될 것 같아 MockMvc를 유지하기로 결정하였습니다.