3주차 과제 리뷰
Repository 테스트- 과제의 목표
1. Repository 테스트는 편리하다.
프로그램을 만들지 않아도 Repository 테스트가 가능하다.
2. 좋은 코드를 찾아낼 수 있다.
정상! 이미 만들어져있다.
setUp
테스트 전 실행하는 코드
teardown
테스트가 종료된 후 실행되는 코드
dummy data 한 곳에 모아넣기
📌 테스트 시에 Log4j 사용하지 말기! Build 안됨
요청 DTO (Data Transfer Object) 유효성 검사
- 소프트웨어 개발에서 DTO 객체의 데이터가 요구사항에 맞는 유효한 값인지 확인하는 과정을 말한다.
- DTO는 주로 데이터 전송을 위해 사용되는 객체이다.
- 사용자 인터페이스(UI)와 비지니스 로직 간의 데이터 전달을 담당한다.
DTO 생성 방법 ⬇️
https://shout-to-my-mae.tistory.com/313
DTO 유효성 검사 목적
1. 데이터의 유효성 확인
DTO 객체의 데이터 필드에 입력된 값이 올바른 형식, 범위, 제약 조건에 충족하는지 확인
ex) 숫자 필드에 숫자가 입력되었는지 확인
2. 비즈니스 규칙 준수 확인
DTO 객체의 데이터가 비지니스 규칙을 준수하는지 검사
ex) 주문을 나타내는 DTO에서 수량 필드는 음수가 될 수 없다는 비즈니스 규칙이 있다면 해당 규칙 검증
3. 보안 검사
DTO 객체의 데이터가 보안 요구사항을 충족하는지 확인
ex) 사용자의 비밀번호를 전송하는 DTO 객체의 경우, 암호화가 필요하거나 특정 문자열 패턴을 피하기
회원가입, 로그인 유효성 확인
정규식 테스트
public class RegexTest {
@Test
public void 정상적인한글만된다_test() throws Exception {
String value = "한글";
boolean result = Pattern.matches("^[가-힣]+$", value);
System.out.println("테스트 : " + result);
Assertions.assertTrue(result);
}
@Test
public void 한글은안된다_test() throws Exception {
String value = "abc";
boolean result = Pattern.matches("^[^ㄱ-ㅎㅏ-ㅣ가-힣]*$", value);
System.out.println("테스트 : " + result);
Assertions.assertTrue(result);
}
// chatgpt
@Test
public void 이메일형식만된다_test(){
String value = "ssar@nate.com";
boolean result = Pattern.matches("^[\\w._%+-]+@[\\w.-]+\\.[a-zA-Z]{2,6}$", value);
System.out.println("테스트 : " + result);
Assertions.assertTrue(result);
}
@Test
public void 영문숫자특수문자포함_공백안됨_test(){
String value = "s6!안";
boolean result = Pattern.matches("^(?=.*[a-zA-Z])(?=.*\\d)(?=.*[@#$%^&+=!~`<>,./?;:'\"\\[\\]{}\\\\()|_-])\\S*$", value);
System.out.println("테스트 : " + result);
Assertions.assertTrue(result);
}
}
📌 정규표현식 공부하는 것은 추천X, 문법이 헷갈리므로 chatgpt 이용하기
DTO에 정규식 패턴 등록
@Getter
@Setter
public static class JoinDTO {
@NotEmpty
@Pattern(regexp = "^[\\w._%+-]+@[\\w.-]+\\.[a-zA-Z]{2,6}$", message = "이메일 형식으로 작성해주세요")
private String email;
@NotEmpty
@Size(min = 8, max = 20, message = "8에서 20자 이내여야 합니다.")
@Pattern(regexp = "^(?=.*[a-zA-Z])(?=.*\\d)(?=.*[@#$%^&+=!~`<>,./?;:'\"\\[\\]{}\\\\()|_-])\\S*$", message = "영문, 숫자, 특수문자가 포함되어야하고 공백이 포함될 수 없습니다.")
private String password;
@NotEmpty
private String username;
public User toEntity() {
return User.builder()
.email(email)
.password(password)
.username(username)
.roles("ROLE_USER")
.build();
}
}
객체의 각 필드에 검증 사항을 작성한다. => 유효성 검증을 위한 Bean Validator 추가
@Pattern를 붙이면 정규식으로 표현할 수 있다.
⭐️ 알 수 있는 오류에 대해서는 Controller에서 다 잡아야한다! (Repository->DB까지 넘어가지 않도록 함)
📌 NotNull vs NotEmpty
Not Null : NULL만 허용하지 않는다. "", " " 가능
Not Empty : NULL과 ""(Empty)를 허용하지 않는다. " " 가능
Not Blank : NULL과 "", " "을 허용하지 않는다.
✅ 요청 받는 DTO 단에서 입력값 유효성 검사 진행
정규식 테스트 코드 - Controller
@PostMapping("/join")
public ResponseEntity<?> join(@RequestBody @Valid UserRequest.JoinDTO joinDTO, Errors errors) {
if(errors.hasErrors()){
List<FieldError> fieldErrors = errors.getFieldErrors();
return new ResponseEntity<>(
ApiUtils.error(fieldErrors.get(0).getDefaultMessage()+":"+fieldErrors.get(0).getField(), HttpStatus.BAD_REQUEST),
HttpStatus.BAD_REQUEST
);
}
User user = User.builder()
.email(joinDTO.getEmail())
.password(passwordEncoder.encode(joinDTO.getPassword()))
.username(joinDTO.getUsername())
.roles("ROLE_USER")
.build();
userRepository.save(user); //유저 저장
return ResponseEntity.ok().body(ApiUtils.success(null));
}
@Valid
객체의 필드에 달린 제약조건에 대해 검증한다.
Errors errors
스프링의 DispatcherServlet은 메서드 실행 전 요청 DTO에 대해 유효성 검사한다.
검사 중 에러 발생시 모든 에러를 담아 Errors에 주입한다.
📌 @Valid 바로 뒤⭐️에 Errors를 붙여야 에러가 정상적으로 파라미터에 넘겨진다.
fieldErrors.get(0).getField()
✅ 에러 내용에 에러가 발생한 필드 추가하기
출력 결과
✅ 유효성 검사시 모든 에러를 알기 위해서는 String message => List message 로 받기
그러나 프론트에서 유효성 검사를 할 것이기때문에 오류 발생 했음을 보여주기 위해 하나의 오류만 보여주는 것이 좋다.
또한 비정상인 요청일 수 있기때문에 친절하게 모든 에러를 보여줄 필요가 없다.
모든 컨트롤러의 DTO의 유효성 검사하기
Controller DTO를 만들어서 Valid 찾기 => 알 수 있는 오류에 대해서는 모두 체크하기
✅ errors 캐치시 반복되는 코드는 aop를 이용해 하나로 뽑아 묶어주기 (다음주)
커스텀 익셉션(Custom Exception) 만들기
목적
구체적인 예외 정보 제공
자바에서 제공하는 기본 예외 클래스들은 다양한 예외 상황을 다루기 위해 만들어졌지만,
특정한 예외 상황을 더 명확하게 표현하고자 할때는 커스텀 예외 클래스를 정의할 수 있다.
예외가 발생한 원인이나 상황에 대한 자세한 정보를 담을 수 있다.
Exception400
클라이언트가 요청 데이터를 잘못 보냈을때의 예외 커스텀(400번 에러)
@Getter
public class Exception400 extends RuntimeException {
public Exception400(String message) {
super(message);
}
public ApiUtils.ApiResult<?> body(){
return ApiUtils.error(getMessage(), HttpStatus.BAD_REQUEST);
}
public HttpStatus status(){
return HttpStatus.BAD_REQUEST;
}
}
RuntimeException 예외 확장
사용예시
if(errors.hasErrors()){
List<FieldError> fieldErrors = errors.getFieldErrors();
Exception400 ex = new Exception400(fieldErrors.get(0).getDefaultMessage()+":"+fieldErrors.get(0).getField());
return new ResponseEntity<>(
ex.body(),
ex.status()
);
}
커스텀 Exception으로 응답 만들기
응답 body에 상태 코드(status code)를 함께 보내는 이유
Q. 응답시 헤더에 상태코드가 있는데, 왜 header가 이외에 body에 상태코드를 중복하여 붙여 보낼까?
A. Front의 Repository에서는 상태코드와 상관없이 통신 내용을 파싱하는데 집중하고, view에서 상태코드에 따라 처리한다.(ex)alert 보내기)
즉, 프론트가 파싱하기 편하게 하기 위함
✅ SRP(단일 책임의 원칙) 지키기
Exception500
@Getter
public class Exception500 extends RuntimeException {
public Exception500(String message) {
super(message);
}
public ApiUtils.ApiResult<?> body(){
return ApiUtils.error(getMessage(), HttpStatus.INTERNAL_SERVER_ERROR);
} //500번 에러
public HttpStatus status(){
return HttpStatus.INTERNAL_SERVER_ERROR;
}
}
서버쪽 에러를 나타낸다.
RuntimeException 확장
Controller - 예외 처리
@Slf4j
@RequiredArgsConstructor
@RestController
public class UserRestController {
private final UserJPARepository userRepository;
private final ErrorLogJPARepository errorLogJPARepository;
private final PasswordEncoder passwordEncoder;
private final AuthenticationManager authenticationManager;
@PostMapping("/join")
public ResponseEntity<?> join(@RequestBody @Valid UserRequest.JoinDTO joinDTO, Errors errors,
HttpServletRequest request) {
//유효성 검사
if(errors.hasErrors()){
List<FieldError> fieldErrors = errors.getFieldErrors();
//첫번째 에러 메세지, 에러 발생 필드로 Exception 만들기
Exception400 ex = new Exception400(fieldErrors.get(0).getDefaultMessage()+":"+fieldErrors.get(0).getField());
return new ResponseEntity<>(
ex.body(),
ex.status()
);
}
//입력 정보로 유저 생성
User user = User.builder()
.email(joinDTO.getEmail())
.password(passwordEncoder.encode(joinDTO.getPassword()))
.username(joinDTO.getUsername())
.roles("ROLE_USER")
.build();
Optional<User> userOP = userRepository.findByEmail(joinDTO.getEmail());
//중복 email이 없을 경우
if(userOP.isEmpty()){
try {
userRepository.save(user); //유저 저장
}catch (Exception e){ //오류 발생
//ErrorLog 남기기
ErrorLog errorLog = ErrorLog.builder()
.message(e.getMessage())
.userAgent(request.getHeader("User-Agent"))
.userIp(request.getRemoteAddr())
.build();
errorLogJPARepository.save(errorLog);
//응답에 Exception 포함하기
Exception500 ex = new Exception500("unknown server error");
return new ResponseEntity<>(
ex.body(),
ex.status()
);
}
//중복 email일 경우
}else{
Exception400 ex = new Exception400("동일한 이메일이 존재합니다:email");
return new ResponseEntity<>(
ex.body(),
ex.status()
);
}
return ResponseEntity.ok().body(ApiUtils.success(null));
}
}
파라미터
@PostMapping("/join")
public ResponseEntity<?> join(@RequestBody @Valid UserRequest.JoinDTO joinDTO, Errors errors,
HttpServletRequest request) {
...
HttpServletRequest는 DS(DispatcherServlet)으로부터 받아온다.
디스패처 서블릿은 요청자마다 존재하기 때문이다.
IoC 컨테이너는 싱글톤이므로 요청하는 사람들끼리 모두 공유하기때문에 불가능하다.
톰캣이 만든 request, response 객체를 DS에게 전달
DS은 컨트롤러의 메서드 파라미터를 리플렉션하여 알아내고 주입(request 전달)
✅ request 객체 안에 무엇이 들어있는지 확인하기
try-catch문으로 에러 안잡았을 경우
에러 그대로 노출 가능 - 테이블 , unique 제약조건등이 노출됨
Internal Server로 발생시에는 프론트에서 파싱 불가(정해진 양식이 아닌 응답)
✅ 여러 조건을 걸어서(미리 조회하여 중복체크) 최대한 500 에러 터지지 않게 로직 만들기!
ErrorLog
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
@Table(name="error_log_tb")
public class ErrorLog {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private int id;
@Column(nullable = true)
private Integer userId;
@Column(nullable = false)
private String userIp;
@Column(nullable = false)
private String userAgent;
@Column(nullable = false, length = 1000)
private String message;
private LocalDateTime createdAt;
@PrePersist
public void onCreate(){
createdAt = LocalDateTime.now();
}
@Builder
public ErrorLog(int id, Integer userId, String userIp, String userAgent, String message, LocalDateTime createdAt) {
this.id = id;
this.userId = userId;
this.userIp = userIp;
this.userAgent = userAgent;
this.message = message;
this.createdAt = createdAt;
}
}
에러 로그를 DB에 저장하거나, sentry.io와 같은 로그 관리 API로 던져 관리한다.
H2 DB
sentry.io
동일 데이터 Insert 시도시 meta 데이터로 먼저 비교하기때문에, DB에 Insert 쿼리가 안나간다.
즉, 데이터 딕셔너리에서 먼저 에러 터진다.
Exception401 - 인증되지 않음(UNAUTHORIZED)
// 인증 안됨
@Getter
public class Exception401 extends RuntimeException {
public Exception401(String message) {
super(message);
}
public ApiUtils.ApiResult<?> body(){
return ApiUtils.error(getMessage(), HttpStatus.UNAUTHORIZED);
}
public HttpStatus status(){
return HttpStatus.UNAUTHORIZED;
}
}
Exception403 - 권한 없음(FORBIDDEN)
@Getter
public class Exception403 extends RuntimeException {
public Exception403(String message) {
super(message);
}
public ApiUtils.ApiResult<?> body(){
return ApiUtils.error(getMessage(), HttpStatus.FORBIDDEN);
}
public HttpStatus status(){
return HttpStatus.FORBIDDEN;
}
}
SecurityConfig - SecurityFilterChain
@Slf4j
@RequiredArgsConstructor
@Configuration
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
// 1. CSRF 해제
http.csrf().disable(); // postman 접근해야 함!! - CSR 할때!!
// 2. iframe 거부
http.headers().frameOptions().sameOrigin();
// 3. cors 재설정
http.cors().configurationSource(configurationSource());
// 4. jSessionId 사용 거부 (5번을 설정하면 jsessionId가 거부되기 때문에 4번은 사실 필요 없다)
http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
// 5. form 로긴 해제 (UsernamePasswordAuthenticationFilter 비활성화)
http.formLogin().disable();
// 6. 로그인 인증창이 뜨지 않게 비활성화
http.httpBasic().disable();
// 7. 커스텀 필터 적용 (시큐리티 필터 교환)
http.apply(new CustomSecurityFilterManager());
// 8. 인증 실패 처리
http.exceptionHandling().authenticationEntryPoint((request, response, authException) -> {
log.warn("인증되지 않은 사용자가 자원에 접근하려 합니다 : "+authException.getMessage());
//ExceptionHandler는 DS 레이어에서 발동하기때문에 SF에서는 발동X
//sf -> f -> ds
FilterResponseUtils.unAuthorized(response, new Exception401("인증되지 않았습니다"));
});
// 9. 권한 실패 처리
http.exceptionHandling().accessDeniedHandler((request, response, accessDeniedException) -> {
log.warn("권한이 없는 사용자가 자원에 접근하려 합니다 : "+accessDeniedException.getMessage());
FilterResponseUtils.forbidden(response, new Exception403("권한이 없습니다"));
});
// 11. 인증, 권한 필터 설정
http.authorizeRequests(
authorize -> authorize.antMatchers("/carts/**", "/options/**", "/orders/**").authenticated()
.antMatchers("/admin/**")
.access("hasRole('ADMIN')")
.anyRequest().permitAll()
);
return http.build();
}
}
Spring Security의 SecurityConfig에서 예외 발생시 커스텀 Exception 이용하여 응답 처리
FilterResponseUtils
public class FilterResponseUtils {
public static void unAuthorized(HttpServletResponse resp, Exception401 e) throws IOException {
resp.setStatus(e.status().value()); //401
resp.setContentType("application/json; charset=utf-8");
ObjectMapper om = new ObjectMapper();
String responseBody = om.writeValueAsString(e.body());
resp.getWriter().println(responseBody);
}
public static void forbidden(HttpServletResponse resp, Exception403 e) throws IOException {
resp.setStatus(e.status().value()); //403
resp.setContentType("application/json; charset=utf-8");
ObjectMapper om = new ObjectMapper();
String responseBody = om.writeValueAsString(e.body());
resp.getWriter().println(responseBody);
}
}
Exception404 - 권한 없음 (FORBIDDEN)
// 권한 없음
@Getter
public class Exception404 extends RuntimeException {
public Exception404(String message) {
super(message);
}
public ApiUtils.ApiResult<?> body(){
return ApiUtils.error(getMessage(), HttpStatus.NOT_FOUND);
}
public HttpStatus status(){
return HttpStatus.NOT_FOUND;
}
}
자원을 찾지 못했을때의 오류이다.
Controller - 예외 처리
@GetMapping("/products/{id}")
public ResponseEntity<?> findById(@PathVariable int id) {
// 1. 더미데이터 가져와서 상품 찾기
Product product = fakeStore.getProductList().stream().filter(p -> p.getId() == id).findFirst().orElse(null);
if(product == null){
Exception404 ex = new Exception404("해당 상품을 찾을 수 없습니다:"+id);
return new ResponseEntity<>(
ex.body(),
ex.status()
);
}
// 2. 더미데이터 가져와서 해당 상품에 옵션 찾기
List<Option> optionList = fakeStore.getOptionList().stream().filter(option -> product.getId() == option.getProduct().getId()).collect(Collectors.toList());
// 3. DTO 변환
ProductResponse.FindByIdDTO responseDTO = new ProductResponse.FindByIdDTO(product, optionList);
// 4. 공통 응답 DTO 만들기
return ResponseEntity.ok(ApiUtils.success(responseDTO));
}
Controller의 책임 : 요청 / 응답
클라이언트의 값 잘 받기
DispatcherServlet이 DTO만 만들어주면 알아서 값을 받아준다.
Content-Type을 확인해서
✅ application/json이면 @RequestBody를 붙여준다.
✅ x-www-form-urlencoded이면 (?name="sd"&ssid="sjkdlf90") UserRequest.LoginDTO loginDTO로 받아준다.
유효성 검사 잘하기
DispatcherServlet이 @Valid 어노테이션과 Errors errors 매개변수를 작성해주면 자동으로 유효성 검사를 진행해주고,
유효성 검사가 실패하면 errors 변수에 에러를 담아준다.
잘못 받은 값에 대해서 응답 잘하기
if(errors.hasErrors()){
List<FieldError> fieldErrors = errors.getFieldErrors();
//첫번째 에러 메세지, 에러 발생 필드로 Exception 만들기
Exception400 ex = new Exception400(fieldErrors.get(0).getDefaultMessage()+":"+fieldErrors.get(0).getField());
return new ResponseEntity<>(
ex.body(),
ex.status()
);
}
AOP 처리하는 것이 좋다.
관점지향 프로그래밍을 공부하면 해결 가능
정상적으로 처리되었을때 응답 잘하기
return ResponseEntity.ok().body(ApiUtils.success(null));
컨트롤러의 책임이 아닌 것
DB(레포지토리)에 요청하기 ---서비스
응답 DTO 만들기 ---서비스
Service(서비스)
- 스프링 프레임워크에서 비지니스 로직을 구현하고 제공하는 레이어
- 애플리케이션의 비지니스 요구사항을 구현하기 위한 핵심 로직을 포함
- 컨트롤러와 데이터 액세스 계층(DAO, Repository) 사이에서 중간 계층으로 작동
- Spring Boot에서는 @Service 어노테이션을 사용하여 클래스 표시하고, 메서드에서 비즈니스 로직 구현
✅ 서비스 레이어는 스프링의 의존성 주입 기능(DI)을 사용하여 컨트롤러, 데이터 액세스 계층 등과 협력
서비스 레이어의 목적과 역할
1. 비지니스 로직 구현
애플리케이션의 비즈니스 규칙을 구현
비즈니스 로직은 애플리케이션의 핵심 기능과 비즈니스 규칙을 담당하며, 데이터 처리, 알고리즘, 외부 시스템과의 통합 등을 처리
2. 트랜잭션 관리
트랜잭션은 여러개의 데이터 조작 작업을 논리적으로 묶어서 원자적인 작업 단위로 처리한다.
ACID(원자성, 일관성, 격리성, 지속성) 속성을 보장한다.
서비스 레이어는 트랜잭션을 시작하고 커밋 또는 롤백하는 등의 작업을 수행하여 데이터 일관성을 유지한다.
3. 예외 처리
예외처리는 오류 상황을 적절하게 처리하고, 예외를 캡슐화하고 로깅하는 등의 작업을 수행한다.
4. DTO 만들기
DTO를 Service 계층에서 만들 수 있다. (DTO를 생성하여 반환)
DB가 필요없는 유효성 검사 수행하기
위 작업은 Controller가 아닌 Service Layer에서 수행해야한다.
기능 정리
- 회원가입
- 로그인
- 비밀번호 수정하기(요구사항에 X)
- 회원정보 조회(요구사항에 X)
회원가입 서비스 만들기
서비스 레이어 만들고, Exception 위임하기
- @Transactional: 메서드 종료시에 커밋을 자동으로 해주고, 중간에 RuntimeException이 발동하면 rollback
- 서비스에서 오류 발생시 throws 던짐 : 호출한 쪽으로 Exception 위임
- Exception 위임 이유: 위임하지 않으면 Controller로 응답을 줘야하는데, return type이 고정될 수 없으므로 코딩하기 어렵다.
Controller
@Slf4j
@RequiredArgsConstructor
@RestController
public class UserRestController {
private final UserService userService;
private final GlobalExceptionHandler globalExceptionHandler;
@PostMapping("/join")
public ResponseEntity<?> join(@RequestBody @Valid UserRequest.JoinDTO requestDTO, Errors errors,
HttpServletRequest request) {
// 유효성 검사
if (errors.hasErrors()) {
List<FieldError> fieldErrors = errors.getFieldErrors();
Exception400 ex = new Exception400(fieldErrors.get(0).getDefaultMessage() + ":" + fieldErrors.get(0).getField());
return new ResponseEntity<>(
ex.body(),
ex.status()
);
}
// 회원가입 서비스 호출
try {
userService.join(requestDTO);
return ResponseEntity.ok().body(ApiUtils.success(null));
} catch (RuntimeException e) {
return globalExceptionHandler.handle(e, request);
}
}
}
Service - 예외 처리 1
@PostMapping("/join")
public ResponseEntity<?> join(@RequestBody @Valid UserRequest.JoinDTO requestDTO,
Errors errors,HttpServletRequest request) {
//유효성 검사
if (errors.hasErrors()) {
List<FieldError> fieldErrors = errors.getFieldErrors();
Exception400 ex = new Exception400(fieldErrors.get(0).getDefaultMessage()
+ ":" + fieldErrors.get(0).getField());
return new ResponseEntity<>(
ex.body(),
ex.status()
);
}
// 유저 가입
try {
userService.join(requestDTO, request.getHeader("User-Agent"),
request.getRemoteAddr());
return ResponseEntity.ok().body(ApiUtils.success(null));
//예외 잡기
}catch (Exception400 e400){
return new ResponseEntity<>(
e400.body(),
e400.status()
);
}catch (Exception401 e401){
return new ResponseEntity<>(
e401.body(),
e401.status()
);
}catch (Exception404 e404){
return new ResponseEntity<>(
e404.body(),
e404.status()
);
}catch (Exception500 e500){
return new ResponseEntity<>(
e500.body(),
e500.status()
);
}
}
발생할 수 있는 모든 예외에 대해 catch문 작성
=> 너무 코드가 길고 모든 예외에 대해 작성하는 것은 불가능하다.
ExceptionHandler 직접 구현
@RequiredArgsConstructor
@Component
public class GlobalExceptionHandler {
private final ErrorLogJPARepository errorLogJPARepository; //에러 로그
public ResponseEntity<?> handle(RuntimeException e, HttpServletRequest request){
//예외 잡기
if(e instanceof Exception400){
Exception400 ex = (Exception400) e;
return new ResponseEntity<>(
ex.body(),
ex.status()
);
}else if(e instanceof Exception401){
Exception401 ex = (Exception401) e;
return new ResponseEntity<>(
ex.body(),
ex.status()
);
}else if(e instanceof Exception403){
Exception403 ex = (Exception403) e;
return new ResponseEntity<>(
ex.body(),
ex.status()
);
}else if(e instanceof Exception404){
Exception404 ex = (Exception404) e;
return new ResponseEntity<>(
ex.body(),
ex.status()
);
}else if(e instanceof Exception500){
ErrorLog errorLog = ErrorLog.builder()
.message(e.getMessage())
.userAgent(request.getHeader("User-Agent"))
.userIp(request.getRemoteAddr())
.build();
errorLogJPARepository.save(errorLog);
Exception500 ex = (Exception500) e;
return new ResponseEntity<>(
ex.body(),
ex.status()
);
}else{ //그 외 예외
//로그 작성
ErrorLog errorLog = ErrorLog.builder()
.message(e.getMessage())
.userAgent(request.getHeader("User-Agent"))
.userIp(request.getRemoteAddr())
.build();
errorLogJPARepository.save(errorLog);
return new ResponseEntity<>(
"unknown server error",
HttpStatus.INTERNAL_SERVER_ERROR
);
}
}
}
예외를 처리하는 handler 클래스를 따로 만든다.
📌 후에는 이 코드를 DispatcherServlet에서 처리하도록 할 예정
ExceptionHandler 사용
@Slf4j
@RequiredArgsConstructor
@RestController
public class UserRestController {
private final UserService userService;
private final GlobalExceptionHandler globalExceptionHandler;
@PostMapping("/join")
public ResponseEntity<?> join(@RequestBody @Valid UserRequest.JoinDTO requestDTO, Errors errors,
HttpServletRequest request) {
// 유효성 검사
if (errors.hasErrors()) {
List<FieldError> fieldErrors = errors.getFieldErrors();
Exception400 ex = new Exception400(fieldErrors.get(0).getDefaultMessage() + ":" + fieldErrors.get(0).getField());
return new ResponseEntity<>(
ex.body(),
ex.status()
);
}
// 회원가입 서비스 호출
try {
userService.join(requestDTO);
return ResponseEntity.ok().body(ApiUtils.success(null));
//예외 처리
} catch (RuntimeException e) {
return globalExceptionHandler.handle(e, request);
}
}
}
Runtime 예외 발생시 globalExceptionHandler에게 예외 위임
로그인 서비스
Controller
@Slf4j
@RequiredArgsConstructor
@RestController
public class UserRestController {
private final UserService userService;
private final GlobalExceptionHandler globalExceptionHandler;
@PostMapping("/login")
public ResponseEntity<?> login(@RequestBody @Valid UserRequest.LoginDTO requestDTO, Errors errors, HttpServletRequest request) {
if (errors.hasErrors()) {
List<FieldError> fieldErrors = errors.getFieldErrors();
Exception400 ex = new Exception400(fieldErrors.get(0).getDefaultMessage() + ":" + fieldErrors.get(0).getField());
return new ResponseEntity<>(
ex.body(),
ex.status()
);
}
try {
String jwt = userService.login(requestDTO);
return ResponseEntity.ok().header(JWTProvider.HEADER, jwt).body(ApiUtils.success(null));
}catch (RuntimeException e){
return globalExceptionHandler.handle(e, request);
}
}
}
Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
@Service
public class UserService {
private final PasswordEncoder passwordEncoder;
private final UserJPARepository userJPARepository;
public String login(UserRequest.LoginDTO requestDTO) {
//이메일 확인
User userPS = userJPARepository.findByEmail(requestDTO.getEmail()).orElseThrow(
() -> new Exception400("이메일이 틀렸습니다 : " + requestDTO.getEmail())
);
//비밀번호 해시값 비교
if(passwordEncoder.matches(requestDTO.getPassword(), userPS.getPassword())){
throw new Exception400("패스워드가 잘못 입력되었습니다. ");
}
try {
return JWTProvider.create(userPS); //토큰 만들기
} catch (Exception e) {
throw new Exception401("인증되지 않았습니다");
}
}
}
위 코드를 보면 try-catch문에서 try문에서 발생한 모든 예외는 catch문으로 우선 잡힌다.
그러므로 토큰 만드는 부분만 try문으로 감싸주어야한다.
비밀번호 수정하기
SecurityConfig - securityFilterChain : 인증이 필요한 주소 추가
@Slf4j
@RequiredArgsConstructor
@Configuration
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
// 1. CSRF 해제
http.csrf().disable(); // postman 접근해야 함!! - CSR 할때!!
// 2. iframe 거부
http.headers().frameOptions().sameOrigin();
// 3. cors 재설정
http.cors().configurationSource(configurationSource());
// 4. jSessionId 사용 거부 (5번을 설정하면 jsessionId가 거부되기 때문에 4번은 사실 필요 없다)
http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
// 5. form 로긴 해제 (UsernamePasswordAuthenticationFilter 비활성화)
http.formLogin().disable();
// 6. 로그인 인증창이 뜨지 않게 비활성화
http.httpBasic().disable();
// 7. 커스텀 필터 적용 (시큐리티 필터 교환)
http.apply(new CustomSecurityFilterManager());
// 8. 인증 실패 처리
http.exceptionHandling().authenticationEntryPoint((request, response, authException) -> {
log.warn("인증되지 않은 사용자가 자원에 접근하려 합니다 : "+authException.getMessage());
FilterResponseUtils.unAuthorized(response, new Exception401("인증되지 않았습니다"));
});
// 9. 권한 실패 처리
http.exceptionHandling().accessDeniedHandler((request, response, accessDeniedException) -> {
log.warn("권한이 없는 사용자가 자원에 접근하려 합니다 : "+accessDeniedException.getMessage());
FilterResponseUtils.forbidden(response, new Exception403("권한이 없습니다"));
});
// 11. 인증, 권한 필터 설정
http.authorizeRequests(
authorize -> authorize.antMatchers("/carts/**", "/options/**", "/orders/**", "/users/**").authenticated()
.antMatchers("/admin/**")
.access("hasRole('ADMIN')")
.anyRequest().permitAll()
);
return http.build();
}
}
11. 인증, 권한 필터 설정에서 /users/** 추가
DTO 추가
public class UserRequest {
@Getter
@Setter
public static class JoinDTO {
@Getter
@Setter
public static class UpdatePasswordDTO {
@NotEmpty
@Size(min = 8, max = 20, message = "8에서 20자 이내여야 합니다.")
@Pattern(regexp = "^(?=.*[a-zA-Z])(?=.*\\d)(?=.*[@#$%^&+=!~`<>,./?;:'\"\\[\\]{}\\\\()|_-])\\S*$", message = "영문, 숫자, 특수문자가 포함되어야하고 공백이 포함될 수 없습니다.")
private String password;
}
}
비밀번호 수정 요청을 받는 DTO 추가 + 유효성 검증을 위한 Bean Validator 추가
의미있는 setter 추가 - updatePassword
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
@Table(name="user_tb")
public class User{
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private int id;
@Column(length = 100, nullable = false, unique = true)
private String email; // 인증시 필요한 필드
@Column(length = 256, nullable = false)
private String password;
@Column(length = 45, nullable = false)
private String username;
@Column(length = 30)
private String roles;
@Builder
public User(int id, String email, String password, String username, String roles) {
this.id = id;
this.email = email;
this.password = password;
this.username = username;
this.roles = roles;
}
public void updatePassword(String password) {
this.password = password;
}
}
@setter로 추가하기보다는 메서드를 따로 작성
Service 메서드 추가
@Transactional(readOnly = true)
@RequiredArgsConstructor
@Service
public class UserService {
private final PasswordEncoder passwordEncoder;
private final UserJPARepository userJPARepository;
//비밀번호 갱신
@Transactional
public void updatePassword(UserRequest.UpdatePasswordDTO requestDTO, Integer id) {
User userPS = userJPARepository.findById(id).orElseThrow(
() -> new Exception400("회원 아이디를 찾을 수 없습니다. : "+id)
);
// 비밀번호 암호화
String encPassword =
passwordEncoder.encode(requestDTO.getPassword());
//비밀번호 갱신
userPS.updatePassword(encPassword); //영속화된 객체 변경
} // 더티체킹 flush(영속화 되어있는 객체의 변경을 감지) em.flush() 자동 발동
}
📌 더티체킹을 안하려면 @Transactional(readOnly=true)를 붙이면 된다.
Controller 메서드 추가
@Slf4j
@RequiredArgsConstructor
@RestController
public class UserRestController {
private final UserService userService;
private final GlobalExceptionHandler globalExceptionHandler;
@PostMapping("/users/{id}/update-password") //Put 요청이 아니므로 update라고 url에 명시
public ResponseEntity<?> updatePassword(
@PathVariable Integer id,
@RequestBody @Valid UserRequest.UpdatePasswordDTO requestDTO, Errors errors,
@AuthenticationPrincipal CustomUserDetails userDetails, //security session
HttpServletRequest request) {
// 유효성 검사
if (errors.hasErrors()) {
List<FieldError> fieldErrors = errors.getFieldErrors();
Exception400 e = new Exception400(fieldErrors.get(0).getDefaultMessage() + ":" + fieldErrors.get(0).getField());
return new ResponseEntity<>(
e.body(),
e.status()
);
}
// 권한 체크 (DB를 조회하지 않아도 체크할 수 있는 것)
if (id != userDetails.getUser().getId()) { //파라미터 id와 세션 id 비교
Exception403 e = new Exception403("인증된 user는 해당 id로 접근할 권한이 없습니다" + id);
return new ResponseEntity<>(
e.body(),
e.status()
);
}
// 서비스 실행 : 내부에서 터지는 모든 익셉션은 예외 핸들러로 던지기
try {
userService.updatePassword(requestDTO, id); //비밀번호 갱신
return ResponseEntity.ok().body(ApiUtils.success(null));
} catch (RuntimeException e) {
return globalExceptionHandler.handle(e, request);
}
}
}
📌 DB를 조회하지않는 로직은 Controller에서도 수행 가능하다.
⭐️ DTO 만들기 -> 유효성 검증 예외 처리 -> 권한 체크(Controller에서 할 수 있는 것) -> 서비스 실행
회원 정보 조회
요청시 응답을 보낼때 필요한 정보만 응답하기위해 DTO를 만든다.
DTO는 Controller 혹은 Service에서 생성하여 반환할 수 있다. (협업시 명확하게 지정해주기)
DTO - 응답 정보
public class UserResponse {
@Getter @Setter
public static class FindById{
private int id;
private String username;
private String email;
public FindById(User user) {
this.id = user.getId();
this.username = user.getUsername();
this.email = user.getEmail();
}
}
}
Service - DTO 반환
@Transactional(readOnly = true)
@RequiredArgsConstructor
@Service
public class UserService {
private final PasswordEncoder passwordEncoder;
private final UserJPARepository userJPARepository;
public UserResponse.FindById findById(Integer id){
User userPS = userJPARepository.findById(id).orElseThrow(
() -> new Exception400("회원 아이디를 찾을 수 없습니다. : "+id)
);
return new UserResponse.FindById(userPS); //DTO 반환
}
}
✅ Service에서 트랜잭션 관리(@Transactional) , 비지니스 로직 구현, 예외 처리 , DTO 만들기(DTO 생성 후 반환) 수행
Controller에 findById 메서드 추가
@Slf4j
@RequiredArgsConstructor
@RestController
public class UserRestController {
private final UserService userService;
private final GlobalExceptionHandler globalExceptionHandler;
// 클라이언트로 부터 전달된 데이터는 신뢰할 수 없다.
@GetMapping("/users/{id}")
public ResponseEntity<?> findById(
@PathVariable Integer id,
@AuthenticationPrincipal CustomUserDetails userDetails,
HttpServletRequest request
) {
// 권한 체크 (디비를 조회하지 않아도 체크할 수 있는 것)
if (id != userDetails.getUser().getId()) {
Exception403 e = new Exception403("인증된 user는 해당 id로 접근할 권한이 없습니다:" + id);
return new ResponseEntity<>(
e.body(),
e.status()
);
}
// 서비스 실행 : 내부에서 터지는 모든 익셉션은 예외 핸들러로 던지기
try {
UserResponse.FindById responseDTO = userService.findById(id);
return ResponseEntity.ok().body(ApiUtils.success(responseDTO));
} catch (RuntimeException e) {
return globalExceptionHandler.handle(e, request);
}
}
}
Controller 단위 테스트
Open In View(OSIV) : View까지 열어두기
- 웹 애플리케이션에서 사용되는 개념, 주로 ORM 프레임워크에서 사용
- 전통적으로 트랜잭션은 Service 레이어에서 시작하고, DAO(데이터 액세스 계층)에서 종료된다.
- 스프링 부트에서는 @Transactional이 붙은 레이어(Service)에서 트랜잭션이 시작되고, 서비스가 종료될때 트랜잭션이 종료된다. (OSIV=False)와 같음
- OSIV=True란 데이터베이스의 트랜잭션을 뷰(View)가 렌더링 되는 시점까지 연장하는 것
- 트랜잭션의 시작은 서비스이고, 트랜잭션의 종료도 서비스가 종료될때로 같다.
- 트랜잭션 안에서는 read/write 가능하다.
- 그러나 DB에 Select할 수 있는 세션이 View에 렌더링될때까지 유지된다.
- 트랜잭션 종료 후에는 read(조회)만 가능
- JSP에서 OSIV는 기본적으로 True이다.
- OSIV를 켜두고 Entity를 Controller에서 응답하는 순간 Lazy Loading으로 인해 예상치 못한 결과가 발생할 수 있다.
- OSIV=False라면, 객체가 초기화되지않은 상황에서 준영속된 해당 객체를 초기화하려할때 View에서 이미 영속성 컨텍스트가 닫혀있으므로 에러 발생
- OSIV=True로 설정해두면 영속성 컨텍스트가 View까지 열려있으므로 View에서 지연 로딩이 가능하다.
OSIV = False (전통적인 방식)
트랜잭션의 시작은 서비스이고, 서비스 종료 후에는 트랜잭션도 종료된다.
OSIV = True
📌 Service 종료 후에는 조회만 가능
Open In View 활용 방법
기본 open in view(OSIV)는 false로 설정하여 세션을 서비스가 끝나는 시점에 종료시키기
=> 서비스가 끝나는 시점에서 Entity를 DTO로 옮기면서 Lazy Loading 처리하기 (또는 join fetch 이용)
Open In View 주의점
장기간 트랜잭션 유지로 인해 데이터베이스 리소스가 오랜 시간동안 점유될 수 있으며, 불필요한 쿼리나 성능 문제가 발생할 수 있다.
따라서 OSIV 사용시 성능 측면과 리소스 관리에 유의하여 적절한 사용 방식을 선택해야한다.
@Mock : 가짜로 띄우고 싶을때 사용
@MockBean : 가짜를 스프링 빈에 띄운다.
@WebMvcTest
- 웹 애플리케이션의 MVC 컨트롤러를 테스트하기위해 사용
- 특정 컨트롤러를 대상으로 하는 단위 테스트를 작성할때 사용됨
- 웹 레이어에서 발생하는 요청과 응답을 테스트할 수 있음
@WebMvcTest(value={UserRestController.class})
- 특정 Controller를 IoC 컨테이너에 등록 가능
📌 Controller 계층만을 슬라이스 테스트할 수 있도록 도와주는 어노테이션
✅ 원하는 컨트롤러를 등록하여 직접 메모리에 올려줘야한다.
✅ SecurityConfig를 등록하여 직접 메모리에 올려줘야한다.
Controller 테스트 - 가입
@Import({
SecurityConfig.class, //스프링 시큐리티
GlobalExceptionHandler.class //예외 처리
})
@WebMvcTest(controllers = {UserRestController.class})
public class UserRestControllerTest {
// 객체의 모든 메서드는 추상메서드로 구현됩니다. (가짜로 만들면)
// 해당 객체는 SpringContext에 등록됩니다.
@MockBean //가짜로 띄움
private UserService userService;
@MockBean
private ErrorLogJPARepository errorLogJPARepository;
// @WebMvcTest를 하면 MockMvc가 SpringContext에 등록되기 때문에 DI할 수 있습니다.
@Autowired
private MockMvc mvc; //요청 보낼때 사용
// @WebMvcTest를 하면 ObjectMapper가 SpringContext에 등록되기 때문에 DI할 수 있습니다.
@Autowired
private ObjectMapper om; //직렬화
@Test
public void join_test() throws Exception {
// given
UserRequest.JoinDTO requestDTO = new UserRequest.JoinDTO();
requestDTO.setEmail("ssarmango@nate.com");
requestDTO.setPassword("meta1234!");
requestDTO.setUsername("ssarmango");
String requestBody = om.writeValueAsString(requestDTO);
// when
ResultActions result = mvc.perform(
MockMvcRequestBuilders
.post("/join")
.content(requestBody)
.contentType(MediaType.APPLICATION_JSON)
);
String responseBody = result.andReturn().getResponse().getContentAsString();
System.out.println("테스트 : "+responseBody);
// then
result.andExpect(MockMvcResultMatchers.jsonPath("$.success").value("true"));
}
}
@WebMvcTest에서 Controller 빈을 띄울때 필요한 것들을 이용해 띄울 수 있다.
Controller 테스트시 필요한 것 띄우기
- Controller와 함께 메모리에 띄울 것 => @Import
- 가짜 환경에 띄울 것 => 의존성 주입 위에 @MockBean
- @MockBean : 행위의 구체적인 부분은 없고, 추상적 행위만 제공되는 가짜 객체
- @SpyBean : 스프링 애플리케이션에 있는 빈을 스파이(Spy) 객체로 대체하여 테스트 수행
Controller 테스트 - 로그인 : stub 사용
@Import({
SecurityConfig.class,
GlobalExceptionHandler.class
})
@WebMvcTest(controllers = {UserRestController.class})
public class UserRestControllerTest {
// 객체의 모든 메서드는 추상메서드로 구현됩니다. (가짜로 만들면)
// 해당 객체는 SpringContext에 등록됩니다.
@MockBean //가짜로 띄움
private UserService userService;
@MockBean
private ErrorLogJPARepository errorLogJPARepository;
// @WebMvcTest를 하면 MockMvc가 SpringContext에 등록되기 때문에 DI할 수 있습니다.
@Autowired
private MockMvc mvc; //요청 보낼때 사용
// @WebMvcTest를 하면 ObjectMapper가 SpringContext에 등록되기 때문에 DI할 수 있습니다.
@Autowired
private ObjectMapper om; //직렬화
@Test
public void login_test() throws Exception {
// given
UserRequest.LoginDTO loginDTO = new UserRequest.LoginDTO();
loginDTO.setEmail("ssar@nate.com");
loginDTO.setPassword("meta1234!");
String requestBody = om.writeValueAsString(loginDTO); //직렬화
// stub
User user = User.builder().id(1).roles("ROLE_USER").build();
String jwt = JWTProvider.create(user);
Mockito.when(userService.login(any())).thenReturn(jwt); //로그인시 생성한 jwt 반환
// when
ResultActions result = mvc.perform(
MockMvcRequestBuilders
.post("/login")
.content(requestBody)
.contentType(MediaType.APPLICATION_JSON)
);
String responseBody = result.andReturn().getResponse().getContentAsString();
String responseHeader = result.andReturn().getResponse().getHeader(JWTProvider.HEADER);
System.out.println("테스트 : "+responseBody);
System.out.println("테스트 : "+responseHeader);
// then
result.andExpect(MockMvcResultMatchers.jsonPath("$.success").value("true"));
Assertions.assertTrue(jwt.startsWith(JWTProvider.TOKEN_PREFIX));
}
}
userService는 가짜로 띄워졌기 때문에(@MockBean) Controller에서 jwt 값이 null이 된다.
try {
String jwt = userService.login(requestDTO);
return ResponseEntity.ok().header(JWTProvider.HEADER, jwt).body(ApiUtils.success(null));
}catch (RuntimeException e){
return globalExceptionHandler.handle(e, request);
}
stub 사용
// stub
User user = User.builder().id(1).roles("ROLE_USER").build();
String jwt = JWTProvider.create(user);
Mockito.when(userService.login(any())).thenReturn(jwt); //로그인시 생성한 jwt 반환
stub을 사용해서 jwt 값을 넣어준다.
결과
헤더에 stub을 사용해 만든 jwt가 잘 반환됨을 볼 수 있다.
CartRestController
@Import({
FakeStore.class,
SecurityConfig.class
})
@WebMvcTest(controllers = {CartRestController.class})
public class CartRestControllerTest {
@Autowired
private MockMvc mvc;
@Autowired
private ObjectMapper om;
@WithMockUser(username = "ssar@nate.com", roles = "USER") //가짜 인증객체 만들기
@Test
public void update_test() throws Exception {
// given
List<CartRequest.UpdateDTO> requestDTOs = new ArrayList<>();
CartRequest.UpdateDTO d1 = new CartRequest.UpdateDTO();
d1.setCartId(1);
d1.setQuantity(10);
CartRequest.UpdateDTO d2 = new CartRequest.UpdateDTO();
d2.setCartId(2);
d2.setQuantity(10);
requestDTOs.add(d1);
requestDTOs.add(d2);
String requestBody = om.writeValueAsString(requestDTOs);
System.out.println("테스트 : "+requestBody);
// when
ResultActions result = mvc.perform(
MockMvcRequestBuilders
.post("/carts/update")
.content(requestBody)
.contentType(MediaType.APPLICATION_JSON)
);
String responseBody = result.andReturn().getResponse().getContentAsString();
System.out.println("테스트 : "+responseBody);
// then
result.andExpect(MockMvcResultMatchers.jsonPath("$.success").value("true"));
result.andExpect(MockMvcResultMatchers.jsonPath("$.response.carts[0].cartId").value(1));
result.andExpect(MockMvcResultMatchers.jsonPath("$.response.carts[0].optionId").value(1));
result.andExpect(MockMvcResultMatchers.jsonPath("$.response.carts[0].optionName").value("01. 슬라이딩 지퍼백 크리스마스에디션 4종"));
result.andExpect(MockMvcResultMatchers.jsonPath("$.response.carts[0].quantity").value(10));
result.andExpect(MockMvcResultMatchers.jsonPath("$.response.carts[0].price").value(100000));
}
}
@WithMockUser
@WithMockUser(username = "ssar@nate.com", roles = "USER") //가짜 인증객체 만들기
@Test
public void update_test() throws Exception {
...
}
특정 유저를 설정해 테스트를 수행할 수 있다.
인증이 필요한 테스트 수행시 사용한다.
과제
1. user쪽 말고 product, cart 컨트롤러 테스트
2. fakeStore로 테스트 해도 되지만 repository와 service 까지 만드는 것 추천
공부할 것
ACID에서 A지키기 ( I 공부하기) - 쉬운코드 DB 입문
Dirty Read, Non Repeatable Read, Phantom Read 공부하기
(거의 repeatable read 사용함)
'Spring > 카테캠 - TIL' 카테고리의 다른 글
카테캠 4주차 과제 중 문제 - 해결 (유효성 검사, Mockito 인자+anyInt, UserDetails 직접 주입) (0) | 2023.07.21 |
---|---|
카카오테크캠퍼스 : 4주차 과제 (0) | 2023.07.19 |
카테캠 : 2주차 코드 리뷰 (0) | 2023.07.14 |
TIL [0714] : 3주차 과제 수행 (0) | 2023.07.14 |
TIL[0712] : 3주차 강의 (DTO, 스트림, HTTP, JDBC, JPA) (0) | 2023.07.10 |