⚓ DATT 04 - JWT 기반 로그인 흐름 구현
개요
DATT 프로젝트 1주차 4일차에서는 회원가입 및 로그인 API를 구현하였다.
이번 작업의 핵심은 단순 로그인 기능 구현이 아니라, JWT 기반 인증 흐름을 실제 서비스 구조에 맞게 구성하는 것이었다.
특히 다음 요소들을 중심으로 설계하였다.
- BCrypt 기반 비밀번호 암호화
- Validation 기반 입력 검증
- JWT Access Token 발급
- 인증 실패 예외 처리 전략
- 공통 응답 포맷 기반 API 구조
회원가입 API 구현
회원가입 API는 다음 흐름으로 구성하였다.
1
2
3
4
5
6
1. DTO Validation 수행
2. 이메일 중복 검사
3. 닉네임 중복 검사
4. 비밀번호 암호화
5. 회원 저장
6. 응답 반환
회원가입 요청 DTO는 다음과 같이 구성하였다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public record SignupRequest(
@Email(message = "이메일 형식이 올바르지 않습니다.")
@NotBlank(message = "이메일은 필수입니다.")
String email,
@NotBlank(message = "비밀번호는 필수입니다.")
@Size(min = 8, max = 30)
String password,
@NotBlank(message = "닉네임은 필수입니다.")
@Size(min = 2, max = 30)
String nickname
) {
}
Validation 기반 입력 검증 구조
Spring Validation을 통해 요청 데이터 검증을 수행하였다.
핵심은 다음 두 가지이다.
1
@Valid
1
2
3
@NotBlank
@Email
@Size
Controller에서:
1
2
3
public ApiResponse<SignupResponse> signup(
@Valid @RequestBody SignupRequest request
)
처럼 @Valid를 선언하면 DTO Validation이 자동 수행된다.
검증 실패 시:
1
MethodArgumentNotValidException
이 발생하고, GlobalExceptionHandler에서 이를 처리하도록 구성하였다.
이를 통해 모든 Validation 실패 응답을 공통 포맷으로 통일할 수 있었다.
예시 응답:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{
"success": false,
"data": {
"code": "common.invalid_input",
"message": "요청 값이 올바르지 않습니다.",
"status": 400,
"errors": [
{
"field": "email",
"message": "이메일 형식이 올바르지 않습니다."
}
]
},
"error": null
}
BCryptPasswordEncoder 적용 이유
회원가입 시 비밀번호는 평문으로 저장하지 않고 BCrypt 기반 암호화를 적용하였다.
1
2
String encodedPassword =
passwordEncoder.encode(request.password());
BCrypt를 사용한 이유는 다음과 같다.
- 단방향 해시 기반
- Salt 자동 생성
- Rainbow Table 공격 방어
- Spring Security 기본 지원
- 실무 표준 수준의 사용 빈도
특히 BCrypt는 같은 비밀번호라도 매번 다른 해시값이 생성된다.
즉:
1
2
3
password123
→ $2a$10$...
→ 매번 다른 결과
구조로 동작한다.
따라서 로그인 검증 시에는 문자열 비교가 아니라:
1
2
3
4
passwordEncoder.matches(
rawPassword,
encodedPassword
);
방식으로 검증해야 한다.
JWT 기반 로그인 흐름
로그인 API는 다음 흐름으로 구성하였다.
1
2
3
4
1. 이메일 조회
2. 비밀번호 검증
3. JWT Access Token 생성
4. Access Token 반환
핵심 구현:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
Member member = memberRepository.findByEmail(
request.email()
).orElseThrow(() ->
new BusinessException(
ErrorCode.INVALID_CREDENTIALS
)
);
if (!passwordEncoder.matches(
request.password(),
member.getPassword()
)) {
throw new BusinessException(
ErrorCode.INVALID_CREDENTIALS
);
}
String accessToken =
jwtProvider.createAccessToken(
member.getId(),
member.getRole().name()
);
JWT 내부에는 다음 정보를 Claim으로 저장하였다.
1
2
- memberId
- role
현재는 DB 조회 없이 Claim 기반 인증 구조로 구현하였다.
이후에는 Refresh Token 및 Redis 기반 인증 구조로 확장할 예정이다.
인증 실패 예외 처리 전략
로그인 실패 시에는 다음 두 경우를 구분하지 않았다.
1
2
- 존재하지 않는 이메일
- 비밀번호 불일치
둘 다:
1
auth.invalid_credentials
로 통일하였다.
이유는 이메일 존재 여부 노출을 방지하기 위해서이다.
만약 다음처럼 분리하면:
1
2
존재하지 않는 이메일입니다.
비밀번호가 일치하지 않습니다.
공격자가 가입된 이메일 목록을 추론할 수 있다.
따라서 인증 실패 응답은 하나로 통일하는 것이 일반적인 보안 전략이다.
최종 ErrorCode:
1
2
3
4
5
INVALID_CREDENTIALS(
HttpStatus.UNAUTHORIZED,
"auth.invalid_credentials",
"이메일 또는 비밀번호가 올바르지 않습니다."
)
JSON Parse 예외 처리
추가로 잘못된 JSON 요청 처리도 보완하였다.
예:
1
2
3
{
"email": "test@test.com",
}
같은 malformed JSON 요청은:
1
HttpMessageNotReadableException
으로 처리된다.
이를 GlobalExceptionHandler에서 별도 처리하여 공통 응답 포맷으로 통일하였다.
테스트 코드 기반 검증
회원가입 및 로그인 로직은 테스트 코드 기반으로 검증하였다.
테스트 메서드명은 BDD 스타일 네이밍 규칙을 사용하였다.
1
2
3
4
5
givenSignupRequest_whenSignup_thenSuccess()
givenDuplicatedEmail_whenSignup_thenThrowException()
givenValidCredential_whenLogin_thenSuccess()
이를 통해:
1
2
3
4
5
6
7
8
Given
→ 어떤 상황에서
When
→ 어떤 행위를 수행했고
Then
→ 어떤 결과가 발생하는가
를 명확하게 표현할 수 있었다.
마무리
이번 작업을 통해 DATT 프로젝트의 인증 구조가 실제 서비스 형태에 가까워졌다.
특히 다음 요소들을 직접 구현하면서 인증 시스템의 핵심 흐름을 이해할 수 있었다.
- BCrypt 기반 비밀번호 암호화
- Validation 기반 입력 검증
- JWT Access Token 발급
- 인증 실패 예외 처리 전략
- 공통 응답 포맷 구조
- 테스트 코드 기반 검증
이후에는 Refresh Token 및 Redis 기반 인증 구조를 추가하고, JWT 인증 필터와 실제 인증 API 흐름을 완전히 연결할 예정이다.