[Study-12, 13주차] Spring Security, JWT + 회원가입, 로그인 - ①
[Study-12, 13주차] Spring Security, JWT + 회원가입, 로그인 - ①
[Study-10, 11주차] SpringSecurity 적용 [Study-10, 11주차] SpringSecurity 적용 [Study-8, 9주차] @RestControllerAdvice 활용 Exception + 동적쿼리 적용 및 고찰 + N+1 문제에 대한 고찰 - ② [Study-8, 9주차] @RestControllerAdvice
soohykeee.tistory.com
앞서는 Spring Security와 JWT를 사용해주기 위한 설정들을 해주었다. 이제는 회원가입과 로그인을 위한 API와 service를 구현해 볼 것이다.
그전에 Owner, Pet 등에 적용했던 것처럼 MemberMapper, MemberRequestDto, MemberResponseDto, MemberService를 생성해 주었다. 또한 동적쿼리를 사용하여 검색할 수 있는 MemberSearchRepository 추가해 줄 것이다.
MemberRequestDto
package kr.co.jshpetclinicstudy.service.model.request;
public class MemberRequestDto {
@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor
public static class CREATE {
private String identity;
private String password;
// private String confirmPassword;
private String name;
private String role;
}
@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor
public static class UPDATE {
private Long memberId;
private String name;
private String role;
}
@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor
public static class LOGIN {
private String identity;
private String password;
}
@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor
public static class CONDITION {
private List<Long> memberIds;
private String name;
private String role;
}
}
MemberResponseDto
package kr.co.jshpetclinicstudy.service.model.response;
public class MemberResponseDto {
@Builder
@AllArgsConstructor
@NoArgsConstructor
@Getter
public static class READ {
private Long memberId;
private String identity;
private String name;
private String role;
private String token;
}
}
MemberSearchRepository
package kr.co.jshpetclinicstudy.persistence.repository.search;
@Repository
@RequiredArgsConstructor
public class MemberSearchRepository {
private final JPAQueryFactory queryFactory;
private final QMember member = QMember.member;
public List<Member> find(MemberRequestDto.CONDITION condition) {
return queryFactory
.selectFrom(member)
.where(
memberIdIn(condition.getMemberIds()),
memberNameEq(condition.getName()),
memberRoleEq(condition.getRole())
)
.fetch();
}
private BooleanExpression memberIdIn(List<Long> memberIds) {
if (CollectionUtils.isEmpty(memberIds)) {
return null;
}
return member.id.in(memberIds);
}
private BooleanExpression memberNameEq(String name) {
if (!StringUtils.hasText(name)) {
return null;
}
return member.name.eq(name);
}
private BooleanExpression memberRoleEq(String role) {
if (!StringUtils.hasText(role)) {
return null;
}
return member.role.eq(Role.valueOf(role));
}
}
MemberService
package kr.co.jshpetclinicstudy.service;
@Service
@RequiredArgsConstructor
public class MemberService {
private final MemberRepository memberRepository;
private final MemberSearchRepository memberSearchRepository;
private final MemberMapper memberMapper;
private final PasswordEncoder passwordEncoder;
private final JwtProvider jwtProvider;
@Transactional
public void createMember(MemberRequestDto.CREATE create) {
Member member = Member.builder()
.identity(create.getIdentity())
.password(passwordEncoder.encode(create.getPassword()))
.name(create.getName())
.role(Role.valueOf(create.getRole()))
.build();
isIdentity(create.getIdentity());
memberRepository.save(member);
}
public MemberResponseDto.READ loginMember(MemberRequestDto.LOGIN login) {
final Optional<Member> member = memberRepository.findMemberByIdentity(login.getIdentity());
isMember(member);
isPassword(login.getPassword(), member.get().getPassword());
return MemberResponseDto.READ.builder()
.memberId(member.get().getId())
.identity(member.get().getIdentity())
.name(member.get().getName())
.role(String.valueOf(member.get().getRole()))
.token(jwtProvider.createToken(member.get().getIdentity(), String.valueOf(member.get().getRole())))
.build();
}
@Transactional
public void updateMember(MemberRequestDto.UPDATE update) {
final Optional<Member> member = memberRepository.findById(update.getMemberId());
isMember(member);
member.get().updateMember(update);
memberRepository.save(member.get());
}
@Transactional
public MemberResponseDto.READ readMember(String identity) {
final Optional<Member> member = memberRepository.findMemberByIdentity(identity);
isMember(member);
return MemberResponseDto.READ.builder()
.memberId(member.get().getId())
.identity(member.get().getIdentity())
.name(member.get().getName())
.role(String.valueOf(member.get().getRole()))
// .token(jwtProvider.createToken(member.get().getIdentity(), member.get().getRole().getUserRole()))
.build();
}
public List<MemberResponseDto.READ> getMembersByCondition(MemberRequestDto.CONDITION condition) {
final List<Member> members = memberSearchRepository.find(condition);
return members.stream()
.map(memberMapper::toReadDto)
.collect(Collectors.toList());
}
private void isMember(Optional<Member> member) {
if (member.isEmpty()) {
throw new NotFoundException(ResponseStatus.FAIL_MEMBER_NOT_FOUND);
}
}
private void isIdentity(String identity) {
if (memberRepository.existsByIdentity(identity)) {
throw new DuplicatedException(ResponseStatus.FAIL_MEMBER_IDENTITY_DUPLICATED);
}
}
private void isPassword(String requestPassword, String getPassword) {
if (!passwordEncoder.matches(requestPassword, getPassword)) {
throw new WrongPasswordException(ResponseStatus.FAIL_MEMBER_PASSWORD_NOT_MATCHED);
}
}
}
MemberService 클래스는 회원 관련 비즈니스 로직을 처리하는 서비스이다.
해당 Service에서도 MemberMapper interface를 통해서 toEntity, toDto 를 사용해주려 했지만, 암호화를 위한 PasswordEncoder 와 JWT 토큰 생성을 위한 JwtProvider를 @Autowired를 MemberMapper 에 사용해주니 'Can't generate mapping method with no input arguments.' 라는 오류가 발생하였다.
해당 오류를 해결하기 위해 구글링 및 chatGPT를 사용해주니 다음과 같은 결론에 도달할 수 있었다.
MemberMapper 인터페이스에서 @Autowired를 사용할 수 없으므로, MemberService에서 PasswordEncoder를 주입받아 createMember 메서드에 직접 전달하는 방법은 유효한 대안입니다. 이 방식을 사용하면 MemberService는 PasswordEncoder의 인스턴스를 갖고 있어서 암호화에 필요한 기능을 수행할 수 있게 됩니다. 이렇게 함으로써 MemberMapper는 본래의 역할을 수행하고, MemberService에서는 필요한 의존성을 제공받아 비밀번호 암호화를 수행할 수 있습니다.
이제 해당 MemberService에서의 메서드들을 살펴보면 다음과 같다.
- createMember(MemberRequestDto.CREATE create): 회원 생성을 처리하는 메서드이다. 입력받은 정보를 기반으로 Member 객체를 생성하고 저장한다. 이미 존재하는 아이디인지 확인한 후, 암호를 인코딩하여 저장합니다.
- loginMember(MemberRequestDto.LOGIN login): 회원 로그인을 처리하는 메서드이다. 입력받은 아이디로 회원을 조회한 후, 회원이 존재하는지 확인하고 비밀번호를 검증한다. 검증이 성공하면 MemberResponseDto.READ 객체를 생성하여 반환하고, JWT 토큰을 생성하여 포함시킨다.
- updateMember(MemberRequestDto.UPDATE update): 회원 정보 업데이트를 처리하는 메서드이다. 회원 ID로 회원을 조회한 후, 회원이 존재하는지 확인하고 업데이트를 수행한다.
- readMember(String identity): 회원 정보 조회를 처리하는 메서드이다. 입력받은 아이디로 회원을 조회한 후, 회원이 존재하는지 확인하고 MemberResponseDto.READ 객체를 생성하여 반환한다.
- getMembersByCondition(MemberRequestDto.CONDITION condition): 조건에 맞는 회원 목록을 조회하는 메서드이다. 입력받은 조건으로 회원을 검색한 후, MemberResponseDto.READ 객체의 리스트를 반환한다.
- isMember(Optional<Member> member): 회원이 존재하지 않는 경우 NotFoundException을 발생.
- isIdentity(String identity): 이미 존재하는 아이디인 경우 DuplicatedException을 발생.
- isPassword(String requestPassword, String getPassword): 입력받은 비밀번호와 저장된 비밀번호를 비교하여 일치하지 않는 경우 WrongPasswordException을 발생.
package kr.co.jshpetclinicstudy.infra.exception;
public class WrongPasswordException extends BusinessLogicException {
public WrongPasswordException(ResponseStatus responseStatus) {
super(responseStatus);
}
public WrongPasswordException(String message) {
super(message);
}
}
* 여기서 작성해준 ResponseStatus 에 있는 여러 예외 발생 코드는 추가적으로 작성을 해주었다.
MethodLoggingAspect
package kr.co.jshpetclinicstudy.infra.aop;
@Slf4j
@Aspect
@Component
public class MethodLoggingAspect {
// ... 생략
@Pointcut("execution(* kr.co.jshpetclinicstudy.service.MemberService.*(..))")
private void memberService() {
}
@Around("ownerService() || petService() || vetService() || visitService() || memberService()")
public Object logServiceTime(ProceedingJoinPoint joinPoint) throws Throwable {
// ... 생략
}
}
메소드 명과 서비스 시간, 예외 처리를 알려주는 AOP인 MethodLoggingAspect 클래스에도 MemberService에 대한 값을 넣어주었다.
MemberController
package kr.co.jshpetclinicstudy.controller;
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v1")
public class MemberController {
private final MemberService memberService;
/**
* Create Member API (회원가입)
*
* @param create
* @return
*/
@PostMapping("/register")
public ResponseFormat<Void> createMember(@RequestBody @Valid MemberRequestDto.CREATE create) {
memberService.createMember(create);
return ResponseFormat.success(ResponseStatus.SUCCESS_CREATE);
}
/**
* Login Member API (로그인)
*
* @param login
* @return
*/
@PostMapping("/login")
public ResponseFormat<MemberResponseDto.READ> loginMember(@RequestBody @Valid MemberRequestDto.LOGIN login) {
return ResponseFormat.successWithData(ResponseStatus.SUCCESS_OK, memberService.loginMember(login));
}
/**
* Get Member By Identity API
* ADMIN, USER 접근 가능
*
* @param identity
* @return
*/
@GetMapping("/members/get")
public ResponseFormat<MemberResponseDto.READ> getMember(@RequestParam String identity) {
return ResponseFormat.successWithData(ResponseStatus.SUCCESS_OK, memberService.readMember(identity));
}
/**
* Update Member API
* ADMIN, USER 접근 가능
*
* @param update
* @return
*/
@PutMapping("/members/update")
public ResponseFormat<Void> updateMember(@RequestBody @Valid MemberRequestDto.UPDATE update) {
memberService.updateMember(update);
return ResponseFormat.success(ResponseStatus.SUCCESS_NO_CONTENT);
}
/**
* Read(Get) Member API
* ADMIN 접근 가능
*
* @param condition
* @return
*/
@PostMapping("/admin/search")
public ResponseFormat<List<MemberResponseDto.READ>> getMembersByCondition(@RequestBody @Valid MemberRequestDto.CONDITION condition) {
return ResponseFormat.successWithData(ResponseStatus.SUCCESS_OK, memberService.getMembersByCondition(condition));
}
}
여기까지 작성해주었다면, 기본적인 코드는 다 작성해 준 것이다. 이제 해당 기능들이 제대로 동작하는지 PostMan을 통해 회원가입과 로그인, 회원 정보 조회, 수정이 제대로 동작하는 지와 권한과 인증 설정 또한 제대로 동작하는 지 확인해보겠다.
우선 JPA를 사용해주기에 자동으로 TBL_MEMBERS 가 생성이 되고, 해당 Entity에서 선언해준 속성들이 컬럼으로 생성된 것을 확인할 수 있다.
회원가입 - 성공
입력한 대로 회원 정보가 제대로 저장이 되고, password 또한 암호화 되어서 저장 되었다.
회원가입 - 실패
중복된 아이디나, @Valid한 값을 제대로 입력해주지 않았을 때 저장이 되지 않고 Exception 과 StatusCode, message가 출력이 된다.
로그인 - 성공
회원가입했던 아이디와 비밀번호로 로그인하게 되면, 로그인이 성공적으로 되고, token이 생성되어 보여진다.
로그인 - 실패
로그인 시 입력한 아이디가 존재하지 않거나, 비밀번호가 다르다면 그에 맞는 오류가 출력 되는 것을 확인할 수 있다.
조회 - 인증 실패
login 했을 때 생성되었던 token을 입력해주지 않으면 인증되지 않았다는 오류가 발생하게 된다.
조회 - 권한 실패
login 했을 때 생성 되었던 token을 입력, 미입력 시에도 권한이 부여되지 않은 URL에 접근 시 오류가 발생하게 된다.
조회 - 권한 및 인증 성공
검색 - ADMIN만 접근이 가능
해당 검색의 경우는 Member를 검색하는 것 이기에 ROLE_ADMIN 만 접근이 가능하도록 해주었다. 그렇기에 앞서 Auth 쪽에 token 값을 ADMIN이 로그인 했을 때 생성되는 token으로 넣어주지 않으면 마찬가지로 인증실패가 발생하게 된다. token 값을 채워준 후, 검색 조건을 써서 요청을 보내면 아래처럼 결과를 반환 하는 것을 확인할 수 있다.