Spring 심화 주차 개인 과제

SJ.CHO·2024년 10월 30일

필수구현

Lv.1

개선 전

Controller
    @PostMapping("/registration")
    public ResponseEntity<UserCreateResponseDto> createUser(@Valid @RequestBody UserCreateRequestDto ucrDto) {
        return ResponseEntity
                .status(HttpStatus.CREATED)
                .body(userService.createUser(ucrDto));
    }
================================================================================================
Service
    public UserCreateResponseDto createUser(UserCreateRequestDto ucrDto) {
        if (userRepository.existsByEmail(ucrDto.getEmail())) {
            throw new CustomException(ALREADY_EMAIL_USER);
        }
        if (userRepository.existsByUserName(ucrDto.getUserName())) {
            throw new CustomException(ALREADY_USERNAME_USER);
        }
        User user = UserCommand.Create.toEntity(ucrDto, passwordEncoder, ADMIN_KEY);
        userRepository.save(user);
        return userMapper.userToUserCreateResponseDto(user);
    }
  • 요구사항에서는 회원가입시에도 토큰을 발행하도록 변경이 필요.

개선 후

Controller
    @PostMapping("/registration")
    public ResponseEntity<UserCreateResponseDto> createUser(@Valid @RequestBody UserCreateRequestDto ucrDto, HttpServletResponse res) {
        UserCreateResponseDto resDto = userService.createUser(ucrDto);
        jwtUtil.addJwtToCookie(resDto.getToken(), res);
        return ResponseEntity
                .status(HttpStatus.CREATED)
                .body(resDto);
    }
================================================================================================
Service
    public UserCreateResponseDto createUser(UserCreateRequestDto ucrDto) {
        if (userRepository.existsByEmail(ucrDto.getEmail())) {
            throw new CustomException(ALREADY_EMAIL_USER);
        }
        if (userRepository.existsByUserName(ucrDto.getUserName())) {
            throw new CustomException(ALREADY_USERNAME_USER);
        }
        User user = UserCommand.Create.toEntity(ucrDto, passwordEncoder, ADMIN_KEY);
        userRepository.save(user);
        String token = jwtUtil.createToken(user.getId(), user.getRole());
        UserCreateResponseDto resDto = userMapper.userToUserCreateResponseDto(user);
        resDto.setToken(token);
        return resDto;
    }
  • 유저가 회원가입시 가입한 정보를 가지고 토큰을 만들어서 토큰을 전달하도록 변경.
  • 제대로 회원가입시 가입한 회원에게 토큰을 내려주도록 변경.

개선 전

    @PostMapping("/login")
    public ResponseEntity<UserLoginResponseDto> logIn(@Valid @RequestBody UserLoginRequestDto ulrDto, HttpServletResponse res) {
        UserLoginResponseDto resDto = userService.logIn(ulrDto);
        String token = jwtUtil.createToken(resDto.getId(), resDto.getRole());
        jwtUtil.addJwtToCookie(token, res);
        resDto.setToken(token);
        return ResponseEntity.status(HttpStatus.OK).body(resDto);
    }
  • 현재 로그인단에서 컨트롤러를 통해서 토큰을 생성하고 반환을 해주고있지만 토큰을 생성하는건 Serivce의 역할이라는 생각이 들었고 추가로 개선하기로 함.

개선 후

Controller
    @PostMapping("/login")
    public ResponseEntity<UserLoginResponseDto> logIn(@Valid @RequestBody UserLoginRequestDto ulrDto, HttpServletResponse res) {
        UserLoginResponseDto resDto = userService.logIn(ulrDto);
        jwtUtil.addJwtToCookie(resDto.getToken(), res);
        return ResponseEntity.status(HttpStatus.OK).body(resDto);
    }
================================================================================================
Service
    public UserLoginResponseDto logIn(UserLoginRequestDto ulrDto) {
        User user = userRepository.findByEmail(ulrDto.getEmail()).orElseThrow(() -> new CustomException(LOGIN_FAILED));
        if (!user.isValidPassword(ulrDto.getPassWord(), passwordEncoder)) {
            throw new CustomException(LOGIN_FAILED);
        }
        String token = jwtUtil.createToken(user.getId(), user.getRole());
        UserLoginResponseDto resDto = userMapper.userToUserLoginResponseDto(user);
        resDto.setToken(token);
        return resDto;
    }
  • 회원가입과 동일한 로직으로 토큰을 내려줄 수 있도록 변경하였음
    • 서비스 단에서 resDto 안에 token을 삽입.
    • 컨트롤러 단에서 token을 HttpServletResponse 쿠키 안에 삽입.
    • 컨트롤러의 주요관심사는 ID,PW 의 매칭이 중요한게 아닌 요청 수신과 응답의 발송을 주요하기 때문에 토큰생성의 대한부분은 서비스에게 책임을 위임하였다.

