저번에 등록(create) 기능까지 만들었고, 이번엔 조회(Read), 수정(Update), 삭제(Delete) 기능을 만들어보려 한다. 우선 조회부터 시작쓰.

 

게시글의 상세내용을 보기 위해서 클라쪽에서 서버 쪽으로 게시글id를 넘기도록 할 건데, 이 때 url변수로 넘기든가(ex : /board/1) 아님 쿼리스트링 형태로 넘기든가(ex : /board?id=1) 해야 한다. (body에 적는 방법 등도 있지만..패쓰) 아무래도 대중적인 방법은 url변수로 하는 방법이라 어떻게 하는지 알아봤다.

 

방법은 간단했다. @PathVariable이라는 어노테이션을 사용하면 됐다! 방법은

 

  • GetMapping(Post든 뭐든 상관 X)의 url입력하는 부분에 {받을 변수명}
  • 메서드의 파라미터로 @PathVariable("받을 변수명")

 

이다. 다음과 같이 활용해줬다.

 

@GetMapping("/{id}")
    public BoardResponseDTO getBoard(@PathVariable("id") Long id) {
        Board board = boardService.findBoard(id).get();
        // ... 생략
    }

 

그리고 다음과 같이 BoardResponseDTO를 만들어줬다.

 

@Getter
@AllArgsConstructor(access = AccessLevel.PRIVATE)
public class BoardResponseDTO {
    private Long boardId;
    private String writerNickname;
    private String title;
    private String content;
    private Long hits;
    private Long like;
    private LocalDateTime createdDate;
    private LocalDateTime updatedDate;
    private int categoryId;

    public static BoardResponseDTO from(Member writer, Board board) {
        return new BoardResponseDTO(
                board.getBoardId(),
                writer.getNickname(),
                board.getTitle(),
                board.getContent(),
                board.getHits(),
                board.getLike(),
                board.getCreatedDate(),
                board.getUpdatedDate(),
                board.getCategoryId()
        );
    }
}

 

요즘 이 토이플젝을 하면서 참고하는 분이 있는데, 그 분은 이런 식으로 from메서드를 만들어 활용하는 식으로 하는 것 같아 참고해봤다. AccessLevel지정해주는 건 Lombok으로 만들어주는 생성자의 접근지정자를 설정하는거다. private로 한 것은 외부에서 요 놈의 인스턴스를 만드는 것을 막아주므로, BoardResponseDTO를 만들려면 from메서드를 사용하는 수밖에 없다!

 

암튼! 그래서 결과는..

 

 

성공~! 저번에 인터셉터 통해서 응답값들을 통일시켜준 방식 그대로 뱉어지는 모습도 곁들여 볼 수 있었다.

 

그 다음으론 삭제기능을 만들어보기로 했다. 삭제 기능에서의 고민은 바로 "삭제 권한". 글의 작성자만이 본인의 글을 삭제할 수 있도록 해야 할 것이다. 저번 학기에 프론트를 했을 때는 따로 프론트 쪽에서 현재 로그인한 유저의 정보를 가지고 있었기 때문에, 내가 지금 보고 있는 글의 작성자와 내가 가진 유저의 정보가 같으면 그 글에 삭제버튼을 보이게 하는 식으로 이를 만들었다. 그러나 지금 드는 생각은 어차피 contextHolder?거기에 현재 로그인한 유저의 정보를 가진다 하니 걔를 통해서 비교해줄 수 있을 것 같다!

 

그래서 다음과 같이 일단 delete에 해당하는 메서드를 만들어줬다.

 

@DeleteMapping("/{id}")
public String deleteBoard(@AuthenticationPrincipal Member member, @PathVariable("id") Long id) {
    Board board = boardService.findBoard(id).get();
    Member writer = board.getWriter();
    if (writer.getEmail().equals(member.getEmail())) {
        return "same";
    }
    return "diff";
}

 

현재 유저와 게시글의 작성자 이메일이 같다면 same을 뱉고, 다르면 diff를 뱉을 거다. 우선 작성자로 로그인하고 request를 보내봤다! 결과는..

 

 

같다고 잘 나왔다. 이번엔 다른 사용자로 로그인하고 (즉 포스트맨 상에서는 헤더에 담는 토큰을 다른 걸로 설정) 보내봤다. 결과는..

 

 

다르다고 잘 나온다! 삭제코드 자체는 BoardRepository에 deleteById메서드를 만들어 다음과 같이 구현해줬다.

 

@Override
public void deleteById(Long id) {
    Board board = em.find(Board.class, id);
    em.remove(board);
}

 

암튼 그렇게 해서 만든 삭제기능은..

 

 

27번 게시물이 잘 삭제된 것을 볼 수 있었다.

 

마지막으로 수정 기능을 만들 차례. 제목이나 본문의 내용이 잘 바뀌는지도 중요하겠지만, 아무래도 관건은 

 

  1. 수정일이 알아서 업데이트되는지, 생성일자는 변함없는지
  2. 게시물의 id가 그대로 유지되는지

 

정도가 될 것 같다. 

컨트롤러에서 다음과 같이 코드를 작성했다.

 

