이번주학습내용
1. Exception 와 spring security
2. Redis
3. vo 와 dto 그리고 순환참조
Exception
들어가기전에 Exception에 대해 짤막하게 정리하고자 한다.
(분량이 많아 정말 최소한으로 가볍게하고 다음에 Exception 정리 포스팅..예정..)
- Exception은 프로그램시 실행될 때 발생되는 예상치 못한, 원하지 않은 event.
- Exception은 프로그램에 의해 잡히거나 핸들링 될 수 있다.
메소드 안에서 Exception이 발생할 시 object를 생성하게 되는데 이 object를 Exception Object라고 부른다. Exception Object는 Exception에 관한 정보(예외 이름, 예외에 대한 설명 등)를 담고 있다.
Exception이 발생하는 주요 원인들
- 코드 에러
- 기계 결함
- 네트워크 연결 장애
- 물리적인 한계 (디스크 메모리 등)
- 실행시킬 수 없는 파일을 여는 것 등이 있다.
우리가 개발하면서 마주치게 되는 Exception들을 처리하기 위해 Exception Handler와 Controller Advice를 사용할 수 있는데 이 둘의 개념을 학습하면서 간단히 아래처럼 정리해보았다.
Exception Handler
@Controller 와 @ControllerAdvice 클래스들은 Controller 메소드들로부터 발생한 exception들을 처리하기 위해 @ExceptionHandler 메소드를 갖는다.
예시)
@Controller
public class SimpleController {
// ...
@ExceptionHandler
public ResponseEntity<String> handle(IOException ex) {
// ...
}
}
특정 타입의 exception을 잡고 싶은 경우에는 아래의 예시처럼 (@ExceptionHandler({FileSystemException.class, RemoteException.class})) 작성하면 특정 Exception 하나 또는 Exception 여러개를 잡을 수 있다. 이 때 value를 넣어주지 않으면 모든 Exception을 잡는다.
스프링 공식문서에서는 Exception들을 구체적으로 명시하기를 권하고 있다.
@ExceptionHandler({FileSystemException.class, RemoteException.class})
public ResponseEntity<String> handle(IOException ex) {
// ...
}
Controller Advice
@ExceptionHandler, @InitBuilder 그리고 @ModelAttribute 메소드들은 @Controller 클래스 이거나 해당 클래스 계층에서만 적용이 가능하다.
하지만@ControllerAdvice 또는 @RestControllerAdvice를 사용하면 어떤 컨트롤러에나 적용이 가능하다.
(= ExceptionHandler 사용해서 Exception을 핸들링 할 수 있다는 이야기)
또한 5.3 버전에서는 @Controller가 있으면 Exception을 핸들링하기 위해 @ControllerAdvice가 가지고 있는@ExceptionHandler 메소드들이 사용될 수 있다.
요약: 어플리케이션에 같은 에러를 핸들링 하는 것을 가능하게 하는 인터셉터
Controller Advice와 Rest Controller Advice
- @ControllerAdvice는 컴포넌트 스캐닝을 통해 스프링 빈으로 등록될 수 있는 @Component의 메타 어노테이션이다.
- @RestControllerAdvice는 @ControllerAdvice와 @ResponseBody의 메타 어노테이션이다. 그렇기 때문에 @ExceptionHandler가 response body로 렌더링 된 리턴 값을 갖는다.
Controller Advice로 잡을 수 없는 예외?
Spring Security Exception
(예시, runtime exception에 속하는 AuthenticationException과 AccessDeniedException)들은 핸들링 할 수 없다.
DispatcherServlet과 컨트롤러 메소드들이 불러지기 전에 authentication filter에 의해 exception이 던져지기 때문이다. 이 같은 exception들을 @ExceptionHandler와 @ControllerAdvice로 핸들링하기 위해서는 AuthenticationEntryPoint를 커스텀해야한다.
자세한 내용은 baeldung의 spring security exceptionhandler 참고.
적용해보기
프로젝트 진행 중 아래와 같이 작성해보았다.
아직 커스텀 예외들을 작성하지 않아 일단은 RuntimeException을 잡는 것으로 작성해두었는데 작동은 잘 하는 것으로 보인다.
하지만 이렇게 쓰면 예외가 발생 했을 때 정확히 어떤 예외가 발생했는지 확인이 어려워 해당 부분은 주석 처리해두고 프로젝트를 진행하면서 이후에 예외상황들을 좀 더 세세하게 나누어(스프링 공식문서에서 권장하듯이) 해당 클래스를 구현할 계획이다.
위 링크에 접속하여 3.2.100버전 Redis-x64-3.2.100.zip파일은 다운받고 압축을 풀어주세요.
압축을 풀면 위와 같은 파일들이 생성될 것입니다.
먼저 비밀번호를 설정해보도록 하겠습니다.
redis.window-service.conf 파일을 메모장으로 열고
#requirepass foobared
위 라인을 주석해제하고
원하는 비밀번호를 설정합니다. 여기 실습에서는
requirepass abcd1234
라고 설정했습니다.
redis.window.conf 파일에도 똑같이 적용해주세요.
이제 cmd에서 redis를 사용하기위해 윈도우 redis 서비스 등록을 하겠습니다.
cmd 창을 열어 redis폴더로 이동해주세요.
redis-server --service-install redis.windows.conf --loglevel notice
위 명령어를 실행하여 윈도우에 redis를 서비스로 등록합니다.
레디스 서버 스타트 명령어와 스탑 명령어는 아래와 같습니다.
redis-server --service-start
redis-server --service-stop
redis-server --service-start 명령어를 실행하여 레디스 서버를 start 하겠습니다.
그런 다음 이제 cmd가 아닌 redis 폴더로 와서 redis-cli.exe 파일을 실행해주세요.
이전에 설정한 비밀번호 abcd1234 로 접속하여 정상적으로 실행됬는지 확인하기 위해 ping 명령어를 날려봅니다.
pong이라고 라인이 찍히면 정상적으로 실행된 것입니다.
스프링부트 설정
build.gradle 파일의 dependencies 설정
implementation 'org.springframework.boot:spring-boot-starter'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
implementation 'org.springframework.session:spring-session-data-redis'
application.properties
spring.session.store-type=redis
server.servlet.session.timeout=3600
spring.session.redis.namespace=spring:session
spring.redis.host=localhost
spring.redis.password=abcd1234
spring.redis.port=6379
첫번째 라인의 spring.session.store-type=redis 설정으로 스프링부트의 세션을 redis서버에 저장할 수 있습니다.
위 설정은 @EnableRedisHttpSession과 같은 효과로 아래처럼 설정할 수 도 있습니다.
@SpringBootApplication
@EnableRedisHttpSession
public class SpringBootRedisTestApplication {
public static void main(String[] args) {
SpringApplication.run(SpringBootRedisTestApplication.class, args);
}
}
그 다음 server.servlet.session.timeout=3600 설정은 초단위로 세션의 만료시간을 결정합니다.
spring.session.redis.namespace=spring:session는 레디스 저장공간 namespace의 이름을 spring:session으로 설정한 것입니다.
그다음 아래 설정은 redis경로와 redis에 접근하기 위한 비밀번호를 설정한 것입니다.
spring.redis.host=localhost
spring.redis.password=abcd1234
spring.redis.port=6379
TestController.java
@RestController
public class TestController {
@RequestMapping("test")
public String test1(HttpSession session) {
session.setAttribute("email","test");
return "test";
}
}
이제 테스트를 위해 임의로 세션을 생성해보겠습니다.
위와같이 자바파일을 만들고
스프링부트 프로젝트를 실행시켜 해당 /test url로 접속해보세요.
레디스 세션정보 확인하기
이전에 실행시킨 redis-cli.exe 파일에서 keys * 명령어를 날리면 아래와 같이 세션정보가 생성됨을 확인 할 수 있습니다.
type 명령어를 통해 각 라인의 저장정보가 어떤 타입으로 저장됬는지 확인합니다.
각각 string, set, hash 타입으로 저장됨을 확인할 수 있습니다.
라인별로 하나씩 값을 조회해보겠습니다.
String타입은 비어있고 set 타입은 조회가 불가능하여 조회하지 않았습니다.
hash타입을 조회해보니 4가지 속성이 조회되었습니다.
세션의 키값, 만료시간, 생성시각, 최근 접근시간에 대한 속성이 나와 있습니다.
우리가 설정한 세션의 키값 email이 들어있음 확인할 수 있습니다.
이 속성값을 가져와보면
위와 같이 email에 대한 value 값 test까지 redis 서버에 잘 저장됬음이 확인 되었습니다.
이렇게해서 스프링부트에서 생성된 세션정보를 redis서버에 저장 시켜보았습니다.
---------------------------
JPA에서 순환참조 문제는 이제 알고보니까는, 엔티티를 조회할 떄 발생하는 게 아니라
entity를 json으로 변환할 떄, 즉, Entity To Json serialize할 때 (보통 controller 단)에서 일어나는 문제였다.
Entity를 Json으로 변환하면서, 연관된 객체를 다시 Json으로 변환하고, 이 변환 과정에서, 다시 연관된 객체의 연관된 객체를 참조... 참조 지옥,,, 무한 참조,,, 크기가 펑펑펑,,
스프링 무한 순환 참조 문제 해결 방법
@jsonignore (지양함)
- 간단하게 순환참조를 해결할 수 있지만, 연관관계 복잡하게 얽혀있으면 문제가 많음 (사용해야할 떄, 사용하지 못하기도 함)
- 어노테이션이 붙은 객체를 Json 직렬화를 하지 않음
DTO (지향)
- 무한 순환 참조는 객체를 JSON 형태로 변환하는 직렬화 과정에서 발생
- JSON으로 직렬화 할 객체에 연관 관계의 필드를 넣지 않음으로 문제 해결 가능
- 즉, DTO에, 필요한 필드만 만들어서 담아, 순환참조가 일어나지 않도록 하자
무한 순환 참조 에러 코드
Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed; nested exception is org.springframework.http.converter.HttpMessageNotWritableException: Could not write JSON: Infinite recursion (StackOverflowError);
이런식으로 계속 뭔가 참조되는 볼수 있다.
(StackOverflowError) (through reference chain: org.hibernate.collection.internal.PersistentBag[0]->com.sparta.weeklytestspring.domain.PostComment["user"]->com.sparta.weeklytestspring.domain.User["posts"]->org.hibernate.collection.internal.PersistentBag[0]->com.sparta.weeklytestspring.domain.Post["comments"]->org
Post 엔티티
package com.sparta.weeklytestspring.domain;
import com.sparta.weeklytestspring.dto.SetArticleDto;
import lombok.*;
import org.hibernate.annotations.SQLDelete;
import org.hibernate.annotations.Where;
import javax.persistence.*;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
@AllArgsConstructor
@NoArgsConstructor@Setter
@Getter
@Builder
@Entity
@Where(clause = "deleted_at IS NULL")
@SQLDelete(sql = "UPDATE post SET deleted_at = CURRENT_TIMESTAMP where id = ?")
public class Post extends Timestamped {
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Id
private Long idx;
@Column(nullable = false)
private String title;
@Column(nullable = false)
private String content;
//게시글에 달린 댓글 목록
@OneToMany(mappedBy = "post", cascade = CascadeType.ALL)
private List<PostComment> comments;
//게시글에 달린 좋아요 목록
@OneToMany(mappedBy = "post", cascade = CascadeType.ALL)
private List<PostLike> postLikes = new ArrayList<>();
//작성한 유저
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name="user_idx", nullable = false)
private User user;
}
엔티티를 바로 참조함(모든 필드를)
package com.sparta.weeklytestspring.dto;
import com.sparta.weeklytestspring.domain.PostComment;
import com.sparta.weeklytestspring.domain.PostLike;
import com.sparta.weeklytestspring.domain.UserRole;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
import java.util.List;
@Setter
@Getter
public class GetPostDto {
@Getter
@Setter
public static class Response {
private Long idx;
private String title;
private String content;
private List<PostComment> comments;
private List<PostLike> postLikes;
private User user;
private LocalDateTime createdAt;
}
}
참조하는 엔티티의 필요한 부분만, 클래스로 만들어 순환참조가 일어나지 않도록 함
package com.sparta.weeklytestspring.dto;
import com.sparta.weeklytestspring.domain.PostComment;
import com.sparta.weeklytestspring.domain.PostLike;
import com.sparta.weeklytestspring.domain.UserRole;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;
import java.util.List;
@Setter
@Getter
public class GetPostDto {
@Getter
@Setter
public static class Response {
private Long idx;
private String title;
private String content;
private List<Comment> comments;
private List<Like> postLikes;
private User user;
private LocalDateTime createdAt;
}
@Getter
@Setter
public static class Comment {
private Long idx;
private String comment;
}
@Getter
@Setter
public static class Like {
private Long idx;
private String comment;
}
@Getter
@Setter
public static class User {
private String username;
private UserRole account_type;
}
}
깔금!