HwangHub

[JPA] 비밀번호 정규표현식 적용 및 비밀번호 수정 구현 본문

DEV-STUDY/Spring

[JPA] 비밀번호 정규표현식 적용 및 비밀번호 수정 구현

HwangJerry 2023. 11. 13. 17:00

시나리오

구현하려는 시나리오는 다음과 같다.

  1. 로그인 창에서 비밀번호 찾기를 누르면 이메일을 입력하는 창이 나온다.
  2. 우리 서비스에서 이메일은 unique 제약조건이 걸려있으므로, 이메일을 기준으로 멤버 엔티티를 조회한다.
  3. 이메일이 유효한지는 인증번호를 통해 검증하고, 그 이후 수정하려는 비밀번호를 입력받아 DB 데이터를 수정한다.

 

구현

위 시나리오를 구현하기 위해 다음 사항들을 개발하였다.

  • 회원가입/비밀번호 수정 요청 dto에 비밀번호 정규표현식적용하여 요청에 대한 유효성 검사 수행
  • json->java object로 dto를 역직렬화하기 위해 requestDto에 기본생성자 선언
  • 멤버 엔티티 비밀번호 수정을 위한 setter를 제한적으로 오픈
  • 영속성 컨텍스트의 더티 체킹을 활용한 엔티티 업데이트 구현

 

정규표현식 처리

정규표현식은 다음과 같다

