저번에 등록(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로 하는게 맞는걸까? 이는 연관관계 매핑이라는 것과 관련이 있다고 한다. 이것에 대해서 알아봐야겠다.

우선 현재 내 mysql에는 member 테이블밖에 없다. 게시글 등록을 위해서는 Category 테이블과 SubCategory테이블, 그리고 Board테이블을 만들어야 한다!

 

우선 Category테이블부터 시작했다.

 

 

원래는 카테고리id라는 컬럼은 없고 카테고리명을 pk로 해서 만들라고 했는데, 멘토님이 따로 id컬럼을 만드는 걸 추천해주셔서 반영했다. 나는 카테고리명 자체가 고유한 이름이 될 것이니까 pk로 둬도 된다고 생각했지만, 카테고리명을 나중에 바꾸게 되는 상황이 생길 때 카테고리명이 pk라면 대처가 어렵다고 말씀해주셨다. pk는 고유한 이름이 되는 것 뿐만 아니라  변경될 여지가 없는 컬럼에 박아주는게 좋다고 하셨다. 

 

또한 변경이 거의 없고 데이터를 소규모로 가지는 테이블에 한해서는 Enum으로 하면 좋다고 해서 카테고리명은 Enum으로 바꿔줬다. 

 

public enum Category {
    QNA, // QnA
    KNOWLEDGE, // 지식
    COMMUNITY, // 커뮤니티
    NOTICE // 공지사항
}

 

 그리고 위와 같이 Enum클래스를 만들어줬다. 만들면서 "근데 내가 왜 이 클래스를 작성하고 있지?" 라는 생각이 들었다. 사실 그에 대한 근거는 없다. 단순히 member를 등록하는 예제에서 멤버의 role에 Enum으로 작성한 것을 넣어줬던 기억이 있어, 나중에 게시글을 등록할 때도 Category값에 저 Enum을 넣어주지 않을까라는 추측에서 만든 거다. 실제로 쓰일지는 나중에 봐야 알 듯 하다.. 

 

 

암튼 이렇게 dokky에 category테이블이 잘 생겼다. 그리고 바로 쿼리문을 통해 category테이블들에 데이터들을 추가해줬다.

 

 

음..잘 된 것 같다. 이젠 SubCategory 테이블을 만들 차례.

 

 

다음과 같이 Enum 클래스를 만들어줬다.

 

public enum SubCategory {
    SKILL, // 기술 in QNA
    CAREER, // 커리어 in QNA
    COLUMN, // 칼럼 in 지식
    REVIEW, // 리뷰 in 지식
    DAILY, // 사는얘기 in 커뮤니티
    STUDY, // 스터디 in 커뮤니티
    NOTICE // 공지사항 in 공지사항
}

 

다음과 같이 쿼리를 멕여서 테이블을 만들어줬다.

 

CREATE TABLE `dokky`.`subcategory` (
  `sub_category_id` INT NOT NULL AUTO_INCREMENT,
  `parent_category_id` INT NOT NULL,
  `sub_category_name` ENUM("SKILL", "CAREER", "COLUMN", "REVIEW", "DAILY", "STUDY", "NOTICE") NOT NULL,
  FOREIGN KEY (`parent_category_id`) REFERENCES `category` (`category_id`),
  PRIMARY KEY (`sub_category_id`));

 

암튼 SubCategory테이블도 잘 생겼다. 간단한 실험을 위해 데이터 하나를 추가해보기로 했다! sql쿼리를 잘 모르는 만큼 외래키 값을 insert into를 통해 어떻게 넣는지 궁금했기 때문.

 

 

별 거 없었다. 참조하는 테이블에서 사용하는 값을 그냥 넣어주면 됐다..암튼 SubCategory까지 만드는데 성공!

마지막으로 Board 테이블을 만들 차례다.

 

 

created_date와 updated_date컬럼의 경우, 추가/삭제가 빈번하거나 추적하기 어려운 테이블에 넣어주면 좋다고 한다. 운영하는 관점에서 해당 데이터가 언제 수정되고 생성됐는지에 대한 정보는 중요하기 때문이라고 한다.

아 mysql에서 이미지는 BLOB타입으로 저장한다고 한다. 이유는 간단한데, 이미지는 바이너리 파일이고 BLOB이 그런 녀석을 위한 타입이어서이다. (BLOB = Binary Large Object의 약자)

 

 

이렇게 Board 테이블을 만들어준 후, 간단한 실험을 해보기로 했다! 이번엔 datetime값을 insert into를 통해 어떻게 넣는지 궁금했기 때문이다.

 

역시나 간단했다. datetime같은 경우 지금처럼 create_date에 주는거면 now()를 쓰면 된다고 한다!

 

insert into board (writer, title, content, hits, like, created_date, category_id) 
     values("success@google.com", "도서관에서 공부중", 
    "수지도서관 맛돌이네여ㅎㅎ 다들 한번씩 오세요", 0, 0, now(), 5);

 

근데 에러가 난다..문법적인 에러란다. 찾아보니까 like필드가 mysql에서 예약어로 사용되는 애들이라고 한다! 이 경우 이런 필드들을 ``으로 묶어주니까 간단하게 해결됐다.

 

 

잘 되는 것 같다!! 중간에 멤버테이블에 테스트용 데이터 급하게 추가해주고 오류나는거 찾아보느라 시간이 꽤 걸렸다,,ㅠ

hits와 like, created_date의 경우 기본값을 설정해주는 게 좋을 것 같은데(각자 0, 0, 만들어지는 시간으로), 이걸 DB에서 지금 설정해주는게 맞는지 스프링 쪽에서 해줘야 되는지는 모르겠다. 일단 보류하기로 했다.

 

 

이제 DB 만지는 거에서 넘어와서, 게시글의 Entity 클래스를 만들어줄 차례..근데 여기서 많이 헷갈렸다. 찾아보니까 이것저것 이상한게 많기 때문

 

  • 방금 mysql에서 테이블만들 땐 Board테이블의 writer 컬럼에 member의 pk, 즉 이메일을 했는데 그걸 그대로 쓰면 안되는건가? Entity클래스 설계할 땐 Member객체를 필드로 줘야 하남?
  • 이미지는 어떻게 주지?
  • 시간은 어떻게 처리하지? 그냥 따로 시간에 대한 컬럼 만들지말고 DB에서 알아서 하게 해야 하나?

 

등등..

뭐 그래도 모르는 걸 쭉 뽑아 놓았으니, 하나하나 찾으면 된다!

 

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

    private String writer;

    @Column(length = 80)
    private String title;

    @Column(columnDefinition = "TEXT") // 필드의 타입을 텍스트로 지정
    private String content;

    private Long hits;
    private Long like;

    @Column(name = "created_date")
    private LocalDateTime createdDate;

    @Column(name = "updated_date", nullable = true)
    private LocalDateTime updatedDate;
}

 