개선 전

Service
    public ScheduleCreateResponseDto createSchedule(User user, ScheduleCreateRequestDto scrDto) {
        Schedule schedule = ScheduleCommand.Create.toSchedule(scrDto, user, weatherService);
        scheduleRepository.save(schedule);
        UserSchedule userSchedule = ScheduleCommand.Create.toUserSchedule(schedule);
        userScheduleRepository.save(userSchedule);
        return scheduleMapper.scheduleToScheduleCreateResponseDto(schedule);
    }
================================================================================================
Command
    public static class Create {
        private static String title;
        private static String scheduleDetails;
        private static String weather;

        public static Schedule toSchedule(ScheduleCreateRequestDto scrDto, User user, WeatherService weatherService) {
            title = scrDto.getTitle();
            scheduleDetails = scrDto.getScheduleDetails();
            weather = weatherService.getWeather();
            return Schedule.builder()
                    .user(user)
                    .title(title)
                    .scheduleDetails(scheduleDetails)
                    .weather(weather)
                    .build();
        }
  • 현재 코드는 weatherService 라는 클라이언트 객체 자체를 받아와서 Command 단에서 날씨를 받아 적용하고 있음
  • Service 단에서 날씨를 조회하고 Command에게 넘겨주는 방식으로 변경이 필요.

개선 후

Service
    public ScheduleCreateResponseDto createSchedule(User user, ScheduleCreateRequestDto scrDto) {
        Schedule schedule = ScheduleCommand.Create.toSchedule(scrDto, user, weatherService.getWeather());
        scheduleRepository.save(schedule);
        UserSchedule userSchedule = ScheduleCommand.Create.toUserSchedule(schedule);
        userScheduleRepository.save(userSchedule);
        return scheduleMapper.scheduleToScheduleCreateResponseDto(schedule);
    }
================================================================================================
Command
    public static class Create {
        private static String title;
        private static String scheduleDetails;

        public static Schedule toSchedule(ScheduleCreateRequestDto scrDto, User user, String weather) {
            title = scrDto.getTitle();
            scheduleDetails = scrDto.getScheduleDetails();
            return Schedule.builder()
                    .user(user)
                    .title(title)
                    .scheduleDetails(scheduleDetails)
                    .weather(weather)
                    .build();
        }
  • 큰 변동사항없이 객체->String 값으로 파라미터가 바뀌는 작업으로 끝났지만 Command 내부에서는 weatherService 를 알고 있어야 하는 의존성을 끊어낼 수 있었다. 앞으로도 더 디테일하게 코드를 작성하도록 노력이 필요할것 같다.


  • 조금 당황했지만 튜터님의 피드백중에서 최대한 근접한 답안을 찾아냈다.

  • MapStruct 의 경우 많이들 사용하는것이 아니다 보니 설명이 필요할거라 생각치 못했다. (자세한 설명은 (링크))

Lv.2

2-1

@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class UserCreateRequestDto {
    @NotBlank(message = "닉네임이 비어있습니다.")
    @Size(min = 3, max = 20, message = "닉네임은 3 ~ 20 글자사이로 입력해주세요.")
    private String userName;
    @NotBlank(message = "이메일이 비어있습니다.")
    @Email(message = "입력된 이메일의 형태가 올바르지 않습니다.")
    private String email;
    @NotBlank(message = "비밀번호가 비어있습니다.")
    @Size(min = 3, max = 20, message = "비밀번호는 3 ~ 20 글자사이로 입력해주세요.")
    private String passWord;
    private String adminKey;
}
  • 해당 항목의 경우 이미 프로그램 내부의 적용된 사항이라 어떻게 적용되었는지만 간단히 설명하겠음.
    • 모든 항목은 DB의 필수적으로 적용되기에 @NotBlank 를 통해 빈값 혹은 공백이 오지 못하도록 적용.
    • @Email 을 통해 Email 형태만 올수 있도록 구현. 더 정교한 검증이 필요하다면 @Pattern 사용을 고려해볼 듯.
      Ex) ^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$
    • Password 또한 현재요구사항에선 따로 조건이없지만 생긴다면 @Pattern 을통해 검증가능
      Ex) 문자,숫자,특수문자가 1회씩 필요한경우
      @Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d)(?=.*[!@#$%^&*()_+={}:;\"'<>,.?/\\[\\]\\\\-])[A-Za-z\\d~!@#$%^&*()+|=]+$"
    • 또 한 메세지를 통해 클라이언트에게 어느 부분이 잘 못됐는지 발송 가능. (이후 예외처리 부분과 연계)

2-2

{
    "status": "BAD_REQUEST",
    "message": "일정의 상세한 내용을 입력해주세요."
}
  • 현재의 에러처리 DTO 는 status 값과 처리가 필요한 부분의 message 를 내려주고있음.
  • 이 정도로도 충분하지 않을까 생각했지만 FE와 합의된 에러코드를 같이 내려주면 업무가 수월 할 수 있을거라 판단 변경을 진행.

개선 전

개선 후

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler({CustomException.class})
    protected ResponseEntity<ErrorDto> handleCustomException(CustomException e, HttpServletRequest req) {
        log.error("url:{}, trace:{}", req.getRequestURI(), e.getStackTrace());
        return new ResponseEntity<>(new ErrorDto(e.getErrorCode().getCode(), e.getErrorCode().getDescription(),e.getErrorCode().getErrorCode()), HttpStatus.valueOf(e.getErrorCode().getCode().value()));
    }

    @ExceptionHandler({MethodArgumentNotValidException.class})
    protected ResponseEntity<ErrorDto> handleValidationException(MethodArgumentNotValidException e, HttpServletRequest req) {
        log.error("url:{}, trace:{}", req.getRequestURI(), e.getStackTrace());
        return new ResponseEntity<>(new ErrorDto((HttpStatus) e.getStatusCode(), Objects.requireNonNull(e.getBindingResult().getFieldError()).getDefaultMessage(),"ERROR 001"), HttpStatus.valueOf(e.getStatusCode().value()));
    }
}
  • ENUM 객체에 ErrorCode 필드를 삽입, 규칙은 에러의 맨 앞자리, 뒷자리를 가져와 정했다. Ex)403 = 431 (403 에러중 첫번째 선언)
  • 에러를 내려주는 ErrorDto 역시 동일한 필드를 가질수 있도록 설정
  • GlobalExceptionHandler 또한 해당 정해진 ErrorCode를 가져오도록 변경 및 MethodArgumentNotValidException 의 경우 값입력에 대한 @Valid 이기에 에러코드를 001 로 고정하였음.
  • HTTPStatus 의 경우 ResponseEntity 에서도 설정이 가능하기에 생략도 고려해볼만 할 것 같다.


