카카오톡 클론코딩(2) - Auth

2025. 3. 8. 19:29Project

전 글에서 말했던데로 Auth 관련 기능에 대해 소개해보자 한다.

 

@Service
@Slf4j
@RequiredArgsConstructor
public class AuthService {
    private final MemberRepository memberRepository;
    private final PasswordEncoder passwordEncoder;
    private final JwtTokenizer jwtTokenizer;
    private final RefreshTokenService refreshTokenService;

    @Transactional
    public void signUp(SignUpRequestDto signUpRequestDto) {
        if(memberRepository.findByLoginId(signUpRequestDto.getAccountId()).isPresent()) {
            throw new CustomException(ExceptionCode.DUPLICATE_USER_ID);
        }

        Member member = Member.builder()
                .loginId(signUpRequestDto.getAccountId())
                .nickname(signUpRequestDto.getNickname())
                .password(passwordEncoder.encode(signUpRequestDto.getPassword()))
                .build();

        memberRepository.save(member);
    }

    @Transactional
    public String[] login(String userId, String password, Boolean rememberMe) {
        Member member = memberRepository.findByLoginId(userId)
                .orElseThrow(() -> new CustomException(ExceptionCode.MEMBER_NOT_FOUND));

        if (!passwordEncoder.matches(password, member.getPassword())) {
            throw new CustomException(ExceptionCode.PASSWORD_MISMATCH);
        }
        member.rememberMe(rememberMe);
        memberRepository.save(member);

        String accessToken = jwtTokenizer.createAccessToken(member.getKokoaId(), member.getLoginId(), member.getRole().name());

        // 리프레시 토큰 검증
        RefreshToken existingToken = refreshTokenService.findRefreshTokenByLoginId(member.getLoginId())
                .orElse(null);

        String refreshToken;
        Long refreshTokenTTL = JwtTokenizer.REFRESH_TOKEN_EXPIRE_COUNT / 1000;
        if (existingToken == null || existingToken.isExpired()) {
            refreshToken = jwtTokenizer.createRefreshToken(member.getKokoaId(), member.getLoginId(), member.getRole().name());
            RefreshToken newRefreshToken = new RefreshToken(member.getLoginId(), refreshToken, refreshTokenTTL);
            refreshTokenService.saveRefreshToken(newRefreshToken);
        } else {
            refreshToken = existingToken.getRefresh();
        }

        return new String[]{accessToken, refreshToken};
    }

    @Transactional
    public void logout(String refreshToken) {
        if (refreshToken == null) {
            throw new CustomException(ExceptionCode.UNAUTHORIZED);
        }
        refreshTokenService.deleteAllRefreshTokenData(refreshToken);
    }

}

 

JWT를 이용한 로그인을 진행했고

회원가입시 아래처럼 제한을 두기로 했다.

  1. 회원가입
    1. nickname
      1. 아무거나
      2. 1~20자
    2. ID (로그인할 때, 친추할 때도 이거 씀)
      1. 영어, 숫자
      2. 5~20자
    3. passsword
      1. 영어, 숫자, 특수문자 필수
      2. 8~20자
@Getter
@AllArgsConstructor(access = AccessLevel.PUBLIC)
public class SignUpRequestDto {

    @NotBlank(message = "ID를 입력해주세요")
    @Size(min = 5, max = 20, message = "ID는 5자에서 20자 사이여야 합니다.")
    @Pattern(regexp = "^[a-zA-Z0-9]+$", message = "ID는 영어와 숫자만 입력 가능합니다.")
    private final String accountId;

    @NotBlank(message = "비밀번호를 입력해주세요.")
    @Size(min = 8, max = 20, message = "비밀번호는 8자에서 20자 사이여야 합니다.")
    @Pattern(regexp = "^(?=.*[a-zA-Z])(?=.*\\d)(?=.*[@$!%*?&#])[A-Za-z\\d@$!%*?&#]+$",
            message = "비밀번호는 영어, 숫자, 특수문자를 포함해야 합니다.")
    private final String password;

    @NotBlank(message = "닉네임을 입력해주세요.")
    @Size(min = 1, max = 20, message = "닉네임은 1자에서 20자 사이여야 합니다.")
    private final String nickname;
}

Validation 을 이용했고 정규식 패턴으로 제한을 뒀다. 그리고 해당 DTO의 내용이 객체 생성이후 변경될 필요는 없으므로 final을 사용해 불변성을 보장해줬다.