@PutMapping("/{id}")
public BoardResponseDTO updateBoard(
        @AuthenticationPrincipal Member member,
        @PathVariable Long id,
        @RequestBody BoardDTO boardDTO) {
    Board oldBoard = boardService.findBoard(id).get();
    Member writer = oldBoard.getWriter();

    if (writer.getEmail().equals(member.getEmail())) {
        Board updatedBoard = boardService.updateBoard(id, boardDTO);
        return BoardResponseDTO.from(writer, updatedBoard);
    }
    
    return null;
}

 

역시나 요청을 보낸 사람의 정보와 게시글 작성자가 같을 때에만 수정이 이뤄지게 했다. 그리고 게시글 작성 때 사용했던 BoardDTO를 수정할 때도 그대로 재탕해줬다. 어차피 게시글 작성할 때 작성하는 부분이 수정할 때 작성하는 부분이랑 같으니까..

BoardRepository에서 다음과 같이 코드를 짜 수정이 이루어지게끔 했다.

 

@Override
public Board updateBoard(Long id, BoardDTO boardDTO) {
    Board board = em.find(Board.class, id);
    board.setTitle(boardDTO.getTitle());
    board.setContent(boardDTO.getContent());
    board.setCategoryId(boardDTO.getCategoryId());
    em.persist(board);
    return board;
}

 

세터 메서드들을 사용해 Board엔티티의 내용물들을 바꿔치기해주고 저장해주는 식. entitymanager의 save라는 메서드가 인자로 받는 entity의 id값이 있냐없냐에 따라 insert를 할지 update를 할지 결정해준다고 한다. 이 경우에선 이미 board는 id가 있는 상태이니 update가 될 것이다.

 

이제 한 번 시험해볼 차례! 우선 수정 전 db상황은 다음과 같았다. 여기서 28번 게시물을 바꿀 거다.

 

 

포스트맨을 사용해 수정 테스트하기. 결과는..!

 

 

우선 응답값 자체는 문제가 없어보인다! 과연 db에서는..?

 

 

수정한 부분들이 잘 바뀌었음을 확인했다. title, content, category_id 컬럼값이 바뀌었으며 board_id나 writer는 변함없는 걸 볼 수 있다. 또한 created_date는 변화가 없고 updated_date에만 변화가 생겼음을 잘 확인할 수 있었다.

저번 시간에 했던 것.

 

1. 카테고리 테이블, 서브카테고리 테이블, 게시글 테이블을 생성했다.

 

ㅎㅎ 소마 준비하면서 프로그래머스 sql 고득점 kit을 여러번 돌리면서 sql이 공부가 돼서.. join으로 글 정보랑 카테고리 정보들 가져오는 거 자랑(?)하기

 

2. 게시글 생성일, 수정일 자동으로 db에 저장되게 함.


우선 한 가지 확인하고 싶은게 생겼다. 이전에 필터를 만들어둔 상태고, 로그인/회원가입을 제외한 곳들은 전부다 막아둔 상태다. 

 

@Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        return http.csrf().disable() // CSRF 공격에 대한 방어를 해제
                .cors().and() // cors 허용
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) // 서버를 stateless하게 유지 즉 세션 X
                .and()
                .formLogin().disable() // 시큐리티가 기본 제공하는 로그인 화면 없앰. JWT는 로그인과정을 수동으로 클래스로 만들어야 하니까
                .httpBasic().disable() // 토큰 방식을 이용할 것이므로 보안에 취약한 HttpBasic은 꺼두기
                .authorizeRequests() // HttpServletRequest를 사용하는 요청들에 대한 접근제한을 설정하겠다
                .requestMatchers("/account/signup", "/account/login", "/").permitAll() // 요 세 놈에 대한 요청은 인증없이 접근 허용
                .anyRequest().authenticated() // 나머지에 대해선 인증을 받아야 한다.
                .and()
                // 여러 필터들 중 UsernamePassword필터 앞에 내가 만든 필터를 둔다. 이렇게 하면 커스텀 필터로 인가인증을 다룰 수 있음
                .addFilterBefore(jwtAuthenticationFilter,
                        UsernamePasswordAuthenticationFilter.class)
                .build();
    }

 

게시글에 관한 것은 BoardController에서 일괄적으로 처리하게 할 계획이다. 이 때 /board로 들어오는 놈들은 인증을 받아야 한다!

 

@RestController
@RequestMapping(value = "/board")
@RequiredArgsConstructor
public class BoardController {
    private final BoardService boardService;

    @GetMapping("/test")
    public String boardTest() {
        return "test";
    }
}

 

한마디로 /board/test로 생으로 그냥 들어가면 test라는 응답을 받을 수 없을 거고, 토큰을 함께 보내면 test라는 응답을 받을 수 있다는 소리가 되는데.. 정말 잘 동작하는가?를 시험해보고 싶었다.

 

우선 생으로 /board/test로 보내봤다.

 

 

결과는 실패..

이번엔 토큰을 헤더에 담고 보내봤다. 결과는..?

 

 

성공한 모습!! 😄😄

 

 

참고로 위는 유효기간이 지난 토큰인데, 이렇게 유효하지 않은 토큰을 담아 보내면 응답이 오지 않는, 즉 필터가 일을 잘 하고 있는 모습을 볼 수 있었다.

 

 

이제 글 작성 api를 만들 차례. 컨트롤러에 다음과 같은 createBoard메서드를 추가해줬다.

 

