Spring Calendar Next 트러블슈팅

SJ.CHO·2024년 10월 14일
post-thumbnail

1. Command & Mapper

  1. 문제발생 : 기존 Calendar 는 모든 API를 RequestDto, ResponseDto 단 2개로 관리를 진행했음. DTO가 Entity형태를 띄면서모든레이어를 관통. 해당 문제는 API별로 Dto를 새로생성하면 문제가 되지않지만 레이어를 관통하는문제는 그대로임. 또한 기존의 Entity가 DTO를 받아서 변환하면 Entity가 DTO를 의존하는 문제또한 발생.

  2. 원인 : 기존의 Service 는 RequestDto 를 바로 받아 바로 Entity로 변환해줬음. 이렇게 진행이됄경우 정보에대한 유효성과 변환등을 Entity가 책임을 져야하기에 Entity Class 에 필요이상이 책임이 들어간다고 판단.

  3. 가설 : DTO를 레이어단위에 종속시키게 만드는 기법 혹은 코딩방법이 있지않을까?

  4. 문제해결 : 서비스레이어 앞단에 정적유틸클래스인 Command 와 끝단에 Mapper를 두어서 Entity의 책임과 관심사를 분리.
    데이터 검증과 변환은 Command가 책임을 질수있게끔 흐름을 만들어주었음. Command 내부에선 StaticInner Class 를 통해 기능별 필요한 필드만을 받아서 Entity로 변환을 이루어준다.
    이로 인해 Entity나 DTO가 수정되더라도 Command 혹은 Mapper만 수정하면된다.

  5. 생각해볼 점 : 정적유틸클래스 Command를 통해 컨트롤러와 서비스레이어의 의존도는 내려갔지만, DTO가 계층을 관통을 하는 문제는 여전하다. 하지만 컨트롤러와 서비스가 원하는 관심사가 크게 다르지않을때 이 문제가 정말로 문제인가의 관점이 더 중요하다고 생각이됌.

  • 이 문제로 여러튜터님들에게 질문을 해봤을땐 일반적인 상황에선 잘발생하지않는 문제로 서비스 DTO가 존재했을때의 유지보수적인 관점등에선 오히려 손해가 아닐까 라는 답변을 받기도했다. 차라리 필드의 갯수가 적다면 RequestDto를 그대로 서비스로 보내는게 아닌 필드자체를 분해해서 파라미터로 보내는게 좀더 직관적인일수도 있다고 느낌.

  • Service

    public ScheduleCreateResponseDto createSchedule(ScheduleCreateRequestDto scrDto) {
        Schedule schedule = ScheduleCommand.Create.toEntity(scrDto);
        scheduleRepository.save(schedule);
        return scheduleMapper.scheduleToScheduleCreateResponseDto(schedule);
    }
  • Command

public class ScheduleCommand {

    public static class Create {
        private static String userName;
        private static String title;
        private static String scheduleDetails;

        public static Schedule toEntity(ScheduleCreateRequestDto scrDto) {
            userName = scrDto.getUserName();
            title = scrDto.getTitle();
            scheduleDetails = scrDto.getScheduleDetails();
            return Schedule.builder()
                    .userName(userName)
                    .title(title)
                    .scheduleDetails(scheduleDetails)
                    .build();
        }
    }
  • Mapper

@Mapper(componentModel = "spring")
public interface ScheduleMapper {
    ScheduleCreateResponseDto scheduleToScheduleCreateResponseDto(Schedule schedule);

2. Mapper

  1. 문제발생 : Entity -> ResponseDto 로 변환을 해주면서 Command Class 처럼 하나씩 Entity값을 ResponseDto 의 필드들을 주입해줘야하는 상황이 발생.
  2. 원인 : 계층간의 이동및 객체지향의 장점활용을 위한 DTO를 사용하기위해선 Entity를 바로 반환해서는 안된다.
  3. 가설 : Entity 값에 대한 매칭을 편하게 가져올수있는 방법이 뭐가없을까?
  4. 문제해결 : 기존의 수박겉핥기 식으로 사용해본 Mapper InterFace를 이번엔 제대로활용해보고자 사용.
@Mapper(componentModel = "spring")
public interface ScheduleMapper {
    ScheduleCreateResponseDto scheduleToScheduleCreateResponseDto(Schedule schedule);