회원가입, 로그인 관련 DTO 들에 객체생성이후 변경될 필요가 없어 불변을 보장하면 좋을경우 모두 final로 필드를 선언해주고

생성자보다 어떤역할인지 바로 알 수 있도록 정적 팩토리 메서드를 이용해 DTO를 생성했다.

아래는 그 예시다.

@Getter
@AllArgsConstructor(access = AccessLevel.PRIVATE)
public class MemberLoginResponseDto {
    private final String accountId;
    private final String nickname;
    private final String profileImageUrl;
    private final String backgroundImageUrl;
    private final String bio;

    public static MemberLoginResponseDto createMemberLoginResponseDto (String accountId, String nickname, String profileImageUrl, String backgroundImageUrl, String bio) {
        return new MemberLoginResponseDto(accountId, nickname, profileImageUrl, backgroundImageUrl, bio);
    }


}

정적 팩토리 메서드를 이용하면서도 @AllArgsConstrutor의 접근제한자를 PRIVATE으로 설정한 이유는 정적 팩토리메서드에서 사용하는것 외엔 생성자를 이용할 필요가 없을것이라 생각했기 때문에 다른곳에서의 접근을 제한시켜놨다.

 

전 글에서 말했듯이 리프레시토큰을 빠르게 조회하기위해 레디스를 이용했는데 레디스에 저장하기위한 엔티티이다.

아까의 DTO 같이 정적팩토리 메서드를 이용했다.

@Getter
@NoArgsConstructor
@RedisHash("refresh_token")
@AllArgsConstructor(access = AccessLevel.PRIVATE)
public class RefreshToken implements Serializable {
    @Id
    private String kokoaId;

    @Indexed
    private String refresh;

    private Instant expiration;

    @TimeToLive
    private Long ttl;

    private RefreshToken(String kokoaId, String refresh, Long ttl) {
        this.kokoaId = kokoaId;
        this.refresh = refresh;
        this.expiration = Instant.now().plusSeconds(ttl);
        this.ttl = ttl;
    }
    
    public static RefreshToken create(String kokoaId, String refresh, Long ttl) {
        return new RefreshToken(kokoaId, refresh, ttl);
    }

    public boolean isExpired() {
        return this.expiration.isBefore(Instant.now());
    }
}

그리고 로그아웃하면 리프레시토큰을 삭제해야하는데 맨처음에 CRUD 레포지토리를 상속받는 Repository를 이용해 삭제를 진행했는데 제대로 삭제가 진행되지 않아서 애를 먹었었다.. 

원인은 @RedisHash를 사용하면 Redis의 키값이 자동으로 생성되는데, 이때의 키 값이 예상과 다를 수 있기 때문이었다.

하지만 해결방법은 간단했다 단순히 RedisTemplate를 사용하여 삭제를 진행하면 됐다.

아래 처럼 키를 직접 지정해주면 된다.

@Transactional
public void deleteAllRefreshTokenData(String refresh) {
    // refresh_token:refresh:<token> 삭제
    String refreshKey = "refresh_token:refresh:" + refresh;
    Boolean refreshDeleted = redisTemplate.delete(refreshKey);
    if (Boolean.TRUE.equals(refreshDeleted)) {
        log.info("리프레시 토큰 키 삭제 성공: {}", refreshKey);
    } else {
        log.warn("리프레시 토큰 키 삭제 실패: {}", refreshKey);
    }

    // refresh_token:<username>:idx 삭제
    String userIdxKey = "refresh_token:" + extractUserIdFromRefreshToken(refresh) + ":idx";
    Boolean userIdxDeleted = redisTemplate.delete(userIdxKey);
    if (Boolean.TRUE.equals(userIdxDeleted)) {
        log.info("리프레시 토큰 인덱스 삭제 성공: {}", userIdxKey);
    } else {
        log.warn("리프레시 토큰 인덱스 삭제 실패: {}", userIdxKey);
    }

    // refresh_token:<username> 해시 테이블 삭제
    String hashKey = "refresh_token:" + extractUserIdFromRefreshToken(refresh);
    Boolean hashDeleted = redisTemplate.delete(hashKey);
    if (Boolean.TRUE.equals(hashDeleted)) {
        log.info("리프레시 토큰 해시 삭제 성공: {}", hashKey);
    } else {
        log.warn("리프레시 토큰 해시 삭제 실패: {}", hashKey);
    }
}

 

 

