2024.10.02.(수)
참고 자료: GDG on Campus: Seoultech Back Session
2주차
User 레포지터리와 서비스 생성
레포지토리는 생성된 데이터베이스 테이블의 데이터들을 저장, 조회, 수정, 삭제할 수 있도록 도와주는 인터페이스이다. JpaRepository는 JPA가 제공하는 인터페이스 중 하나로 CRUD 작업을 처리하는 메서드들을 이미 내장하고 있다.
CRUD는 Create, Read, Update, Delete의 앞글자만 따 만든 단어로, 데이터 처리의 기본 기능을 의미한다.
@Repository: 스프링이 레포지토리로 인식하게 만들어주는 애너테이션
// user/repository/UserRepository.java
@Repository
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByEmail(String email);
// TODO: 페이징, 검색 기능 추가
}
User 서비스에는 User 레포지터리를 사용하여 회원을 생성하는 signup 메서드를 추가했다. 이때 User의 비밀번호는 보안을 위해 반드시 암호화하여 저장해야 한다. 그래서 PasswordEncoder를 구현하여 암호화하여 비밀번호를 저장했다.
@RequiredArgsConstructor: final 변수를 모두 포함하는 생성자를 만들어주는 애너테이션
@Transactional: DB와 동시 연결하는 요청을 한 개로 제한해주는 애너테이션, 동시성 문제 해결
// user/service/UserService.java
package gdsc.session.user.service;
import gdsc.session.user.dto.UserInfo;
import gdsc.session.util.PasswordEncoder;
import gdsc.session.user.domain.User;
import gdsc.session.user.dto.LoginRequest;
import gdsc.session.user.dto.SignupRequest;
import gdsc.session.user.exception.AlreadyExistsEmailException;
import gdsc.session.user.exception.PasswordNotMatchException;
import gdsc.session.user.exception.UserNotFound;
import gdsc.session.user.repository.UserRepository;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpSession;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.Objects;
import java.util.Optional;
@Service
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
@Transactional
public void signup(SignupRequest singupRequest) {
Optional<User> userOptional = userRepository.findByEmail(signupRequest.getEmail());
String encryptedPassword = getEncryptedPassword(signupRequest.getEmail(), signupRequest.getPassword1());
User user = User.builder()
.email(signupRequest.getEmail())
.password(encryptedPassword)
.username(signupRequest.getUsername())
.build();
userRepository.save(user);
}
@Transactional(readOnly = true)
public UserInfo login(LoginRequest loginRequest) {
User user = userRepository.findByEmail(loginRequest.getEmail()).get();
return UserInfo.builder()
.id(user.getId())
.email(user.getEmail())
.username(user.getUsername())
.build();
}
private String getEncryptedPassword(String email, String password) {
return passwordEncoder.encode(email, password);
}
}
PasswordEncoder 클래스 생성
앞서 얘기했듯이 비밀번호는 데이터베이스에 암호화되어 저장되어야 한다. 그래서 비밀번호를 암호화하는 클래스를 생성했다.
@Component: 스프링 빈으로 생성하는 애너테이션
// utils/PasswordEncoder.java
@Component
public class PasswordEncoder {
public String encode(String email, String password) {
try {
KeySpec spec = new PBEKeySpec(password.toCharArray(), getSalt(email), 85319, 128);
SecretKeyFactory factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA1");
byte[] hash = factory.generateSecret(spec).getEncoded();
return Base64.getEncoder().encodeToString(hash);
} catch (NoSuchAlgorithmException | UnsupportedEncodingException | InvalidKeySpecException e) {
throw new RuntimeException(e);
}
}
// 비밀번호가 같은 사용자끼리 암호화한 값이 같으므로 이메일을 이용해 솔트값 생성
// 사용자별로 암호화된 비밀번호 값이 다르다
private byte[] getSalt(String email) throws NoSuchAlgorithmException, UnsupportedEncodingException {
MessageDigest digest = MessageDigest.getInstance("SHA-512");
byte[] keyBytes = email.getBytes("UTF-8");
return digest.digest(keyBytes);
}
}
회원가입 컨트롤러 생성
회원가입을 위한 엔티티와 서비스가 준비되었으니 URL 폼을 위한 컨트롤러를 만들어 보자.
@PostMapping(“/signup”)을 통해 /user/signup 으로 들어오는 POST 요청을 signup 메서드가 처리한다. 빈 검증을 도입하고 서비스 코드를 호출하여 회원가입 로직을 진행한다.
@Slf4j: 로그를 남기는 애너테이션 @RestController: API 통신 방식으로 컨트롤러 작성 @XXXMapping: HTTP, 메서드, uri 설정 @RequestBody: JSON으로 온 요청을 객체로 매핑 @Validated: bean validation 자동 적용
- HttpEntity, ResponseEntity, RequestEntity
// ResponseEntity.java
public class ResponseEntity<T> extends HttpEntity<T> {
}
// RequestEntity.java
public class RequestEntity<T> extends HttpEntity<T> {
}
HttpEntity: HTTP request와 response에 사용될 수 있는 객체이며, 헤더와 바디를 가지고 있다. 컨트롤러에서 사용한다. HTTP 통신을 위해 정의된 DTO라고 생각하면 된다.
RequestEntity: HttpEntity를 HTTP method와 target URL을 가질 수 있도록 확장시킨 버전이다. HttpEntity가 제공하는 헤더, 바디에 더해 HTTP method, target url, 바디의 타입까지 제공한다.
ResponseEntity: HttpStatus Code를 추가적으로 제공하는 HttpEntity의 확장 버전. HttpEntity가 제공하는 헤더, 바디에 더불어 Status Code를 지정할 수 있다.
// user/controller/UserController.java
@Slf4j
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/user")
public class UserController {
private final UserService userService;
@PostMapping("/signup")
public ResponseEntity<String> signup(@RequestBody @Validated SignupRequest signupRequest) {
userService.signup(signupRequest);
return new ResponseEntity<>("가입 완료", HttpStatus.CREATED);
}
@PostMapping("/login")
public ResponseEntity<String> login(@RequestBody @Validated LoginRequest loginRequest, HttpServletRequest request) {
UserInfo loginUser = userService.login(loginRequest);
return new ResponseEntity<>("로그인 완료", HttpStatus.OK);
}
}
password1과 password2가 달라도 회원가입처리가 되는 모습이다. 둘이 일치하지 않으면 회원가입을 거절해야 한다!
API 예외 처리
API 예외 처리는 어떻게 해야할까? API에서 오류를 반환할 때 각 오류 상황에 맞는 오류 응답 스펙을 정하고, JSON으로 데이터를 내려주어야 한다.
예외 발생 시 요청 전달 흐름
WAS -> 필터 -> 서블릿 -> 인터셉터 -> 컨트롤러 -> 컨트롤러(예외발생) -> 인터셉터 -> 서블릿 -> 필터 -> WAS -> WAS -> 필터 -> 서블릿 -> 인터셉터 -> 컨트롤러(BasicErrorController)
체크 예외와 언체크 이외
Exception 클래스
애플리케이션 로직에서 다룰 수 있는 실질적인 최상위 예외
이다. 컴파일 단계에서 체크하기 때문에 체크 예외
라고 한다.
단 RuntimeException
은 Exception
의 하위 예외 객체이지만 언체크 예외이다. 이름을 따서 주로 런타임 예외라고 불린다.
예외 기본 규칙
예외는 폭탄 돌리기와 같다. 잡아서 처리하거나 처리할 수 없으면 외부로 던져야 한다.
체크 예외 vs 언체크 예외
체크 예외는 try-catch로 잡아서 처리하거나 throws를 명시해서 외부로 던지거나 둘 중 하나를 택해야 한다. 그렇지 않으면 컴파일 단계에서 오류가 발생한다.
체크 예외에 해당하는 대부분의 예외는 복구 불가능하고 throws를 명시하게 되면 의존성이 생겨서 기본적으로 사용하지 않는다.
언체크 예외는 개발자가 잡아 처리하거나 외부로 던지지 않아도 된다. 개발자가 예외 처리를 생략한다면 언체크 예외는 자동적으로 외부로 던져진다.
-> 기본적으로 런타임 예외(언체크 예외)를 사용하자
사용자 정의 예외 클래스와 @RestControllerAdvice
회원가입의 경우 사용자가 비밀번호를 안적거나 이메일 형식으로 보내지 않으면 잘못되었다고 알려주어야 한다.
에러를 바로 반환하면 상태코드가 4XX 대로 설정되지 않고 프론트엔드와의 협업에서 에러 양식이 통일되지 않아 문제가 생긴다.
따라서 원하는 에러 반환 양식대로 ErrorResponse 클래스를 생성하고 활용하자. 그리고 어떤 오류인지 명확하게 하기 위해 여러 에러 클래스를 생성한다. PasswordNotMatchException의 경우 비밀번호가 틀렸을 때 반환하는 클래스이다.
// user/dto/ErrorResponse.java
/**
* {
* "code": "400",
* "message": "잘못된 요청입니다."
* }
*/
@Getter
public class ErrorResponse {
private final Integer code;
private final String message;
@Builder
public ErrorResponse(Integer code, String message) {
this.code = code;
this.message = message;
}
}
// user/exception/
@Getter
public abstract class UserException extends RuntimeException {
public UserException(String message) {
super(message);
}
public abstract HttpStatus getStatusCode();
}
---
public class UserNotFound extends UserException {
private static final String MESSAGE = "사용자를 찾을 수 없습니다.";
public UserNotFound() {
super(MESSAGE);
}
@Override
public HttpStatus getStatusCode() {
return HttpsStatus.NOT_FOUND;
}
}
---
public class PasswordNotMatchException extends UserException {
private static final String MESSAGE = "비밀번호가 일치하지 않습니다.";
public PasswordNotMatchException() {
super(MESSAGE);
}
@Override
public HttpStatus getStatusCode() {
return HttpStatus.BAD_REQUEST;
}
}
---
public class AlreadyExistsEmailException extends UserException {
private static final String MESSAGE = "이미 가입된 이메일입니다.";
public AlreadyExistsUsernameException() {
super(MESSAGE);
}
@Override
public HttpStatus getStatusCode() {
return HttpStatus.BAD_REQUEST;
}
}
- 유저 서비스에 예외 추가
// user/controller/UserController.java
@Service
@RequiredArgsController
public class UserService {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
@Transactional
public void signup(SignupRequest signupRequest) {
Optional<User> userOptional = userRepository.findByEmail(signupRequest.getEmail());
if (userOptional.isPresent()) {
if (userOptional.get().getUsername().equals(signupRequest.getUsername())) {
throw new AlreadyExistsEmailException();
}
throw new AlreadyExistsEmailException();
}
if (!Objects.equals(signupRequest.getPassword1(), signupRequest.getPassword2)) {
throw new PasswordNotMatchException();
}
String encryptedPassword = getEncryptedPassword(signupRequest.getEmail(), signupRequest.getPassword1());
User user = User.builder()
.email(signupRequest.getEmail())
.password(encryptedPassword)
.username(signupRequest.getUsername())
.build();
userRepository.save(user);
}
@Transactional(readOnly = true)
public UserInfo login(LoginRequest loginRequest) {
User user = userRepository.findByEmail(loginRequest.getEmail())
.orElseThrow(UserNotFound::new);
String encryptedPassword = getEncryptedPassword(loginRequest.getEmail(), loginRequest.getPassword());
if (!Objects.equals(user.getPassword(), encryptedPassword)) {
throw new PasswordNotMatchException();
}
return UserInfo.builder()
.id(user.getId())
.email(user.getEmail())
.password(user.getPassword())
.username(user.getUsername())
.build();
}
private String getEncryptedPassword(String email, String password) {
return passwordEncoder.encode(email, password);
}
}
@RestControllerAdvice
예외 클래스를 원하는 양식으로 만들어도 상태코드가 여전히 500이다. 스프링에서 서버에 문제가 있다고 판단해 자동으로 반환한다. 이대로면 클라이언트에서 서버에 문제가 있다고 생각할 것이다.
예외를 잡아서 @RestControllerAdvice를 활용해 4XX번대 코드를 반환하자.
@RestControllerAdvice: 전역적으로 예외를 처리할 수 있게 만들어주는 어노테이션. 한 마디로 각종 에러를 하나의 클래스 안에서 처리하게 만들어준다.
@ExceptionHandler: 처리할 에러 클래스를 지정
@ExceptionHandler(UserException.class)로 설정하면 UserException이 발생했을 때 해당 메서드가 실행된다.
ResponseEntity: HTTP 헤더와 HTTP 바디를 포함하는 클래스. HTTP 응답을 나타낸다.
@ResponseStatus: 응답시에 상태 코드를 변경해주는 어노테이션
참고로 빈 검증 실행시에 발생하는 오류는 MethodArgumentNotValidException으로 반환된다.
package gdsc.session.user.controller;
@Slf4j
@RestControllerAdvice
public class UserExceptionController {
@ResponseBody
@ResponseStatus(HttpsStatus.BAD_REQUEST)
@ExceptionHandler(MethodArgumentNotValidException.class)
public ErrorResponse invalidRequestHandler(MethodArgumentNotValidException e) {
// DTO에서 작성 오류 메세지가 defaultMessage에 저장된다.
// 하나만 지정했으므로 첫번째 값을 가져오면 된다.
String defaultMessage = e.getBindingResult()
.getFieldErrors()
.stream()
.map(FieldError::getDefaultMessage)
.findFirst()
.get();
return ErrorResponse.builder()
.code(HttpStatus.BAD_REQUEST.value())
.message(defaultMessage)
.build();
}
@ResponseBody
@ExceptionHandler(UserException.class)
public ResponseEntity<ErrorResponse> userException(UserException e) {
HttpStatus statusCode = e.getStatusCode();
ErrorResponse body = ErrorResponse.builder()
.code(statusCode.value())
.message(e.getMassge())
.build();
return ResponseEntity.status(statusCode)
.body(body);
}
@ResponseBody
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> exception(Exception e) {
ErrorResponse body = ErrorResponse.builder()
.code(HttpStatus.INTERNAL_SERVER_ERROR.value())
.message(e.getMessage())
.build();
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(body);
}
}