이전 과제에서 기능은 동작했지만, 한 가지 큰 문제가 있었다.
예외가 발생하면 대부분 400 또는 500으로만 내려갔다.
이 상태는 동작은 하지만 API답지 않은 상태였다.
그래서 Lv5에서는 단순 기능 추가가 아니라 API를 실무스럽게 다듬는 작업을 진행했다.
Validation은 Controller가 아니라 DTO에 두는 게 맞다.
@NotBlank(message = "username은 필수입니다.")
@Size(max = 4, message = "username은 4글자 이내여야 합니다.")
private String username;
@Email(message = "email 형식이 올바르지 않습니다.")
@NotBlank(message = "email은 필수입니다.")
private String email;
@NotBlank(message = "password는 필수입니다.")
@Size(min = 8, message = "password는 최소 8자 이상이어야 합니다.")
private String password;
@NotBlank(message = "title은 필수입니다.")
@Size(max = 10, message = "title은 10글자 이내여야 합니다.")
private String title;
@PostMapping
public ResponseEntity<ScheduleResponse> create(
@Valid @RequestBody ScheduleRequest request,
HttpSession session
)
여기서 중요한 건:
DTO에만 Validation을 붙이면 아무 일도 안 일어난다.
반드시@Valid를 Controller에서 붙여야 동작한다.
이전까지는 예외가 발생하면:
그래서 전역 예외 처리 클래스를 추가했다.
exception
┣ CustomException
┣ ErrorResponse
┗ GlobalExceptionHandler
@Getter
public class ErrorResponse {
private final LocalDateTime timestamp = LocalDateTime.now();
private final int status;
private final String message;
private final Map<String, String> fieldErrors;
}
모든 에러는 이 구조로 내려가게 만들었다.
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleValidation(...) { ... }
@ExceptionHandler(CustomException.class)
public ResponseEntity<ErrorResponse> handleCustomException(...) { ... }
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleException(...) { ... }
}
이제 어디서 예외가 터져도 이 클래스가 전부 받아서 처리한다.
이전에는:
throw new IllegalArgumentException("로그인이 필요합니다.");
-> 전부 400
이건 명확하지 않다.
그래서 상태 코드를 포함한 예외 클래스를 만들었다.
public class CustomException extends RuntimeException {
private final HttpStatus status;
public CustomException(HttpStatus status, String message) {
super(message);
this.status = status;
}
}
throw new CustomException(HttpStatus.UNAUTHORIZED, "로그인이 필요합니다.");
-> 401
.orElseThrow(() -> new CustomException(
HttpStatus.NOT_FOUND,
"해당 일정이 존재하지 않습니다."
));
-> 404
if (userRepository.existsByEmail(request.getEmail())) {
throw new CustomException(HttpStatus.CONFLICT, "이미 존재하는 이메일입니다.");
}
-> 409

{
"title": "12345678901",
"content": "내용"
}


POST /api/schedules
-> 401 Unauthorized


GET /api/schedules/99999
-> 404 Not Found

POST /api/users
같은 이메일 2번 요청
-> 409 Conflict


처음에는 이메일 중복 시 500이 발생했다.
원인은:
DB에 UNIQUE 제약이 걸려 있어서
DataIntegrityViolationException이 터진 것
이걸 해결하기 위해:
existsByEmail() 추가결과: 500 -> 409 Conflict로 정상 변경
처음에는 로그인 안 한 상태도 400으로 처리했다.
하지만
예외를 Controller마다 처리하면 코드가 지저분해진다.
전역으로 처리하니:
Lv3에서 password 필드를 추가했지만, 사실 그때는 평문 그대로 저장하고 있었다.
기능은 동작했지만 보안 관점에서는 치명적인 구조였다.
이번 Lv6에서는 비밀번호를 암호화하여 저장하고,
로그인 시에는 암호화된 값과 비교하도록 수정했다.
지금까지의 구조:
new User(
request.getUsername(),
request.getEmail(),
request.getPassword()
);
그리고 로그인 시에는
if (!user.getPassword().equals(request.getPassword())) {
...
}
이 방식의 문제점은
그래서 단방향 암호화(해시) 방식으로 전환했다.
implementation 'at.favre.lib:bcrypt:0.10.2'
Gradle을 reload한 뒤, 암호화 클래스를 추가했다.
Spring Security를 쓰지 않고,
과제 요구사항에 맞게 직접 PasswordEncoder를 구현했다.
@Component
public class PasswordEncoder {
public String encode(String rawPassword) {
return BCrypt.withDefaults()
.hashToString(BCrypt.MIN_COST, rawPassword.toCharArray());
}
public boolean matches(String rawPassword, String encodedPassword) {
BCrypt.Result result = BCrypt.verifyer()
.verify(rawPassword.toCharArray(), encodedPassword);
return result.verified;
}
}
encode() -> 회원가입 시 암호화matches() -> 로그인 시 비교기존 코드:
request.getPassword()
수정 코드:
String encodedPassword = passwordEncoder.encode(request.getPassword());
User saved = userRepository.save(
new User(
request.getUsername(),
request.getEmail(),
encodedPassword
)
);
이제 DB에 저장되는 값은 다음과 같은 형태가 된다.
$2a$10$g9dS9s8sK...

이전:
if (!user.getPassword().equals(request.getPassword())) {
수정:
if (!passwordEncoder.matches(request.getPassword(), user.getPassword())) {
throw new CustomException(HttpStatus.UNAUTHORIZED,
"이메일 또는 비밀번호가 올바르지 않습니다.");
}
이제는 raw password vs encoded password를 안전하게 비교한다.
{
"username": "lee",
"email": "lee@test.com",
"password": "12345678"
}

DB 확인 -> 암호화된 문자열 저장됨

{
"email": "lee@test.com",
"password": "12345678"
}
-> 200 OK

{
"email": "lee@test.com",
"password": "11111111"
}
-> 401 Unauthorized

암호화 적용 후, 기존에 평문으로 저장된 유저는 로그인에 실패했다.
이유는 간단하다.
그래서 테스트를 위해 새로 회원가입을 진행했다.
-> 암호화 적용 이후 생성된 유저만 정상 로그인 가능
단순 equals 비교는 보안적으로 매우 취약하다.
BCrypt는:
실무에서 가장 널리 사용되는 방식이라는 점도 직접 체감했다.
Lv5에서 예외 처리를 정리했다면,
Lv6은 보안을 신경 쓰는 단계였다.
단순히 기능이 되는 API가 아니라, 운영 가능한 API로 발전하는 느낌이었다.
이제야 비로소 백엔드 API답다는 느낌이 들었다.