도전구현

Lv.3

3-1

개선 전

    @Transactional
    public SignupResponse signup(SignupRequest signupRequest) {

        String encodedPassword = passwordEncoder.encode(signupRequest.getPassword());

        UserRole userRole = UserRole.of(signupRequest.getUserRole());

        if (userRepository.existsByEmail(signupRequest.getEmail())) {
            throw new InvalidRequestException("이미 존재하는 이메일입니다.");
        }

        User newUser = new User(
                signupRequest.getEmail(),
                encodedPassword,
                userRole
        );
        User savedUser = userRepository.save(newUser);

        String bearerToken = jwtUtil.createToken(savedUser.getId(), savedUser.getEmail(), userRole);

        return new SignupResponse(bearerToken);
    }

개선 후

  @Transactional
  public SignupResponse signup(SignupRequest signupRequest) {

    if (userRepository.existsByEmail(signupRequest.getEmail())) {
      throw new InvalidRequestException("이미 존재하는 이메일입니다.");
    }

    String encodedPassword = passwordEncoder.encode(signupRequest.getPassword());

    UserRole userRole = UserRole.of(signupRequest.getUserRole());

    User newUser = new User(signupRequest.getEmail(), encodedPassword, userRole);
    User savedUser = userRepository.save(newUser);

    String bearerToken = jwtUtil.createToken(savedUser.getId(), savedUser.getEmail(), userRole);

    return new SignupResponse(bearerToken);
  }
  • passwordEncoderUserRole 을 먼저 세팅해두지 않고 Email을 먼저 비교하면 되는 간단한 문제.

3-2

개선 전

public String getTodayWeather() {
        ResponseEntity<WeatherDto[]> responseEntity =
                restTemplate.getForEntity(buildWeatherApiUri(), WeatherDto[].class);

        WeatherDto[] weatherArray = responseEntity.getBody();
        if (!HttpStatus.OK.equals(responseEntity.getStatusCode())) {
            throw new ServerException("날씨 데이터를 가져오는데 실패했습니다. 상태 코드: " + responseEntity.getStatusCode());
        } else {
            if (weatherArray == null || weatherArray.length == 0) {
                throw new ServerException("날씨 데이터가 없습니다.");
            }
        }

        String today = getCurrentDate();

        for (WeatherDto weatherDto : weatherArray) {
            if (today.equals(weatherDto.getDate())) {
                return weatherDto.getWeather();
            }
        }

        throw new ServerException("오늘에 해당하는 날씨 데이터를 찾을 수 없습니다.");
    }

개선 후

