저번 시간에 했던 것.

 

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

 

우선 현재 내 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;
    
    // .. 생략

 

결과는..

 

 

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

 요즘 들어 많이 마주치는 단어 중 하나는 DI, Dependency Injection이다. 말 그대로 번역하면 "의존성 주입"이고, 좀 더 풀어서 해석하면 "의존관계를 외부에서 넣어주는"이라는 문장이 된다. 책이나 강의를 통한 예제를 통해서는 단순히 어떤 객체가 가지는 멤버변수가 있을 때, 자신 스스로 멤버변수를 만들어서 설정하는게 아니라 외부에서 이미 만들어진 녀석을 갖고 와서 쓰는 느낌으로만 알고 있었다. 그러나 DI를 나 스스로가 정말 무엇을 말하는지 알고 있는가?라고 물으면..음 글쎄다.

 

public class MemberService {
    private final MemberRepository memberRepository;
    
    public MemberService(MemberRepository memberRepository) {
        this.memberRepository = memberRepository;
    }
}

내가 아는 DI는 단순히 이렇게 "외부에서 만들어진 녀석을 내부로 갖고 와서 쓰게 해주는 것" 이다

 

이번 기회에 의존성 관계가 뭔지 확실히 정리해보자.


의존성(의존 관계)란

public class MemberService {
    private final MemberRepository memberRepository;
}

 

MemberService가 멤버변수로 MemberRepository를 가지는 모습이다. MemberService의 메서드들은 MemberRepository라는 멤버변수를 다루는 행동들을 할 것이다. 당연하게도, MemberRepository라는 놈이 변하면 그에 따라 MemberRepository를 다루는 MemberService에게도 영향이 미칠 것이다. 때문에 이런 관계에서, "MemberService는 MemberRepository에게 의존한다"라고 표현한다.

 

MemberService가 MemberRepository를 가진다

당연히 MemberService가 MemberRepository에 의한 영향을 받는다

→ MemberService가 MemberRepository에 의존한다

 

인 것이다. 하지만 나는 소프트웨어학과이므로 좀 더 추상적인 수준에서 "의존관계(의존성)"를 설명할 수 있어야 한다.

추상적은 수준에서의 "의존관계"란,

 

"두 객체 사이에서 한 객체가 변하면 다른 객체에게 영향이 가는 관계, 즉 변경에 의한 영향을 받는 관계"

 

라고 표현할 수 있을 것이다. 영향을 받는 객체가 영향을 발생시키는 객체를 의존한다고 표현하는 것이고. 구체적인 설명으로는 한 객체가 다른 객체의 메서드를 쓰는 관계, A가 B를 사용해야만 A의 역할을 수행할 수 있는 관계..라고도 볼 수 있다.

 

 

주입(Injection)이란

이건 단어 그대로 보면 된다. 외부에서 꽂아넣는거다.

 

public class MemberService {
    private final JofeMemberRepository memberRepository;
    
    public MemberService() {
        this.memberRepository = new JofeMemberRepository();
    }
}

 

위 코드는 JofeMemberRepository필드를 클래스 내부에서 스스로 찍어내고(?) 있다.

 

public class MemberService {
    private final JofeMemberRepository memberRepository;
    
    public MemberService(JofeMemberRepository memberRepository) {
        this.memberRepository = memberRepository;
    }
}

 

위 코드는 생성자를 통해(setter메서드 등으로 하는 것도 상관없음) 외부에서 JofeMemberRepository를 받아와서 자신의 필드로 세팅하는 모습이다. 외부에서 가져온다 → 외부에서 "주입"한다.

 

이것을 주입이라고 하는 것이다. 

근데 굳이 왜 주입하는거지? 어차피 내부에서 찍어내나 외부에서 주입하나 어차피 다른 거 없지 않아?

 

그것에 대한 답은 "역할을 분리하기 위해서"이다.

생성과 사용에 대한 관심을 분리하여 생성에 대한 책임은 다른 누군가에게 위임하고, 나는 사용하는 역할을 맡겠다는 것. 쉽게 말해서

 

"만드는 건 너가 해. 쓰는 건 내가 할게"

 