    ScheduleReadResponseDto scheduleToScheduleReadResponseDto(Schedule schedule);

    ScheduleReadPageResponseDto scheduleToScheduleReadPageResponseDto(Schedule schedule);
}
  • Mapper InterFace 는 기본적으로 A To B 형태를 지니는 JPA의 쿼리메소드와 비슷하다고 볼수 있으면 전체적인 기능은 B 객체를 A 객체의 필드의 값을 매칭해서 동일한 필드의 값을 주입해주는 기능을 가진다.
    이를 통해 Entity를 원하는 DTO형태를 통해 마스킹하는 작업을 쉽게 진행이 가능하다.
  1. 추가문제발생 : 해당 ResponseDto의 필드가 온전히 Entity 필드와 동일하게 존재하는 상황이면 문제가없지만 ResponseDto 내부에서 Entity에 없는 필드값을 요구할때도있다.
    (Ex: 게시글들의 덧글수를 카운팅해서 같이줘 등...)
    @Mapping(source = "schedule.id", target = "scheduleId")
    CommentCreateResponseDto commentToCommentCreateResponseDto(Comment comment);
  • @Mapping 어노테이션을 활용해 Entity 내부의 명시적으로 없는 필드값 또한 받아올수있게 지원을 하고있기에 이를 활용해 문제를 해결하였다.

3. 중간테이블 설계 & 활용

  1. 문제발생 : 일정 : 유저N : M 관계로 지정되면서 의도하지 않아도 중간테이블이 발생.
  2. 원인 : JPA의 경우 N : M 관계인 @ManyToMany 어노테이션을 사용하게되면 자동적으로 중간테이블을 작성해서 관리한다.
  3. 가설 : 어차피 발생할 중간테이블이면 차라리 개발자가 의도하고 사용할 수 있게 명시적으로 작성을 하는게 좋지않을까?
  4. 문제해결 : 일정이 생성됄 때 생성한 유저 와 일정의 Entity 를 받아와 해당 객체들의 PK를 받아와 자동적으로 등록되게 설정 했다.
  • 작성자의 경우 해당 일정의 작성자인지 확인 후 담당유저를 설정할 수 있게끔 코드작성. 또한 영속성 전이 기능을 활용하여 유저, 일정이 삭제됄때 연관된 ROW가 자동적으로 삭제되게끔 설정 하였다.
  • 매번 불러올 이유가 없으므로 @ManyToOne(fetch = FetchType.LAZY) 를 추가해 필요할때만 쿼리문을 요청해 성능을 향상.

  • Entity

@Setter
@Getter
@Entity
@Table(name = "userschedule")
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class UserSchedule {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "user_id")
    private User user;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "schedule_id")
    private Schedule schedule;

    @Column(name = "role", nullable = false)
    private String role;

    private LocalDateTime joinedAt;

    public boolean isValidateCreator(Long creatorId) {
        if (!this.schedule.getUser().getId().equals(creatorId)) {
            return true;
        }
        return false;
    }
}
  • Service

    public ScheduleCreateResponseDto createSchedule(User user, ScheduleCreateRequestDto scrDto) {
        Schedule schedule = ScheduleCommand.Create.toSchedule(scrDto, user);
        scheduleRepository.save(schedule);
        UserSchedule userSchedule = ScheduleCommand.Create.toUserSchedule(schedule);
        userScheduleRepository.save(userSchedule);
        return scheduleMapper.scheduleToScheduleCreateResponseDto(schedule);
    }
  • Command

public static UserSchedule toUserSchedule(Schedule schedule) {
            return UserSchedule.builder()
                    .user(schedule.getUser())
                    .schedule(schedule)
                    .role("creator")
                    .joinedAt(LocalDateTime.now())
                    .build();
        }