@PostMapping("/write")
    public String createBoard(@RequestBody BoardDTO boardDTO) {
        Board newBoard = new Board();
        newBoard.setWriter(boardDTO.getEmail());
        newBoard.setTitle(boardDTO.getTitle());
        newBoard.setContent(boardDTO.getContent());
        newBoard.setCategoryId(boardDTO.getCategoryId());
        newBoard.setLike(0L);
        newBoard.setHits(0L);
        
        boardService.registerNewBoard(newBoard);
        return "success";
    }

 

BoardDTO는 다음과 같다.

 

@Getter
@Setter
public class BoardDTO {
    private String email;
    private String title;
    private String content;
    @JsonProperty("category_id")
    private int categoryId;
}

 

RequestBody는 예전에 작성했듯 클라이언트가 보낸 json데이터를 자바객체로 매핑해주는 역할을 하는데, 이 때 json데이터의 키 값과 매핑될 클래스의 멤버변수들 이름이 같아야 정상적으로 착착 매핑된다. 그러나 json에서 category_id라는 snake형식으로 보낸 값이 클래스에서 camel형식으로 작성된 categoryId에 매핑시키고 싶다면, 저렇게 해당 필드에 @JsonProperty라는 어노테이션을 멕이면 된다.

 

 

게시글이 잘 등록되는 걸 볼 수 있다!

 

현재 이 게시판 등록 기능은, 클라이언트 측에서 json으로 작성자의 이메일을 함께 넘겨주는 방식이다. 그러나 구글에 게시글 등록과 관련된 포스트들을 보면, 이메일이 아니라 멤버자체를 게시글 엔티티의 필드로 설정하는 방식도 있었다. 차이점이 뭔지는 잘 모르겠으나..일단 한 번 따라해봤다.

 

우선 Board클래스를 다음과 같이 수정해줬다.

 

@Getter
@Setter
@Entity
@EntityListeners(AuditingEntityListener.class)
public class Board {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "board_id")
    private Long boardId;

    @JoinColumn(name = "writer", nullable = false)
    @ManyToOne(fetch = FetchType.LAZY)
    private Member writer;

    @Column(length = 80)
    private String title;
    
    // ... 생략

 

기존엔 write필드를 String으로 해서 이메일 자체를 세팅해줬었지만, 보시다시피 멤버로 받는 모습.

 

@ManyToOne(fetch = FetchType.LAZY) : 게시글 입장에선 멤버와 다대일(N : 1) 관계임. 이 다중성을 나타내는 어노테이션. FetchType은 즉시로딩과 지연로딩에 관한 거라는데..음...뭔 소린지 몰라서 패쓰

 

@JoinColumn(name = "writer") : 외래키를 매핑할 때 사용하는 어노테이션.  name은 외래키의 이름, 즉 조인의 대상으로 사용할 컬럼의 이름을 지정해줌. Board테이블의 writer컬럼에 Member의 pk인 email이 들어갈 것임을 명시하는 것!! Member클래스에서 email필드에 @Id 어노테이션에 멕여져 있어서 가능.

 

그리고 BoardDTO에서 다음과 같이 email필드를 빼줬다.

 

@Getter
@Setter
public class BoardDTO {
    private String title;
    private String content;
    @JsonProperty("category_id")
    private int categoryId;
}

 

그리고 BoardController에서 다음과 같이 인증정보를 가져와서 멤버를 꽂아넣도록(?) 바꿔줬다.

 

@PostMapping("/write")
    public String createBoard(@AuthenticationPrincipal Member member, @RequestBody BoardDTO boardDTO) {
        Board newBoard = new Board();
        newBoard.setWriter(member);
        newBoard.setTitle(boardDTO.getTitle());
        newBoard.setContent(boardDTO.getContent());
        newBoard.setCategoryId(boardDTO.getCategoryId());
        newBoard.setLike(0L);
        newBoard.setHits(0L);

        boardService.registerNewBoard(newBoard);
        return "success";
    }

 

@AuthenticationPrincipal : UserDetails를 구현한 인증객체를 주입할 때 사용하는 어노테이션.  요청을 보낸 사용자가 인증된 경우SecurityContextHolder라는 곳에 저장된 인증객체(현재사용자의 정보!)를 꺼내와 넣어주는 역할! 나같은 경우는 Member자체가 UserDetails를 구현하도록 만들었기 때문에 Member로 받도록 했다.

 

결과는..

 

 

 

서..성공!

 

첫 질문으로 들어와서, 그럼 게시글 등록을 할 때 Board엔티티의 writer필드를 String으로 하는게 맞는걸까 아님 Member로 하는게 맞는걸까? 이는 연관관계 매핑이라는 것과 관련이 있다고 한다. 이것에 대해서 알아봐야겠다.

@Value를 사용해 application.properties에 적은 값을 가져오는 과정, 구체적으론 jwt secret key를 가져오는 과정에서 null값을 가져오는 문제가 생겨버렸다.

 

이상하게도 AccountController에서 jwt관련 기능들을 쓸 때는 null이 아니라 제대로 된 값을 가져오는데, 필터 쪽에서 jwt 토큰 인증을 할 때에만 null로 가져오는 게 문제였다..

 

원인은 찾아보니 알 수 있었다. 바로 AccountController에서는 jwt key를 담는 멤버변수가 있는 객체가 빈으로 등록된 애였지만, 같은 jwt key를 담는 멤버변수를 가지는 필터는 빈으로 등록된 애가 아니었던 것

 

