Deep Dive Community 에서 어떻게 회원가입을 진행하면 좋을까?
우리 프로젝트는 API를 아래와 같은 형식으로 반환하기로 했다.
{
code:
message:
result: []
}
회원가입 API를 명세할 때 회원가입에 대한 응답 정보를 어떻게 반환하지? 라는 고민을 했다. 그래서 딥다이브 멘토링을 통해 어떻게 반환하면 좋을까요? 라고 질문해봤다.
굳이, 반환할 필요가 없었다. 그럼 code, message만 작성하면 되는 것이다.
하지만, 이 질문은 이미 개발한 뒤에 물어봤던거라 이후에 바꿔야겠다고 생각했고 해당 포스트에서는 리팩토링 이전의 내용을 작성한다.
@RestController
@RequestMapping("/members")
@RequiredArgsConstructor
public class MemberController implements MemberControllerDocs {
private final MemberService memberService;
@PostMapping("/sign-up")
public ResponseEntity<MemberSignUpResponse> signUp(@Valid @RequestBody final MemberSignUpRequest request) {
final MemberSignUpResponse response = memberService.signUp(request);
return ResponseEntity.ok(response);
}
}
회원 가입의 경우 /member/sign-up End Point에서 진행된다.
요청 정보에 대해 입력값 검증을 하고 회원가입을 진행한다.
@Tag(name = "사용자", description = "사용자 관련 API")
public interface MemberControllerDocs {
@Operation(summary = "회원가입", description = "회원가입을 하는 기능")
@ApiResponse(
responseCode = "1000",
description = """
1. 사용자 회원가입에 성공하였습니다.
"""
)
@ApiResponse(
responseCode = "400",
description = """
1. 사용자 이메일 정보가 잘못되었습니다.
2. 사용자 비밀번호 정보가 잘못되었습니다.
3. 사용자 닉네임 정보가 잘못되었습니다.
4. 사용자 전화번호 정보가 잘못되었습니다.
""",
content = @Content(schema = @Schema(implementation = ExceptionResponse.class))
)
ResponseEntity<MemberSignUpResponse> signUp(MemberSignUpRequest request);
}
Docs interface는 Swagger의 단점을 최소화하기 위해 선택한 방안이다.
Swagger는 아주 편한 문서화 툴이지만, 프로덕트에 문서 코드가 삽입되는 단점이 있다. 그래서 interface를 통해 작성한다.
기본적으로 문서화에 대해서는 Swagger Reflection으로 응답되는 Class의 필드 정보를 보고 알아서 만들어준다.
하지만, reponseCode가 400일 경우를 보자. 예외가 발생할 경우에는 어떤 응답이 떨어질 지 모르므로 공통 예외 클래스로 예외에 대한 예제를 구현하도록 명시한다.
@Schema(description = "사용자 회원가입 하기")
public record MemberSignUpRequest(
@Valid MemberAccountInfo memberAccountInfo,
@Valid MemberRegisterInfo memberRegisterInfo
) {
}
@Schema(description = "사용자 계정")
public record MemberAccountInfo(
@Schema(description = "사용자 이메일", example = "test@mail.com")
@NotBlank(message = "사용자 이메일 정보가 필요합니다.")
@Email(message = "이메일 형식으로 입력해주세요.")
String email,
@Schema(description = "사용자 비밀번호", example = "test1234!")
@NotBlank(message = "사용자 비밀번호 정보가 필요합니다.")
String password
) {
}
@Schema(description = "사용자 회원가입 정보")
public record MemberRegisterInfo(
@Schema(description = "사용자 닉네임", example = "구름이")
@NotBlank(message = "사용자 닉네임 정보가 필요합니다.")
String nickname,
@Schema(description = "사용자 이미지", example = "http://localhost:8080/images/profile.png")
@NotBlank(message = "사용자 이미지 정보가 필요합니다.")
String imageUrl,
@Schema(description = "사용자 전화번호", example = "010-0000-0000")
@NotBlank(message = "사용자 전화번호 정보가 필요합니다.")
String phoneNumber
) {
}
Request 정보에 필요한 내용을 Object로 구현했는데, 이 부분도 현재는 수정되었다. 그 이유는 리팩토링에 대한 포스트에서 작성하겠다. 이런 record또한 docs 클래스로 분리할 수 있다. 하지만, 많은 내용이 없어서 굳이 분리하지는 않았다.
나중에 분리할 수도 있겠지만 일단은 안했다.
그 외, 검증에 대한 정보를 작성했다.
@Schema(description = "사용자 회원가입 응답")
public record MemberSignUpResponse(
@Schema(description = "응답 코드", example = "1000")
Integer code,
@Schema(description = "응답 코드", example = "사용자 회원가입에 성공하였습니다.")
String message,
MemberSignUpResult result
) {
public static MemberSignUpResponse of(final ResultType resultType, final Account account) {
final MemberSignUpResult result = MemberSignUpResult.from(account);
return new MemberSignUpResponse(resultType.getCode(), resultType.getMessage(), result);
}
}
@Schema(description = "사용자 회원가입 결과")
public record MemberSignUpResult(
@Schema(description = "회원가입 순서", example = "1")
Long id,
@Schema(description = "회원 닉네임", example = "구름이")
String nickname,
@Schema(description = "회원가입일", example = "1900-01-01 00:00:00", type = "string")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
LocalDateTime createdAt
) {
public static MemberSignUpResult from(final Account account) {
return new MemberSignUpResult(account.getId(), account.getMember().getNickname(), account.getCreatedAt());
}
}
@Getter
@AllArgsConstructor
public enum MemberResultType implements ResultType {
MEMBER_SIGN_UP_SUCCESS(1000, "사용자 회원가입에 성공하였습니다.");
private final Integer code;
private final String message;
}
@Getter
@AllArgsConstructor
public enum MemberExceptionType implements ExceptionType {
INVALID_PASSWORD_FORMAT(2000, "사용자 비밀번호는 8글자부터 16글자로 영어 소문자, 특수문자, 숫자를 조합해주세요."),
INVALID_NICKNAME_LENGTH(2001, "사용자 닉네임은 2글자부터 최대 20자입니다."),
INVALID_NICKNAME_FORMAT(2002, "사용자 닉네임은 영어 대소문자와 한글 및 숫자의 조합으로 작성해주세요."),
ALREADY_REGISTERED_EMAIL(2003, "이미 가입된 사용자 이메일입니다."),
ALREADY_REGISTERED_NICKNAME(2004, "이미 가입된 사용자 닉네임입니다."),
INVALID_PHONE_NUMBER_FORMAT(2005, "전화번호 형식을 맞춰주세요. ex) 010-0000-0000");
private final int code;
private final String message;
}
위에서 명시된대로 응답을 던져준다.



