스프링에서의 응답(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 {
이 방법은 막혔다고 한다.
이제 만들긴 해뒀고, 테스트해볼 차례!! 우선 회원가입했을 때 비밀번호가 암호화된 형식으로 잘 저장되는지 볼거고, 로그인을 할 때 토큰이 발급되는지 볼거다.
결과는..
회원가입 시 비밀번호가 암호화돼서 저장된다
로그인 시 토큰이 resonse에 담겨 전달되는 모습!
ㅁ...무작정 따라하느라 이론은 좀 부족하지만,, 아무튼 성공! 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);
}
}
package dokky.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import lombok.Getter;
import lombok.Setter;
// Entity : 실제 DB의 테이블과 매칭되는 클래스
@Getter
@Setter
@Entity
public class Member {
// Id : 해당 테이블의 primary key
@Id
@Column(name = "email")
private String email;
@Column(name = "password")
private String password;
@Column(name = "nickname")
private String nickname;
}
JPA를 쓰려면 실제 DB의 테이블과 매칭되는 클래스에게 @Entity라는 어노테이션을 멕여야 된다고 한다. 이래야 매칭되는 듯함. @Id어노테이션은 PK가 되는 컬럼에 멕이는 거고, @Column은 테이블의 어느 컬럼에 매핑되는지를 쓴다. (필드의 이름과 테이블의 칼럼 이름이 같다면 생략 가능하다고 함. 근데 이 플젝에선 그냥 썼다.)
public class JpaMemberRepository implements MemberRepository {
private final EntityManager em;
public JpaMemberRepository(EntityManager em) {
this.em = em;
}
@Override
public Member save(Member newMember) {
em.persist(newMember);
return newMember;
}
}
김영한 님의 입문강의에서는 MemberRepository란 인터페이스를 만들고 걔의 구현체로 실제 사용할 Repository를 만드는 식으로 하길래 따라해봤다. 회원가입을 먼저 만들거니까 일단 save메서드만 만들었다. 사실 김영한님 입문 강의에서는 중간에 DB를 바꾸는 작업 때문에 레포지토리를 인터페이스로 바꾼 거라고 생각해서 굳이 내가 하는 것도 레포지토리 인터페이스를 만들고 할 필요가 없다곤 생각했는데, 그래도 이런 식으로 만드는게 다형성을 활용하는 거니까 나중에 유지보수에 좋다고 생각해서 그냥 인터페이스를 만들고 구현하는 식으로 했다.
입문강의에서는 JPA를 쓰려면 EntityManager를 injection하는 구조로 만들어야 한다고 했었다. 이 놈이 db와의 통신을 내부적으로 처리해준다고 강의에 나왔었다. 그래도 이번 기회에 EntityManager가 뭔지 한 번 찾아봤다.
Entity객체들(@Entity어노테이션이 멕여진)을 관리하며 이놈들을 실제 DB의 테이블과 매핑하여 데이터를 CRUD해주는 객체
내부에 PersistenceContext라는 논리적 영역을 두어, 이를 통해 Entity들을 관리함
...음..Entity들을 내부적으로 관리해준다는 것만 여기선 이해하고 넘어갈 수 있을 듯 하다.
아! 참고로 JPA를 정의한 게 javax.persistence패키지라고 한다.
@RequiredArgsConstructor
// jpa를 통한 모든 데이터 변경은 트랜잭션 안에서 실행!
@Transactional
public class MemberService {
private final MemberRepository memberRepository;
public String join(Member newMember) {
memberRepository.save(newMember);
return newMember.getEmail();
}
}
@Transactional은 클래스나 메서드에 붙여줄 경우, 해당 범위 내 메서드가 트랜잭션이 되도록 보장해주는 어노테이션이라고 한다. 김영한님 강의피셜 JPA를 통한 모든 데이터 변경은 트랜잭션 안에서 실행돼야 한다고 한다. 그래서 저 어노테이션을 붙여줬다.
음..모르는게 너무 많다. 이걸 다 하나하나 짚으면서 이해하고 넘어가긴 지금은 무리다. 일단 하면서 나중에 "이게 이거였구나"라고 알아야 되는 영역같다. ㅠ;
암튼 이제 Config클래스를 만들어서 이 놈들을 Bean으로 등록해줬다.
@Configuration
public class SpringConfig {
private final EntityManager em;
@Autowired
public SpringConfig(EntityManager em) {
this.em = em;
}
@Bean
public MemberService memberService() {
return new MemberService(memberRepository());
}
@Bean
public MemberRepository memberRepository() {
return new JpaMemberRepository(em);
}
}
근데 갑자기 의문이 생겼다.
"왜 Member클래스는 Bean으로 등록하지 않지?"
강의를 통해 내가 이해한 것은, 스프링 프로젝트에서 작성한 클래스들을 스프링에서 쓸려면 빈으로 반드시 등록해야 한다는 것이었기 때문이다. 근데 강의를 돌아봐도, Member클래스는 Bean으로 등록을 안 했다.
빈으로 등록되는 애들은 싱글턴으로 등록되기 때문에 Member를 Bean으로 하면 안 되는 건 이해되지만, 내가 알고 있던 전제인 "스프링에서 쓸려면 빈으로 반드시 등록해야 함"이 잘못됐다는 걸 깨달았다. 일단 지금은 스프링 컨테이너에 의해 만들어지는 객체 = 빈 이 정도만 이해하고 넘어가야 할 것 같다.
이후엔 간단하게 회원가입을 위한 폼을 띄우는 페이지 만들어주고, 회원가입 버튼을 누르면 /account/signup으로 POST요청을 보내게끔 만들었다.
이제 이 요청을 처리하는 메서드를 컨트롤러에 작성하면 되는데, 여기서 또 의문이 생겼다. 강의내용에서는 따로 MemberForm이란 클래스를 만들고 이걸 파라미터로 받는 식으로 회원가입 메서드를 만들었다. 근데 MemberForm클래스는 Member클래스와 필드가 똑같고 단순히 Getter, Setter만 있다.
@PostMapping("/signup")
public String signup(MemberForm memberForm) {
Member newMember = new Member();
newMember.setEmail(memberForm.getEmail());
newMember.setPassword(memberForm.getPassword());
newMember.setNickname(memberForm.getNickname());
memberService.join(newMember);
return "success";
}
대충 이런 식으로 흐름이 진행되던데..어차피 MemberForm이랑 Member랑 필드가 똑같으면 파라미터를 MemberForm이 아니라 Member로 해도 괜찮지 않나?란 생각이 들었다. 이건 그냥 바로 실험으로 옮겨봤다. 되나 안되나..
아래 코드로 바꾸고 실험해봤다. 두구두구두구..
@PostMapping("/signup")
public String signup(Member newMember) {
memberService.join(newMember);
return "success";
}
? 결과는 성공이었다. 뭐지 그럼 왜 강의에선 Member가 아니라 MemberForm으로 받던거지..했는데 이 글을 쓰다가 그걸 알게 됐다.
실제 멤버클래스가 가지는 필드 중에 회원가입 폼에서 작성하지 않는 필드가 있을 수 있기 때문이다! 내가 만드는 이 플젝에서는 멤버클래스의 필드 모두를 온전히 멤버폼이 가지고 있으니까 상관없지만, 강의에 나온 예제에서는 멤버가 가지는 id필드값이 폼에서는 작성하지 않는 필드였으니까 멤버폼을 파라미터로 쓰던 것.
오키..근데 아직도 의문인 건 MemberForm은 왜 빈으로 등록 안 하는건지 모르겠다. 으음..스프링이 알아서 MemberForm으로 바인딩해주고 이걸 컨트롤러에 인자로 넘겨주는거면, 내가 이해한 바로는 MemberForm이 빈으로 등록돼있어야 한다. (그래야만 스프링이 관리를 해주는 거고, 관리를 해준다는 것은 그 빈을 꺼내서 주입해준다 등으로 이해했음)
모르는 것 투성이다..
MemberForm에 대해 더 알아봤다. 저렇게 Client ~ Controller 이런 식으로 각 계층 간에서 단순 데이터 전달을 위한 목적으로 사용되는 객체를 DTO(Data Transfer Object)라고 부른다고 한다. 한 마디로 각 계층끼리 주고받는 편지의 느낌. 로직을 갖고 있지 않은 순수한 데이터 객체로 메서드로는 getter/setter 만을 가진다고 한다.
이제 내가 헷갈렸던 거는 멤버폼(DTO)이 아니라 멤버클래스(Entity)를 써서 해도 되던데 왜 멤버폼을 쓰지? 였다. 물론 내가 위에서 기술한 이유인 "실제 멤버클래스가 가지는 필드 중에 회원가입 폼에서 작성하지 않는 필드가 있을 수 있기 때문"도 맞지만, 더 나아가 역할의 분리를 위해서 DTO와 Entity로 구분하는 것이기도 하다. Entity클래스는 단순히 데이터를 담는 것을 넘어 실제 DB와 관련된 중요한 역할을 하는 반면 DTO는 목적 자체가 '전달'이라 그냥 일회성으로만 쓰이기 때문에, 이런 역할 분리를 위해 MemberForm과 Member로 분리해서 쓰던 것. 또한 DTO와 Entity의 생명주기가 다르다는 이유도 있다고 함.
학교에서 하는 스프링 스터디에 참가했는데, 스터디를 위한 공부도 할겸 내 개인 프로젝트도 할겸 OKKY사이트를 모방한 DOKKY라는 서비스를 한 번 이번 겨울방학동안 만들어볼라 한다. 자바 공부도 시작한지 정말 별로 안 됐고 스프링은 이번에 완전 처음 접하기 때문에 완전 야생학습이 됐다..현재 내 스프링에 대한 지식수준은 김영한 님의 스프링 입문 강의(무료)가 전부이다.
현재 참여중인 스터디에서 Spring security, Lombok 이런 것들을 사용해서 일단 걔네들도 같이 끌고와서 프로젝트를 생성했다. 그리고 냅다 main메서드를 구동시켰다.
localhost:8080에 접속하니까 저런 페이지가 나왔다.
음? 나는 웰컴페이지같은 걸 만든 적이 없는데? 뭐지? 싶었다. 스프링이 원래 이런 걸 띄워주는건 아닌거 같고..뭔지 몰라도 security같은 놈이 띄워주는거구나 싶거니 하고 일단 넘어갔다.
이번 스터디에서 인증/인가는 Spring security와JWT로 구현하라고 지정돼있었다. jwt는 프론트 공부할 때 사용한 적이 있어서 그리 낯설지만은 않았지만 문제는 시큐리티라는 놈. 스프링에서 인증/인가를 쉽게 해주는 프레임워크다..라는 내용은 구글링해서 쉽게 찾을 수 있었지만 애당초 스프링을 이제 막 떼기 시작한 내게는 이 놈이 너무 낯설었다(사실 글을 작성하는 지금도 이 놈이 정확히 뭔지 왜 쓰는지 잘 모름). 사실 백엔드 단에 대한 지식이 워낙 바닥에 가까워서 그런 것 같기도.. 암튼 구글링을 통해 시큐리티와 jwt를 활용한 로그인 구현 등을 봤지만 도통 무슨 소리들인지 알 수 없었다. 자바, 스프링, 객체지향, 인증/인가 등 다 너무 낯설었고, 낯선 것들이 여기저기 얽혀 있으니 커다란 블랙박스에 마주친 것 같았다.
일단 만들면서 배우는 야생형 공부가 효율적이란 건 겪어봐서 알지만, 생판 정말 1도 모르는 상태에서 무작정 해본 적은 없었다. 근데 지금은 그래야 한다! ㅋㅋㅋㅋㅋ
이론 공부를 하고 만들기엔 시간이 너무 부족하다. 정말 야생으로 만들면서 배우는게 낫다 싶었다. 우선 DB연결부터 시작했다.
application.properties
저 파일 gitignore통해서 git으로 관리 안 되게 하는 것에서도 조금 애먹었다. 별 거 아닌데 자꾸 안 됐다. 그래도 해내서 뿌듯했음(정말 별 거 아니지만..)
암튼 이제 로그인 기능 만들어볼 차례. post요청을 통해서 /account/login으로 이메일이랑 비번 보내면 처리하게 해야지~라고 생각했다. 김영한 님 입문 강의에서 봤던 방식은 컨트롤러 클래스의 각 메서드마다 @GetMapping같은 어노테이션 멕인 다음에 value로 "/account/login"을 주는 방식이었는데, 클래스에 @RequestMapping을 멕이면 공통된 url을 뺄 수 있었다. 그래서 클래스에 저 어노테이션을 통해 /account로 들어오는 요청들은 요 컨트롤러가 처리하도록 만들었다.
@RestController
@RequestMapping(value = "/account")
public class AccountController {
...
}
아 그리고 또 하나 이 과정에서 알게 된 건 RestController라는 어노테이션이다. 기존에 내가 알던 스프링으로 api만드는 방식은 @Controller 어노테이션과 @ResponseBody 어노테이션을 조합해 활용하는 방식이었다. @ResponseBody를 사용하면 뷰리졸버를 사용하지 않고 HTTP의 BODY에 직접 데이터를 넣는다고 배웠기 때문! 근데 RestController라는 이 둘을 합친 어노테이션이 따로 있었다. 앞으로 되게 많이 쓸 것 같은 느낌.
ok. 본격적인 회원가입/로그인 메서드를 만들기 전에, 일단 /account/login으로 GET요청 보내면 간단하게 hola라는 문자열을 리턴하는 메서드를 만들어 시험해보기로 했다. 스프링 생초짜인 만큼 일단 아주 기본적인 거라도 내 예상대로 돌아가는 걸 보고 싶었기 때문.
@RestController
@RequestMapping(value = "/account")
public class AccountController {
@GetMapping("/login")
public String login() {
return "hola!";
}
}