public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        return http.csrf().disable() // CSRF 공격에 대한 방어를 해제
                .cors().and() // cors 허용
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) // 서버를 stateless하게 유지 즉 세션 X
                .and()
                .formLogin().disable() // 시큐리티가 기본 제공하는 로그인 화면 없앰. JWT는 로그인과정을 수동으로 클래스로 만들어야 하니까
                .httpBasic().disable() // 토큰 방식을 이용할 것이므로 보안에 취약한 HttpBasic은 꺼두기
                .authorizeRequests() // HttpServletRequest를 사용하는 요청들에 대한 접근제한을 설정하겠다
                .requestMatchers("/account/signup", "/account/login", "/").permitAll() // 요 세 놈에 대한 요청은 인증없이 접근 허용
                .anyRequest().authenticated() // 나머지에 대해선 인증을 받아야 한다.
                .and()
                // 여러 필터들 중 UsernamePassword필터 앞에 내가 만든 필터를 둔다. 이렇게 하면 커스텀 필터로 인가인증을 다룰 수 있음
                .addFilterBefore(new JwtAuthenticationFilter(tokenProvider),
                        UsernamePasswordAuthenticationFilter.class)
                .build();
    }

 

보이다시피 new 연산자를 통해 새로운 필터객체를 만들어서 끼워주는 모습을 볼 수 있다. 이 필터객체가 @Value 어노테이션을 통해 jwt key를 가져와야 하던 놈이다..근데 빈이 아니라 못 가져오던 것! 뭐, 해결법은 간단하다. 필터를 빈으로 만들어 끼우면 된다! 

 

private final JwtAuthenticationFilter jwtAuthenticationFilter;

public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        return http.csrf().disable() // CSRF 공격에 대한 방어를 해제
                .cors().and() // cors 허용
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) // 서버를 stateless하게 유지 즉 세션 X
                .and()
                .formLogin().disable() // 시큐리티가 기본 제공하는 로그인 화면 없앰. JWT는 로그인과정을 수동으로 클래스로 만들어야 하니까
                .httpBasic().disable() // 토큰 방식을 이용할 것이므로 보안에 취약한 HttpBasic은 꺼두기
                .authorizeRequests() // HttpServletRequest를 사용하는 요청들에 대한 접근제한을 설정하겠다
                .requestMatchers("/account/signup", "/account/login", "/").permitAll() // 요 세 놈에 대한 요청은 인증없이 접근 허용
                .anyRequest().authenticated() // 나머지에 대해선 인증을 받아야 한다.
                .and()
                // 여러 필터들 중 UsernamePassword필터 앞에 내가 만든 필터를 둔다. 이렇게 하면 커스텀 필터로 인가인증을 다룰 수 있음
                .addFilterBefore(jwtAuthenticationFilter,
                        UsernamePasswordAuthenticationFilter.class)
                .build();
    }

 

 

결론!

@Value어노테이션을 통해 값이 멕여지는 멤버변수를 가지는 객체는 빈으로 등록돼있어야 값을 잘 가져온다.


근데 왜 Bean으로 등록해야 가져와지는 거지?

결론적으로 Bean 생성과정에서 @Value가 inject되기 때문..

구체적인 과정들은 현재 내 수준에선 뭔 말인지 잘 모르기 때문에 생략(ㅠ)

 


 

참고한 링크

https://wildeveloperetrain.tistory.com/143

 

@Value 어노테이션 null이 나오는 문제 해결 방법

@Value annotation을 사용하여 properties에 있는 메타 정보 값을 가져오는 과정에서, 값이 null으로 들어오는 문제를 해결하며 기록한 내용입니다. @Value Annotation 쉽게 @Value 어노테이션은 데이터베이스

wildeveloperetrain.tistory.com

https://duooo-story.tistory.com/32

 

@Value는 어디서 set이 되는걸까?

프로젝트에서 reousrce로 등록한 데이터를 @value를 통해서 데이터를 받아 사용하고 있습니다. 이 기능 덕분에 각 존별로 다른데이터를 코드의 분기처리없이 사용하고 있습니다. @Configuration public cla

duooo-story.tistory.com

 

직전 포스트에서 ResponseEntity를 소개했다. 이번엔 '인터셉터'라는 놈으로 멋진 일을 하나 해주려고 한다. 바로 컨트롤러로부터 나오는 애들의 형식을 모두 통일해주는 것! 

 

"그냥 컨트롤러에서의 리턴타입을 전부 통일시켜주면 되는거 아닌가요?"

 

라고 할 수 있다. 컨트롤러의 메서드 하나하나마다 리턴타입을 통일시키고 헤더, 응답코드들을 다 세팅해주고 하면 물론 당연히 응답을 다 통일시켜줄 순 있지만, 개발자는 반복을 싫어한다. 만약 컨트롤러의 메서드가 여러 개 있다면 그들 하나하나마다 리턴타입을 ResponseEntity로 해주고..등의 작업을 해야 하는 것이며, 뭐 헤더를 수정한다거나 하면 이들을 모두 하나하나 다시 건드려줘야 한다.

 

"이걸 좀 더 편하게 해주자"가 지금 해볼 내용이다.


그럼 이런 일을 어떻게 해줄 수 있는가? 바로 인터셉터를 통해 가능하다.

 