public String getTodayWeather() {
    ResponseEntity<WeatherDto[]> responseEntity =
        restTemplate.getForEntity(buildWeatherApiUri(), WeatherDto[].class);

    WeatherDto[] weatherArray = responseEntity.getBody();
    if (!HttpStatus.OK.equals(responseEntity.getStatusCode())) {
      throw new ServerException("날씨 데이터를 가져오는데 실패했습니다. 상태 코드: " + responseEntity.getStatusCode());
    }
    if (weatherArray == null || weatherArray.length == 0) {
      throw new ServerException("날씨 데이터가 없습니다.");
    }

    String today = getCurrentDate();

    for (WeatherDto weatherDto : weatherArray) {
      if (today.equals(weatherDto.getDate())) {
        return weatherDto.getWeather();
      }
    }

    throw new ServerException("오늘에 해당하는 날씨 데이터를 찾을 수 없습니다.");
  }
  • 결과에 상관없이 하단의 코드는 실행이 되는 형태인데 불필요한 else 를 포함하고있음.
  • else 만 제거하여도 기존과 동일한 동작을 한다.

3-3

주석

개선 전

  • 주석의 경우 처음에는 협업적 관점에서 주석이 상세할 수 록 좋은게 아닌가 라는 생각을 지니고 거의 모든 분량에 주석을 달았던것 같음
  • 튜터님과의 코드리뷰중 코드와 주석의 추상화 정도가 같다면 주석 자체도 하나의 관리요소가 됄 수 있고, 자신의 코드가 자신이 없고 설명적이지 못하다는 반증이라는 말을 듣고 주석을 최대한 적게 쓰려고 노력중 이다.

개선 후

  • 더욱 개선해야할 점은 내가 납득해버리면 주석을 달지않는 습관이 생겨버린것...
  • 코드를 작성 후 처음보는 코드라 생각하고 설명이 필요한 부분을 찾는 연습이 필요한것 같음.

코드포맷팅

  • 코드포맷팅의 경우 팀원 모두 인텔리J 를 활용하기에 쉽게 맞출 수 있는 google-java-format 를 사용하기로 결정 하였다.

기존 코드

개선 코드

  • 기존의 인텔리J 환경과의 가장 큰 차이를 느낀건 코드를 압축시켜놨다는 느낌이 강했음.
  • tab간격등이나 줄바꿈에서 코드를 읽기위해 시선을 돌리지않아도 읽힌다는 느낌이 강해서 마음에 들었다.
  • 특히 프로젝트 구조 및 DB, 콘솔창을 모두 켜놓은 상태에선 코드창이 너무 작아지는게 불만이었는데 근본적인문제는 아니더라도 해결이 된것 같다.
  • 향후 프로젝트에서 Naver 등의 다른 여러 포맷팅을 경험해볼 예정.

참조 :
https://arthur.tistory.com/43
https://bbubbush.tistory.com/29

일관된 네이밍 컨벤션

  • 네이밍 컨벤션의 경우 따로 정할 필요없이 JAVA 개발자들이 공통으로 사용하는 JAVA 명명규칙을 사용할 예정이다.

  • 물론 DB를보면서 작업하거나 정신이 없을때 아래와 같이 Snake Naming 로 작업할때가 있긴한데 최대한 고쳐갈 예정..

참조 : https://ko-ko.tistory.com/13


3-4

Case.1

개선 전

    public void updateSchedule(Long scheduleId, ScheduleUpdateRequestDto surDto, User user) {
        if (!user.isAdmin()) {
            throw new CustomException(NOT_ADMIN);
        }
        Schedule schedule = scheduleRepository.findById(scheduleId).orElseThrow(() -> new CustomException(SCHEDULE_NOT_FOUND));
        ScheduleCommand.Update.executeUpdate(schedule, surDto);
    }

    public void deleteSchedule(Long scheduleId, User user) {
        if (!user.isAdmin()) {
            throw new CustomException(NOT_ADMIN);
        }
        Schedule schedule = scheduleRepository.findById(scheduleId).orElseThrow(() -> new CustomException(SCHEDULE_NOT_FOUND));
        scheduleRepository.delete(schedule);
    }
  • ADMIN 인 유저만이 일정을 업데이트 및 삭제가 가능하기 때문에 유저의 권한을 검증 하고 일정을 가져오는 형태.
  • ADMIN 인지 확인 후 검증된 일정을 한번에 가져올 수 있지 않을까?

개선 후

    public void updateSchedule(Long scheduleId, ScheduleUpdateRequestDto surDto, User user) {
        Schedule schedule = getValidatedAdminSchedule(scheduleId, user);
        ScheduleCommand.Update.executeUpdate(schedule, surDto);
    }

    public void deleteSchedule(Long scheduleId, User user) {
        Schedule schedule = getValidatedAdminSchedule(scheduleId, user);
        scheduleRepository.delete(schedule);
    }
    
     private Schedule getValidatedAdminSchedule(Long scheduleId, User user) {
        user.isAdmin();
        return scheduleRepository.findById(scheduleId).orElseThrow(() -> new CustomException(SCHEDULE_NOT_FOUND));
    }
  • 기존의 if 문으로 처리하던 어드민확인 여부 및 예외발생 자체를 user Entity에게 위임.
  • 어드민 여부가 확인이되어야 일정을 DB에서 가져오던 작업 자체를 메소드로 묶어서 처리.