이렇게 하고 다음과 같은 테스트 코드를 실행해줬다.

 

@SpringBootTest
@Transactional
class BoardServiceIntegrationTest {
    @Autowired BoardService boardService;

    @DisplayName("게시글 등록 테스트")
    @Test
    void registerNewBoard() {
        Board board = new Board();
        board.setWriter("success@google.com");
        board.setTitle("제에목");
        board.setContent("보오오오오오온문");
        board.setHits(0L);
        board.setLike(0L);
        board.setCreatedDate(LocalDateTime.now());

        Long boardId = boardService.registerNewBoard(board).getBoardId();

        Board foundBoard = boardService.findBoard(boardId).get();
        System.out.println(foundBoard.getWriter());
        System.out.println(foundBoard.getTitle());
        System.out.println(foundBoard.getContent());
        System.out.println(foundBoard.getCreatedDate());
        assertThat(foundBoard.getBoardId()).isEqualTo(boardId);
    }
}

 

근데 에러가 난다..! 알아보니, Board클래스의 like필드가 문제였다. 아까와 마찬가지로 mysql에서 쓰는 예약어였기 때문에, 별도의 조치가 필요했다.

 

다음과 같은 방법으로 해결해줬다. 

 

    @Column(name = "`like`")
    private Long like;

 

ㅋㅋㅋㅋㅋ 컬럼이름을 ``로 감싸주기! 진짜 야매가 따로 없다 이게 아니면 application.properties에 다음을 추가해줘도 된다고 함.

 

