이런 것들을 짚고 넘어가야 할 것 같다는 생각이 들었다. 야생으로 학습 중인 만큼 지금 당장 이해 안 돼도 넘어가야 하는 부분들이 있겠지만, 그렇다고 다 넘기는 건 아닌 것 같다... 일단 한 번 짚어보고, 지금 당장 다 짚일 것 같지 않으면 넘기고 오 좀 된다 싶으면 짚고 가는게 좋을 듯 함.
그래서 서블릿이란 놈을 한 번 패보기로 했다.
배경
초창기 웹 프로그램은 정적 데이터만 전달 가능했다. 클라이언트가 어떤 걸 요청하면 웹서버가 정적데이터를 응답하는 식. 이게 끝
근데 이제 사용자 요청에 따라 다른 처리, 즉 동적인 처리를 해주고 싶었던 거다. 그걸 위해서 '웹 어플리케이션 프로그램'을 만들어 기존에 존재하던 웹 서버에 붙이고 싶은 거라고 보면 된다.
이걸 위해서 CGI가 등장했다. Common Gateway Interface약자로, 웹서버와 앞서 말한 웹 어플리케이션 프로그램 사이의 규약(인터풰이스)이다. C, PHP등으로 요놈의 구현체를 만든다. 이 구현체들은 결국 쉽게 말해서 동적 데이터를 처리해주는 놈, 즉 웹 어플리케이션 프로그램이다.
그래서 예전과 달리 '동적인 처리'를 해줄 수 있게 됐다. 사람들이 CGI를 많이 활용하게 됐으니까.
근데 문제가 많았던 거다. CGI가 많은 사용자를 처리하기엔 힘들었던 것.
클라이언트로부터 request가 들어올 때마다 그 놈들 하나하나마다 웹서버에서 프로세스를 만들어 처리. 프로세스니까 당연히 비용이 비쌌음
request들에 대해 같은 CGI 구현체를 써도 프로세스들이 다르면 여러 개의 구현체를 사용해야 됐음. 당연히 비효율적이었음
이를 해결하기 위해서 프로세스가 아니라 쓰레드를 만들었다. 그리고 같은 종류의 여러 CGI구현체를 만드는 몹쓸 상황을 막기 위해 CGI구현체를 싱글턴으로 만들었고.
이 싱글턴이 바로 서블릿!! 클라이언트로부터 request가 들어올 때마다 쓰레드가 생기고, 이 쓰레드를 통해 싱글턴 CGI구현체에게 동적인 처리를 해달라하는데 이걸 해주는 그 놈을 서블릿이라 부르는 것. 즉 서블릿은 자바로 구현된 CGI기도 한 거다.
결국 서블릿은
= 클라이언트의 요청을 동적으로 처리할 때 쓰이는 자바 기반의 웹 애플리케이션 프로그래밍 기술(인터페이스임)
= 동적 컨텐츠를 만드는데 사용되는 놈!
이라고 말할 수 있다
좀 더 뜯어보기 - 동작 방식
서블릿이 그래 동적인 컨텐츠를 만드는 데 쓰이는 놈이란 건 알겠다. 웹서버가 이 놈한테 말을 건네서 이 서블릿이란 놈이 동적인 처리를 해주는 거구나.
그 과정을 좀 더 뜯어본다.
HTTP request, response를 서블릿(얘 자체는 역시나 인터페이스)의 메서드들을 통해 편하게 다룰 수 있다고 한다.
httpServletRequest = 서블릿 컨테이너가 서블릿에게 전달하는 때 담는 봉투
httpServletResponse = 서블릿이 서블릿 컨테이너에게 돌려줄 때 담아보내라고 지정하는 봉투
사용자가 url입력
HTTP request가 웹서버로 전달됨
웹서버는 이 요청이 정적 자원을 요청하는지 판단(정적 자원이면 그대로 정적 자원 주면 됨)
동적인 처리가 필요하면 그 요청을 그대로 was한테 짬때림.
was의 웹 컨테이너(= 서블릿 컨테이너)가 이를 받고, 처리하기 위한 쓰레드를 만듦
그리고 컨테이너가 HttpServletRequest, HttpServletResponse객체를 만듦. HttpServletRequest객체로는 사용자가 요청한 내용을 편하게 다루고, HttpServletResponse객체에는 응답할 내용을 편하게 작성 가능
컨테이너가 사용자가 입력했던 url이 어느 서블릿에 대한 요청인지 찾고(by web.xml ), 걔를 호출. 이 때 아까만든 두 객체를 서블릿에게 선물로 줌
그 서블릿의 service메서드를 통해 요청이 처리됨! 즉 service메서드에 작성한 코드들이 실행되는 것.
이 때 아까 받은 request객체를 사용하고, 응답할 내용은 아까 받은 response객체에 저장하는 것.
또한 service메서드를 호출한 후 클라이언트가 보낸 요청이 GET인지 POST인지에 따라 doGet() 또는 doPost() 호출
이를 다시 클라이언트에게 최종 결과 응답 후, HttpServletRequest, HttpServletResponse는 삭제
※ service메서드를 호출할 때 HttpServletRequest, HttpServletResponse객체를 넘기는 것임! 즉 서블릿이 만들어질때 이 두 놈을 넘기는게 아니라 service메서드를 호출할 때 두 놈을 선물로 주는 것임에 유의
※ 톰캣은 was면서 서블릿 컨테이너의 기능도 제공한다고 함!
나아가기 - 스프링 web MVC와 서블릿
그러나..서블릿 역시 문제가 있었던 것이었다.
앞서 설명했듯 사용자가 입력한 url별로 서블릿이 매핑된다. 10개의 각기 다른 url들이 들어오면 10개의 서블릿들이 매핑되는 것! 그럴 때마다 서블릿들이 가지는 "공통된 로직"이 반복돼서 실행된다는 문제점이 있었다. 즉 개발 측면에서 상당히 비효율적이었음.
이런 점을 해결하기 위해, 클라이언트로부터의 요청을 받는 서버의 앞쪽에 모든 요청을 받는 하나의 서블릿을 두기로 했다. 그 컨트롤러가 "공통된 로직"을 수행하게 하고, 핵심 비즈니스 로직을 다른 핸들러들에게 위임하는 구조로 바꾼 거다!
이런 방식을 Front Controller Pattern이라고 한다. 하나의 서블릿(Dispatcher Servlet)으로 모든 요청을 받게 했으니, 요청의 진입점이 같아져 관리가 보다 더 수월해진다는 장점이 있다. 또한 각 서블릿마다 가지는 공통로직을 한 곳에서만 처리함으로써 중복되는 로직의 작성도 방지하게 된다.
(디테일하게 디스패처 서블릿이 요청을 처리하는 과정은 본 글에선 다루지 않음)
결국엔 이런 방식(디스패처 서블릿이 모든 요청을 받고 공통로직들을 처리하고..)을 스프링이 사용하는 덕분에, 개발자는 핸들러(즉 컨트롤러)에만 집중하면 되도록 발전해왔다..라고 이해하면 될 듯 하다.
하나의 그래프가 있을 때 그 그래프의 모든 노드를 가지고 있으면서 사이클(Cycle)이 없는 부분 그래프(Sub graph)!
원래 그래프가 N개의 노드를 가진다면, 그 놈의 신장트리는 N개의 노드와 N-1개의 간선(edge)를 갖는다. (간선이 N - 1개보다 적으면 모든 노드가 연결돼있지 않고 N - 1개보다 많으면 사이클이 있는 그래프다).
최소 신장 트리?
신장 트리는 원본 그래프의 부분 그래프이므로 당연히 여러 개 존재할 수 있는데, 그 중 간선들의 가중치 합이 가장 적은 신장 트리를 최소 신장 트리(MST, Minimun Spanning Tree)라고 부른다.
크루스칼 알고리즘?
주어진 그래프에서 최소 신장 트리를 찾는 알고리즘이다. 그리디 알고리즘의 일종으로, 이해하기 매우 쉬운 알고리즘이다.
가장 작은 가중치를 갖는 간선을 선택한다.
선택되지 않은 간선 중 가장 가중치를 갖는 간선을 선택한다
그 간선이 기존에 선택했던 간선들과 사이클을 이루지 않는지 체크. 만약 사이클을 이룬다면 그 간선은 버린다.
모든 노드들이 골라질 때까지 2 ~ 3을 반복
모든 노드들이 골라지는지의 여부는 노드들을 세도 되고, 선택된 간선의 개수가 N - 1개인지로 해도 되고, 아니면 그냥 모든 간선들에 대한 조회가 끝날 때까지 해도 된다. 매 페이즈마다 사이클을 이루지 않게끔 골라왔으니 이렇게 만들어진 서브트리는 신장 트리임이 보장되며, 또한 매 페이즈마다 항상 작은 가중치를 갖는 간선들을 골라왔으니 이렇게 만들어진 신장트리는 당연히 최소 신장 트리임이 보장된다.
가장 까다로운 작업은 사이클을 이루는지 판단하는 작업인데, 이는 유니온 파인드를 통해서 간단히 판별할 수 있다.
유니온 파인드를 이용하면 무방향 그래프에서의 사이클 판별이 가능하다. 방향 그래프에서는 DFS를 통해 가능함. 근데 어떻게 가능한 걸까?
우선 다음 작업으로 사이클을 판단가능하다.
간선을 고른다.
그 간선들에 딸린(?) 노드 두 개가 있을 것이다. 그 두 놈이 같은 집합에 있는지, 즉 같은 그래프에 있는지 확인한다.
두 놈이 다른 그래프에 있다면 두 노드에 대해 Union연산을 한다. 두 놈이 같은 그래프에 있다면 사이클이 있는 거다.
왜 이 작업으로 사이클 판단이 된다는걸까?
유니온 파인드는 집합에 대한 연산들로 생각할 수 있다. 유니온은 두 집합을 합치는거고, 파인드(흔히들 getParant라는 이름으로 메서드를 짓기도 함)는 특정 원소가 속한 집합을 알려주는 연산인 거다. 여기서, 그래프를 일종의 집합으로 볼 수 있다!
이 그래프에서 가장 작은 노드를 리더로 생각한다면 어떨까. 그럼 위 그래프에서의 리더는 0이다.
1이 속한 그래프의 리더 = 0 이다.
5가 속한 그래프의 리더 = 0 이다.
이런 식으로, 그래프를 하나의 집합으로 표현가능한 것이다.
이 그래프에서, 우선 처음에 다음과 같이 리더 테이블을 만들어줬다고 하자. 육안으로 세 놈이 같은 그래프에 있는 걸 당연히 확인가능하지만 일단 각자가 서로 다른 그래프에 있다고 생각하고 테이블을 초기화한다.
1
2
3
리더
1
2
3
간선 (1, 2)를 골랐다. 두 노드가 같은 집합에 속하는지(즉 두 놈이 속한 집합의 리더가 같은지)를 보니까, 다르다. 그럼 두 놈을 Union해준다.
1
2
3
리더
1
1
3
즉 이제 노드 1과 노드 2는 같은 집합, 즉 같은 그래프에 있다고 생각할 수 있다.
이번엔 간선 (1, 3)을 골랐다. 두 노드가 같은 집합에 속하는지를 보니까, 다르다. 그럼 두 놈을 Union해준다.
1
2
3
리더
1
1
1
즉 이제 노드 1과 노드 3은 같은 집합 즉 같은 그래프에 있다고 생각할 수 있는 거다.
이제 간선 (2, 3)을 골랐다. 두 노드가 같은 집합에 속하는지를 보니까 같은 집합에 있다! 노드 2가 속한 집합의 리더와 노드 3이 속한 집합의 리더가 같기 때문이다. 따라서 처음에 그림으로 주어진 그래프가 사이클이 있다고 판별가능하다. 왜냐하면 간선 (2, 3)을 골랐다는 것 자체가 노드 2와 3은 서로 이어져있음을 의미하는 것이기 때문이다.
즉 노드 2와 노드 3이 서로 다른 집합이었다면, 이 둘을 이어도(둘 사이의 간선을 그린다고 생각) 사이클은 생기지 않음. 원래 둘이 속한 집합 즉 그래프가 겹치지 않는 것이었으니까!
그러나 노드 2와 노드 3이 서로 같은 집합이었다면..즉 둘 사이를 잇기도 전에 애시당초 같은 그래프 안에 있던 거라면 그 둘을 잇는다는 것은 사이클을 만드는 것이 되는 것임.
스프링에서의 응답(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);
}
우선 전에 만들어둔 회원가입 기능을 다시 살펴봤는데, 뭔가 이상했다. 생 비밀번호를 그대로 DB에 저장하고 있었다..! 내가 아무리 백엔드 지식이 전무하다지만 비밀번호를 그대로 저장하면 안 된다는 것 정도는 알고 있었다. 역시 찾아보니까 암호화하여 저장하는 방법이 있었다.
우선 다음과 같이 PasswordEncoder를 빈으로 등록해준다. BCryptPasswordEncoder는 PasswordEncoder라는 인터페이스를 구현한 놈이다.
@Bean
public PasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
}
그런다음 회원가입 코드를 다음과 같이 수정했다.
@PostMapping("/signup")
public String signup(SignupDTO signupDTO) {
String rawPassword = signupDTO.getPassword();
String encodedPassword = passwordEncoder.encode(rawPassword);
Member newMember = new Member();
newMember.setEmail(signupDTO.getEmail());
newMember.setPassword(encodedPassword);
newMember.setNickname(signupDTO.getNickname());
newMember.setRole(Role.USER);
memberService.join(newMember);
return "success";
}
passwordEncoder의 encode메서드를 통해 암호화해주고 이를 저장하는 모습. 아 그리고 MemberForm클래스의 이름을 SignupDTO로 바꿔줬다.
그런 다음 우선 로그인 로직을 만들어줬다. SignupDTO와 비슷하게 유저가 로그인할 때의 이메일, 비밀번호를 담아 전달하는 LoginDTO를 만들어줬다.
@Data
public class LoginDTO {
private String email;
private String password;
}
@Data란 어노테이션도 롬복에서 주는 놈으로, @Getter, @Setter, @ToString, @RequiredArgsConsructor 등을 한꺼번에 합쳐둔 종합 어노테이션이라고 한다.
그리고 이를 활용해 AccountController에서 로그인 로직을 다음과 같이 짜줬다.
@PostMapping("/login")
public String login(@RequestBody LoginDTO loginInfo) {
String loginEmail = loginInfo.getEmail();
String loginRawPassword = loginInfo.getPassword();
if (!memberService.login(loginEmail, loginRawPassword)) {
throw new IllegalStateException("로그인에 실패했습니다.");
}
return tokenProvider.createToken(loginEmail, jwtSecretKey, EXPIRED_MS);
}
우선 사용자가 입력한 이메일과 비밀번호를 LoginDTO로 받는다. @RequestBody는 클라이언트가 보내준 HTTP의 BODY에 담긴 데이터를 LoginDTO에 매핑해주는 역할이라고 생각하면 된다. 스프링이 자동으로 JSON데이터를 파라미터로 적힌 자바객체로 역직렬화해주는 것. (참고로 클라 쪽에서 요청데이터를 body에 담고 content-type을 application/json으로 해줘야 한다고 함)암튼 그 다음 DTO로부터 이메일과 비밀번호를 추출한 후, memberService의 로그인메서드를 호출한다. 이 메서드는 사용자가 입력한 이메일과 비밀번호를 갖는 사람(Member)가 있으면 true를 리턴하고, 아니면 false를 리턴한다! 그래서 memberService의 로그인 메서드가 true를 리턴했을 때 토큰을 발급하도록 만들어줬다.
MemberService의 로그인 메서드는 다음과 같다.
public boolean login(String email, String rawPassword) {
Member member = memberRepository.findByEmail(email)
.orElseThrow(() -> new IllegalArgumentException("이메일을 다시 확인해주세요"));
if (isMatchedPassword(member, rawPassword)) {
return LOGIN_SUCCESS;
}
return LOGIN_FAIL;
}
private boolean isMatchedPassword(Member member, String rawPassword) {
// 스트링의 equals메서드로 하면 안됨. encode결과값이 그때그때 달라서리..
return passwordEncoder.matches(rawPassword, member.getPassword());
}
처음에는 isMatchedPassword메서드에서 단순히 member.getPassword()와 파라미터로 받은 rawPassword를 encode한 두 문자열이 같은지를 equals메서드를 통해 비교했었다. 근데 결과는 항상 다르다고 나왔다. 뭔가 하고 보니, PasswordEncoder로 같은 문자열을 encode함에도 불구하도 할 때마다 그 결과값이 계속 달라졌다. 찾아보니까 Bcrypt라는 방식이 해쉬를 멕일 때마다 랜덤의 솔트(salt, 일종의 랜덤데이터를 말한다고 함)를 더해 매번 결과값이 달라지게 해서 보안성을 향상시킨 방법이라고 한다. 때문에 두 비밀번호가 같은지를 비교하려면 equals가 아니라 PasswordEncoder가 제공하는 matches기능을 활용해야 했다.
아 그리고! Bcrypt방식으로 해쉬를 멕인 결과물의 길이는 항상 60이라고 한다! 즉 mysql에서 비밀번호의 길이를 60으로 지정해줘야 함. 안 그러면 data too long이란 오류가 난다.
그러고는 로그인이 잘 동작하는지 확인하기 위해 테스트 코드를 짜줬다.
@Nested
class LoginTest {
@DisplayName("로그인 테스트")
@Test
void login() {
String email = "tt@naver.com";
String rawPassword = "123123";
Member member = new Member();
member.setEmail(email);
member.setPassword(passwordEncoder.encode(rawPassword));
member.setNickname("testestrqe");
member.setRole(Role.USER);
memberService.join(member);
boolean result = memberService.login(email, rawPassword);
assertThat(result).isTrue();
}
@DisplayName("비밀번호를 틀려서 로그인하면 실패한다")
@Test
void loginByNotExistingEmail() {
String email = "tt@naver.com";
String rawPassword = "123123";
Member member = new Member();
member.setEmail(email);
member.setPassword(passwordEncoder.encode(rawPassword));
member.setNickname("testestrqe");
member.setRole(Role.USER);
memberService.join(member);
boolean result = memberService.login(email, "@!31232132");
assertThat(result).isFalse();
}
}
결과는 기분좋게 통과!
그리고 이제 시큐리티랑 짝짝꿍할 차례. 일단 jwt관련해 사용자의 인증 정보를 가져오는 기능을 만들지 않았었으니 그 부분부터 따라만들기로 했다.
UserDetails : 인터페이스고, 사용자에 대한 핵심 정보를 담고 제공하는 객체 역할! 기본적으로 갖는 메서드들은 다음과 같다.
메서드명
리턴 타입
설명
기본값
getAuthorities()
Collection<? extends GrantedAuthority>
계정의 권한 목록 리턴
getPassword()
String
계정의 비번 리턴
getUsername()
String
계정의 고유값 리턴(DB pk 또는 중복이 없는 필드 등)
isAccountNonExpired()
boolean
계정의 만료여부 리턴
true
isAccountNonLocked()
boolean
계정의 잠김여부 리턴
true
isCredentialsNonExpired()
boolean
비밀번호 만료여부 리턴
true
isEnabled()
boolean
계정의 활성화여부 리턴
true
UserDetailsService : 사용자별 데이터(UserDetails)를 로드하는 놈이고 역시나 인터페이스. UserDetails타입을 리턴하는 loadUserByUsername이란 기본 메서드를 가지고 있다.
음..근데 예네들 왜 쓰는걸까? 왜 userDetails가 왜 있는걸까? 에 대해 알아봤다.
일단 아주 러프하게 보면, 시큐리티에서는 사용자 요청에 대한 검증(이 놈이 제대로 된 놈인지)을 해주는데 이 때 인증하고자 하는 회원의 정보를 불러올 수 있어야 한다! 이를 위해서 DB에 접근할 수 있어야 하고, 이를 UesrDetailsService가 하도록 한 것. 또한 회원 정보를 DB에 저장된 형태 그대로 가져오는게 아니라 시큐리티가 사용할 수 있는 형태(UserDetails)로 가져오게 한 거다. 인증에 성공하면, 요 정보를 SecurityContextHolder라는 곳(시큐리티 내부의 저장소)에 저장한다. 고 한다. 이때 SecurityContextHolder가 저장할 수 있는 객체는 Authentication타입이고, 이 객체는 User정보를 갖는데 이 정보가 UserDetails타입으로 정의돼있다! 그래서 UserDetails를 사용하는 것임. 참고로 나중에는 이렇게 저장된 Authentication을 통해서 인가해준다고 함.
암튼 이를 위해서 UserDetails인터페이스를 구현한 놈과 UserDetailsService를 구현한 놈을 만들어줘야 했다. UserDetails의 경우 기존에 가진 Member클래스가 얘를 구현하게 하는 방식이 있고 Member와는 별도로 따로 CustomUserDetails를 만드는 방법이 있었는데, 난 전자를 선택했다.
@Getter
@Setter
@Entity
public class Member implements UserDetails {
@Id
@Column(name = "email")
private String email;
@Column(name = "password")
private String password;
@Column(name = "nickname")
private String nickname;
@Enumerated(EnumType.STRING)
private Role role;
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
Collection<GrantedAuthority> authorities = new ArrayList<>();
authorities.add(new SimpleGrantedAuthority(getRole().name()));
return authorities;
}
@Override
public String getUsername() {
return email;
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
CustomUserDetailsService는 다음과 같이 만들어줬다.
@RequiredArgsConstructor
@Service
public class CustomUserDetailService implements UserDetailsService {
private final MemberRepository memberRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
return memberRepository.findByEmail(username)
.orElseThrow(() -> new UsernameNotFoundException("사용자를 찾을 수 없습니다"));
}
}
클라이언트가 보낸 request의 header에서 토큰 빼내고, 그 놈이 문제가 없다면(?) Authentication을 만들어서 저장하는 모습이다. 그러곤 다음과 같이 SecurityConfig를 작성해줬다.
@Configuration
@RequiredArgsConstructor
@EnableWebSecurity // 기본적인 웹 보안을 활성화하는 어노테이션
public class SecurityConfig {
private final JwtTokenProvider tokenProvider;
@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()
.addFilterBefore(new JwtAuthenticationFilter(tokenProvider),
UsernamePasswordAuthenticationFilter.class)
.build();
}
}
원래는 다음과 같이 만드는 방식이 있었는데,
@Configuration
@EnableWebSecurity // Spring Security 설정을 시작
public class SecurityConfig extends WebSecurityConfigurerAdapter {
이 방법은 막혔다고 한다.
이제 만들긴 해뒀고, 테스트해볼 차례!! 우선 회원가입했을 때 비밀번호가 암호화된 형식으로 잘 저장되는지 볼거고, 로그인을 할 때 토큰이 발급되는지 볼거다.
결과는..
ㅁ...무작정 따라하느라 이론은 좀 부족하지만,, 아무튼 성공! 2일을 이거 하느라 이것저것 삽질하느라 좀 졸리다..자러 가야겠음 ㅠ
테이블에 컬럼 추가하는 것도 좀 헤멨다..ㅠ (근데 컬럼 not null로 추가하게되면 기존에 있던 필드들은 어떻게 되는건지 궁금했는데 알아서 채워짐.)
본격적으로 jwt를 활용한 회원가입/ 로그인을 드디어 만들 차례..ㅠ
우선 jwt를 발급하는 기능부터 만들어야한다. 다음 dependency를 추가해줬다.
implementation 'io.jsonwebtoken:jjwt:0.9.1'
그리고는 다음과 같이 JwtTokenProvider라는 클래스를 작성해줬다.
@Component
public class JwtTokenProvider {
@Value("${jwt.secret}")
private String jwtSecretKey;
private final Long TOKEN_VALID_MS = 30 * 60 * 1000L; // token 유효시간. 30분
public String createToken(String userEmail) {
// claims : jwt에서 내가 원하는 걸 담는 공간. payload라고 보면 됨. 일종의 map
Claims claims = Jwts.claims();
claims.put("email", userEmail);
return Jwts.builder()
.setClaims(claims) // 위에서 만들어둔 claims 넣기
.setIssuedAt(new Date(System.currentTimeMillis())) // 현재 시간 넣기
.setExpiration(new Date(System.currentTimeMillis() + TOKEN_VALID_MS)) // 종료시간 넣기
.signWith(SignatureAlgorithm.HS256, jwtSecretKey) // 서명하기
.compact();
}
}
JWT를 발급하는 경우 secret key를 이용해 생성해줘야 하는데 이 key값을 .java파일에 그대로 쓰면 깃허브에 올릴 시 노출되는게 당연했다. 뭔가 환경변수처럼 어딘가에 두고 활용해야 할 것 같은데,, 문제는 스프링이 첨이라 여기서는 그런 걸 어떻게 하는지 몰랐다. 리액트를 할 때는 .env란 파일을 만들고 거기에 환경변수를 적으면서 활용했었는데, 찾아보니까 자바에서도 그런 기능을 할 수 있는 파일이 있었고 그게 바로 apllication.properties파일이었다. 이 파일에서 jwt.token=블라블라..이런 식으로 작성한 다음, 위 코드에서 보이는 것처럼 @Value어노테이션을 통해 그 값을 불러올 수 있었다. 이렇게 또 하나 배웠다.
그러고는 토큰 검증 클래스와 메서드를 만들어줬다.
@Component
public class JwtTokenValidator {
@Value("${jwt.secret}")
private String jwtSecretKey;
public boolean validateToken(String token) {
try {
// 토큰 복호화
Claims claims = Jwts.parser() // parser 생성
.setSigningKey(jwtSecretKey) // JWS 디지털 서명을 확인하는 데 쓰일 키를 세팅
.parseClaimsJws(token)
.getBody();
return true;
} catch (SignatureException e) {
return false;
} catch (ExpiredJwtException e) {
return false;
}
}
}
그리고 토큰검증용 테스트를 만들어줬다.
class JwtTokenValidatorTest {
private final JwtTokenProvider tokenProvider = new JwtTokenProvider();
private final JwtTokenValidator tokenValidator = new JwtTokenValidator();
@DisplayName("토큰 검증 성공 테스트")
@Test
void validateToken() {
String testToken = tokenProvider.createToken("test@naver.com");
assertThat(tokenValidator.validateToken(testToken)).isTrue();
}
}
근데 오류가 났다.. TokenProvider의 jwtSecretKey필드가 null이란다. 뭐지? 테스트 코드로 안 하고 프로덕션 코드에서 jwt토큰은 잘만 발급되던데..(실험해봤음)
뭔가 하고 찾아보니, SpringBootTest 어노테이션이 없는 단위 테스트시 @value값이 주입되지 않는다고 한다. 단위 테스트에선 Spring의 Application Context가 로딩되지 않기 때문이라고 한다.
그냥 토큰발급 클래스에서 시크릿키를 필드로 가지는게 아니라 메서드의 파라미터로 받도록 했다. 또한 만료기간이 지나는 것도 테스트하기 위해 만료기간도 토큰발급 클래스가 필드로 가지는게 아니라 메서드의 파라미터로 받도록 했다.
결국 최종적으로 바뀐 토큰발급 클래스(JwtTokenProvider클래스)는 다음과 같았다.
@Component
public class JwtTokenProvider {
public String createToken(String userEmail, String jwtSecretKey, long expiredMs) {
// claims : jwt에서 내가 원하는 걸 담는 공간. payload라고 보면 됨. 일종의 map
Claims claims = Jwts.claims();
claims.put("email", userEmail);
return Jwts.builder()
.setClaims(claims) // 위에서 만들어둔 claims 넣기
.setIssuedAt(new Date(System.currentTimeMillis())) // 현재 시간 넣기
.setExpiration(new Date(System.currentTimeMillis() + expiredMs)) // 종료시간 넣기
.signWith(SignatureAlgorithm.HS256, jwtSecretKey) // 서명하기
.compact();
}
}
초기와는 달리 secretKey와 만료시간을 파라미터로 받는 모습. Validator클래스에서도 마찬가지로 secretKey를 파라미터로 받도록 바꿔줬다.
그리고 드디어 얘네를 테스트하는 코드를 마저 작성한 다음, 테스트를 돌려봤다..!
드디어 편안..하다.
이제 jwt와 관련해 또 만들 것은
클라이언트가 토큰을 담아 보내는 request로부터 토큰을 빼내는 기능
토큰에서 내가 담았던 정보(즉 email)을 빼내는 기능
빼낸 정보를 바탕으로 인증정보를 가져오는 기능. 이건 시큐리티랑 같이 짝짝꿍하는 듯함(아닐 수도)
요로코롬 3개다. 구글링해서 나온 예제들에선 내가 위에서 만들었던 토큰발급기능과 토큰검증기능, 그리고 바로 위의 3개 기능을 하나의 클래스에 작성했었다. 하지만 나는 발급 / 검증을 다른 책임으로 보고 클래스를 분리해서 만들어뒀다. (근데 이것도 토큰에 관련된 책임이란 넓은 관점에서 보면 같은 클래스에 쓰는 것도 합당하긴 할 것 같다) . 암튼 그래서 바로 위 3개의 기능은 TokenAnalyzer라는 클래스(토큰 분석기..ㅋㅋㅋㅋ 마땅히 붙여줄 이름이 생각나지 않는다..)에 작성해두기로 했다. 3번 기능은 시큐리티 쓸 때 마저 만들고, 일단 1, 2번만 만들기로!
@Component
public class JwtTokenAnalyzer {
public String getTokenFromHeader(HttpServletRequest request) {
return request.getHeader("X-AUTH-TOKEN");
}
public String getUserEmailFromToken(String token, String jwtSecretKey) {
// 토큰 복호화
Claims claims = Jwts.parser() // parser 생성
.setSigningKey(jwtSecretKey) // JWS 디지털 서명을 확인하는 데 쓰일 키를 세팅
.parseClaimsJws(token)
.getBody();
String userEmail = (String)claims.get("email"); // object형태로 저장돼있어서..문자열로 변환해야 함
return userEmail;
}
}
토큰 생성시 claim들을 만들 땐 문자열을 put하면서 만들었었는데, parser가 토큰으로부터 다시 정보들을 역으로 만드는 과정에서는 문자열 형태가 아니라 객체 형태로 생성되는 듯 하다. 그래서 String으로 형변환을 해서 이메일을 가져온다.
이메일을 정말 잘 가져오나..토큰에 들어가는 이메일이 내가 써둔 이메일이 맞나! 를 테스트하는 코드를 짰다.
class JwtTokenAnalyzerTest {
private final JwtTokenProvider tokenProvider = new JwtTokenProvider();
private final JwtTokenAnalyzer tokenAnalyzer = new JwtTokenAnalyzer();
@DisplayName("토큰에 저장된 이메일이 내가 적어준 이메일이 맞는지 테스트")
@Test
void getUserEmailFromToken() {
String secretKey = "1234560ACB6F1AD6B6A6184A31E6B7E37DB3818CC36871E26235DD67DCFE4041492";
String testEmail = "test@naver.com";
long expiredMs = 30 * 60 * 1000L;
String testToken = tokenProvider.createToken(testEmail, secretKey, expiredMs);
assertThat(tokenAnalyzer.getUserEmailFromToken(testToken, secretKey)).isEqualTo(testEmail);
}
}