Case.2

개선 전

    public UserCreateResponseDto createUser(UserCreateRequestDto ucrDto) {
        if (userRepository.existsByEmail(ucrDto.getEmail())) {
            throw new CustomException(ALREADY_EMAIL_USER);
        }
        if (userRepository.existsByUserName(ucrDto.getUserName())) {
            throw new CustomException(ALREADY_USERNAME_USER);
        }
        User user = UserCommand.Create.toEntity(ucrDto, passwordEncoder, ADMIN_KEY);
        userRepository.save(user);
        String token = jwtUtil.createToken(user.getId(), user.getRole());
        // MapStruct 를 통해 Entity-> ResponseDto
        UserCreateResponseDto resDto = userMapper.userToUserCreateResponseDto(user);
        // ResponseDto 내의 유저 Token 삽입
        resDto.setToken(token);
        return resDto;
    }
    
        public void updateUser(User user, UserUpdateRequestDto uurDto) {
        if (userRepository.existsByEmail(uurDto.getEmail())) {
            throw new CustomException(ALREADY_EMAIL_USER);
        }
        if (userRepository.existsByUserName(uurDto.getUserName())) {
            throw new CustomException(ALREADY_USERNAME_USER);
        }
        UserCommand.Update.executeUpdate(user, uurDto);
    }
  • 회원가입 과 회원정보 수정에서 코드 중복 문제 발생. 동일한 중복조건을 검색하고 있음.

개선 후

    public UserCreateResponseDto createUser(UserCreateRequestDto ucrDto) {
        validateUserUniqueness(ucrDto.getEmail(), ucrDto.getUserName());
        User user = UserCommand.Create.toEntity(ucrDto, passwordEncoder, ADMIN_KEY);
        userRepository.save(user);
        String token = jwtUtil.createToken(user.getId(), user.getRole());
        // MapStruct 를 통해 Entity-> ResponseDto
        UserCreateResponseDto resDto = userMapper.userToUserCreateResponseDto(user);
        // ResponseDto 내의 유저 Token 삽입
        resDto.setToken(token);
        return resDto;
    }

    public void updateUser(User user, UserUpdateRequestDto uurDto) {
        validateUserUniqueness(uurDto.getEmail(), uurDto.getUserName());
        UserCommand.Update.executeUpdate(user, uurDto);
    }

    private void validateUserUniqueness(String email, String userName) {
        if (userRepository.existsByEmail(email)) {
            throw new CustomException(ALREADY_EMAIL_USER);
        }
        if (userRepository.existsByUserName(userName)) {
            throw new CustomException(ALREADY_USERNAME_USER);
        }
    }
  • private Method validateUserUniqueness 를 통해 값을 검증해주는 식으로 코드를 변경.
  • 객체에게 위임해줄 수 있는 방법을 최대한 생각해보았지만 Entity 가 Repository 를 의존하는 관계는 좋지않다고 판단. private Method 로 작성하였음.

Lv.4

4-1

개선 전

    @Test
    void matches_메서드가_정상적으로_동작한다() {
        // given
        String rawPassword = "testPassword";
        String encodedPassword = passwordEncoder.encode(rawPassword);

        // when
        boolean matches = passwordEncoder.matches(encodedPassword, rawPassword);

        // then
        assertTrue(matches);
    }

개선 후

    @Test
    void matches_메서드가_정상적으로_동작한다() {
        // given
        String rawPassword = "testPassword";
        String encodedPassword = passwordEncoder.encode(rawPassword);

        // when
        boolean matches = passwordEncoder.matches(rawPassword,encodedPassword);

        // then
        assertTrue(matches);
    }
}

  • matches 메소드의 매개변수의 순서가 틀려 비교가 안되던 문제.

4-2

개선 전

    @Test
    public void manager_목록_조회_시_Todo가_없다면_NPE_에러를_던진다() {
        // given
        long todoId = 1L;
        given(todoRepository.findById(todoId)).willReturn(Optional.empty());

        // when & then
        InvalidRequestException exception = assertThrows(InvalidRequestException.class, () -> managerService.getManagers(todoId));
        assertEquals("Manager not found", exception.getMessage());
    }