인터셉터(Interceptor)

스프링이 제공하는 기술로, 컨트롤러의 핸들러(Handler)를 호출하기 전과 후에 요청과 응답을 참조하거나 가공할 수 있게 해주는 놈이다. 좀 더 풀이하자면 클라이언트로부터 들어온 request라는 편지가 컨트롤러라는 마을의 핸들러라는 집에 도착하기 전에 낚아채서(말 그대로 인터셉트) 개발자가 원하는 작업을 해준 후 마저 핸들러의 집에 보낼 수 있게 해주는 녀석이라고  볼 수 있다. 반대로 핸들러로부터 출발한 response라는 편지를 낚아채 개발자가 원하는 작업을 한 후에 클라이언트에게 마저 보내게 할 수도 있는 것.

(※ 핸들러 : 사용자가 요청한 url에 따라 실행되어야 할 메서드)

 

즉, 특정 컨트롤러의 핸들러가 실행되기 전이나 후에 어떤 작업을 하고 싶을 때 이 인터셉터라는 걸 사용한다. 즉 이를 통해 컨트롤러들이 실행된 후의 객체들에 대해 공통된 작업을 해준다면 응답값 통일을 반복적인 작업없이 간편하게 할 수 있는 거다!

 

암튼 나만의 인터셉터를 만들고 싶다면, 스프링에서 제공하는 HandlerInterceptor라는 인터페이스를 구현하는 커스텀 클래스를 만들면 된다. HandlerInterceptor는 다음과 같은 3개의 메서드를 가진다.

 

메서드명 리턴 타입 설명 기본값
preHandle boolean - 컨트롤러가 호출되기 전에 실행됨

- 컨트롤러의 실행 이전에 처리해야 할 작업이 있을 때 또는 요청정보를 가공할 때 등에 씀

- 실행되어야 할 '핸들러'에 대한 정보를 파라미터로 받음!

- true를 리턴하면 preHandle실행 후 핸들러에 접근하나 false를 리턴하면 작업을 중단함
true
postHandle void - 핸들러 실행 완료 후 View 생성 전에 실행됨

- ModelAndView 타입의 정보를 파라미터로 받음. 즉 Controller에서 View 정보를 전달하기 위해 작업한 Model의 정보를 참조하거나 조작할 수 있음!
-
afterCompletion void - View의 렌더링 이후 실행됨

- 요청 처리중에 사용한 리소스를 반환해주기 적당한 메서드
-

 

여기서 afterCompletion을 활용해 간편한 응답값 통일화 작업이 가능한 거다!

그 방법은 다음과 같다.


우선 다음과 같은 DTO클래스를 만들어준다.

 

@AllArgsConstructor
@Data
public class SuccessResponseDTO<T> {
    private final boolean success;
    private final T data;

    public SuccessResponseDTO(@Nullable T data) {
        this.success = true;
        this.data = data;
    }
}

 

클라이언트로 전달되는 모든 응답들은 요 ResonseDTO 형태로 나가도록 통일할 거다.

그리고 전에 만들어뒀던 필터의 doFilterInternal메서드에 코드들을 추가해줬다.

 

@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
    // 파라미터로 받은 request, response 객체들이 한 번 읽으면 없어지는 애들이라 한 번 감싼 애들을 만들어준다. 일종의 복제
    ContentCachingRequestWrapper wrappingRequest = new ContentCachingRequestWrapper(request);
    ContentCachingResponseWrapper wrappingResponse = new ContentCachingResponseWrapper(response);

    String token = tokenProvider.getTokenFromHeader(request);
    if (token != null && tokenProvider.validateToken(token, jwtSecretKey)) {
        Authentication authentication = tokenProvider.getAuthentication(token, jwtSecretKey);
        SecurityContextHolder.getContext().setAuthentication(authentication);
    }
    // 필터엔 복제한 애들 넘기기. 때문에 인터셉터도 래핑된 객체를 파라미터로 받음
    filterChain.doFilter(wrappingRequest, wrappingResponse);
    // 마지막에 클라이언트에게 response나갈 때 wrapping한 내용을 써줌(복제본 내용을 원본에 쓴다!)
    wrappingResponse.copyBodyToResponse();
}

 

request, response를 감싼 객체들을 만들고 이 녀석들을 doFilter메서드에 인자로 넘기는 모습이다. (이전에는 request, response를 넘겼었음) 그리고 마지막에 캐싱한 응답값을 원래 응답에 덮어씌우는 모습.

 

우리가 하고 싶은 건 컨트롤러에서 뱉어진 응답을 인터셉터에서 가로채 afterCompletion() 메서드를 통해 통일된 응답값 형태로 만들고 싶은 것이니, 이때 필연적으로 인터셉터에서 HttpServletResponse의 Stream을 읽어야만 한다. 그런데 슬픈 사실은 HttpServletRequest, HttpServletResponse은 한 번씩만 읽을 수 있도록 구현된 애들이라는 거..(개발하신 분들이 이렇게 만들었다고) 근데  스프링이 Converter를 이용해 response의 내용을 json으로 바꿔주는 과정에서도 HttpServletResponse를 읽게 되니까 총 2번을 읽게 되어 오류가 생긴다는 거다.

 