spring.jpa.properties.hibernate.globally_quoted_identifiers=true

 

이 값을 요렇게 true로 설정해주면 sql문이 실행될 때 테이블과 컬럼명이 자동으로 ``로 감싸진다고 한다.

암튼 그렇게 한 결과..

 


조금 develop을 해보자. 매번 이렇게 게시글 생성할 때마다 LocalDateTime.now()를 박아줘야 할까? (참고로 요놈은 스태틱 메서드로 현재 시간 객체를 반환해줌) 귀찮다. 이를 위해 @CreatedDate라는 어노테이션이 존재하는데, 이 놈은 엔티티가 생성되어 저장될 때 자동으로 생성시간을 박아준다.

 

    @CreatedDate // Entity가 생성되어 저장될 때 시간이 자동으로 저장되게 하는 어노테이션
    private LocalDateTime createdDate;

 

즉 테스트 코드에서 더 이상 setter를 통해 LocalDateTime.now()를 박아줄 필요가 없을 것이다! 그러나,,

 

 

ㅌ..통수 지대로 당해버렸다. @CreatedDate박으면 시간이 알아서 들어가진다며! 근데 null이 들어가버려 오류가 뜨는 모습;

찾아보니까, Auditing이란 것이 제대로 적용되지 않아 발생한다고 한다. Audit이라고 해서 JPA에서 제공하는 게 있는데 이게 바로 생성일/ 수정일 등의 자동화를 돕는다고 한다. CreatedDate같은 건 이 녀석의 일종인 거고.

 

암튼, 다음과 같이 해결가능하다. 먼저 JpaAuditing을 쓰기 위해 main메서드가 있는 클래스에 다음과 같은 어노테이션을 먹여준다.

 

@EnableJpaAuditing // 바로 이거! JpaAuditing을 활성화하는 기능
@SpringBootApplication
public class DokkyApplication {

	public static void main(String[] args) {
		SpringApplication.run(DokkyApplication.class, args);
	}

}

 

그 다음 Entity클래스에 다음과 같은 어노테이션을 멕인다.

 

@Getter
@Setter
@Entity
@EntityListeners(AuditingEntityListener.class) // 이거! 이 클래스에 Auditing기능을 멕인다는 의미
public class Board {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "board_id")
    private Long boardId;

    private String writer;
    
    // .. 생략

 

결과는..

 

 

테스트에 성공하는 모습!!

직전 포스트에서 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

 

스프링에서의 응답(response)값 반환에 대해 우선 내가 기존에 알고 있는 것은 다음과 같다.

 

1. @ResponseBody 어노테이션이 있으면 viewResolver를 거치지 않고 Http Response의 BODY에 데이터를 담게 된다

2. 문자열은 그대로 담고, 객체는 JSON으로 바꿔서 담는다!

 

이 때 클라이언트가 API를 원활하게 이용하기 위해선 API resonse 형식이 통일돼있든게 좋다고 한다! 생각해보면 나도 저번 학기에 팀플할 때 백엔드 하시는 분이 response 형식을 딱 정해서 줘서 편하게 API를 사용했던 기억이 있다.

 

암튼 클라이언트로부터 Request를 받고 그에 대한 응답(Response)를 반환할 때,  ResonseEntity라는 놈을 활용할 수 있고 이 놈을 통해 response형식을 통일하는 식으로 활용하는 게 가능하다. ResponseEntity의 장점은 우리가 담고 싶은 데이터 뿐만 아니라 HTTP 헤더나 상태 코드 등을 함께 지정해서 response를 보낼 수가 있다는 것이다. 

 

ResponseEitity?

클라이언트가 보내는 http request에 대한 응답을 포함하는 클래스. 스프링에서 기본으로 제공하는 HttpEntity라는 놈을 상속받는 녀석이다(구현이 아니라 상속!). HttpEntity의 생김새는 다음과 같다.

 

public class HttpEntity<T> {
	private final HttpHeaders headers;

	@Nullable
	private final T body;
}

 

