좌충우돌 개발공부

TIL(20240605) [뉴스피드프로젝트:프로필 기능구현,RegExp:정규표현식]


📌 Spring

💡 뉴스피드 프로젝트 : Profile

  • 프로필 관리기능 : 프로필 조회 & 수정
  • 프로필 조회기능 1) 사용자 ID, 이름, 한 줄 소개, 이메일을 볼 수 있습니다. 2) ID(사용자 ID X), 비밀번호, 생성일자, 수정일자와 같은 데이터는 노출하지 않습니다.
  • 프로필 수정기능 :로그인한 사용자는 본인의 사용자 정보를 수정할 수 있습니다. 1) 수정 가능한 정보 : 이름, 이메일, 한줄 소개, 비밀번호 2) 비밀번호 수정 조건 :
    • 비밀번호 수정 시, 본인 확인을 위해 현재 비밀번호를 입력하여 올바른 경우에만 수정할 수 있습니다.
    • 현재 비밀번호와 동일한 비밀번호로는 변경할 수 없습니다. 3) 필수 예외처리 :
  • ⚠️ 필수 예외처리
    • 비밀번호 수정 시, 본인 확인을 위해 입력한 현재 비밀번호가 일치하지 않은 경우
    • 비밀번호 형식이 올바르지 않은 경우
    • 현재 비밀번호와 동일한 비밀번호로 수정하는 경우

프로필 관리기능을 위 사항을 고려해서 구현해보자.

일단 처음 초기세팅부분에서 팀원들과 소통이 잘 되지 않아 나중에 merge하는 부분에서 충돌로 인해 어려움을 겪었다.

이번 기회로 처음 초기세팅이 정말 중요하다는 것을 깨달았고, merge하는 부분에서도 충돌이 많이 발생해 프로젝트의 전체적인 흐름을 파악하여 merge하려고 노력했다.

이번 프로젝트에서는 branch를 좀 더 세부적으로 나누어 보았다.