4. 예외 처리 DTO+

  1. 문제 발생 : 기존의 Calendar 프로젝트 와 동일하게 현재 프로젝트에서도 ErrorDto 를 통해 예외에 대한 정보를 클라이언트에게 전달하고 있지만 발전을 위해서 조금 더 클린코드를 작성이 필요함.
  2. 원인 :
    • HttpStatus 값을 포함하고있지않음.
    • Exception 클래스의 기능을 활용하지 않고 직접 하나하나 필드를 꺼내 바인딩하고있음.
    • 동일한 문제로 볼 수 있는 예외를 따로 사용하고있음
  3. 가설 : 설계의 변화가 필요할 때 다!
  4. 문제 해결 : 방법 (흐름) 자체는 기존과 동일하다. 단지 클린코드적 관점에서의 문제이다.
@Slf4j
@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()), 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()), HttpStatus.valueOf(e.getStatusCode().value()));
    }
}
  • if 문을 통해 하나하나 바인딩 해주던 이전과 다르게 Exception Class 의 메소드를 최대한 활용하여 값을 바인딩, 코드 수를 줄이고 가독성 증대.

  • 기존 처럼 예외마다 같은 HttpStatus 를 보내는게 아닌 알맞은 에러 코드를 전송.

  • 기존 에러 DTO

  • 변경 된 에러 DTO

참조 :
https://velog.io/@kiiiyeon/%EC%8A%A4%ED%94%84%EB%A7%81-ExceptionHandler%EB%A5%BC-%ED%86%B5%ED%95%9C-%EC%98%88%EC%99%B8%EC%B2%98%EB%A6%AC
https://hocheon.tistory.com/68

5. 필터 예외처리

@Slf4j(topic = "AuthFilter")
@Component
@Order(2)
@RequiredArgsConstructor
public class AuthFilter extends OncePerRequestFilter {
    private final JwtUtil jwtUtil;


    @Override
    protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain) throws ServletException, IOException {
        if (isFilterApplicable(req)) {
            chain.doFilter(req, res);
        }
        String tokenValue = jwtUtil.getTokenFromRequest(req);
        if (!StringUtils.hasText(tokenValue)) {
            throw new CustomException(TOKEN_NOT_FOUND);
        }
        String accessToken = jwtUtil.substringToken(tokenValue);
        if (!jwtUtil.validateToken(accessToken)) {
            throw new CustomException(INVALID_TOKEN);
        }
        chain.doFilter(req, res);
    }

    private boolean isFilterApplicable(HttpServletRequest req) {
        String path = req.getRequestURI();
        return path.startsWith("/user/registration") || path.startsWith("/user/login");
    }
}
@Order(0)
@Component
public class AuthenticationExceptionHandlerFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain) throws ServletException, IOException {
        try {
            chain.doFilter(req, res);
        } catch (CustomException e) {
            if (!res.isCommitted()) {
                res.setStatus(e.getErrorCode().getStatus());
                res.setContentType("application/json");
                res.setCharacterEncoding("UTF-8");
                res.getWriter().write(e.getErrorCode().toString());
            }
        }
    }
}
  1. 문제 발생 : 해당 Server 는 로그인 시 JWT를 사용해 상태성을 확인하기에 Filter 를 두어서 요청마다 유저의 토큰을 검증하는 형태를 띄고있음. 하지만 Filter의 예외는 기존의 ExceptionHandler 가 동작하지않는다.
  2. 원인 : Filter는 Spring MVC 안에서 작동되는 게 아닌 Dispatcher-Servlet 앞단에서 실행되기에 기존의 @RestControllerAdvice 을 활용한 ExceptionHandler 는 동작하지않는다.
  3. 가설 : 그렇다면 Filter의 예외를 잡아주는 Handler가 필요할까?
  4. 문제해설 : Filter 의 앞 단 에 필터의 예외를 잡아주는 ExceptionHandlerFilter Handler를 하나 두어서 필터의 예외 가 생겼을 때 예외를 잡아주게 설정.
  • Filter의 앞단에 달아주는 이유는 FilterChain 은 진입과 퇴출이 역순의 순서를 띄기에 마지막 단에 존재해야 예외들을 잡아줄 수 있다!

  • JWT 토큰이 존재하지않는 (로그인 하지않은 유저) 가 인증이 필요한 API 호출시 400 에러를 반환한다.