헤더와 바디를 갖는 모습을 살펴볼 수 있다. 앞서 말했듯 ResponseEitity는 이 놈을 상속받으면서 request에 대한 응답을 포함하는 녀석이기 때문에,

 

 

요로코롬 status도 추가적으로 갖는 모습을 살펴볼 수 있다. 요놈은 뜯어보면 여러 개의 생성자가 존재하는데, 이 녀석들을 통해 헤더, 응답코드, 바디 등을 여러 방법을 통해 세팅할 수 있다. 헤더랑 바디는 null이 될 수 있지만 응답코드는 항상 세팅해줘야 한다. (참고로 생성자뿐만이 아닌 static 메서드로 만드는 방법들도 있음)

 

암튼 그럼 이 놈을 통해서 내가 저번에 만들었던 로그인 응답을 꾸며보기로(?) 했다. 

 

우선 토큰을 담을 DTO클래스를 만들었다. 참고로 Response에 쓰일 DTO클래스도 정의하는게 좋다고 하는데, 그 이유는 저번에 작성했던 request내용을 Entity로 받는게 아니라 DTO로 받는 이유와 동일. 결국 역할의 분리를 위해서라는 것. 

 

@AllArgsConstructor
@Data
public class LoginResponseDTO {
    private String accessToken;
}

 

(음..생각해보니까 refresh token도 만들고 access token만료될 때마다 다시 갱신하는 부분도 만들어야 하구나..갈 길이 멀다)

암튼 이걸 ResponseEntity에 담아 반환하고자 한다. 다음과 같이 컨트롤러의 코드를 수정해줬다.

 

    @PostMapping("/login")
    public ResponseEntity<LoginResponseDTO> login(@RequestBody LoginDTO loginInfo) {
        String loginEmail = loginInfo.getEmail();
        String loginRawPassword = loginInfo.getPassword();
        if (!memberService.login(loginEmail, loginRawPassword)) {
            throw new IllegalStateException("로그인에 실패했습니다.");
        }
        
        String accessToken = tokenProvider.createToken(loginEmail, jwtSecretKey, EXPIRED_MS);
        LoginResponseDTO loginResponseDTO = new LoginResponseDTO(accessToken);
        
        return new ResponseEntity<>(loginResponseDTO, HttpStatus.OK);
    }

 

자! 이제 response의 모습을 보면..

 

 

짠~

...사실 여기까지만 놓고 보면,

 

"어차피 객체를 리턴하면 JSON으로 바꿔서 전달해주니까 ResponseEntity가 아니라 LoginResponseDTO를 리턴하도록 해도 되잖아요!"

 

라고 할 수 있다. (사실 나도 순간 그렇게 생각함). 그러나 단순 객체를 리턴할 때와 비교해 ResponseEntity의 장점은 앞서 언급했듯이  바로 헤더와 응답코드를 내 맘대로 꾸며줄 수 있다는 점에 있다.

 

 

내 입맛대로 헤더 설정하기

@PostMapping("/login")
public ResponseEntity<LoginResponseDTO> login(@RequestBody LoginDTO loginInfo) {
    String loginEmail = loginInfo.getEmail();
    String loginRawPassword = loginInfo.getPassword();
    if (!memberService.login(loginEmail, loginRawPassword)) {
        throw new IllegalStateException("로그인에 실패했습니다.");
    }

    String accessToken = tokenProvider.createToken(loginEmail, jwtSecretKey, EXPIRED_MS);
    LoginResponseDTO loginResponseDTO = new LoginResponseDTO(accessToken);

    HttpHeaders httpHeaders = new HttpHeaders();
    httpHeaders.add("myFavoriteFood", "pizza");
    httpHeaders.setContentType(new MediaType("application", "json", Charset.forName("utf-8")));

    return new ResponseEntity<>(loginResponseDTO, httpHeaders, HttpStatus.OK);
}

 

이렇게 할 수 있다는 얘기다. 실제 response header를 뜯어보면,

 

 

이렇게 내가 설정한 정보들이 반영된 모습을 볼 수 있다!

 

 

내 입맛대로 응답코드 설정하기

이건 뭐..사진으로 대체. 

 

 

이미지에 보이는 것처럼 여러 응답코드가 있다. 

+ Recent posts