Spring Boot JWT 인증 구현 - 회원가입부터 로그아웃까지

kik·2026년 4월 1일
  1. 회원가입
AuthContoller.java

@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthController {

    private final MemberService memberService;

    // 회원가입: 중복 아이디 검증 후 MEMBER 역할로 계정을 생성한다
    @PostMapping("/register")
    public ResponseEntity<String> register(@RequestBody @Valid RegisterRequest request) {
        memberService.register(request);
        return ResponseEntity.ok("회원가입 성공");
    }
}

memberService의 register를 무사히 통과하면 회원가입 성공을 응답값으로 보낸다.
@RequestBody
 - JSON 형식으로 데이터를 받을때 만들어져 있는 객체와 자동으로 매핑시켜 받도록 하는 어노테이션이다.
@Valid
 - RegisterRequest 안에 검증조건 어노테이션이 있는데 그 검증조건 어노테이션에 맞게 데이터가 왔는지 검증해주는 어노테이션이다.
RegisterRequest.java 안 검증조건 어노테이션은 이런식으로 돼있다.
// 아이디: 영문·숫자·밑줄만 허용, 4~20자
@NotBlank
@Size(min = 4, max = 20)
@Pattern(regexp = "^[a-zA-Z0-9_]+$", message = "영문, 숫자, 밑줄(_)만 사용 가능합니다.")
private String username;
MemberService.java

// 회원 관련 비즈니스 로직을 처리하는 서비스
// 인증(로그인/로그아웃/토큰 재발급), 프로필 관리, 비밀번호 변경, 회원 탈퇴를 담당한다
@Service
@RequiredArgsConstructor
@Transactional
public class MemberService {

    private final MemberMapper memberMapper;
    private final PasswordEncoder passwordEncoder;
    private final JwtProvider jwtProvider;
    private final RefreshTokenMapper refreshTokenMapper;

    // Refresh Token 유효기간 (기본값: 7일, 밀리초 단위)
    @Value("${jwt.refresh-expiration-ms:604800000}")
    private long refreshExpirationMs;

    // 회원가입: 중복 아이디 검증 후 BCrypt로 비밀번호를 암호화해 MEMBER 역할로 저장한다
    public void register(RegisterRequest request) {
        if (memberMapper.findByUsername(request.getUsername()).isPresent()) {
            throw new BusinessException(ErrorCode.DUPLICATE_USERNAME);
        }

        Member member = Member.builder()
                .roleId(MemberRole.MEMBER.getRoleId())
                .username(request.getUsername())
                .name(request.getName())
                .password(passwordEncoder.encode(request.getPassword()))
                .build();

        memberMapper.save(member);
    }
}