라는 거다. 또한 이렇게 책임을 짬때리는위임하는 동시에 필요에 따라 객체가 생성되는 방식을 선택할 수 있게 된다! 결국엔 의존하는 관계에서 가지던 일종의 강한 결합을 느슨하게 만들게 되고 이것을 통해 설계의 유연성을 가질 수 있기 때문에 굳이 주입하는 식으로 만드는 것.

 

 

이렇게 의존관계를 주입하는 것, 즉 외부에서 끼워넣는 것을 의존성 주입이라고 한다. 그러나! 이렇게 단순히 의존관계를 주입하는 것은 진정한 의존성 주입(DI)이 아니다.

(아니 의존관계를 주입하는 것을 의존성 주입이라고 부르지 않는다니,,이게 뭔말일까?)

 

즉 단순히 외부에서 만들어진 녀석을 내부로 가져온다고 해서 죄다 DI라고 하진 않는 것.

하나 더 알아둬야 할 것이 있다. 바로, 의존성 분리다.

 

 

의존성 분리란

앞서 설명했듯, 의존성(의존관계)란 변경에 의한 영향을 받는 관계를 말한다. 이것을 분리한다는 것은, 한마디로 변경에 의한 영향을 안 받게 하겠다! 라는 거다.

 

물론 A가 B에게 의존하는 관계에서 B가 b라는 메서드를 가지고 있다고 할 때, b메서드의 기능을 바꾼다고 하면 당연히 A에게도 영향이 간다. 여기서 말하고자 하는 영향은 이런 영향이 아니라, 바로 A가 이제부터는 B가 아니라 C라는 모듈을 사용하기로 변경한 경우를 의미한다고 보면 된다. 이 경우 A는 B를 사용하던 코드를 싹 다 바꿔야 한다. 

 

이는 A와 B가 강하게 결합돼있기 때문에 발생한다. 다르게 표현하자면 A가 B를 강하게 의존하기 때문, 즉 A가 B를 너무나도 잘 알기 때문에(?) 발생한다. 둘의 의존관계의 끈끈한 정도가 상당히 센 것이다.(너 없음 난 아무것도 못해~의 느낌..)

 

의존성을 분리한다는 것은 이러한 관계를 끊어낸다는 것으로, 아예 손절친다는게 아니라 그 관계의 끈끈한 정도(?)를 느슨~하게 만든다는 의미다. (강한 결합 느슨한 결합이 되게 추상적인 표현이라고는 생각하는데, 이 말 말고 더 느낌있는 말이 없는 것 같다)

 

이에 대한 전통적인 예시로 연극팀 예시가 있다. 어떠한 배역이 있고 그 배역을 맡은 배우가 있을 때, 내가 연극팀을 운영하는 입장이라면 배우가 아니라 배역에 집중해서 기획을 해야 한다. 배우에 집중한다고 하면(이 경우가 강한 결합!!) 그 배우가 아파서 연극에 못 온다고 하면 그냥 망하는거다. 그러나 특정 배우가 아닌 배역에 집중한다면(이 경우가 느슨한 결합!!) 실제로 이 일을 맡은 배우가 누군지 몰라도 "이걸 누가 하는지는 모르겠는데, 이 역할 맡은 배우는 이렇게 연기하면 돼!"를 알고 있는 거니까 상관없는거다. 즉 배우가 바뀌거나 아프거나 뭐 그런 것에 영향을 받지 않게 되는 것. 

 

연극팀 인원들이 원래는 배우에게 의존하는 관계를 더 이상 배우가 아니라 배역에게 의존함으로써, 연극팀과 배우간의 사이가 상당히 느슨해진다! 라고 표현가능하다. 이렇게 의존관계가 느슨해지는 것을 다른 관점에서 보면 연극팀이 배우가 아닌 배역에 의존함으로써 기존에 가지던 배우에게 의존하던 관계가 분리되기 때문에 "의존성이 분리된다"라고 볼 수 있는 거다. 

 

이렇게 특정 구현체(배우)가 아니라 인터페이스(배역)에 의존함으로써 의존성 분리가 가능하다. 이를 "의존관계 역전 원칙(의존성 뒤집기 원칙, Dependency Inversion Principle)"을 적용함으로써 의존성을 분리한다고 표현한다.

 

 

의존관계 역전 원칙