아래는 컨트롤러다. 회원가입, 로그인, 로그아웃 외에 한가지 메서드가 더있는데

/refresh 는 액세스 토큰이 만료됐지만 리프레시토큰이 유효할경우 액세스토큰을 재발급 해주는 메서드다

@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
@Slf4j
public class AuthRestController {
    private final AuthService authService;
    private final CookieService cookieService;
    private final MemberService memberService;
    private final RefreshTokenService refreshTokenService;
    private final JwtTokenizer jwtTokenizer;

    @PostMapping("/signup")
    public ResponseEntity<String> signup(@Valid @RequestBody SignUpRequestDto signUpRequestDto) {
        authService.signUp(signUpRequestDto);
        return ResponseEntity.ok("회원가입 성공");
    }

    @PostMapping("/signin")
    public ResponseEntity<LoginResponseDto> login(@RequestBody LoginRequestDto loginRequestDTO, HttpServletResponse response) {

        // 인증 처리
        String[] tokens = authService.login(loginRequestDTO.getAccountId(), loginRequestDTO.getPassword(), loginRequestDTO.isRememberMe());
        String accessToken = tokens[0];
        String refreshToken = tokens[1];

        // 쿠키 추가
        cookieService.addCookie(response, "accessToken", accessToken, (int) (JwtTokenizer.ACCESS_TOKEN_EXPIRE_COUNT / 1000));
        cookieService.addRefreshToken(response, "refreshToken", refreshToken, (int) (JwtTokenizer.REFRESH_TOKEN_EXPIRE_COUNT / 1000));

        // 로그인 성공한 사용자 정보 응답
        Member member = memberService.findByLoginId(loginRequestDTO.getAccountId());

        MemberLoginResponseDto memberLoginResponseDto = MemberLoginResponseDto.createMemberLoginResponseDto(String.valueOf(member.getLoginId()), member.getNickname(), member.getProfileUrl(), member.getBackgroundUrl(), member.getBio());
        LoginResponseDto loginResponseDto = LoginResponseDto.createLoginResponseDto(accessToken, memberLoginResponseDto);
        log.info("로그인 성공 : " + member.getLoginId());
        return ResponseEntity.ok(loginResponseDto);
    }
    @PostMapping("/signout")
    public ResponseEntity<String> logout(HttpServletRequest request, HttpServletResponse response) {
        String refreshToken = cookieService.getCookieValue(request, "refreshToken");
        authService.logout(refreshToken);

        cookieService.deleteCookie(response, "accessToken");
        cookieService.deleteCookie(response, "refreshToken");

        return ResponseEntity.ok("로그아웃되었습니다.");
    }
    @PostMapping("/refresh")
    public ResponseEntity<?> refreshAccessToken(HttpServletRequest request, HttpServletResponse response) {
        // Step 1: 쿠키에서 리프레시 토큰 추출
        String refreshToken = null;
        if (request.getCookies() != null) {
            for (var cookie : request.getCookies()) {
                if ("refreshToken".equals(cookie.getName())) {
                    refreshToken = cookie.getValue();
                    break;
                }
            }
        }

        if (refreshToken == null) {
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
                    .body("리프레시 토큰이 없습니다.");
        }

        // Step 2: 리프레시 토큰 유효성 검증
        if (!refreshTokenService.isRefreshTokenValid(refreshToken)) {
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
                    .body("리프레시 토큰이 유효하지 않습니다.");
        }

        // Step 3: 새로운 액세스 토큰 발급
        String loginId = refreshTokenService.getUserIdFromRefreshToken(refreshToken); // 사용자 ID 추출
        Member member = memberService.findByLoginId(loginId);
        String newAccessToken = jwtTokenizer.createAccessToken(member.getKokoaId(), member.getLoginId(), member.getRole().name());

        // Step 4: 새로운 액세스 토큰 반환
        return ResponseEntity.ok()
                .body(Map.of("accessToken", newAccessToken));
    }


}

 

 

jwt관련 내용까지 모두 작성할라면 너무 복잡해지는 이유도 있고 이 프로젝트의 목적과 다르기 때문에 자세히는 다루지 않았다.

그래서 Auth 관련 코드를 작성하고 진행하면서 있었던 문제에 관한 이야기만 간략하게 다뤘고 정적팩토리 메서드 방식을 적극 활용한 부분을 적었다. 다음 글에서는 Member 관련 내용을 적을것이다.