개선 후

    @Test
    public void manager_목록_조회_시_Todo가_없다면_IRE_에러를_던진다() {
        // given
        long todoId = 1L;
        given(todoRepository.findById(todoId)).willReturn(Optional.empty());

        // when & then
        InvalidRequestException exception = assertThrows(InvalidRequestException.class, () -> managerService.getManagers(todoId));
        assertEquals("Todo not found", exception.getMessage());
    }

  • 예외의 메세지가 달랐던 문제, 또한 NullPointException 이 아닌 InvalidRequestException 를 던지기에 메서드명을 IRE 로 수정

4-3

개선 전

    @Test
    public void comment_등록_중_할일을_찾지_못해_에러가_발생한다() {
        // given
        long todoId = 1;
        CommentSaveRequest request = new CommentSaveRequest("contents");
        AuthUser authUser = new AuthUser(1L, "email", UserRole.USER);

        given(todoRepository.findById(anyLong())).willReturn(Optional.empty());

        // when
        ServerException exception = assertThrows(ServerException.class, () -> {
            commentService.saveComment(authUser, todoId, request);
        });

        // then
        assertEquals("Todo not found", exception.getMessage());
    }

개선 후

    @Test
    public void comment_등록_중_할일을_찾지_못해_에러가_발생한다() {
        // given
        long todoId = 1;
        CommentSaveRequest request = new CommentSaveRequest("contents");
        AuthUser authUser = new AuthUser(1L, "email", UserRole.USER);

        given(todoRepository.findById(anyLong())).willReturn(Optional.empty());

        // when
        InvalidRequestException exception = assertThrows(InvalidRequestException.class, () -> {
            commentService.saveComment(authUser, todoId, request);
        });

        // then
        assertEquals("Todo not found", exception.getMessage());
    }

  • InvalidRequestException 이 아닌 ServerException 을 던져주던 문제.

4-4

개선 전

    @Transactional
    public ManagerSaveResponse saveManager(AuthUser authUser, long todoId, ManagerSaveRequest managerSaveRequest) {
        // 일정을 만든 유저
        User user = User.fromAuthUser(authUser);
        Todo todo = todoRepository.findById(todoId)
                .orElseThrow(() -> new InvalidRequestException("Todo not found"));

        if (!ObjectUtils.nullSafeEquals(user.getId(), todo.getUser().getId())) {
            throw new InvalidRequestException("담당자를 등록하려고 하는 유저가 일정을 만든 유저가 유효하지 않습니다.");
        }

        User managerUser = userRepository.findById(managerSaveRequest.getManagerUserId())
                .orElseThrow(() -> new InvalidRequestException("등록하려고 하는 담당자 유저가 존재하지 않습니다."));

        if (ObjectUtils.nullSafeEquals(user.getId(), managerUser.getId())) {
            throw new InvalidRequestException("일정 작성자는 본인을 담당자로 등록할 수 없습니다.");
        }

        Manager newManagerUser = new Manager(managerUser, todo);
        Manager savedManagerUser = managerRepository.save(newManagerUser);

        return new ManagerSaveResponse(
                savedManagerUser.getId(),
                new UserResponse(managerUser.getId(), managerUser.getEmail())
        );
    }

개선 후

    @Transactional
    public ManagerSaveResponse saveManager(AuthUser authUser, long todoId, ManagerSaveRequest managerSaveRequest) {
        // 일정을 만든 유저
        User user = User.fromAuthUser(authUser);
        Todo todo = todoRepository.findById(todoId)
                .orElseThrow(() -> new InvalidRequestException("Todo not found"));

    if (!ObjectUtils.nullSafeEquals(
        user.getId(),
        Optional.ofNullable(todo.getUser())
            .map(User::getId)
            .orElseThrow(
                () -> new InvalidRequestException("담당자를 등록하려고 하는 유저가 일정을 만든 유저가 유효하지 않습니다.")))) {
      throw new InvalidRequestException("담당자를 등록하려고 하는 유저가 일정을 만든 유저가 유효하지 않습니다.");
    }

        User managerUser = userRepository.findById(managerSaveRequest.getManagerUserId())
                .orElseThrow(() -> new InvalidRequestException("등록하려고 하는 담당자 유저가 존재하지 않습니다."));

        if (ObjectUtils.nullSafeEquals(user.getId(), managerUser.getId())) {
            throw new InvalidRequestException("일정 작성자는 본인을 담당자로 등록할 수 없습니다.");
        }

        Manager newManagerUser = new Manager(managerUser, todo);
        Manager savedManagerUser = managerRepository.save(newManagerUser);

        return new ManagerSaveResponse(
                savedManagerUser.getId(),
                new UserResponse(managerUser.getId(), managerUser.getEmail())
        );
    }

  • Todo 가 가지는 User가 Null 일 경우 NPE 가 발생하면서 프로그램이 종료되던 문제.
  • Optional 을 통해서 Todo 가 가지는 User의 값이 NULL 일 경우 테스트코드의 예외가 발생하도록 수정.
  • 개인적으로 의도를 읽기 가장 힘들었던 문제로 로직이 변경되면 테스트코드가 맞춰서 수정되는게 맞지 않나 싶었지만 기존의 로직내서 발생할수있는 예외를 잡는문제라 생각하면 말이 되는것 같다.