일단 이름은 간지난다. 처음부터 직접적으로 와닿게 설명하자면 "구상 클래스가 아닌 추상 클래스에 의존하게 만드는" 원칙을 말한다. 조금 더 파헤치면, 고수준 모듈이 저수준  모듈에 의존하면 안 된다는 뜻이 담겨 있다. 

 

구상 클래스가 아닌 추상 클래스에 의존하게 함으로써, 고수준 모듈가 저수준 모듈에게 의존하는 전통적인 의존관계를 뒤집히고 고수준 모듈이 저수준 모듈의 구현체들로부터 독립되게 할 수 있게 된다. 즉 의존성을 분리시킬 수 있다!

 

한마디로 저수준 모듈이 고수준 모듈에게 의존하도록 뒤집으라는건데..이걸 바로 구상 클래스가 아닌 추상 클래스에 의존하게 함으로써 뒤집으라는 얘기다.

 

원래 이랬던 거를
이렇게 역전, 즉 뒤집으라는 거

 

 

※ 여기서 고수준/저수준은 프로그래밍 언어를 말할 때의 고수준(사람에 가까운)/저수준(컴퓨터에 가까운)과 같은 의미다. 고수준 모듈이 어떤 의미있는 기능을 제공하는 모듈이라 한다면, 저수준 모듈은 고수준 모듈의 그 기능을 구현하는 실제적인 역할을 맡는 애들이라고 보면 된다. MemberService의 기능이 실제적으론 MemberRepository의 기능들을 통해 이루어진다면 MemberService가 고수준, MemberRepository가 저수준이 된다. 반대로 MemberRepository의 메서드들은 실제적으로 그들을 구현하는 구현체(ex: JofeMemberRepository)에서 구현되므로 이들사이는 MemberRepository가 고수준, JofeMemberRepository가 저수준이 된다.

 

MemberService는 이제 추상화된 MemberRepository에만 의존한다. 뿐만 아니라 다양한 MemberRepository구현체들도 역시 추상화된 MemberRepositiry클래스에 의존하게 된다. MemberService입장에서는 MemberRepository가 저수준 모듈들이지만, 기존에 의존하던 JofeMemberRepository입장에서는 MemberRepository라는 추상 클래스가 자신보다 고수준이다. 왜냐하면 그 놈의 인터페이스를 실제로 구현하는 역할을 JofeMemberRepository가 하니까. 

 

public class MemberService {
    private final MemberRepository memberRepository;
    
    public MemberService(MemberRepository concreteMemberRepository) {
        this.memberRepository = concreteMemberRepository;
    }
}

 

실제 memberRepository필드(= 배우)로 어떤 놈이 들어올지는(= 주입될지는) 모른다. 그래도 그 놈이 MemberRepository(= 배역)라는 역할을 맡은 것은 알고 있다. 때문에 실제 배우가 누가 되든 나는 상관없다.

 

이렇게 의존성 분리를 해주면서, 외부에서 의존관계를 주입받는 것. 이게 진정한 Dependency Injection이다. 단순히 외부에서 때려넣는다고 죄다 dependency injection이 아님!!

 

마지막으로 이 DI의 장점을 훑고 가면 다음과 같다.

 

  • 의존성이 줄어든다 : 의존성 분리를 해줬기 때문에, 의존대상이 바뀌어도 그로 인한 코드의 수정을 안 해도 된다. 즉 배우가 아닌 배역에 의존하니까 배우로 누가 들어오든 변하는 건 없다. 어차피 얘가 할 일은 정해져있으니까. 변경에 유연해진다고도 표현할 수 있겠다.
  • 재사용성이 높아진다 : 의존관계를 가지는 객체를 내부에서 만드는게 아니라 외부에서 만들어진 애를 받는거니까, 다른 클래스에서도 이 놈을 쓸 수 있다
  • 테스트하기 좋아진다 : 외부에서 주입받는 거니까, 주입되는 놈의 테스트를 주입받는 애의 테스트와 분리해서 가능하다. 
  • 가독성이 좋아진다 : DI를 하는 이유가 아까 말했듯 역할의 분리니까, 기능들이 분리되므로 자연스레 가독성이 높아진다.

 

 

 

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

 

+ Recent posts