1) main(master) 2) develop 3) feature/**

3번 부분에서 기능구현을 한 후 테스트까지 완료되면 develop에 기능 당 1개씩 push (+merge)하는 형식으로 진행했다.

나는 프로필 수정부분을 맡게되어서 feature/update-user라고 브랜치이름을 정했다.

그리고 덧붙여 커밋메세지에 준수하여 push하려고 노력했다.

API명세서는 이미 만들어두었기에 바로 controller작업부터 시작했다. 사실 UserController에 합쳐도 되었지만 profile 관련기능을 만약에 이후에 추가하려고 할 때 조금 혼란스럽지 않을까.. 해서 ProfileController class를 만들었다.

@RestController
@RequestMapping("/api/user/")
@RequiredArgsConstructor
public class ProfileController {

    private final ProfileService profileService;

    // 사용자의 프로필 조회
    @GetMapping("/profile")
    public ResponseEntity<ProfileResponseDto> getProfile(@AuthenticationPrincipal UserDetailsImpl userDetails) {
        ProfileResponseDto responseDto = profileService.getProfile(userDetails.getUser());
        return new ResponseEntity<>(responseDto, HttpStatus.OK);
    }

    // 사용자의 프로필 수정
    @PutMapping("/profile")
    public ResponseEntity<String> updateProfile(@RequestBody @Valid ProfileRequestDto requestDto, @AuthenticationPrincipal UserDetailsImpl userDetails) {
        profileService.updateProfile(requestDto, userDetails.getUser());
        return new ResponseEntity<>("프로필이 성공적으로 수정되었습니다.", HttpStatus.OK);
    }

사실 반환을 메세지와 상태코드로 반환하고 싶어 Message라는 클래스를 만들어 시도했는데 HttpMediaType에러가 발생해서 아직 이 부분에 대한 개념이 부족한거 같아 공부를 좀 더 해서 나중에 추가구현 부분에서 시간이 남는다면 제대로 구현해보려고 한다.

@Service
@RequiredArgsConstructor
public class ProfileService {

    private final UserRepository userRepository;
    private final PasswordEncoder passwordEncoder; 

    // 사용자 프로필 조회
    public ProfileResponseDto getProfile(User user) {
        User currentUser = findById(user);
        ProfileResponseDto responseDto = new ProfileResponseDto(user);
        return responseDto;
    }

    // 사용자 프로필 수정
    @Transactional
    public void updateProfile(ProfileRequestDto requestDto, User user) {
        User currentUser = findById(user.getId());
        String password = requestDto.getPassword();
        String changePassword = requestDto.getChangePassword();

        // 현재 비밀번호가 사용자의 비밀번호와 맞는지 검증
        if (!passwordEncoder.matches(password, currentUser.getPassword())) {
            throw new IllegalArgumentException("현재 비밀번호와 사용자의 비밀번호가 일치하지 않습니다.");
        }

        // 변경할 비밀번호와 현재 비밀번호가 동일한지 검증
        if (passwordEncoder.matches(changePassword, currentUser.getPassword())) {
            throw new IllegalArgumentException("동일한 비밀번호로는 변경할 수 없습니다.");
        }
        // 변경할 비밀번호로 수정
        currentUser.setPassword(passwordEncoder.encode(changePassword));
        currentUser.update(requestDto);
    }

    private User findById(Long id) {
        return userRepository.findById(id).orElseThrow(() ->
                new IllegalArgumentException("해당 사용자는 존재하지 않습니다.")
        );
    }

처음엔 위 수정조건에서 본인확인 검증을 따로 해야하는 API를 만들어야 하는가 생각했다. 그래서 본인 검증을 위한 비밀번호 확인기능을 구현을 햇는데, 생각해보니 다른 서비스를 이용을 할 때 보통 현재 비밀번호와 변경할 비밀번호를 넣고 한 번에 수정을 했던 기억이 대부분이었다. 그래서 별도로 API를 만들어 검증하지 않고 수정 시에 현재 비밀번호를 받아서 검증한 다음 프로필을 변경하는 형태로 바꾸었다.

그래서 현재 비밀번호를 사용자에게 입력받아 그 비밀번호와 사용자가 회원가입을 할 때 설정했던 비밀번호(암호화되어진 비밀번호)를 PasswordEncoder matches()함수로 비교하여 본인확인 검증을 하였고 변경할 비밀번호와 현재 비밀번호가 일치하면 안된다는 조건을 만족시키기 위해 한번 더 변경할 비밀번호와 현재 비밀번호를 PasswordEncoder matches()함수로 확인하여 일치하지 않을 경우에는 비밀번호를 변경할 수 있도록 구현했다.

이하 requestDto랑 responseDto 코드 입니다.

@Setter
@Getter
public class ProfileRequestDto {

    @NotBlank(message = "이름을 입력해주세요")
    private String userName;

    @NotBlank(message = "Email을 입력해주세요.")
    private String email;

    @NotBlank(message = "내용을 입력해주세요.")
    private String tagLine;

    @NotBlank(message = "패스워드를 입력해주세요.")
    @Pattern(
            regexp = "^(?=.*[a-zA-Z])(?=.*\\d)(?=.*[!@#$%^&*()_+\\-=\\[\\]{};':\"\\\\|,.<>\\/?]).{10,}$",
            message = "비밀번호는 대소문자 영문, 숫자, 특수문자를 최소 1글자씩 포함하며 최소 10자 이상이어야 합니다."
    )
    private String password;

    @NotBlank(message = "변경하려는 패스워드를 입력해주세요.")
    @Pattern(
            regexp = "^(?=.*[a-zA-Z])(?=.*\\d)(?=.*[!@#$%^&*()_+\\-=\\[\\]{};':\"\\\\|,.<>\\/?]).{10,}$",
            message = "비밀번호는 대소문자 영문, 숫자, 특수문자를 최소 1글자씩 포함하며 최소 10자 이상이어야 합니다."
    )
    private String changePassword;
    
}
@Setter
@Getter
public class ProfileResponseDto {
    private String userId;
    private String userName;
    private String email;
    private String tagline;

    public ProfileResponseDto(User user) {
        this.userId = user.getUserId();
        this.userName = user.getUserName();
        this.email = user.getEmail();
        this.tagline = user.getTagLine();
    }

팀원분께서 비밀번호 변경과 프로필 수정 기능은 분리하는게 어떨지 물어보셔서 그 부분에 대해서 고려해보니, 비밀번호 변경과 프로필 내용 수정을 분리한 기능을 구현한 서비스들을 많이 경험했던 것 같아 다시 수정하게 된다. 내가 이런 사소한 부분에서 조금씩 놓치고 있는 것 같아서 처음 기획설계를 할 때 조금 더 신중하게 생각해보자고 마음 먹었다.

수정한 프로필 관리 기능은 다음과 같다. 비밀번호 변경 API를 하나 더 만들어 프로필 내용 수정 API, 비밀번호 변경 API를 각각 구현해보았다.

@RestController
@RequestMapping("/api")
@RequiredArgsConstructor
public class ProfileController {

    private final ProfileService profileService;

    // 사용자의 프로필 조회
    @GetMapping("/profile")
    public ResponseEntity<ProfileResponseDto> getProfile(@AuthenticationPrincipal UserDetailsImpl userDetails) {
        ProfileResponseDto responseDto = profileService.getProfile(userDetails.getUser());
        return new ResponseEntity<>(responseDto, HttpStatus.OK);
    }

    // 사용자의 프로필 수정
    @PutMapping("/profile")
    public ResponseEntity<String> updateProfile(@RequestBody @Valid ProfileRequestDto requestDto, @AuthenticationPrincipal UserDetailsImpl userDetails) {
        profileService.updateProfile(requestDto, userDetails.getUser());
        return new ResponseEntity<>("프로필이 성공적으로 수정되었습니다.", HttpStatus.OK);
    }

    // 비밀번호 변경
    @PutMapping("/profile/password")
    public ResponseEntity<String> updatePassword(@RequestBody @Valid PasswordRequestDto requestDto, @AuthenticationPrincipal UserDetailsImpl userDetails) {
        profileService.updatePassword(requestDto,userDetails.getUser());
        return new ResponseEntity<>("비밀번호 변경이 완료되었습니다.", HttpStatus.OK);
    }
}
@Service
@RequiredArgsConstructor
public class ProfileService {

    private final UserRepository userRepository;
    private final PasswordEncoder passwordEncoder;

    // 사용자 프로필 조회
    public ProfileResponseDto getProfile(User user) {
        User currentUser = findById(user);
        ProfileResponseDto responseDto = new ProfileResponseDto(user);
        return responseDto;
    }

    // 사용자 프로필 수정
    @Transactional
    public void updateProfile (ProfileRequestDto requestDto, User user) {
        User currentUser = findById(user);
        currentUser.update(requestDto);
    }

    // 비밀번호 변경
    @Transactional
    public void updatePassword(PasswordRequestDto requestDto, User user) {
        User currentUser = findById(user);
        String password = requestDto.getPassword();
        String changePassword = requestDto.getChangePassword();

        // 현재 비밀번호가 사용자의 비밀번호와 맞는지 검증
        if (!passwordEncoder.matches(password, currentUser.getPassword())) {
            throw new IllegalArgumentException("현재 비밀번호와 사용자의 비밀번호가 일치하지 않습니다.");
        }

        // 변경할 비밀번호와 현재 비밀번호가 동일한지 검증
        if (passwordEncoder.matches(changePassword, currentUser.getPassword())) {
            throw new IllegalArgumentException("동일한 비밀번호로는 변경할 수 없습니다.");
        }
        // 변경할 비밀번호로 수정
        currentUser.setPassword(passwordEncoder.encode(changePassword));
    }

    private User findById(User user) {
        return userRepository.findById(user.getId()).orElseThrow(() ->
                new IllegalArgumentException("해당 사용자는 존재하지 않습니다.")
        );
    }

}

PasswordRequestDto는 요청값을 따로 받기 위해 만들었다.


import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import lombok.Getter;

@Getter
public class PasswordRequestDto {
    @NotBlank(message = "패스워드를 입력해주세요.")
    @Pattern(
            regexp = "^(?=.*[a-zA-Z])(?=.*\\d)(?=.*[!@#$%^&*()_+\\-=\\[\\]{};':\"\\\\|,.<>\\/?]).{10,}$",
            message = "비밀번호는 대소문자 영문, 숫자, 특수문자를 최소 1글자씩 포함하며 최소 10자 이상이어야 합니다."
    )
    private String password;

    @NotBlank(message = "변경하려는 패스워드를 입력해주세요.")
    @Pattern(
            regexp = "^(?=.*[a-zA-Z])(?=.*\\d)(?=.*[!@#$%^&*()_+\\-=\\[\\]{};':\"\\\\|,.<>\\/?]).{10,}$",
            message = "비밀번호는 대소문자 영문, 숫자, 특수문자를 최소 1글자씩 포함하며 최소 10자 이상이어야 합니다."
    )
    private String changePassword;
}


🚩 정규표현식

이전에 @Validation을 공부하며 정규표현식에서도 잠깐 공부를 했었는데, 다시 가물가물 해지는 터라 리마인드한다고 생각하며 다시 정규표현식을 정리해보자.

💡 정규식 기본 기호


 ^ : 문자열의 시작을 의미함 ex) ^a 라면 a로 시작하는 단어
// [] 대괄호 안에 넣게 되면 부정의 의미로 사용됨
, [^a] -> a가 아닌 철자인 문자 1


 $ : 문자열의 끝을 의미함 ex) a$ 라면 a로 끝나는 단어

 자주 사용되는 정규식 샘플
^[0-9]*$	------------------> 숫자
^[a-zA-Z]*$	------------------> 영문자
^[-]*$	------------------> 한글
\w+@\w+\.\w+(\.\w+)?	------------------> E-mail
^\d{2,3}-\d{3,4}-\d{4}$	 ------------------> 전화번호
`^01(?:0	------------------> 1
\d{6} - [1-4]\d{6}	------------------> 주민등록번호
^\d{3}-\d{2}$	------------------> 우편번호

💡 자바 정규식 문법

  • String 클래스의 정규식 문법
  • String 문자열에 바로 정규표현식을 적용하여 필터링이 가능함
  • String 클래스에서 지원하는 정규식 메서드는 3가지가 있다.
boolean matches(String regex): 인자로 주어진 정규식에 매칭되는 값이 있는지 확인
-> 아래 문자열다루기 코딩테스틀 푸는데 사용했다.

// 일치 확인
String s = "12345";
boolean b = s.matches("[0-9]+"); // 숫자로 구성되어있는지 확인 true

String replaceAll(String regex, String replacement): 문자열내에 있는 정규식 regex와 매치되는 모든 문자열을 replacement 문자열로 바꾼 문자열을 반환

// 정규표현식과 일치하는 모든 값을 치환
String s = "apple123*-;";
String check = s.replaceAll("[^a-z0-9]","4"); // 영문자,숫자를 제외하고 모두 0으로 치환 apple123444

String[] split(String regex): 인자로 주어진 정규식과 매치되는 문자열을 구분자로 분할

// 정규표현식과 일치하는 값 기준으로 나누기
String s = "apple123*-;";
String[] a = s.split("[0-9]+"); // 숫자 기준으로 분할 s[0] = "apple" s[1] = "123*-;"

💡 regex 패키지 클래스

  • 자바에서 정규 표현식 전문적으로 다루는 클래스인 java.util.regex 패키지를 제공해준다. 패키지 안의 클래스중 주로 Pattern 클래스와 Matcher 클래스가 사용된다.

그렇다면 pattern 클래스나 Matcher클래스에는 어떤 메서드가 있는지 확인해보자

💡 Pattern 클래스

  • 이 클래스의 주요 역할은 문자열을 정규표현식 패턴 객체로 변환해주는 역할을 한다. 물론 이때 문자열을 정규식 문법에 알맞게 구성해주어야 한다. 그렇지않으면 예외(Exception)이 발생하게 된다.
compile(String regex): 정규표현식의 패턴을 작성

matches(String regex, CharSequence input): 정규표현식의 패턴과 문자열이 일치하는지 체크일치할 경우 true, 일치하지 않는 경우 false를 리턴(일부 문자열이 아닌 전체 문자열과 완벽히 일치 해야한다)

asPredicate(): 문자열을 일치시키는  사용할 수있는 술어를 작성

pattern(): 컴파일된 정규표현식을 String 형태로 반환

split(CharSequence input): 문자열을 주어진 인자값 CharSequence 패턴에 따라 분리

💡 Matcher 클래스

  • Matcher 클래스는 대상 문자열의 패턴을 해석하고 주어진 패턴과 일치하는지 판별하고 반환된 필터링된 결과값들을 지니고 있다. Matcher 클래스 역시 Pattern 클래스와 마찬가지로 공개된 생성자가 없다. Matcher 객체는 Pattern 객체의 matcher() 메소드를 호출해서 얻는다.
find():	패턴이 일치하는 경우 true를 반환, 불일치하는 경우 false반환(여러개가 매칭되는 경우 반복실행하면 일치하는 부분 다음부터 이어서 매칭됨)

find(int start):	start 위치 이후부터 매칭검색

start():	매칭되는 문자열의 시작위치 반환

start(int group):	지정된 그룹이 매칭되는 시작위치 반환

end():	매칭되는 문자열 끝위치의 다음 문자위치 반환

end(int group):	지정된 그룹이 매칭되는 끝위치의 다음 문자위치 반환

group():	매칭된 부분을 반환

group(int group):	그룹화되어 매칭된 패턴중 group 번째 부분 반환

groupCount():	괄호로 지정해서 그룹핑한 패턴의 전체 개수 반환

matches():	패턴이 전체 문자열과 일치할 경우 true반환(일부 문자열이 아닌 전체 문자열과 완벽히 일치 해야한다)

정규표현식 이해하기 문자열이 숫자인지 확인하는 방법


📌 코딩테스트1️⃣ : 문자열 다루기 기본

🔒 문제 : 문자열 s의 길이가 4 혹은 6이고, 숫자로만 구성돼있는지 확인해주는 함수, solution을 완성하세요. 예를 들어 s가 “a234”이면 False를 리턴하고 “1234”라면 True를 리턴하면 됩니다.

🚫 조건 :

  • s는 길이 1 이상, 길이 8 이하인 문자열입니다.
  • s는 영문 알파벳 대소문자 또는 0부터 9까지 숫자로 이루어져 있습니다.

🔓 문제풀이

class Solution {
    public boolean solution(String s) {
        boolean answer = true;
        String REGEXP_P = "^[\\d]*$"; // 정규표현식 - 숫자만 허용
        
        // s의 문자열 길이가 4이거나 6 이고 s안에 숫자만 포함되어 있다면
        if ((s.length() == 4 || s.length() == 6) && s.matches(REGEXP_P)) {
            answer = true;
        } else {
            answer = false;
        }
        return answer;
    }
}