이런 말도 안되는 문제를 해결하기 위해 wrapper클래스를 만들어 이 놈들의 내용을 저장해놓은 후 계속해서 쓰는 방식을 이용하는 것.

 

이 때 response를 wrapping한 객체는 마지막에 copyBodyToResponse()메서드를 꼭 불러줘야 한다. 실제로 클라이언트에게 보낼 response에 자신(wrapping한 객체)의 버퍼에 있는 내용을 덮어씌우는 메서드라고 함.

 

그리고 다음과 같은 커스텀 인터셉터를 만들어줬다.

 

@RequiredArgsConstructor
@Component
public class MyInterceptor implements HandlerInterceptor {
    private final String SUCCESS_PREFIX = "2";
    private final String JSON_CONTENT_TYPE = "application/json";
    // response를 object로 매핑해야 됨
    private final ObjectMapper objectMapper;

    @Override
    public void afterCompletion(
            HttpServletRequest request,
            HttpServletResponse response,
            Object handler, Exception ex
    ) throws Exception {
        // 필터에서 wrapping됐던 response가 산 넘고 강 건너 결국 이 메서드로 넘어올 것임
        final ContentCachingResponseWrapper cachingResponse = (ContentCachingResponseWrapper) response;
        // 200번대 응답이 아니면 인터셉터를 거치지 않도록
        if (!isSuccessStatus(response.getStatus())) {
            return;
        }
        if (cachingResponse.getContentType() != null
                && cachingResponse.getContentType().contains(JSON_CONTENT_TYPE)
                && cachingResponse.getContentAsByteArray().length != 0) {
            // String 변환
            String body = new String(cachingResponse.getContentAsByteArray());
            // Object 형식으로 변환 (Response에 꽂아주기 위함)
            Object data = objectMapper.readValue(body, Object.class);
            // 컨트롤러가 뱉는 모든 DTO들을 형식에 상관없이 ResponseDTO에 담는 것!
            SuccessResponseDTO<Object> objectSuccessResponseDTO = new SuccessResponseDTO<>(data);
            // String 변환
            String wrappedBody = objectMapper.writeValueAsString(objectSuccessResponseDTO);
            // 비우고 (원래는 여기에 SuccessResponseDTO가 아닌 다른 놈, 즉 통일된 형식을 갖추기 전의 무언가가 있었을 거니까)
            cachingResponse.resetBuffer();
            // 웅답값 교체
            cachingResponse.getOutputStream().write(wrappedBody.getBytes(), 0, wrappedBody.getBytes().length);
        }

    }

    private boolean isSuccessStatus(int status) {
        return String.valueOf(status).startsWith(SUCCESS_PREFIX);
    }
}

 

※ ObjectMapper : JSON을 Java객체로 역직렬화하거나 Java 객체를 JSON으로 직렬화할 때 자주 쓰이는 클래스(from Jackson 라이브러리). 그 외에도 Java객체의 내용물을 확인하거나 파싱하는데에 쓰임.

 

생성자가 한개라면 @Autowired 어노테이션을 생략가능한 점 + @RequiredArgsConstructor를 통해서 objectMapper를 주입받는 모습인데, 나는 ObjectMapper를 빈으로 등록한 적이 없다! 근데 왜 되는건 궁금해서 검색해보니까, 스프링 부트가 기본적으로 빈으로 등록한 ObjectMapper가 있다고 한다. JacksonAutoConfiguration을 뜯어보니까 요로코롬 자동으로 등록된 놈이 있는 걸 볼 수 있었음.

 

@AutoConfiguration
@ConditionalOnClass(ObjectMapper.class)
public class JacksonAutoConfiguration {

    // 생략
    
	@Configuration(proxyBeanMethods = false)
	@ConditionalOnClass(Jackson2ObjectMapperBuilder.class)
	static class JacksonObjectMapperConfiguration {

		@Bean
		@Primary
		@ConditionalOnMissingBean
		ObjectMapper jacksonObjectMapper(Jackson2ObjectMapperBuilder builder) {
			return builder.createXmlMapper(false).build();
		}

	}

    // 생략

}

 

암튼..

이렇게 내가 만든 커스텀 인터셉터를 다음과 같이 등록해줬다.

 

@Configuration
@RequiredArgsConstructor
public class WebConfig implements WebMvcConfigurer {
    private final MyInterceptor myInterceptor;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(myInterceptor)
                .addPathPatterns("/**");
    }
}

 

  • addInterceptor : 내가 만든 인터셉터를 등록하는 메서드
  • addPathPatterns : 이 인터셉터를 호출할 경로 추가
  • excludePathPatterns : 이 인터셉터를 호출하지 않을 경로 설정

 

이제 끝났다.

내가 이해한 대로 동작순서를 정리하면 결국 다음과 같다.

 

  1. 클라이언트가 쏜 요청이 디스패처 서블릿에 도착하기 전에 필터를 만나게 됨.
  2. 필터에서 request, response를 래핑한 객체를 만들어서 얘를 디스패처 서블릿으로 보냄
  3. 디스패처 서블릿은 얘를 이러쿵저러쿵해서 컨트롤러한테 보냄
  4. 컨트롤러의 핸들러(본 케이스에서는 login메서드)에 의해 응답(본 케이스에서는 LoginResponseDTO)가 뱉어짐. 즉 얘가 response래핑한 객체에 담김
  5. 인터셉터에서 얘를 낚아서 SuccessResponseDTO로 형식 맞춰주고 이걸 response래핑한 객체에 갈아끼워넣음
  6. 필터에서 response래핑한 객체에 있는 내용물을 실제 response로 덮어씌워줌

 