4-5

@Slf4j(topic = "ManagerLogAop")
@Aspect
@Component
@RequiredArgsConstructor
public class ManagerLogAop {
  @Pointcut("execution(* org.example.expert.domain.comment.controller.CommentAdminController.*(..))")
  private void deleteComment() {}

  @Pointcut("execution(* org.example.expert.domain.user.controller.UserAdminController.*(..))")
  private void changeUserRole() {}

  @Around("deleteComment() || changeUserRole()")
  public Object execute(ProceedingJoinPoint proceedingJoinPoint) throws Throwable {
    // 요청시각 측정
    LocalDateTime startTime = LocalDateTime.now();

    // 요청 정보를 가져옴
    ServletRequestAttributes attributes =
        (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
    HttpServletRequest request = attributes.getRequest();

    Object output = proceedingJoinPoint.proceed();

    // 요청 본문 캡쳐
    ContentCachingRequestWrapper requestWrapper = new ContentCachingRequestWrapper(request);
    String requestBody = new String(requestWrapper.getContentAsByteArray(), StandardCharsets.UTF_8);
    if (requestBody.isEmpty()) {
      requestBody = proceedingJoinPoint.getArgs()[0].toString();
    }

    // 접근 정보 가져오기
    Long userId = (Long) requestWrapper.getAttribute("userId");
    String url = requestWrapper.getRequestURI();

    // 응답 본문 캡쳐
    ContentCachingResponseWrapper responseWrapper =
        new ContentCachingResponseWrapper(attributes.getResponse());
    try {
      responseWrapper.copyBodyToResponse();
      return output;
    } finally {
      int status = responseWrapper.getStatus();
      log.info(
          "UserID : {} | URL : {} | RequestTime : {} | RequestBody OR PathVariable : {} | StatusCode : {}",
          userId,
          url,
          startTime,
          requestBody,
          status);
    }
  }
}

  • Pointcut 을 이용하여 ADMIN 만 사용가능한 2개의 메소드를 지정. ADMIN 전용 패키지를 두거나 하는식으로 하나로 관리가 가능할 것 같다.

  • 요청정보, 응답정보 둘 다 필요하기에 @Around("deleteComment() || changeUserRole()") 를 사용하여 전 후 로 실행되도록 설정하였다.

  • ContentCachingRequestWrapper 를 사용하였는데 그 이유는

    • 일반적으로 HttpServletRequest은 한 번 읽으면 더이상 접근이 불가능함.
    • HTTP 요청을 여러번 사용하기위해 HttpServletRequest 를 복사해둔 데이터로써 저장한다.
    • 복사한 데이터를 통해 로깅 및 모니터링 등의 정보로 활용하는것이 가능하다.
  • ContentCachingResponseWrapper 또한 비슷한 이유인데 다른점은 HttpServletResponse 는 한 번 작성되면 수정이 불가능하다.

  • 요구사항은 Request/Response 까지 요구하였지만 두 메소드 다 반환값이 없기에 HTTPStatusCode 로 사양을 변경하였다.

  • RequestBody 가 존재하지 않을경우 @PathVariable 값을 리턴하도록 했다.

참조 : https://velog.io/@dlwlrma/%EC%95%8C%EA%B3%A0-%EC%93%B0%EC%9E%90-ContentCachingRequestWrapper


LV.5

중복 쿼리 문제 해결

  1. 문제인식 : 유저 가입, 수정의 과정에서 코드중복은 해결되었지만 중복요소를 조회하면서 2번의 쿼리가 발생하고 있음. 하나의 쿼리로 합쳐서 검증이 가능하지 않을까?
  private void validateUserUniqueness(String email, String userName) {
    if (userRepository.existsByEmail(email)) {
      throw new CustomException(ALREADY_EMAIL_USER);
    }
    if (userRepository.existsByUserName(userName)) {
      throw new CustomException(ALREADY_USERNAME_USER);
    }
  }
  1. 해결방안
  • 2-1 의사결정 과정 :

      1. 기존의 방식을 유지하는 방법
      • 장점 :
        • 협업 진행과정 중 코드에 대한 인식이 쉽다.
        • 회원이 가지는 추가요소들에 대한 코드 구현 난이도가 쉬움.
      • 단점 :
        • 추가되는 중복요소 마다 쿼리문이 N+1 개 씩 늘어남.
        • 잦은 DB 접속으로 인한 성능 저하
      1. 하나의 쿼리를 통해 한번에 값을 가져와 검증
      • 장점 :
        • 1개의 쿼리로 검증을 마침으로 성능향상
      • 단점 :
        • 요소가 추가됄때마다 기존의 JPA 쿼리메소드 수정 필요.
        • JPA 쿼리메소드의 변경이 서비스로직으로 전파.
  • 프로젝트 규모가 커질수록 검증조건이 많아질거라 판단 성능을 위해 2번 방법을 선택.