실제 Swagger에서도 제대로 적용된 것이 보인다.
@Embeddable
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PROTECTED)
public class ActivityStats {
@Column(nullable = false)
private Integer postCount;
@Column(nullable = false)
private Integer commentCount;
public static ActivityStats createDefault() {
return new ActivityStats(0, 0);
}
}
@Embeddable
@Getter
@AllArgsConstructor(access = AccessLevel.PROTECTED)
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@EqualsAndHashCode(of = "value")
public class Nickname {
private static final Pattern PATTERN = Pattern.compile("^[a-zA-Z가-힣][a-zA-Z0-9가-힣]*$");
private static final int MIN_LENGTH = 2;
private static final int MAX_LENGTH = 20;
@Column(name = "nickname", nullable = false, length = 20)
private String value;
private static void validate(final String nickname) {
validateNicknameLength(nickname);
validateNickNameFormat(nickname);
}
private static void validateNicknameLength(final String nickname) {
if (nickname.length() < MIN_LENGTH || nickname.length() > MAX_LENGTH) {
throw new BadRequestException(MemberExceptionType.INVALID_NICKNAME_LENGTH);
}
}
private static void validateNickNameFormat(final String nickname) {
if (!PATTERN.matcher(nickname).matches()) {
throw new BadRequestException(MemberExceptionType.INVALID_NICKNAME_FORMAT);
}
}
public static Nickname from(final String nickname) {
final String nicknameAfterTrimmed = nickname.trim();
validate(nicknameAfterTrimmed);
return new Nickname(nicknameAfterTrimmed);
}
@Override
public boolean equals(final Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}
final Nickname nickname = (Nickname) o;
return value.toLowerCase(Locale.ROOT).equals(nickname.value.toLowerCase(Locale.ROOT));
}
@Override
public int hashCode() {
return value.toLowerCase(Locale.ROOT).hashCode();
}
}
@Embeddable
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
public class Password {
private static final Pattern PATTERN =
Pattern.compile("^(?=.*[A-Za-z])(?=.*\\d)(?=.*[@$!%*#?&])[A-Za-z\\d@$!%*#?&]{8,16}$");
@Column(name = "password", nullable = false, length = 100)
@Getter
private String value;
public static Password of(final Encryptor encryptor, final String password) {
final String passwordAfterTrimmed = password.trim();
if (!PATTERN.matcher(passwordAfterTrimmed).matches()) {
throw new BadRequestException(MemberExceptionType.INVALID_PASSWORD_FORMAT);
}
return new Password(encryptor.encrypt(passwordAfterTrimmed));
}
}
@Embeddable
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Setter(AccessLevel.PROTECTED)
public class Contact {
private static final Pattern PATTERN = Pattern.compile("^01(?:0|1|[6-9])-(?!0000)\\d{4}-(?!0000)\\d{4}$");
@Column(nullable = false, length = 20)
private String phoneNumber;
@Column(nullable = false, length = 100)
private String githubAddr;
@Column(nullable = false, length = 200)
private String blogAddr;
@Builder
public Contact(final String phoneNumber) {
this.phoneNumber = phoneNumber;
this.githubAddr = StringUtils.EMPTY;
this.blogAddr = StringUtils.EMPTY;
}
private static void validatePhoneNumber(final String phoneNumber) {
if (!PATTERN.matcher(phoneNumber).matches()) {
throw new BadRequestException(MemberExceptionType.INVALID_PHONE_NUMBER_FORMAT);
}
}
public static Contact from(final String phoneNumber) {
final String phoneNumberAfterTrimmed = phoneNumber.trim();
validatePhoneNumber(phoneNumberAfterTrimmed);
return new Contact(phoneNumberAfterTrimmed);
}
}
Model은 초기 설계와 달리 리팩토링 되었다.
우선, 활동 통계의 경우 이후에 추가될 부분이 더 있을 것 같았다.
예를 들어 팔로우 수, 팔로잉 수, ... 그래서 하나의 관심사로 묶었다.
물론, 좋은 방법인지는 모르겠다... 누가좀 알려줬으면 ㅠㅠㅠ
Nickname의 경우, 하나의 도메인으로 볼 수 있다고 생각했다.
왜냐하면 닉네임 형식이나 중복 검사 등에서 닉네임만이 따로 할 수 있는 책임이 있기 때문이다. 그래서 형식 검증이나 Aa와 aa를 동일한 닉네임으로 구분하기 위한 방식같은 것을 추가했다.
Password, 전화번호도 마찬가지다. 형식검증은 물론, 특히 비밀번호의 경우 누군가 백도어로 DB에 불법적이게 침입했을 때 비밀번호 정보가 노출된다면 큰 일이다. 그래서 Bcrypt 방식으로 인코딩을 진행했다. Bcrypt 암호화는 랜덤한 문자열로 비밀번호에 단방향 해시를 적용하는 것인데 자세한 정보는 해당 블로그에서 확인하면 좋을 것 같다. SpringSecurity에서 Bcrypt가 지원되지만 아직, Security를 전혀 모르기 때문에 그냥 Bcrypt library를 Maven Repository에서 확인하고 gradle에 추가했다.
dependencies {
implementation 'org.mindrot:jbcrypt:0.4'
}
public interface Encryptor {
String encrypt(String plainText);
Boolean matches(String plainText, String encodedText);
}
public class IpEncryptor implements Encryptor {
private static final String SALT = "abcdefghijklmnopqrstuvwxyz";
public String encrypt(String ip) {
try {
final MessageDigest digest = MessageDigest.getInstance("SHA-256");
final String saltedIp = ip + SALT;
byte[] encodedHash = digest.digest(saltedIp.getBytes(StandardCharsets.UTF_8));
return Base64.getEncoder().encodeToString(encodedHash);
} catch (final NoSuchAlgorithmException e) {
throw new NotFoundException(EncryptorExceptionType.NOT_FOUND_SHA_256_ALGORITHM);
}
}
public Boolean matches(String rawIp, String encryptedIp) {
return encrypt(rawIp).equals(encryptedIp);
}
}
public class PasswordEncryptor implements Encryptor {
public String encrypt(final String plainText) {
return BCrypt.hashpw(plainText, BCrypt.gensalt());
}
public Boolean matches(final String plainText, final String encodedText) {
return BCrypt.checkpw(plainText, encodedText);
}
}
@Getter
@AllArgsConstructor
public enum EncryptorExceptionType implements ExceptionType {
NOT_FOUND_SHA_256_ALGORITHM(500, "SHA-256 알고리즘을 찾을 수 없습니다.");
private final int code;
private final String message;
}
@Qualifier
@Target({ElementType.METHOD, ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
public @interface EncryptorBean {
EncryptorTypes value() default EncryptorTypes.PASSWORD;
}
public enum EncryptorTypes {
PASSWORD, IP
}
@Configuration
public class EncryptorConfig {
@Bean
@Primary
@EncryptorBean(EncryptorTypes.PASSWORD)
public Encryptor passwordEncryptor() {
return new PasswordEncryptor();
}
@Bean
@EncryptorBean(EncryptorTypes.IP)
public Encryptor ipEncryptor() {
return new IpEncryptor();
}
}
당장에는 Pw 암호화만 사용되고 있지만, 게시글 방문자에 대한 IP 암호화도 필요하다고 생각해서 같이 구현했다.
이 작업에서도 고민이 있었는데 Interface를 사용할 지... 아니면 Component로 만들어서 각 각 사용할 지 너무 고민됐다. 일단 스프링 부트를 잘쓰는 것보다 객체지향 설계를 어느정도 이해하고 스프링 Core 기술을 사용해서 적용해 볼 수 있다!라는 것을 조금 어필하고 싶은 보상심리(?), 인정욕구(?)가 있었던 작업같다...
이 부분도 조금 더 공부해보고 어떤 방법이 좋을지 선택해야겠다.
@Service
@RequiredArgsConstructor
@Transactional
public class MemberService {
@EncryptorBean
private final Encryptor encryptor;
private final MemberRepository memberRepository;
private final AccountRepository accountRepository;
public MemberSignUpResponse signUp(final MemberSignUpRequest request) {
signUpValidate(request);
final Member member = Member.registerMember(request.memberRegisterInfo());
final Account account = Account.accountSignUp(request.memberAccountInfo(), encryptor, member);
return MemberSignUpResponse.of(MemberResultType.MEMBER_SIGN_UP_SUCCESS, accountRepository.save(account));
}
private void signUpValidate(final MemberSignUpRequest request) {
validateUniqueEmail(request.memberAccountInfo().email());
validateUniqueNickname(request.memberRegisterInfo().nickname());
}
private void validateUniqueEmail(final String email) {
final Boolean isDuplicateEmail = accountRepository.existsAccountByEmail(email);
if (isDuplicateEmail) {
throw new BadRequestException(MemberExceptionType.ALREADY_REGISTERED_EMAIL);
}
}
private void validateUniqueNickname(final String nickname) {
final Boolean isDuplicateNickname = memberRepository.existsMemberByNicknameValue(nickname);
if (isDuplicateNickname) {
throw new BadRequestException(MemberExceptionType.ALREADY_REGISTERED_NICKNAME);
}
}
}
비즈니스 로직이다. JPA Method를 이용해 계정에 대한 검증을 진행하고 회원가입을 실시한다. 기본적으로 하나의 비즈니스 기능이 한 Transaction이므로 Transactional 어노테이션은 필수라고 생각한다.
@WebMvcTest(controllers = MemberController.class)
@Import(EncryptorConfig.class)
class MemberControllerTest {
@MockBean
private MemberService memberService;
@MockBean
private Encryptor encryptor;
@BeforeEach
void setUp() {
RestAssuredMockMvc.standaloneSetup(
MockMvcBuilders
.standaloneSetup(new MemberController(memberService))
.setControllerAdvice(GlobalExceptionHandler.class)
);
}
@Test
@DisplayName("회원가입 요청이 성공적으로 처리되면 200 OK와 함께 응답을 반환한다")
void signUpSuccessfullyReturns200OK() {
// given
MemberAccountInfo accountInfo = new MemberAccountInfo("test@email.com", "test1234!");
MemberRegisterInfo registerInfo = new MemberRegisterInfo("테스트", "테스트", "010-1234-5678");
MemberSignUpRequest request = new MemberSignUpRequest(accountInfo, registerInfo);
Account account = Account.accountSignUp(accountInfo, this.encryptor, Member.registerMember(registerInfo));
MemberSignUpResponse mockResponse = MemberSignUpResponse.of(MemberResultType.MEMBER_SIGN_UP_SUCCESS, account);
given(memberService.signUp(any(MemberSignUpRequest.class))).willReturn(mockResponse);
// when
MemberSignUpResponse response = RestAssuredMockMvc
.given().log().all()
.contentType(MediaType.APPLICATION_JSON)
.body(request)
.when().post("/members/sign-up")
.then().log().all()
.status(HttpStatus.OK)
.extract()
.as(MemberSignUpResponse.class);
// then
assertThat(response).isNotNull();
assertThat(response).usingRecursiveComparison().isEqualTo(mockResponse);
}
MemberSignUpRequest getMockRequest(String p1, String p2, String p3, String p4, String p5) {
MemberAccountInfo accountInfo = new MemberAccountInfo(p1, p2);
MemberRegisterInfo registerInfo = new MemberRegisterInfo(p3, p4, p5);
return new MemberSignUpRequest(accountInfo, registerInfo);
}
@Test
@DisplayName("회원가입 요청 시 이메일 형식이 올바르지 않다면 400 BadRequest 를 반환한다.")
void signUpWrongEmailFormatReturns400BadRequest() {
// given
MemberSignUpRequest request = getMockRequest("test@ma .com", "test1234!", "테스트", "테스트", "010-1234-5678");
// when, then
RestAssuredMockMvc
.given().log().all()
.contentType(MediaType.APPLICATION_JSON)
.body(request)
.when().post("/members/sign-up")
.then().log().all()
.status(HttpStatus.BAD_REQUEST)
.body("code", equalTo(101))
.body("message", containsString("이메일 형식으로 입력해주세요."));
}
@Test
@DisplayName("회원가입 요청 시 이메일 정보가 존재하지 않는다면 400 BadRequest 를 반환한다.")
void signUpNullEmailReturns400BadRequest() {
// given
MemberSignUpRequest request = getMockRequest(null, "test1234!", "테스트", "테스트", "010-1234-5678");
// when, then
RestAssuredMockMvc
.given().log().all()
.contentType(MediaType.APPLICATION_JSON)
.body(request)
.when().post("/members/sign-up")
.then().log().all()
.status(HttpStatus.BAD_REQUEST)
.body("code", equalTo(101))
.body("message", containsString("사용자 이메일 정보가 필요합니다."));
}
@Test
@DisplayName("회원가입 요청 시 닉네임 정보가 존재하지 않는다면 400 BadRequest 를 반환한다.")
void signUpNullNicknameReturns400BadRequest() {
// given
MemberSignUpRequest request = getMockRequest("test@email.com", "test1234!", null, "테스트", "010-1234-5678");
// when, then
RestAssuredMockMvc
.given().log().all()
.contentType(MediaType.APPLICATION_JSON)
.body(request)
.when().post("/members/sign-up")
.then().log().all()
.status(HttpStatus.BAD_REQUEST)
.body("code", equalTo(101))
.body("message", containsString("사용자 닉네임 정보가 필요합니다."));
}
@Test
@DisplayName("회원가입 요청 시 이미지 주소 정보가 존재하지 않는다면 400 BadRequest 를 반환한다.")
void signUpNullImageUrlReturns400BadRequest() {
// given
MemberSignUpRequest request = getMockRequest("test@email.com", "test1234!", "테스트", null, "010-1234-5678");
// when, then
RestAssuredMockMvc
.given().log().all()
.contentType(MediaType.APPLICATION_JSON)
.body(request)
.when().post("/members/sign-up")
.then().log().all()
.status(HttpStatus.BAD_REQUEST)
.body("code", equalTo(101))
.body("message", containsString("사용자 이미지 정보가 필요합니다."));
}
@Test
@DisplayName("회원가입 요청 시 전화번호 정보가 존재하지 않는다면 400 BadRequest 를 반환한다.")
void signUpNullPhoneNumberReturns400BadRequest() {
// given
MemberSignUpRequest request = getMockRequest("test@email.com", "test1234!", "테스트", "테스트", null);
// when, then
RestAssuredMockMvc
.given().log().all()
.contentType(MediaType.APPLICATION_JSON)
.body(request)
.when().post("/members/sign-up")
.then().log().all()
.status(HttpStatus.BAD_REQUEST)
.body("code", equalTo(101))
.body("message", containsString("사용자 전화번호 정보가 필요합니다."));
}
}
Test는 Given, When, Then 기법을 사용한다.
testImplementation 'io.rest-assured:spring-mock-mvc'
rest-assured를 사용하는 Spring mock-mvc, mockito 기술로 테스트를 진행한다. 단위 테스트 속도를 급격히 올려주는 mockito는 필수이다.
RestAssuredMockMvc는 Spring Boot 내에서 API에 요청할 수 있는 Mock 통신(?)이라고 생각하면 편한 것 같다. 독립형으로 필요한 정보를 (해당 테스트에서는 Member Controller) 생성해서 테스트한다.
@ExtendWith(MockitoExtension.class)
class AccountTest {
@Mock
private Encryptor encryptor;
@Test
@DisplayName("유효한 사용자 계정 생성을 확인한다.")
void accountSignUpShouldCreateValidAccount() {
// given
String nickname = "닉네임";
String imageUrl = "img";
String phoneNumber = "010-1234-5678";
String email = "test@mail.com";
String password = "password1!";
String encryptedPassword = "encryptedPasswordValue";
Member member = new Member(nickname, imageUrl, phoneNumber);
MemberAccountInfo accountInfo = new MemberAccountInfo(email, password);
when(encryptor.encrypt(password)).thenReturn(encryptedPassword);
// when
Account account = Account.accountSignUp(accountInfo, encryptor, member);
// then
assertThat(account).isNotNull();
assertThat(account.getEmail()).isEqualTo(email);
assertThat(account.getPassword().getValue()).isEqualTo(encryptedPassword);
assertThat(account.getMember()).usingRecursiveComparison().isEqualTo(member);
}
@ParameterizedTest
@CsvSource({
"닉네임, img, 010-1234-5678, email, 123!",
"닉네임, img, 010-1234-5678, email, test!",
"닉네임, img, 010-1234-5678, email, test1!",
"닉네임, img, 010-1234-5678, email, test12345 !",
})
@DisplayName("유효하지 않은 사용자 계정일 경우 예외를 확인한다.")
void accountSignUpShouldInValidAccountException(
String nickname, String imageUrl, String phoneNumber, String email, String password
) {
// given
Member member = new Member(nickname, imageUrl, phoneNumber);
MemberAccountInfo accountInfo = new MemberAccountInfo(email, password);
// when, then
assertThatThrownBy(() -> Account.accountSignUp(accountInfo, encryptor, member))
.isInstanceOf(BadRequestException.class);
}
}
@Embeddable
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Setter(AccessLevel.PROTECTED)
public class Contact {
private static final Pattern PATTERN = Pattern.compile("^01(?:0|1|[6-9])-(?!0000)\\d{4}-(?!0000)\\d{4}$");
@Column(nullable = false, length = 20)
private String phoneNumber;
@Column(nullable = false, length = 100)
private String githubAddr;
@Column(nullable = false, length = 200)
private String blogAddr;
@Builder
public Contact(final String phoneNumber) {
this.phoneNumber = phoneNumber;
this.githubAddr = StringUtils.EMPTY;
this.blogAddr = StringUtils.EMPTY;
}
private static void validatePhoneNumber(final String phoneNumber) {
if (!PATTERN.matcher(phoneNumber).matches()) {
throw new BadRequestException(MemberExceptionType.INVALID_PHONE_NUMBER_FORMAT);
}
}
public static Contact from(final String phoneNumber) {
final String phoneNumberAfterTrimmed = phoneNumber.trim();
validatePhoneNumber(phoneNumberAfterTrimmed);
return new Contact(phoneNumberAfterTrimmed);
}
}
,,, 너무 많아서 모두 다 작성하기엔 벅차다.
https://github.com/groom-study-team1-project/team-project-back
깃허브에서 직접 확인하는게 더 좋을 것 같다.
@SpringBootTest
@Transactional
class MemberServiceTest {
@Autowired
private MemberService memberService;
@Autowired
private AccountRepository accountRepository;
@Autowired
private MemberRepository memberRepository;
@Test
@DisplayName("회원 가입이 성공했을 경우를 통합 테스트한다.")
void signUpSuccessTest() {
// Given, test.sql
MemberAccountInfo accountInfo = new MemberAccountInfo("test@mail.com", "password1234!");
MemberRegisterInfo registerInfo = new MemberRegisterInfo("test", "test", "010-1234-5678");
MemberSignUpRequest request = new MemberSignUpRequest(accountInfo, registerInfo);
long lastAccountId = 10L;
LocalDateTime testStartTime = LocalDateTime.now();
// When
MemberSignUpResponse response = memberService.signUp(request);
// Then
LocalDateTime testEndTime = LocalDateTime.now();
ResultType resultType = MemberResultType.MEMBER_SIGN_UP_SUCCESS;
MemberSignUpResult responseResult = response.result();
assertThat(response).isNotNull();
assertThat(response.code()).isEqualTo(resultType.getCode());
assertThat(response.message()).isEqualTo(resultType.getMessage());
assertThat(responseResult.id()).isEqualTo(lastAccountId + 1L);
assertThat(responseResult.nickname()).isEqualTo(registerInfo.nickname());
assertThat(responseResult.createdAt()).isBetween(testStartTime, testEndTime);
}
@Test
@DisplayName("중복 이메일로 회원 가입 시 예외 발생하는 경우를 테스트한다.")
void signUpDuplicateEmailTest() {
// Given test.sql
MemberAccountInfo accountInfo = new MemberAccountInfo("email1@test.com", "password1!");
MemberRegisterInfo registerInfo = new MemberRegisterInfo("test", "test", "010-1234-5678");
MemberSignUpRequest request = new MemberSignUpRequest(accountInfo, registerInfo);
// When & Then
assertThatThrownBy(() -> memberService.signUp(request))
.isInstanceOf(BadRequestException.class)
.hasFieldOrPropertyWithValue("exceptionType", MemberExceptionType.ALREADY_REGISTERED_EMAIL);
}
@Test
@DisplayName("중복 닉네임으로 회원 가입 시 예외 발생 테스트")
void signUpDuplicateNicknameTest() {
// Given
MemberAccountInfo accountInfo = new MemberAccountInfo("test@mail.com", "password123!");
MemberRegisterInfo registerInfo = new MemberRegisterInfo("User9", "test", "010-1234-5678");
MemberSignUpRequest request = new MemberSignUpRequest(accountInfo, registerInfo);
// When & Then
assertThatThrownBy(() -> memberService.signUp(request))
.isInstanceOf(BadRequestException.class)
.hasFieldOrPropertyWithValue("exceptionType", MemberExceptionType.ALREADY_REGISTERED_NICKNAME);
}
}
서비스의 경우, 통합 테스트를 진행했다.
Why?
- Persist Layer
- JPA는 검증된 기술이므로 따로 테스트를 진행하지 않아도 된다.
- Presentation Layer
- Controller에서 예상하는 Request, Response, exception에 대한 단위테스트가 완료되었다.
- Business Layer
- Model에 대한 단위 테스트가 완료되었다.
- 각 계층별로 단위테스트가 되었으니, 서비스에서 통합테스트로 최종 테스트를 진행한다면 안정적으로 테스트를 할 수 있을 것이라고 생각했다.
그래서 서비스의 경우, SpringBoot 어노테이션으로 실제 Bean들을 주입받아서 통합 테스트를 진행한다.
@SpringJUnitConfig
@ContextConfiguration(classes = {EncryptorConfig.class})
class EncryptorTest {
private static final String IP = "192.168.0.1";
private static final String DIFFERENT_IP = "192.168.0.2";
private static final String PASSWORD = "testPassword";
@Autowired
@EncryptorBean
private Encryptor passwordEncryptor;
@Autowired
@EncryptorBean(EncryptorTypes.IP)
private Encryptor ipEncryptor;
@Test
@DisplayName("PASSWORD의 BCRYPT 알고리즘 암호화된 결과를 반환하는지 확인한다.")
void passwordEncryptShouldReturnEncryptedString() {
String encryptedPassword = passwordEncryptor.encrypt(PASSWORD);
assertThat(encryptedPassword).isNotNull();
assertThat(encryptedPassword).isNotEqualTo(PASSWORD);
}
@Test
@DisplayName("BCRYPT 알고리즘이 같은 Password에 다른 암호화 결과를 반환하는지 확인한다.")
void encryptShouldReturnDifferentValuesForSameInput() {
String firstEncryption = passwordEncryptor.encrypt(PASSWORD);
String secondEncryption = passwordEncryptor.encrypt(PASSWORD);
assertThat(firstEncryption).isNotEqualTo(secondEncryption);
}
@Test
@DisplayName("암호화된 PASSWORD와 평문 PASSWORD가 동일할 경우 True를 반환하는지 확인한다.")
void matchesShouldReturnTrueForMatchingPasswordAndEncryptedPassword() {
String encryptedPassword = passwordEncryptor.encrypt(PASSWORD);
assertThat(passwordEncryptor.matches(PASSWORD, encryptedPassword)).isTrue();
}
@Test
@DisplayName("암호화된 PASSWORD와 평문 PASSWORD가 동일하지 않을 경우 False를 반환하는지 확인한다.")
void matchesShouldReturnFalseForNonMatchingPasswordAndEncryptedPassword() {
String password2 = "differentPassword";
String encryptedPassword1 = passwordEncryptor.encrypt(PASSWORD);
assertThat(passwordEncryptor.matches(password2, encryptedPassword1)).isFalse();
}
@Test
@DisplayName("IP의 SHA-256 알고리즘 암호화를 확인한다.")
void ipEncryptShouldReturnEncryptedString() {
String encryptedIp = ipEncryptor.encrypt(IP);
assertThat(encryptedIp).isNotNull();
assertThat(encryptedIp).isNotEqualTo(IP);
}
@Test
@DisplayName("동일한 IP를 SHA-256 알고리즘 암호화 시 동일한 암호화가 되는지 확인한다.")
void encryptShouldReturnSameValueForSameInput() {
String firstEncryption = ipEncryptor.encrypt(IP);
String secondEncryption = ipEncryptor.encrypt(IP);
assertThat(firstEncryption).isEqualTo(secondEncryption);
}
@Test
@DisplayName("평문 IP를 암호화 후 같은 평문 IP와 동일성을 확인한다.")
void matchesShouldReturnTrueForMatchingIpAndEncryptedIp() {
String encryptedIp = ipEncryptor.encrypt(IP);
assertThat(ipEncryptor.matches(IP, encryptedIp)).isTrue();
}
@Test
@DisplayName("평문 IP를 암호화 후 다른 평문 IP와 동일성을 확인한다.")
void matches_shouldReturnFalseForNonMatchingIpAndEncryptedIp() {
String encryptedIp = ipEncryptor.encrypt(IP);
assertThat(ipEncryptor.matches(DIFFERENT_IP, encryptedIp)).isFalse();
}
}
Encpryptor Config 에서 내가 작성한 코드이므로 테스트는 필수이다.
각 각에 대한 회고는 이미 다 작성한 것 같다.
통합적으로 돌이켜보면 자신이 생각과 걱정 그리고 고려하는 것이 너무 많은 것 같다. 우선은 개발을 진행하고 리팩토링을 해야하는데 처음 접한 언어가 C언어라 그런가..? 개발하는 과정이 심하게 절차적이다.. 하나를 못 끝내면 진행하질 않는,,,
당장에 고쳐지진 않겠지만 노력해봐야겠다.
앞으로는 코드를 얼마나 잘 작성할까? 라는 고민보다 작성하고 리팩토링하자! 라는 마인드를 셋팅하게 되는 시간이었다.
우테코 프리코스에서 전해들은 말이 떠오른다.
동작을 못하는 것보다 동작하는 쓰레기가 더 낫다!
쓰레기 많이 만들어도 재활용하면 된다!