@NotEmpty
@Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d)(?=.*[~!@#$%^&*()+|=])[A-Za-z\\d~!@#$%^&*()+|=]{8,16}$",
        message = "비밀번호는 영문/숫자/특수문자 최소 1개씩 포함, 8~16자로 설정해주세요. (특수문자 일부 제외)")
private String newPassword;

 

위 정규표현식은 아래와 같이 의미를 쪼개어 생각할 수 있다.

  • (?=.*[A-Za-z]): 적어도 하나의 알파벳 문자가 포함되어야 한ㄷ다.
  • (?=.*\d): 적어도 하나의 숫자가 포함되어야 합니다.
  • (?=.*[~!@#$%^&*()+|=]): 적어도 하나의 특수 문자 (물결표(~), 느낌표(!), @, #, $, %, ^, &, *, (, ), +, | 등)가 포함되어야 합니다.
  • [A-Za-z\d~!@#$%^&*()+|=]{8,16}: 8에서 16자 사이의 길이를 가져야 하며, 이는 알파벳, 숫자, 특수 문자 중 하나 이상으로 이루어져야 합니다.
  • ^: 문자열의 시작을 나타냅니다. 따라서 ^ 뒤에 오는 정규표현식은 문자열의 시작 부분에서 일치해야 합니다.
  • $: 문자열의 끝을 나타냅니다. 따라서 $ 앞에 오는 정규표현식은 문자열의 끝 부분에서 일치해야 합니다.

 

이를 통해 유효한 패스워드인지 검사하고, 올바른 비밀번호 포맷을 따를 경우에 로직이 이어질 수 있도록 하였다. 만약 유효성 검사에서 예외가 터질 경우, ExceptionHandler에서 캐치해서 response를 뿌려준다.

 

 

dto에 기본생성자 처리

스프링부트의 Jackson 라이브러리는 ObjectMapper를 이용하여 json과 java object 간 serialize / deserialize를 지원한다. 이 중 json을 java object로 deserialize 하는 과정에서 dto의 기본 생성자를 이용하여 비어있는 dto 객체를 생성한 뒤 값들을 넣어 처리한다. 따라서 requestDto에 NoArgsConstructor를 추가해주었다. 이 때, 기본 생성자는 개발자가 딱히 쓸 일이 없는 생성자일테니 접근제어를 걸어주는 것이 좋다.

많은 블로그에서 역직렬화를 이유로 기본생성자를 붙여주는 과정에서 접근제어를 PROTECTED로 걸고 있었다. 이는 아마 JPA를 이용하여 Entity를 선언할 때 Proxy 객체 사용과 Java Reflection API 사용으로 인해 기본 생성자를 선언하는데, Proxy 객체 생성 과정에서 상속을 이용하여 생성하기 때문에 PROTECTED까지만 닫아줬던 것을 관성적으로 사용한 게 아닐까 싶다. 여기서는 그 매커니즘이 아니기 때문에 PRIVATE으로 확실히 제어해주는 것이 의도와 맞다.
@Data
@AllArgsConstructor
@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class MemberPwChangeRequestDto {
	...
}

 

참고 :

@NoArgsConstructor, @Getter 언제, 왜 사용할까?

왜 JPA의 Entity는 기본생성자를 가져야 하는가?

 

 

 

제한적으로 Setter 오픈

처음 배우기로, setter의 무분별한 사용을 막으라 하였다. 이러한 정책에 동의하는 것이, 단순히 생각하더라도 여러 개발자가 같이 개발을 하다보면 각자가 만든 메서드의 목적이 무엇인지 원활히 파악하기 어려운 적이 많았다. 따라서 자유롭게 setter가 열려있다고 한다면 이를 쉽게 가져다 쓸 수 있고, 이로 인해서 DB 데이터가 언제든 쉽게 변경될 수 있다면 나중에 데이터 변경으로 인한 문제가 발생하였을 때 원인 포인트를 찾아내기 어려워진다. 그렇다고 항상 주석을 모든 코드에 달아서 설명을 하기도 깔끔하지 않다.

 

비밀번호 수정을 구현하기 위해서 setter를 사용해야 할 것 같았는데, 위와 같은 생각 때문에 고민이 많았다. 가장 좋은 코드는 주석 없이도 그 의도가 분명히 이해되는 코드라 하였기에, setter를 열 때에 아래 두 가지를 중점으로 구현하였다.

  • 이 setter는 어떤 목적으로 선언된 setter인지 분명하게 이해할 수 있어야 했으며,
  • 선언된 setter를 의도와 다른 목적으로 자유롭게 끌어다 쓰지 않길 바랐다.

이를 기준으로 아래와 같이 선언해주었다.

// Member entity 클래스 내의 메서드
public Member setPassword(MemberPwChangeRequestDto pwChangeRequestDto) {
    this.password = pwChangeRequestDto.getNewPassword();
    return this;
}

 

위와 같이 파라미터를 선언해 줌으로써

  • 이 setter는 비밀번호 변경 API에서만 사용하고자 함을 이해할 수 있도록 하였고,
  • 의도적으로 String newPassword와 같이 변경하는 문자열을 그대로 파라미터로 받는 것이 아니라 해당 API에서 사용하는 dto를 파라미터로 받아서 의도와 다른 목적으로 무분별하게 setter가 사용되는 것을 막고자 하였다.

단순히 setter 하나를 짜더라도 고민해서 짜보는 경험을 하게 된 것인데, 나로써는 상당히 인상깊었다.

 

 

영속성 컨텍스트의 더티 체킹을 활용한 엔티티 업데이트

JPA를 현재 사용하고 있으므로 영속성 컨텍스트를 활용하여 데이터를 업데이트하는 로직으로 구성하였다. 이 때, 쉽게 떠오른 방법이 두 가지였다.

  • save()를 활용하여 DB 업데이트
  • 더티 체킹을 활용한 DB 업데이트

두 방식이 체감상 비슷해 보여도, 그 내부 원리를 고려했을 때에는 절대 비슷하지 않을 것이다.

 

save() 방식은 아래 매커니즘으로 로직을 수행한다.

  • (persist) 파라미터로 받은 엔티티 객체에 pk 값이 없으면 insert
  • 파라미터로 받은 엔티티 객체에 pk 값이 있으면,
    • (persist) 그 pk값이 DB에 존재하지 않으면 insert
    • (merge) 그 pk값이 DB에 존재하면 update

 

즉, save라는 메서드를 수행하기 위해 서버는 커넥션을 물고 select 문을 활용하여 id 값을 탐색한 뒤, 데이터를 flush하여 반영하고 이를 영속성 컨텍스트로 들고오게 되어 있다. 업데이트를 위한 로직이라고 하기엔 딱 봐도 오버헤드가 있을 것임이 느껴진다. 이 뿐만 아니라,  save 메서드는 update 과정에서 merge() 메서드를 활용하기 때문에 모든 속성이 업데이트 되도록 구성되며, 만약 일부 칼럼의 데이터가 누락되어 null인 상태로 merge되면 DB에 의도치 않게 null로 업데이트가 반영되게 된다. 이렇게 null이 되어버리는 것은 매우 치명적인 일이므로, 그 내부 원리를 잘 이해한 채 적용해야 한다.

 

반면 더티 체킹은 트랜잭션 내에서 1차 캐시인 영속성 컨텍스트에 있는 스냅샷과 비교하여 변경된 엔티티의 칼럼 데이터를 확인하고 이를 insert 또는 update 해주는 매커니즘으로 구성되어 있다. 즉, save()에 비해 데이터가 업데이트되는 과정이 조금 더 가벼운 것이다. 뿐만 아니라 save()에서는 merge() 메서드 사용으로 인해 모든 칼럼 데이터를 전부 업데이트해야 하지만, 더티 체킹은 변경된 부분만 추적하여 업데이트 쿼리를 날린다.

 

따라서 나는 영속성 컨텍스트를 활용한 더티 체킹 방식을 활용하여 update를 구현하였다.

    @Override
    @Transactional
    public MemberResponseDto.CommonResponseDto changeMemberPassword(MemberPwChangeRequestDto pwChangeRequest, String email) {
        log.info("INFO : 회원 패스워드 변경 진행");
        Member findMember = memberRepository.findByKauEmail(email).orElseThrow(() -> new CustomNotFoundException("가입 정보가 없습니다."));
        Member changeMember = findMember.setPassword(pwChangeRequest.encode(passwordEncoder));
//        memberRepository.flush();
        return new MemberResponseDto.CommonResponseDto(changeMember);
    }

 

처음에는 좀 더 명시적으로 처리되는 것을 이해하고자 flush()를 넣어두었었는데, 로직이 애초에 저기서 끝이기 때문에 굳이 인위적인 flush()를 발생시킬 이유가 없을 것이라 보고, transaction이 커밋되면서 발생하는 flush를 활용하여 로직을 마무리하는 것으로 변경하였다.

 

참고:

변경 감지와 병합

JPA 공부

JPA update와 QueryDsl 벌크연산

회원수정 테스트, save() 와 더티체킹

 

회고

비밀번호 변경 API 개발은 사실 전혀 어렵지 않을 거라 보고 있던 task였다. 실제로도 구현 자체가 어렵진 않았다. 다만 하나를 개발하더라도 어떻게 구현하는 것이 더욱 나에게 최선의 개발일지 고민하게 되는 시간이 점점 길어지는 것 같다. 사실 이러한 고민이 재밌기도 한데, 아무래도 주입식으로 정답을 익혀왔던 한국인으로써 이러한 고민에 정답이 무엇일지 궁금하면서도 누군가가 속 시원하게 알려주지 않으니 아쉽기도 하다. 아, 정답이 있긴 한건가?

Comments