참조 :
https://velog.io/@kimdy0915/Spring-Security-Filter-%EC%98%88%EC%99%B8%EC%B2%98%EB%A6%AC%EB%8A%94-%EC%96%B4%EB%96%BB%EA%B2%8C-%ED%95%A0%EA%B9%8C
https://velog.io/@lionjojo/JWT-%EC%84%B8%EC%85%98

6. HandlerMethodArgumentResolver

  1. 문제 발생 : 현재 인증/인가 가 필요한 API들은 JWT 토큰의 담겨져 있는 UserId(PK) 와 권한을 요청마다 가져와 User Entity 객체를 가져와 사용하고있다. 이로인해 요청마다 User를 DB에서 가져오는 공통코드가 발생.
  2. 원인 : JWT 토큰은 해당유저의 정보만을 가지고 있고 결국 이 정보를 객체화 하기위해선 DB접속에대한 코드는 필수불가결해진다.
  3. 가설 : 토큰은 어차피 API 요청전에 확인되기에 정보는 무조건 Controller 앞단에서 실행이 된다. 그렇다면 해당정보를 객체화 해서 Controller 에게 전달해주는것도 가능하지 않을까?
  4. 문제 해결 : HandlerMethodArgumentResolver 를 통해 이를 해결할수 있다. HandlerMethodArgumentResolver 는 컨트롤 메서드의 파라미터중 특정 조건에 맞는 파라미터가 있다면, 요청값을 이용해 원하는 객체를 만들어 바인딩이 가능하다.
  • Resolver

@Component
@RequiredArgsConstructor
public class LoginUserResolver implements HandlerMethodArgumentResolver {

    private final UserService userService;
    private final JwtUtil jwtUtil;

    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        boolean hasLoginUserAnnotation = parameter.hasParameterAnnotation(LoginUser.class);
        boolean isUserType = User.class.isAssignableFrom(parameter.getParameterType());
        return hasLoginUserAnnotation && isUserType;
    }

    @Override
    public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
        HttpServletRequest req = (HttpServletRequest) webRequest.getNativeRequest();
        String token = jwtUtil.getTokenFromRequest(req);
        if (!StringUtils.hasText(token)) {
            throw new CustomException(TOKEN_NOT_FOUND);
        }
        token = jwtUtil.substringToken(token);
        Claims claims = jwtUtil.getUserInfoFromToken(token);
        token = claims.getSubject();
        return userService.getLogInUser(token);
    }
}
  • supportsParameter : 해당 메소드의 매개변수가 해당 resolver가 지원하는지 체크.

    • @LogingUser 이 붙어있는지, 매개변수 타입이 User 인지 체크한다.
  • resolveArgument : 매개변수로 넣어 줄 값을 제공하는 메소드

    • request의 헤더에서 토큰을 꺼내 해당 토큰의 User 객체를 반환한다.
    • 해당 토큰의 형태는 이렇다.
  • WebMvcConfigurer

@Configuration
@RequiredArgsConstructor
public class WebConfig implements WebMvcConfigurer {
    private final LoginUserResolver loginUserResolver;

    @Override
    public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
        resolvers.add(loginUserResolver);
    }
}
  • 해당 resolver를 사용하기 위해선 WebMvcConfigurer 의 구현체가 필요하다.

  • 해당 구현체를 통해 Bean 으로 등록한 resolver를 주입받아서 사용한다.

  • Controller

    @PostMapping()
    public ResponseEntity<ScheduleCreateResponseDto> createsSchedule(@LoginUser User user, @Valid @RequestBody ScheduleCreateRequestDto scrDto) {
        return ResponseEntity
                .status(HttpStatus.CREATED)
                .body(scheduleService.createSchedule(user, scrDto));
    }
  • 해당 작업을 모두 완료했다면, JWT 토큰의 존재하는 유저 정보를 통해 컨트롤러로 해당 객체를 파라미터로 넘겨줄수 있다!