그래서 한번 요청을 보내본 결과는..

 

 

응답값이 SuccessResponseDTO에서 맞춰준대로 통일된 모습이다.

 

이런식으로 개발한다면, 응답값을 만들어주는 작업을 컨트롤러 단에서 할 필요가 없다. 단순히 DTO만 반환시키면 된다! 반환한 DTO가 인터셉터 단에서 SuccessResponseDTO의 data로 꽂아지고, response를 갈아끼워서 응답을 내보내게 되기 때문. 이렇게해서 응답값이 통일이 된다.


소소한 실험

필터에서 request와 response를 래핑하는데, 사실 request까지 래핑할 필요가 있을까? 란 생각이 들었다. response는 두 번 읽게 되는데 request는 그렇지 않을 것 같았기 때문.

 

그래서 request는 래핑하지 말고 실험해봤다.

 

protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
    ContentCachingResponseWrapper wrappingResponse = new ContentCachingResponseWrapper(response);

    String token = tokenProvider.getTokenFromHeader(request);
    if (token != null && tokenProvider.validateToken(token, jwtSecretKey)) {
        Authentication authentication = tokenProvider.getAuthentication(token, jwtSecretKey);
        SecurityContextHolder.getContext().setAuthentication(authentication);
    }
   
    filterChain.doFilter(request, wrappingResponse);
    wrappingResponse.copyBodyToResponse();
}

 

결과는 성공이었따..!

 


참고한 글들입니다.

https://popo015.tistory.com/115

 

[Spring] 스프링 인터셉터(Interceptor)란 ?

목표 Interceptor 란 무엇인지 알아본다. Interceptor 를 직접 구현해본다. 순서 1. 인터셉터(Interceptor) 1.1 인터셉터란? 1.2 왜 사용하는가? 1.3 구현수단 1.4 어떤 메서드를 가지고 있는가? 2. 인터셉터 동작

popo015.tistory.com

https://kihwan95.tistory.com/7

 

스프링 인터셉터란(Interceptor)란? 인터셉터 적용해보기

스프링 인터셉터란 사전적 의미로 가로채다는 의미가 있습니다. 이걸 스프링에 적목 시키면 인터셉터는 Controller에 들어오는 요청(HttpRequest), 응답(HttpResponse)를 가로채는 역할을 하는 객체입니다

kihwan95.tistory.com

https://kimvampa.tistory.com/127

 

[spring] Spring Interceptor 란?(HandlerInterceptor, HandlerInterceptorAdapter)

목표 Interceptor 란 무엇인지 알아본다. Interceptor를 직접 구현해본다. 순서 1. 인터셉터(Interceptor) 1.1 인터셉터란? 1.2 왜 사용하는가? 1.3 구현 수단 1.4 어떤 메서드를 가지고 있는가? 2. 인터셉터 동작

kimvampa.tistory.com

 

스프링 공부하면서 많이 마주치는 키워드들이 있었다. DI, Ioc, 서블릿.. 등등

이런 것들을 짚고 넘어가야 할 것 같다는 생각이 들었다. 야생으로 학습 중인 만큼 지금 당장 이해 안 돼도 넘어가야 하는 부분들이 있겠지만, 그렇다고 다 넘기는 건 아닌 것 같다... 일단 한 번 짚어보고, 지금 당장 다 짚일 것 같지 않으면 넘기고 오 좀 된다 싶으면 짚고 가는게 좋을 듯 함.

그래서 서블릿이란 놈을 한 번 패보기로 했다.


배경

초창기 웹 프로그램은 정적 데이터만 전달 가능했다. 클라이언트가 어떤 걸 요청하면 웹서버가 정적데이터를 응답하는 식. 이게 끝

근데 이제 사용자 요청에 따라 다른 처리, 즉 동적인 처리를 해주고 싶었던 거다. 그걸 위해서 '웹 어플리케이션 프로그램'을 만들어 기존에 존재하던 웹 서버에 붙이고 싶은 거라고 보면 된다.

 

이걸 위해서 CGI가 등장했다. Common Gateway Interface약자로, 웹서버와 앞서 말한 웹 어플리케이션 프로그램 사이의 규약(인터풰이스)이다. C, PHP등으로 요놈의 구현체를 만든다. 이 구현체들은 결국 쉽게 말해서 동적 데이터를 처리해주는 놈, 즉 웹 어플리케이션 프로그램이다.

 

 

그래서 예전과 달리 '동적인 처리'를 해줄 수 있게 됐다. 사람들이 CGI를 많이 활용하게 됐으니까.

근데 문제가 많았던 거다. CGI가 많은 사용자를 처리하기엔 힘들었던 것.

 

  • 클라이언트로부터 request가 들어올 때마다 그 놈들 하나하나마다 웹서버에서 프로세스를 만들어 처리. 프로세스니까 당연히 비용이 비쌌음
  • request들에 대해 같은 CGI 구현체를 써도 프로세스들이 다르면 여러 개의 구현체를 사용해야 됐음. 당연히 비효율적이었음

 