회원가입 서비스이다.
if (memberMapper.findByUsername(request.getUsername()).isPresent()) {

.isPresent()는 조회한 회원의 ID가 있는지 null체크를 한다.
MemberMapper 인터페이스에 보면
Optional<Member> findByUsername(String username);
이렇게 Optional을 사용해야 Service에서 .isPresent()를 사용할 수 있다.
Optional을 사용한 이유는 null체크를 할 때 코드가 깔끔해지고 잊지않고 null 체크를 할 수 있기 때문이다.

Member member = Member.builder()
                .roleId(MemberRole.MEMBER.getRoleId())
                .username(request.getUsername())
                .name(request.getName())
                .password(passwordEncoder.encode(request.getPassword()))
                .build();
Member 객체에는 '@Setter'가 없다.
그 이유는 '@Setter'를 사용해 객체에 데이터를 담게 된다면
.set을 사용해 member 객체의 내용물을 중간에 수정할 수 있기 때문에 안정성이 떨어진다.
@Setter 가 없기 때문에 .builder 로 만들어진 member 객체를 더이상 수정할 수 없다

.password(passwordEncoder.encode(request.getPassword()))
에선 비밀번호를 BCrypt로 단방향 암호화해서 객체에 담는다.

생성한 member 객체를 정상적으로 저장하면 회원가입이 완료된다.
MemberMapper.xml

<select id="findByUsername" parameterType="string" resultType="com.aenggukland.letspt.member.Member">
    SELECT *
    FROM member
    WHERE username = #{username}
    AND is_deleted = FALSE
</select>
findByUsername의 간단한 쿼리문이다.
탈퇴하지 않은 회원의 정보를 회원아이디를 이용해 조회한다.

<!-- 신규 회원 저장: 가입 시 role_id는 항상 MEMBER(1)로 고정 -->
<insert id="save" parameterType="com.aenggukland.letspt.member.Member">
	INSERT INTO member (role_id, username, name, password)
    VALUES (#{roleId}, #{username}, #{name}, #{password})
</insert>
member 객체의 데이터(권한, id, 회원명, 비밀번호)를 저장한다.
  1. 로그인
AuthContoller.java

@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthController {

    private final MemberService memberService;

    // 운영: true(HTTPS 전용), 로컬: false(HTTP 허용) — application.yml의 cookie.secure 값
    @Value("${cookie.secure:false}")
    private boolean cookieSecure;

    // 로그인: 인증 성공 시 AccessToken을 HttpOnly 쿠키에 담고, 응답 본문으로도 반환한다
    // 쿠키 유효기간은 1시간이며 JWT 만료시간(30분)과 별개이다 (TODO B3)
    // ResponseCookie를 사용해 SameSite=Strict 와 Secure 속성을 설정한다
    @PostMapping("/login")
    public ResponseEntity<Map<String, String>> login(@RequestBody @Valid LoginRequest request,
                                                     HttpServletResponse response) {
        Map<String, String> tokens = memberService.login(request);

        // SameSite=Strict: 크로스 사이트 요청 시 쿠키 전송 차단 (CSRF 방어)
        // Secure: 운영 환경에서 HTTPS 전용 전송 강제 (cookie.secure=true 시 활성화)
        ResponseCookie cookie = ResponseCookie.from("accessToken", tokens.get("accessToken"))
                .httpOnly(true)
                .path("/")
                .maxAge(60 * 60) // 1시간
                .secure(cookieSecure)
                .sameSite("Strict")
                .build();
        response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString());

        return ResponseEntity.ok(tokens);
    }
}
login에선 파라미터에 'HttpServletResponse response'가 있다.
'HttpServletResponse'은 헤더에 데이터를 넣어서 응답값을 리턴해야할 때 사용한다.
파라미터에 써놓으면 Spring이 자동으로 주입해주는 Spring 핵심 기능 중 하나이다.

memberService의 login을 통과하면 cookie 세팅을 한다.
ResponseCookie cookie = ResponseCookie.from("accessToken", tokens.get("accessToken"))
memberService의 login을 통과할때 리턴받은 새 accessToken으로 쿠키 객체를 생성한다.

.httpOnly(true)
JavaScript에서 쿠키를 읽을 수 없도록 차단한다. 
XSS 공격으로 document.cookie를 통해 토큰을 탈취하는 것을 방지한다.
.httpOnly(false)JavaScript에서 쿠키를 읽을 수 있다.
.httpOnly(true)를 하는 이유는 JavaScript에서 쿠키를 읽을 수 없게해 XSS 공격을 방지하기 위함이다.

XSS 공격 흐름 : 
1. 해커가 악성 스크립트를 사이트에 심음
2. 사용자가 페이지 접근
3. 악성 스크립트가 document.cookie 로 JWT 탈취
4. 해커가 탈취한 JWT로 사용자인 척 활동
여기서 3번을 막음으로써 XSS 공격을 방지할 수 있다.

path()는 쿠키를 어떤 경로에서 사용할지 설정한다.
path("/") 면 모든경로
만약 path("/api/auth"):
/api/auth 경로에서만 쿠키 전송,
/api/board 요청할 때는 쿠키를 안 보낸다.

.secure(cookieSecure)은 상단 변수를 사용한다. 
// 운영: true(HTTPS 전용), 로컬: false(HTTP 허용) — application.yml의 cookie.secure 값
@Value("${cookie.secure:false}")
private boolean cookieSecure;

${cookie.secure}false 일땐 HTTP에서의 요청을 허용하고
${cookie.secure}true 일땐 HTTPS의 요청만 허용한다.
그래서 로컬 개발환경에선 false로 놓고 운영에선 true로 수정해야한다.
true 일때 HTTP에서 요청이 온다면
1. 브라우저가 쿠키 안 보냄
2. 서버가 JWT 못 받음
3. 인증 실패 → 401 응답
이렇게 처리된다.

.sameSite("Strict")
는 다른 사이트에서 온 요청은 쿠키를 안보낸다는 의미다.
CSRF 공격 흐름:
1. 사용자가 우리 사이트 로그인 상태
2. 해커의 낚시 사이트 클릭
3. 낚시 사이트가 우리 사이트로 몰래 요청
4. 브라우저가 쿠키 자동으로 담아서 전송 → 공격 성공
에서 3번을 막음으로써 CSRF 공격을 방지할 수 있다.

response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString());
브라우저가 SET_COOKIE 헤더를 보고 쿠키에 자동으로 저장한다. 서버는 명령서만 보낼 뿐 직접 쿠키를 저장하지 않는다.
1. 서버가 SET_COOKIE 헤더에 쿠키 데이터를 담아서 응답
2. 브라우저가 SET_COOKIE 헤더를 보고
   "아 이 값을 쿠키에 저장하라는 거구나"
