새로운 팀프로젝트로 공연 예매 서비스(TickerBell)를 구현하던 중 로그인 처리를 어디서 해야할지 고민했던 경험을 담은 글이다.
Spring 과 Spring Security, JWT 를 이용해 로그인을 구현했다. 난 평소 "/api/login" 이라는 url 경로로 요청이 들어오면 작동하는 필터를 만들어 로그인 처리를 진행했다. 아래는 필터를 이용해 구현한 코드이다.
public class JwtAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
private final JwtTokenProvider jwtTokenProvider;
private final ObjectMapper objectMapper = new ObjectMapper();
public JwtAuthenticationFilter(JwtTokenProvider jwtTokenProvider) {
// /api/login url 로 요청 시에만 필터 작동
super(new AntPathRequestMatcher("/api/login"));
this.jwtTokenProvider = jwtTokenProvider;
}
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
ObjectMapper objectMapper = new ObjectMapper();
LoginDto loginDto = null;
// 로그인 요청 데이터 -> loginDto 로 변환
try {
loginDto = objectMapper.readValue(request.getInputStream(), LoginDto.class);
} catch (IOException e) {
throw new RuntimeException(e);
}
// authentication token 생성 후 UserDetailsService 호출
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(loginDto.getUsername(), loginDto.getPassword());
return getAuthenticationManager().authenticate(authenticationToken);
}
@Override
protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException {
// 로그인 성공 시 실행 메서드
MemberContext memberContext = (MemberContext) authResult.getPrincipal();
Member loginMember = memberContext.getMember();
String username = loginMember.getUsername();
Role role = loginMember.getRole();
// 토큰 생성
String accessToken = jwtTokenProvider.createAccessToken(username, role);
String refreshToken = jwtTokenProvider.createRefreshToken(username, role);
LoginResponseDto loginResponseDto = new LoginResponseDto();
loginResponseDto.setAccessToken(accessToken);
loginResponseDto.setRefreshToken(refreshToken);
String loginSuccess = objectMapper.writeValueAsString(loginResponseDto);
response.setContentType("application/json");
response.setCharacterEncoding("UTF-8");
response.getWriter().println(loginSuccess);
}
}
AbstractAuthenticationProcessingFilter 를 상속받은 뒤 로그인을 처리했다. 생성자에서 AntPathRequestMatcher 객체를의 생성자를 "/api/login" 로 넘겨 해당 url 로 요청이 오면 동작하게 만들었다. 이후 attemptAuthentication 에서 AuthenticationToken 객체를 만든 뒤 AuthenticationManager 에게 넘겨 로그인 처리를 위임 했다. 로그인이 성공하면 successfulAuthentication 메서드를 이용해 Access Token 과 Refresh Token 을 클라이언트에게 전달해줬다.
그러던 어느날 같이 백엔드를 개발하던 팀원분이 "로그인 처리를 Controller가 아닌 Filter 에서 진행하는 이유가 있나요?"라고 질문했다. 나 역시도 예전에 로그인을 구현하며 같은 고민을 한적이 있었다. 내가 본 강의는 필터 기능을 이용해 로그인 처리를 하지만 구글링을 했을 때 주로 사람들은 Controller에 login url을 맵핑하여 처리하였다. 두 방식중 뭐가 더 나을까 라고 고민했을 때 필터를 사용하면 조금 더 Spring Security 의 기능을 적극적으로 사용할 수 있을 것 같았다. 해당 필터 역시 attemptAuthentication 에서 AuthenticationManger 에게 위임하고 Manager 는 Provider 를 찾아 또 다시 위임한 뒤 Provider는 사용자의 Id 와 비밀번호를 검증한다. 난 이러한 기능들을 통해 Spring Security 개발자분들이 필터를 이용해 로그인 처리를 하는것을 지향한다고 생각했다.
그러나.. 팀원분이 "/api/login"은 스웨거에 뜨지 않는데 어떻게 생각하냐고 물어봤다. 이전 프로젝트를 친구와 개발할 땐 로그인 api가 스웨거에 뜨는게 큰 문제라고 생각하지 않았다. 하지만 다시 생각해보니 이는 몇가지 프론트엔드 개발자와 협업하기에 몇가지 문제를 가지고 있었다.
우선, 로그인의 url을 스웨거만으로 직관적으로 찾아볼 수 없다. 필터를 사용할 땐 스웨거와 별개로 로그인의 url을 따로 알려주어야 했다.
두 번째로 입출력 데이터 역시 프론트엔드 개발자분에게 따로 전달해드려야 했다. 어떤 데이터를 넘겼을 때 로그인이 성공하고 로그인이 성공하면 어떤 데이터를 넘겨 받는지 스웨거에 표시되지 않기 때문에 프론트엔드 개발자분들은 확인할 방법이 없었다.
세 번째로 프론트엔드 개발자분들이 테스트를 위해 Postman이나 console을 추가로 사용해야 했다. 다른 api는 스웨거를 통해 입출력 값을 쉽게 테스트해볼 수 있지만 로그인의 경우 아니 로그인 만을 위해서 프론트엔드 개발자분들은 포스트맨을 다운받거나 로그인 api 요청 시 반환값을 console.log()를 이용해 확인해야 했다.
이는 프론트 개발자분들에게 큰 불편함을 줬다. 이러한 불편점을 깨닫고 곧바로 로그인 방식을 필터에서 Controller로 코드를 변경했다.
@Data
@NoArgsConstructor
public class LoginRequest {
@NotEmpty
@Size(min = 2)
@Schema(description = "사용자 Id", example = "username")
private String username;
@NotEmpty
@Size(min = 6)
@Schema(description = "사용자 비밀번호", example = "pass12!")
@Pattern(regexp = "^(?=.*[0-9])(?=.*[a-zA-Z]).*$", message = "비밀번호는 숫자와 영문자를 모두 포함해야 합니다.")
private String password;
}
@Operation(summary = "로그인 요청")
@PostMapping("/api/login")
public ResponseEntity<Response> login(@RequestBody LoginRequest loginRequest) {
LoginResponse loginResponse = memberService.login(loginRequest.getUsername(), loginRequest.getPassword());
return ResponseEntity.ok(new Response(loginResponse, "로그인이 완료되었습니다."));
}
@Override
public LoginResponse login(String username, String password) {
// 사용자가 입력한 Id 검증
Member findMember = memberRepository.findByUsername(username).orElseThrow(
() -> new CustomException(ErrorCode.MEMBER_NOT_FOUND)
);
// 사용자가 입력한 Password 검증
if(!passwordEncoder.matches(password, findMember.getPassword())) {
throw new CustomException(ErrorCode.INVALID_PASSWORD);
}
// access & refresh Token 생성
String accessToken = jwtTokenProvider.createAccessToken(findMember.getUsername());
String refreshToken = jwtTokenProvider.createRefreshToken(findMember.getUsername());
// refresh Token redis 저장
jwtTokenProvider.saveRefreshTokenInRedis(findMember.getUsername(), refreshToken);
return LoginResponse.builder()
.accessToken(accessToken)
.refreshToken(refreshToken)
.build();
}
위 코드는 컨트롤러를 이용해 사용자의 Id와 비밀번호를 검증한 뒤 AccessToken과 RefreshToken을 클라이언트에게 전달하는 코드이다.
컨트롤러를 이용해 개발하니 Swagger 의 기능을 이용해 url과 dto등을 쉽게 프론트엔드 개발자분들에게 전달할 수 있었고 swagger 를 이용해 간단한 테스트까지 가능했다.
'Project > 구현' 카테고리의 다른 글
| [Tickerbell] 테스트 시 S3, Redis 목킹하기 (1) | 2023.12.23 |
|---|---|
| [Independe] 예외처리 방법 (0) | 2023.10.05 |