이를 해결하기 위해서 프로세스가 아니라 쓰레드를 만들었다. 그리고 같은 종류의 여러 CGI구현체를 만드는 몹쓸 상황을 막기 위해 CGI구현체를 싱글턴으로 만들었고.

 

이 싱글턴이 바로 서블릿!! 클라이언트로부터 request가 들어올 때마다 쓰레드가 생기고, 이 쓰레드를 통해 싱글턴 CGI구현체에게 동적인 처리를 해달라하는데 이걸 해주는 그 놈을 서블릿이라 부르는 것. 즉 서블릿은 자바로 구현된 CGI기도 한 거다.

 

결국 서블릿은

 

= 클라이언트의 요청을 동적으로 처리할 때 쓰이는 자바 기반의 웹 애플리케이션 프로그래밍 기술(인터페이스임)

= 동적 컨텐츠를 만드는데 사용되는 놈!

 

이라고 말할 수 있다


좀 더 뜯어보기 - 동작 방식

서블릿이 그래 동적인 컨텐츠를 만드는 데 쓰이는 놈이란 건 알겠다. 웹서버가 이 놈한테 말을 건네서 이 서블릿이란 놈이 동적인 처리를 해주는 거구나. 

 

그 과정을 좀 더 뜯어본다.

 

HTTP request, response를 서블릿(얘 자체는 역시나 인터페이스)의 메서드들을 통해 편하게 다룰 수 있다고 한다.

 

 

httpServletRequest = 서블릿 컨테이너가 서블릿에게 전달하는 때 담는 봉투

httpServletResponse = 서블릿이 서블릿 컨테이너에게 돌려줄 때 담아보내라고 지정하는 봉투

 

 

  1. 사용자가 url입력 
  2. HTTP request가 웹서버로 전달됨
  3. 웹서버는 이 요청이 정적 자원을 요청하는지 판단(정적 자원이면 그대로 정적 자원 주면 됨)
  4. 동적인 처리가 필요하면 그 요청을 그대로 was한테 짬때림. 
  5. was의 웹 컨테이너(= 서블릿 컨테이너)가 이를 받고, 처리하기 위한 쓰레드를 만듦
  6. 그리고 컨테이너가 HttpServletRequest, HttpServletResponse객체를 만듦. HttpServletRequest객체로는 사용자가 요청한 내용을 편하게 다루고, HttpServletResponse객체에는 응답할 내용을 편하게 작성 가능
  7. 컨테이너가 사용자가 입력했던 url이 어느 서블릿에 대한 요청인지 찾고(by web.xml ), 걔를 호출. 이 때 아까만든 두 객체를 서블릿에게 선물로 줌
  8. 그 서블릿의 service메서드를 통해 요청이 처리됨! 즉 service메서드에 작성한 코드들이 실행되는 것.
  9. 이 때 아까 받은 request객체를 사용하고, 응답할 내용은 아까 받은 response객체에 저장하는 것.
  10. 또한 service메서드를 호출한 후 클라이언트가 보낸 요청이 GET인지 POST인지에 따라 doGet() 또는 doPost() 호출
  11. 이를 다시 클라이언트에게 최종 결과 응답 후, HttpServletRequest, HttpServletResponse는 삭제

 

※ service메서드를 호출할 때 HttpServletRequest, HttpServletResponse객체를 넘기는 것임! 즉 서블릿이 만들어질때 이 두 놈을 넘기는게 아니라 service메서드를 호출할 때 두 놈을 선물로 주는 것임에 유의

 

※ 톰캣은 was면서 서블릿 컨테이너의 기능도 제공한다고 함!


나아가기 - 스프링 web MVC와 서블릿

그러나..서블릿 역시 문제가 있었던 것이었다. 

 

앞서 설명했듯 사용자가 입력한 url별로 서블릿이 매핑된다. 10개의 각기 다른 url들이 들어오면 10개의 서블릿들이 매핑되는 것! 그럴 때마다 서블릿들이 가지는 "공통된 로직"이 반복돼서 실행된다는 문제점이 있었다. 즉 개발 측면에서 상당히 비효율적이었음.

 

이런 점을 해결하기 위해, 클라이언트로부터의 요청을 받는 서버의 앞쪽에 모든 요청을 받는 하나의 서블릿을 두기로 했다. 그 컨트롤러가 "공통된 로직"을 수행하게 하고, 핵심 비즈니스 로직을 다른 핸들러들에게 위임하는 구조로 바꾼 거다!

 

원래는 이렇게 했는데
이렇게 바꿔줬다는 거!

 

이런 방식을 Front Controller Pattern이라고 한다. 하나의 서블릿(Dispatcher Servlet)으로 모든 요청을 받게 했으니, 요청의 진입점이 같아져 관리가 보다 더 수월해진다는 장점이 있다. 또한 각 서블릿마다 가지는 공통로직을 한 곳에서만 처리함으로써 중복되는 로직의 작성도 방지하게 된다.

 

(디테일하게 디스패처 서블릿이 요청을 처리하는 과정은 본 글에선 다루지 않음)

 

결국엔 이런 방식(디스패처 서블릿이 모든 요청을 받고 공통로직들을 처리하고..)을 스프링이 사용하는 덕분에, 개발자는 핸들러(즉 컨트롤러)에만 집중하면 되도록 발전해왔다..라고 이해하면 될 듯 하다. 

+ Recent posts