Token 방식을 사용한다는 것은 Service 계층에서 Token을 처리하는 로직이 존재해야 가능합니다. MemberService등 여러 서비스 및 컨트롤러 계층에서 사용하게 될 Token을 처리하는 로직부터 먼저 생성하겠습니다.
@Repository
public interface RefreshTokenRepository extends JpaRepository<RefreshToken, String> {
    Optional<RefreshToken> findByMember(Member member);
}
@Service
@RequiredArgsConstructor
public class TokenService {
    private final JwtTokenProvider tokenProvider;
    private final RefreshTokenRepository refreshTokenRepository;
    private final MemberRepository memberRepository;
    public TokenDTO createToken(MemberDTO memberDTO) {
        TokenDTO tokenDTO = tokenProvider.createTokenDTO(memberDTO.getUserId(), memberDTO.getRoles());
        Member member = memberRepository.findByUserId(memberDTO.getUserId()).orElseThrow(() -> new RuntimeException("Wrong Access (member does not exist)"));
        RefreshToken refreshToken = RefreshToken.builder()
                .member(member)
                .token(tokenDTO.getRefreshToken())
                .build();
        refreshTokenRepository.save(refreshToken);
        return tokenDTO;
    }
    public TokenDTO createToken(Member member) {
        TokenDTO tokenDTO = tokenProvider.createTokenDTO(member.getUserId(), member.getRoles());
        RefreshToken refreshToken = RefreshToken.builder()
                .member(member)
                .token(tokenDTO.getRefreshToken())
                .build();
        refreshTokenRepository.save(refreshToken);
        return tokenDTO;
    }
    public TokenDTO refresh(TokenDTO tokenDTO) {
        if(!tokenProvider.validateToken(tokenDTO.getRefreshToken())) {
            throw new RuntimeException("Refresh Token이 유효하지 않습니다.");
        }
        Authentication authentication = tokenProvider.getAuthentication(tokenDTO.getAccessToken());
        RefreshToken refreshToken = refreshTokenRepository.findByMember(memberRepository.findByUserId(authentication.getName()).get())
                .orElseThrow(() -> new RuntimeException("로그아웃 된 사용자입니다."));
        if (!refreshToken.getToken().equals(tokenDTO.getRefreshToken())) {
            throw new RuntimeException("Refresh Token이 일치하지 않습니다.");
        }
        Member member = memberRepository.findByUserId(refreshToken.getMember().getUserId()).orElseThrow(() -> new RuntimeException("존재하지 않는 계정입니다."));
        TokenDTO tokenDto = tokenProvider.createTokenDTO(member.getUserId(), member.getRoles());
        RefreshToken newRefreshToken = refreshToken.updateValue(tokenDto.getRefreshToken());
        refreshTokenRepository.save(newRefreshToken);
        return tokenDto;
    }
}
MemberDTO를 받아와서 토큰을 생성합니다.Member를 받아와서 토큰을 생성합니다.다음으로 서비스 계층에서 인증과 관련한 작업을 처리해줄 서비스도 생성해야 합니다. 현재에는 UsernamePasswordAuthenticationToken을 이용한 인증만 하지만, 이후 어플리케이션이 커지면서 더 많은 부분에서 인증이 필요하게 될 것입니다.
@Service
@RequiredArgsConstructor
public class AuthService {
    private final AuthenticationManagerBuilder authenticationManagerBuilder;
    public void authenticateLogin(LoginRequestDTO requestDTO) {
        UsernamePasswordAuthenticationToken authenticationToken = requestDTO.toAuthentication();
        authenticationManagerBuilder.getObject().authenticate(authenticationToken);
    }
}
본격적으로 로그인/회원가입과 관련된 서비스를 생성하겠습니다. `MemberService를 생성합니다.
@Service
@Log4j2
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class MemberService implements UserDetailsService {
    private final MemberRepository memberRepository;
    private final MemberImageRepository imageRepository;
    private final TokenService tokenService;
    private final FileService fileService;
    private final AuthService authService;
    @Value("${spring.servlet.multipart.location}")
    private String uploadPath;
    @Override
    public UserDetails loadUserByUsername(String userId) throws UsernameNotFoundException {
        return memberRepository.findByUserId(userId)
                .map(this::createUserDetails)
                .orElseThrow(() -> new UsernameNotFoundException("userId: " + userId + "를 데이터베이스에서 찾을 수 없습니다."));
    }
    private UserDetails createUserDetails(Member member) {
        GrantedAuthority grantedAuthority = new SimpleGrantedAuthority(member.getRoles().stream().map(Role::getType).collect(Collectors.joining(",")));
        return new User(
                member.getUserId(),
                member.getPassword(),
                Collections.singleton(grantedAuthority)
        );
    }
    //이미지를 eager로 불러옴
    public Member findMemberByUserId(String userId) {
        return memberRepository.findByUserIdEagerLoadImage(userId)
                .orElseThrow(() -> new RuntimeException("해당 ID를 가진 사용자가 존재하지 않습니다."));
    }
    public MemberDTO findMemberByEmail(String email) {
        Member member = memberRepository.findByEmail(email).orElseThrow(() -> new RuntimeException("해당 email을 가진 사용자가 존재하지 않습니다."));
        return MemberMapper.INSTANCE.memberToMemberDTO(member);
    }
    public MemberDTO getMember(String userId) {
        return MemberMapper.INSTANCE.memberToMemberDTO(findMemberByUserId(userId));
    }
    @Transactional
    public void saveMember(MemberDTO memberDTO) {
        memberRepository.save(MemberMapper.INSTANCE.memberDTOToMember(memberDTO));
    }
    /**
     * UsernamePasswordAuthenticationToken을 통한 Spring Security인증 진행
     * 이후 tokenService에 userId값을 전달하여 토큰 생성
     * @param requestDTO
     * @return TokenDTO
     */
    @Transactional
    public TokenDTO login(LoginRequestDTO requestDTO) {
        authService.authenticateLogin(requestDTO);
        Member member = memberRepository.findByUserId(requestDTO.getUserId()).get();
        return tokenService.createToken(member);
    }
    @Transactional(readOnly = false)
    public void signup(MemberRequestDTO requestDTO) {
        if(memberRepository.existsByUserId(requestDTO.getUserId())) {
            throw new RuntimeException("이미 존재하는 아이디입니다.");
        }
        Member member = MemberMapper.INSTANCE.memberRequestDTOToMember(requestDTO);
        member.updateRole(Role.ROLE_USER);
        if(!(requestDTO.getMemberImage() == null)) {
            MemberImage memberImage = saveMemberImage(requestDTO.getMemberImage());
            member.updateMemberImage(memberImage);
        }
        memberRepository.save(member);
    }
    @Transactional(readOnly = false)
    private MemberImage saveMemberImage(MultipartFile file) {
        String originalName = file.getOriginalFilename();
        Path root = Paths.get(uploadPath, "member");
        try {
            ImageDTO imageDTO =  fileService.createImageDTO(originalName, root);
            MemberImage memberImage = MemberImage.builder()
                    .uuid(imageDTO.getUuid())
                    .fileName(imageDTO.getFileName())
                    .fileUrl(imageDTO.getFileUrl())
                    .build();
            file.transferTo(Paths.get(imageDTO.getFileUrl()));
            return imageRepository.save(memberImage);
        } catch (IOException e) {
            log.warn("업로드 폴더 생성 실패: " + e.getMessage());
        }
        return null;
    }
}
MemberService는 UserDetailsService를 상속받고 있습니다. 이를 상속받아야 Security Chain의 과정에서(바로 다음 글에서 설명할) loadUserByUsername을 호출할 수 있기 때문입니다.
login : login에서 인증 후 토큰을 발급하여 Controller로 넘겨줄 예정입니다.
signup : 매핑 후 DB에 저장합니다.
Spring강의를 보면 Service를 인터페이스화하고, 이를 구현한 구현체를 Spring Bean에 등록하는 방식을 대부분 적용합니다. 하지만 현재 진행하는 프로젝트는 큰 기업 프로젝트가 아니기에 어짜피 Interface : Impl = 1 : 1의 관계로 만들어질 것이라고 예상되어 불필요한 인터페이스화를 없앴습니다.
현재 사진을 처리하는 Service는 구현되어 있지 않는데, 이는 이후에 작성할 Image처리에서 추가할 예정입니다.
@RestController
@Log4j2
@RequestMapping("/member")
@RequiredArgsConstructor
public class MemberController {
    private final MemberService memberService;
    private final PasswordEncoder passwordEncoder;
    @PostMapping("/signup")
    public ResponseEntity<String> memberSignup(@ModelAttribute MemberRequestDTO memberRequestDTO) {
        log.info(memberRequestDTO);
        memberRequestDTO.setPassword(passwordEncoder.encode(memberRequestDTO.getPassword()));
        memberService.signup(memberRequestDTO);
        return new ResponseEntity<>("", HttpStatus.OK);
    }
    @PostMapping("/login")
    public ResponseEntity<TokenResponseDTO> memberLogin(@ModelAttribute LoginRequestDTO loginRequestDTO) {
        log.info(loginRequestDTO);
        TokenDTO tokenDTO = memberService.login(loginRequestDTO);
        ResponseCookie responseCookie = ResponseCookie
                .from("refresh_token", tokenDTO.getRefreshToken())
                .httpOnly(true)
                .secure(true)
                .sameSite("None")
                .maxAge(tokenDTO.getDuration())
                .path("/")
                .build();
        TokenResponseDTO tokenResponseDTO = TokenResponseDTO.builder()
                .isNewMember(false)
                .accessToken(tokenDTO.getAccessToken())
                .build();
        return ResponseEntity.ok().header("Set-Cookie", responseCookie.toString()).body(tokenResponseDTO);
    }
    @GetMapping("/getMemberData")
    public ResponseEntity<MemberDTO> loadMemberData() {
        return ResponseEntity.ok(memberService.getMember(SecurityUtil.getCurrentUsername()));
    }
}
passwordEncoder : SecurityConfig에서 Spring Bean으로 등록했던 인코더입니다. 회원가입 시 패스워드를 인코딩하고 DB에 저장합니다.
loadMemberData : 로그인 후 발급받은 accessToken을 헤더에 담아 보내면 회원정보를 반환해줍니다.
왜 로그인 시에는 패스워드 인코딩의 과정이 없을까?
AuthService에서 토큰 생성 전에 아래의 코드를 실행합니다.
authenticationManagerBuilder.getObject().authenticate(authenticationToken);
authenticate()메서드는 AuthenticationManager 인터페이스 아래에 있는 메서드로, 이는 Spring Security가 ProviderManager로 구현하고 있습니다.
ProviderManager의 authenticate()메서드 내부에 아래의 코드를 확인할 수 있습니다.try { result = provider.authenticate(authentication); if (result != null) { copyDetails(authentication, result); break; } }여기서 provider는 AuthenticationManager가 생성될 때 자동으로 주입받는 인증 Provider로, 이중에서 UsernamePasswordAuthentication은 이전에는 DaoAuthenticationProvider가 처리합니다.
DaoAuthenticationProvider는 UserDetailsService를 주입하는데, 이는 현재 MemberService가 구현하고 있습니다. loadUserByUsername() 메서드에 접근하여 Provider는 Member객체를 가져오고, additionalAuthenticationChecks() 메서드에서 로그인 시 입력된 비밀번호를 인코딩하여 비교합니다.if (!this.passwordEncoder.matches(presentedPassword, userDetails.getPassword())) { this.logger.debug("Failed to authenticate since password does not match stored value"); throw new BadCredentialsException(this.messages.getMessage( "AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials")); }
토큰 만료 시 갱신할 컨트롤러도 생성합니다. 토큰 이외에도 API URI로 접근할 기능들이 많을 예정이므로 ApiController에 생성합니다.
@RestController
@Log4j2
@RequestMapping("/api")
@RequiredArgsConstructor
public class ApiController {
    private final TokenService tokenService;
    @Operation(summary = "토큰 갱신")
    @PostMapping("/refreshToken")
    public ResponseEntity<TokenDTO> refreshToken(@RequestBody TokenDTO tokenDTO) {
        return ResponseEntity.ok(tokenService.refresh(tokenDTO));
    }
}
현재 Refresh Token은 MySQL DB에 저장되는데, 이것의 문제는 토큰 시간이 만료되도 지워지지 않아 중간중간 스케줄링을 하여 직접 처리해야 한다는 점입니다. 때문에 좀 더 효율적인 Redis DB를 추후 적용할 예정입니다.
이제 다음 게시글에서 Redis DB를 적용하는 방법을 확인하실 수 있습니다!
다음 글에서는 로그인 로직에 대해 세부적으로 분석해보는 과정을 보여드리겠습니다.