참조 :
https://breakcoding.tistory.com/402
https://velog.io/@uiurihappy/Spring-Argument-Resolver-%EC%A0%81%EC%9A%A9%ED%95%98%EC%97%AC-%EC%9C%A0%EC%A0%80-%EC%A0%95%EB%B3%B4-%EB%B6%88%EB%9F%AC%EC%98%A4%EA%B8%B0

7. 파서드 패턴

문제라기보단 하나의 고찰이라고 보는게 맞을것 같음.

  1. 문제 발생 : 도전구현 Lv.4 를 진행하며 외부 API의 결과값을 받아올때 일정을 작성하기 위해 Service -> Service 의 의존관계가 발생.
  2. 원인 : 각각의 책임을 분리하면서 일정이 작성되기위해 작성될 일정의 날씨를 가져올 외부 API Client를 알고 있어야 해당 값을 받아오면서 의존성이 생김.
  3. 가설 : 추상화의 단계를 높인다면 서비스의 구체적객체를 알지않아도 구현이 가능하지 않을까?
  4. 문제 해결 (방식) :

    1. 순환참조가 발생하지 않고 규모가 작은 프로젝트는 Service -> Service 의 형태가 문제되지않음.

public class ScheduleService {

    private final ScheduleRepository scheduleRepository;
    private final UserScheduleRepository userScheduleRepository;
    private final ScheduleMapper scheduleMapper;
    private final WeatherService weatherService;


    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);
    }
public class ScheduleCommand {

    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();
        }
  • 현재 프로젝트에서 사용하고 있는 방식으로 일정 Service -> 날씨 Client 의 구조를 지니지만, 반대의 형식인 날씨 Client -> 일정 Service 의 형태를 이루고있지 않기에 순환참조가 이뤄지지않는다 판단하여 사용.

    2. 상위단인 Controller 가 Service 를 지니는 형태.

@RestController
@RequiredArgsConstructor
public class TestController {

    private final TestService testService;
    
    private final TestOtherService testOtherService;
    
    @GetMapping(value = "/test")
    public void test() {
    	// testService a 메소드 호출
    	testService.a();
        
        // testOtherService b 메소드 호출
        testOtherService.b();
    }

}
  • 해당 방식은 컨트롤러가 어느 Service 를 사용하는지 개발자가 파악을하기 편하지만 가장 큰문제가 존재한다.

  • 트랜잭션의 원자성 을 지키지못하는게 가장 큰문제로 각각의 Service Method 인 a(),b() 가 각각의 트랜잭션의 단위에서 실행되기에 모두 실행되거나 아예 실행되지않아야하는 트랜잭션의 원자성을 보장하지 못한다.

    3. 파사드 패턴 (Facade Pattern)

  • 해당 항목을 작성한 이유 중 하나로 여러개의 낮은 추상화 인터페이스를 하나의 고수준 인터페이스로 묶어주는 디자인 패턴중 하나다.

  • 파사드패턴을 통해 시스템을 모듈화하고 높은 추상화의 단계로 하나의 인터페이스로 여러개의 인터페이스를 조종할 수 있다.

  • 단점으론 파사드 클래스가 사용할 기능만을 노출시키기에 모든 기능을 제공하지 않을수도 있다. 또한 다수의 서브인터페이스와 동작함으로 오버헤드 발생가능성이 높다. 즉 구현의 난이도가 높다.

그래서 정답은?

  • 언제나 그렇듯 개발은 정답이 없다. 단지 더 구현의 알맞은 목적만이 있을뿐.
  • 프로젝트 규모가 작고 큰 문제가 되지않는다면 1번 방식을 선택해 구현의 난이도를 낮춰 생산성을 증가시키고 큰규모의 변경이 자주일어난다면 3번의 파사드 패턴을 사용할거 같다.

참조 :
https://jangjjolkit.tistory.com/61
https://jangjjolkit.tistory.com/62
https://jaeseo0519.tistory.com/314

profile
70살까지 개발하고싶은 개발자

0개의 댓글