3. 브라우저가 쿠키 저장소에 저장
4. 이후 요청마다 브라우저가 자동으로 쿠키를 같이 보냄
MemberService.java

// 회원 관련 비즈니스 로직을 처리하는 서비스
// 인증(로그인/로그아웃/토큰 재발급), 프로필 관리, 비밀번호 변경, 회원 탈퇴를 담당한다
@Service
@RequiredArgsConstructor
@Transactional
public class MemberService {

    private final MemberMapper memberMapper;
    private final PasswordEncoder passwordEncoder;
    private final JwtProvider jwtProvider;
    private final RefreshTokenMapper refreshTokenMapper;

    // Refresh Token 유효기간 (기본값: 7일, 밀리초 단위)
    @Value("${jwt.refresh-expiration-ms:604800000}")
    private long refreshExpirationMs;

    // 로그인: 비밀번호 검증 후 AccessToken(JWT)과 RefreshToken(UUID)을 발급한다
    // RefreshToken은 DB에 저장되며, 동일 username으로 재로그인 시 덮어쓴다
    public Map<String, String> login(LoginRequest request) {
        Member member = memberMapper.findByUsername(request.getUsername())
                .orElseThrow(() -> new BusinessException(ErrorCode.MEMBER_NOT_FOUND));

        if (!passwordEncoder.matches(request.getPassword(), member.getPassword())) {
            throw new BusinessException(ErrorCode.INVALID_PASSWORD);
        }

        String roleName = MemberRole.fromRoleId(member.getRoleId()).name();
        String accessToken = jwtProvider.createToken(member.getUsername(), roleName);
        String refreshToken = UUID.randomUUID().toString();

        refreshTokenMapper.save(RefreshToken.builder()
                .username(member.getUsername())
                .token(refreshToken)
                .expiresAt(LocalDateTime.now().plusSeconds(refreshExpirationMs / 1000))
                .build());

        return Map.of("accessToken", accessToken, "refreshToken", refreshToken);
    }
}

로그인 서비스이다.
로그인 서비스에선 회원정보가 있는지, 비밀번호가 일치하는지를 판단하고
accessToken과 refreshToken을 새로 생성하는 작업을 한다.

Member member = memberMapper.findByUsername(request.getUsername())
                .orElseThrow(() -> new BusinessException(ErrorCode.MEMBER_NOT_FOUND));에서
