어떤 순서로 기능을 구현해야 할까를 고민하다가, 어짜피 이후 기능할 구현에 모두 로그인 로직이 연관되어있을 것이라 생각해 로그인 기능을 먼저 구현하고자 했다. 그런데 로그인을 하려면 회원이 있어야 한다. 그렇다, 로그인 구현을 위해서는 회원 가입을 먼저 구현해야한다는 것이다. 먼저 회원 가입부터 구현해보자.
회원 가입 로직은 다음과 같다.
- MemberController#postMember(dto) 메서드를 통해 회원 가입 요청을 받는다. 이 때 urn은 "/member"로 설정하며, MemberService#postMember(dto)로 반환받은 Response DTO를 ResponseEntity객체로 감싸 반환한다.
- MemberService#postMember(dto)는 비밀번호 암호화, 권한 설정 등을 마친 뒤, MemberConverter#convertPostDtoToEntity(dto, password, authority)를 호출해 Member Entity를 반환받고, 반환 받은 MemberCommandService#createMember(member)를 호출해 반환받은 savedEntity를 다시 Response DTO로 변환해 반환한다.
- 이 때, 만일 postDto의 email이 다른 사용자와 중복된다면 예외처리한다. 해당 과정은 MemberQueryService#readUserByEmail(email)을 호출해 Member를 찾는 과정에서 MEMBER_NOT_FOUND 예외가 발생하면 catch해 그대로 진행하고, 그렇지 않고 적절한 Member가 반환되면 EMAIL_ALREADY_EXISTS 예외를 발생시키도록 한다.
- MemberConverter는 DTO -> Entity, Entity -> DTO의 변환을 담당한다.
- MemberCommandService#createMember(member)는 MemberRepository#save(entity) 를 호출해 entity를 저장한다.
일단 Service Layer의 테스트부터 만들어보자. 가장 먼저 2-1번의 예외 처리 로직을 테스트해보자. 모든 회원 가입 비즈니스 로직의 시작점이다.
@Test
@DisplayName("중복된 이메일로 회원 가입을 시도하면 예외가 발생한다.")
void testDuplicateEmail(){
// given
Member member = Member.builder().build();
MemberDto.Post dto = new MemberDto.Post();
dto.email = "duplicate@dup.com";
given(memberQueryService.findMemberByEmail(anyString())).willReturn(member);
//when, then
assertThrows(BusinessException.class, () -> memberService.postMember(dto));
}
상정한 로직에 맞게 테스트를 작성했다. 이를 기반으로 회원 가입 기능을 1차적으로 구현해보자.
@Override
@Transactional
public MemberDto.Response postMember(MemberDto.Post dto) {
verifyExistsEmail(dto.email);
return null;
}
private void verifyExistsEmail(String email) throws BusinessException{
try {
memberQueryService.readMemberByEmail(email);
} catch (BusinessException be) {
return;
}
throw new BusinessException(ExceptionCode.EMAIL_ALREADY_EXISTS);
}
dto의 email을 인자로 MemberQueryService#readMemberByEmail(email) 을 호출한다. memberQueryService는 해당 email로 가입한 회원이 있으면 이를 반환하고 없으면 BusinessException을 발생시킨다. (BusinessException은 별도로 구현한 Custom Exception이다. 다음 포스팅은 Custom Exception과 Advice를 활용한 예외 처리 공통화에 대한 글이 될 것이다.) 만일 BusinessException이 발생했으면, 이를 catch하고 해당 메서드를 종료시킨다. 없으면 예외 발생 -> 없어야 계속 진행의 서로 반대되는 과정을 수행하기 위해 위와 같이 짰는데, 좋은 구현 방법인지는 모르겠다. 좀 더 고민해 볼 여지가 있다.
아무튼 이 코드를 기반으로 테스트를 수행하면 잘 통과됨을 확인할 수 있다. 패스워드 암호화에 대한 테스트는 별도로 진행하지 않는다. 관련해서 학습한 내용을 링크에 남긴다.
다음으로 사용자의 권한을 설정해야 한다. 이 부분에서는 고민이 좀 많았다. 지금 생각해놓은 것은 관리자, 바버, 고객의 세 역할이 있고 각 역할마다 권한이 크게는 아니지만 조금씩 다르다. 관리자의 경우 모든 권한이 있다. 바버는 고객의 권한에 자신이 근무 중인 바버샵을 등록하거나, 수정, 삭제할 수 있는 권한이 추가된다. 고객은 나머지 기능을 이용할 수 있다.
바버와 고객의 차이는 '근무지가 있는가 없는가?' 로 나뉘게 된다. 여기서 크게 두 가지 선택지로 나뉜다.
- 바버와 고객을 다른 객체로 바라보고 접근한다. 즉, 둘은 서로 다른 Entity이다.
- 바버와 고객은 모두 Member객체다. 근무 중인 샵은 등록을 할 수도 있고 안 할 수도 있다. 이걸 보여주고 말고는 프론트엔드 단에서 설정할 일이다.
고민해 본 결과, 일단 두 번째가 더 맞다고 생각됐다. 만일 Barber - Customer로 분리해서 Member를 다룬다면 Admin도 나뉘어야 한다. 그리고 나누는 이유가 좀 빈약하다. BarberShop 테이블과의 연관관계 설정 때문에 분리가 더 맞을까 생각했는데, 어짜피 테이블 상속을 사용하면 해당 칼럼이 생기기 마련이다. 그래서 Member 테이블 하나만 사용하고 셋을 역할로 분리하기로 했다.
아무튼 정리하자면 아래와 같다.
- 관리자 : 관리자, 바버, 고객의 권한을 모두 가진다.
- 바버 : 바버, 고객의 권한을 가진다.
- 고객 : 고객의 권한만을 가진다.
위 내용을 기반으로 권한을 부여하는 테스트를 진행해보자.
@Test
void testGenerateMemberRoles(){
String adminEmail = "shworud29@gmail.com";
MemberRolesGenerator memberRolesGenerator = new MemberRolesGenerator(adminEmail);
String nonAdminEmail = "test@test.com";
String barberRole = "바버";
String customerRole = "고객";
assertThat(memberRolesGenerator.generateMemberRoles(adminEmail, null)).contains("ADMIN", "BARBER", "CUSTOMER");
assertThat(memberRolesGenerator.generateMemberRoles(nonAdminEmail, barberRole)).doesNotContain("ADMIN");
assertThat(memberRolesGenerator.generateMemberRoles(nonAdminEmail, barberRole)).contains("BARBER", "CUSTOMER");
assertThat(memberRolesGenerator.generateMemberRoles(nonAdminEmail, customerRole)).doesNotContain("ADMIN", "BARBER");
assertThat(memberRolesGenerator.generateMemberRoles(nonAdminEmail, customerRole)).contains("CUSTOMER");
}
간단하게 테스트를 작성할 수 있다. 메서드를 조금만 뜯어보면, assertThat().contains()는 인자로 넘어간 문자열들이 결과로 반환된 Collection에 포함되어 있는지를 검증한다. doesNotContain() 마찬가지로 없는지를 검증한다. 위의 테스트를 기반으로 기능을 작성해보자. List로 구현한 이유는 로그인에 Spring Security를 사용하고 Security Context에 Collection이 저장되기 때문인데, 이는 추후 포스팅 할 예정이다.
아무튼 기능을 구현해보자.
@Service
public class MemberRolesGenerator {
private final String adminMailAddress;
private static final List<String> ADMIN_ROLES_STRING = List.of("ADMIN", "BARBER", "CUSTOMER");
private static final List<String> BARBER_ROLES_STRING = List.of("BARBER", "CUSTOMER");
private static final List<String> CUSTOMER_ROLES_STRING = List.of("CUSTOMER");
public MemberRolesGenerator(@Value("${mail.address.admin}") String adminMailAddress){
this.adminMailAddress = adminMailAddress;
}
public List<String> generateMemberRoles(String email, String role) {
if (email.equals(adminMailAddress)) {
return ADMIN_ROLES_STRING;
}
if(role.equals("바버")){
return BARBER_ROLES_STRING;
}
return CUSTOMER_ROLES_STRING;
}
}
테스트를 위해서는 adminMailAddress의 값을 지정했지만, 이후 코드가 공개되면서 admin 메일이 공개되면 안된다. 그래서 해당 내용은 설정 파일 뒤로 숨긴다. 위와 같이 @Value 설정을 통해 설정 파일에 해당 메일을 지정하도록 설정했다. 이제 테스트를 수행하면 통과함을 확인할 수 있다.
나머지 변환 - 저장 - 변환 로직은 자유 게시판에서 다뤘던 내용이라 따로 포스팅 내용에 포함하지는 않겠다. 분량 조절에도 대실패한 것 같고... 최종적으로 완성된 가입 로직은 다음과 같다.
@Override
@Transactional
public MemberDto.Response postMember(MemberDto.Post dto) {
verifyExistsEmail(dto.email);
dto.password = passwordEncoder.encode(dto.password);
List<String> roles = memberRolesGenerator.generateMemberRoles(dto.email, dto.role);
Member member = memberConverter.convertPostDtoToEntity(dto, roles);
Member savedMember = memberCommandService.createMember(member);
return memberConverter.convertEntityToResponseDto(savedMember);
}
private void verifyExistsEmail(String email) throws BusinessException{
try {
memberQueryService.readMemberByEmail(email);
} catch (BusinessException be) {
return;
}
throw new BusinessException(ExceptionCode.EMAIL_ALREADY_EXISTS);
}
Controller 계층 구현은 Spring Security를 적용하며 한 번에 구현할 예정이다.
전체 코드는 링크에서 확인할 수 있다.
'Programming > TDD Project' 카테고리의 다른 글
Pull Request 022. 사용자 인증 및 권한 확인 기능 TDD로 구현하기 - Spring Security 설정 마무리 (0) | 2023.12.07 |
---|---|
Pull Request 021. 로그인 기능 TDD로 구현하기 - Spring Security / JWT 적용하기 (1) | 2023.12.06 |
Pull Request 019. 페이징 처리 시 페이지 정보도 Response에 함께 담아주기 (0) | 2023.12.01 |
Pull Request 018. 자유 게시글 검색 기능 구현 - Querydsl 사용해보기 (1) | 2023.11.30 |
Pull Request 017. 자유 게시글 여러 개 조회 기능 TDD로 구현하기 (0) | 2023.11.29 |
댓글