  • 2-2 해결 과정 :

private void validateUserUniqueness(String email, String userName) {
    Optional<User> foundUser = userRepository.findByEmailOrUserName(email, userName);
    if (foundUser.isPresent()) {
      User existingUser = foundUser.get();
      existingUser.validateUniqueEmail(email);
      existingUser.validateUniqueUserName(userName);
    }
  }
  • findByEmailOrUserName JPA 쿼리 메소드를 통해 하나의 쿼리로 N개 요소의 중복조회가 가능하도록 변경.
  • User Entity 에게 중복의 대한 부분을 검증하도록 책임을 위임하였음.
  1. 해결 완료
  • 3-1 회고 : 코드의 최적화 뿐만 아니라 DB의 쿼리 최적화 또한 중요한 요소라고 느낌 오히려 성능면 최적화에선 오히려 코드최적화 보다 중요할지도 모를거란 생각이 들었다.

  • 3-2 전후데이터 비교

  • 개선전 :

  • 개선 후:

  • 1개의 쿼리만이 발생하는 것을 확인할 수 있다.

DB Default Null


1. 문제인식 : NewsFeed 프로그램에서 좋아요 시스템을 개발 중 댓글과 게시글에 대한 좋아요를 하나의 테이블로 관리했을때 다른쪽 컬럼에는 NULL 이 삽입되는 상황발생.

  1. 해결방안
  • 2 - 1 의사결정 과정 :

      1. 기존의 방식을 유지하고 Default 값 설정을 통해 사용되지 않는곳은 -1 처리
      • 장점 :
        • 기존구조를 유지하면서 해당 문제 처리가능
        • 모든 좋아요에 대해서 한 테이블에서 조회가 가능하기에 데이터접근이 쉬움
      • 단점 :
        • ManyToOne 관계에선 컬럼 디폴트값 설정이 불가능 즉 -1 이라는 ID 를 가지는 가짜객체 가 필요.
        • 실제 참조하는 값이 아닌 -1 을 가지기에 데이터 무결성강제가 어려움.
        • 사용자가 -1을 찾거나 사용하지 못하게 하기위해 필터링 필요
      1. 게시글, 댓글 테이블 분리
      • 장점 :
        • 각 테이블이 구체적인값을 지니기에 참조 무결성 보장
        • 접근 쿼리가 좀 더 간단해짐.
      • 단점 :
        • 기존의 테이블이 2개로 나눠지기에 파생되는 관리자(레파지토리 등) 생성필요.
        • 추가구현을 통한 설계의 복잡도 증가.
  • DB 데이터의 대한 무결성과 가짜 객체 가 생성됨에 따른 필터링작업등을 고려시 2번이 적합한 상태라 판단.

  • 2 - 2 해결방안

public class CommentLike {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "member_id", nullable = false)
    private Member member;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "comment_id", nullable = false)
    private Comment comment;

    public void addLikeComment(Member member, Comment comment) {
        this.member = member;
        this.comment = comment;
    }
}
================================================================================================
public class NewsLike {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "member_id", nullable = false)
    private Member member;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "news_id", nullable = false)
    private News news;

    public void addLikeNews(Member member, News news) {
        this.member = member;
        this.news = news;
    }
}
  • 기존의 1개의 like Entity만을 이용하던 것을 2개의 Entity로 분할 Repository 또한 동일하게 분할해줌.
  • 테이블을 분할함으로써 각자의 뉴스 혹은 댓글과 멤버의 ID값을 가지게됌으로써 데이터 참조 무결성을 보장한다.
  1. 해결 완료

    3-1 회고 :

    • DB를 설계하면서 요구조건을 상세하게 보지 못하여 댓글 좋아요까지 급하게 추가하느라 테이블이 기존설계와 달라지는것을 예상하지 못한게 컸다. 개발및 설계 과정 전에 요구조건을 확실하게 파악하는것이 중요하다 느낌.
    • DB에 대한 데이터 무결성 및 참조 무결성이 얼마나 중요한지 알 수 있었음. 분할할 수 있는 문제는 최대한 분할하여 해결해보자!

    3-2 전후데이터 비교

  • 개선 전 :

  • 개선 후 :

  • 2개의 테이블로 좋아요를 관리하면서 불필요한 NULL 을 피하며 서로의 기능에 대한 쿼리문 및 별도의 로직추가 및 검증이 쉬워짐.
profile
70살까지 개발하고싶은 개발자

0개의 댓글