파라미터의 회원ID로 회원 정보를 찾고 역시 
MemberMapper.java 인터페이스에서 Optional<Member> findByUsername(String username);
Optional을 쓰고 있기 때문에 null처리를 해준다.

if (!passwordEncoder.matches(request.getPassword(), member.getPassword())) {
조회한 member의 비밀번호와 파라미터로 받은 비밀번호를 비교한다.
BCrypt는 단방향 암호화라 복호화가 불가능하다. 
대신 입력받은 비밀번호를 DB에 저장된 해시값의 salt를 이용해 동일하게 암호화한 후 비교한다.
이러한 방식으로 요청 비밀번호를 재암호화해서 DB의 비밀번호와 비교한 후 같지 않으면 BusinessException을 떨어뜨린다.

String roleName = MemberRole.fromRoleId(member.getRoleId()).name();
조회한 member의 roleId로 Enum의 권한을 담는다.

String accessToken = jwtProvider.createToken(member.getUsername(), roleName);
jwtProvider에서 사용자ID, 권한을 이용해 accessToken을 만든다.

String refreshToken = UUID.randomUUID().toString();
UUID 방식으로 refreshToken을 만든다.

refreshTokenMapper.save(RefreshToken.builder()
                .username(member.getUsername())
                .token(refreshToken)
                .expiresAt(LocalDateTime.now().plusSeconds(refreshExpirationMs / 1000))
                .build());
refreshToken테이블에 회원ID, 위에서 만든 refreshToken, 만료기간을 저장한다.

return Map.of("accessToken", accessToken, "refreshToken", refreshToken);
새로 만든 accessToken과 refreshToken을 리턴한다.
RefreshTokenMapper.xml

<insert id="save" parameterType="com.aenggukland.letspt.security.RefreshToken">
        INSERT INTO refresh_token (username, token, expires_at)
        VALUES (#{username}, #{token}, #{expiresAt})
        ON CONFLICT (username)
        DO UPDATE SET token = EXCLUDED.token,
                 expires_at = EXCLUDED.expires_at,
                 created_at = CURRENT_TIMESTAMP
</insert>

새로 생성된 refreshToken을 저장하는 쿼리문이다.
ON CONFLICT (username)
DO UPDATE SET token = EXCLUDED.token,
	expires_at = EXCLUDED.expires_at,
	created_at = CURRENT_TIMESTAMP
이부분은 username이 UNIQUE 설정이 돼있어 충돌이 날 수가 있는데
충돌이 났을 경우 파라미터로 넘어온 새 refreshToken과 만료일을 저장하도록 해놨다.
  1. AccessToken 토큰 재발급
    프론트엔드에서 요청을 할때 AccessToken을 보내고 서버에서 유효성 검증을 하는데
    유효성 검증을 통과하지 못했을 경우 JwtException를 던진다.
    RefreshToken은 DB에 있지만 만료된 AccessToken으로는 누구인지 특정할 수 없기 때문에
    프론트엔드가 직접 RefreshToken을 담아서 /api/auth/refresh 를 호출해 재발급 받아야한다.
AuthContoller.java

@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthController {

    private final MemberService memberService;

    // 운영: true(HTTPS 전용), 로컬: false(HTTP 허용) — application.yml의 cookie.secure 값
    @Value("${cookie.secure:false}")
    private boolean cookieSecure;

    // Access Token 재발급: 유효한 Refresh Token을 검증하고 새 Access Token을 반환한다
    @PostMapping("/refresh")
    public ResponseEntity<Map<String, String>> refresh(@RequestBody Map<String, String> body) {
        String newAccessToken = memberService.refresh(body.get("refreshToken"));
        return ResponseEntity.ok(Map.of("accessToken", newAccessToken));
    }
}

memberService의 refresh를 통과하면 새로운 accessToken을 응답값으로 내려준다.
MemberService.java

@Service
@RequiredArgsConstructor
@Transactional
public class MemberService {

    private final MemberMapper memberMapper;
    private final PasswordEncoder passwordEncoder;
    private final JwtProvider jwtProvider;
    private final RefreshTokenMapper refreshTokenMapper;

    // Refresh Token 유효기간 (기본값: 7일, 밀리초 단위)
    @Value("${jwt.refresh-expiration-ms:604800000}")
    private long refreshExpirationMs;

    // Access Token 재발급: Refresh Token 유효성(존재 여부, 만료 여부)을 검증하고 새 토큰을 반환한다
    // 만료된 경우 DB에서 토큰을 삭제한 뒤 예외를 던진다
    public String refresh(String refreshToken) {
        RefreshToken rt = refreshTokenMapper.findByToken(refreshToken)
                .orElseThrow(() -> new BusinessException(ErrorCode.INVALID_TOKEN));

        if (rt.getExpiresAt().isBefore(LocalDateTime.now())) {
            refreshTokenMapper.deleteByUsername(rt.getUsername()); // 만료된 토큰 즉시 삭제
            throw new BusinessException(ErrorCode.EXPIRED_TOKEN);
        }

        Member member = memberMapper.findByUsername(rt.getUsername())
                .orElseThrow(() -> new BusinessException(ErrorCode.MEMBER_NOT_FOUND));

        String roleName = MemberRole.fromRoleId(member.getRoleId()).name();
        return jwtProvider.createToken(rt.getUsername(), roleName);
    }
}

refreshTokenMapper에서 refreshToken값으로 저장된 refreshToken을 조회한다.
null이면 INVALID_TOKEN 에러코드 반환

현재 날짜와 조회한 refreshToken의 만료 날짜와 비교하고 만료날짜가 지나면
refreshToken을 제거한 후 EXPIRED_TOKEN 에러코드를 반환한다.

에러코드를 반환받으면 새로 로그인을 해서 새로운 refreshToken을 발급받아야 한다.

조회한 refreshToken의 유저ID로 사용자 정보를 조회한 후
조회한 사용자 정보의 권한과 유저ID로 새로운 accessToken을 발급 받는다.
  1. 로그아웃
    로그아웃을 하는 경우 refreshToken을 제거한다.
    내 코드의 부족한 부분은
    '로그아웃을 해도 AccessToken이 만료될 때까지 유효하다'는 것이다.

그래서 두가지 개선점이 있다.
1. httpOnly 쿠키 삭제 안 함
로그아웃해도 브라우저 쿠키에 AccessToken 남아있음
서버에서 maxAge=0 으로 덮어씌워야 함
2. AccessToken 블랙리스트 없음
탈취된 AccessToken이 30분간 유효
Redis로 블랙리스트 만들면 즉시 차단 가능

AuthContoller.java

@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthController {

    private final MemberService memberService;

    // 로그아웃: Refresh Token을 DB에서 삭제해 재사용을 방지한다
    @PostMapping("/logout")
    public ResponseEntity<Void> logout(@RequestBody Map<String, String> body) {
        memberService.logout(body.get("refreshToken"));
        return ResponseEntity.ok().build();
    }
}

memberService의 logout를 무사히 통과하면 로그아웃 성공을 응답값으로 보낸다.
MemberService.java

@Service
@RequiredArgsConstructor
@Transactional
public class MemberService {

    private final MemberMapper memberMapper;
    private final PasswordEncoder passwordEncoder;
    private final JwtProvider jwtProvider;
    private final RefreshTokenMapper refreshTokenMapper;

    // 로그아웃: DB에서 Refresh Token을 삭제해 재사용을 방지한다
    public void logout(String refreshToken) {
        RefreshToken rt = refreshTokenMapper.findByToken(refreshToken)
                .orElseThrow(() -> new BusinessException(ErrorCode.INVALID_TOKEN));
        refreshTokenMapper.deleteByUsername(rt.getUsername());
    }
}

refreshTokenMapper에서 파라미터로 받은 refreshToken으로 refreshToken을 조회한 후
refreshTokenMapper에서 조회한 refreshToken의 username으로 refreshToken을 제거한다.
profile
신생아 개발자

0개의 댓글