본문 바로가기
스파르타(부트캠프)/Troubleshooting

[내일배움캠프] 일정관리 앱에 대한 트러블슈팅

by Kimuky 2024. 11. 8.

 트러블슈팅

1. Patch로 유저가 넘겨주는 Json에 따라 가변적으로 업데이트?

2. 페이지네이션?

3. 예외 처리?

4. 레포지토리 예외 처리?

 

Git

https://github.com/kimuky/schedule_management

 

GitHub - kimuky/schedule_management

Contribute to kimuky/schedule_management development by creating an account on GitHub.

github.com


1.  Patch로 유저가 넘겨주는 Json에 따라 가변적으로 업데이트?

1) 개요

 - 유저는 [제목], [제목,색깔], [제목, 내용,색깔] 이런식으로 원하는 속성만을 바꾸고 싶을 때 어떻게 레포지토리 단에서 쿼리를 던져줘야할지에 대한 고민

 

2) 문제 상황

- 유저는 쿼리는 한번 밖에 던질 수 없는 상황 물론 여러개의 쿼리를 구현해주면 되나 -> 좋지 않은 구조라고 판단

- 유저가 무엇을 바꿀지 모르는데 거기에 대해 쿼리를 어떻게 가변적으로 바꿀 것이냐.. 

  @Override
    public int updateSchedule(int id, String title, String content, String color) {
        return jdbcTemplate.update
        ("UPDATE schedule SET title = ?, content = ?, color = ?, update_date = CURRENT_DATE() 
        WHERE id = ?", title, content, color, id);
    }

 

3) 해결

- JdbcTemplate.update 메소드는 인자로 "String 쿼리, 배열" 을 받음

- 그럼 쿼리랑 파라미터를 따로 넣어주면 Json에 따라 구분이 가능

    // JdbcTemplate.update 메소드에 대한 설명
    public int update(String sql, @Nullable Object... args) throws DataAccessException {
        return this.update(sql, this.newArgPreparedStatementSetter(args));
    }

 

 2차문제 쿼리는 가변적으로 어떻게 짜줄 것이냐

- 조건문을 걸어 쿼리에 하나씩 추가해주면 가능

    @Override
    public int updateScheduleTitle(int id, ScheduleRequestDto dto) {

        // 사용자가 제목, 내용 바꿀 수도 있고, 색깔만 바꿀 수도 있을 것이라 생각해 이렇게 구현했습니다.
        StringBuilder query = new StringBuilder("UPDATE schedule SET update_date = CURRENT_DATE()");
        List<Object> params = new ArrayList<>();

        if (dto.getTitle() != null) {
            query.append(", title = ?");
            params.add(dto.getTitle());
        }
        if (dto.getContent() != null) {
            query.append(", content = ?");
            params.add(dto.getContent());
        }
        if (dto.getColor() != null) {
            query.append(", color = ?");
            params.add(dto.getColor());
        }

        query.append(" WHERE id = ?");
        params.add(id);

        return jdbcTemplate.update(query.toString(), params.toArray());
    }

 

4) 결론

- 쿼리를 가변적으로 바꿀 수 있음에도 여러 개의 쿼리를 구현할려고 했음...

- 쿼리는 String이기 때문에 수정이 비교적 자유로운 특정을 망각했던 것 같다.

- 또한, JdbcTemplate.update는 오버로딩되어 있어 여러 인자를 넣을 수 있다는 것을 망각..

-> 메소드 활용 시, 컨트롤 누르고 메소드 눌러서 어떻게 구현되어있는지 확인하기!


2. 페이지네이션?

1) 개요

- 컨트롤러 - 서비스 - 레포지토리 단에서 유연하게 페이지네이션을 구현을 어떻게 할지 감이 오지 않았다. 아무래도 프런트 단이 없어서 생각이 잘 나지 않았다.

 

2) 문제 상황

- 페이지 번호, 페이지 사이즈를 받는다고 생각했을 때,

  • 데이터가 몇개 있는지 모른다. -> 몇 개 있는지 알아도 mapping 시키는 문제
  • 스케쥴 id가 auto_increment라고 하나 중간에 삭제될 수 있기에 id 기준으로 자를 수 도 없다.
  • 처음과 끝은 SQL의 lmit로 받아 올 수 있겠으나 중간에 있는 것들은 무엇을 기준으로 인덱스를 짜야할지 감이 오지 않는다.

3) 해결

- SQL limit은 한개의 인자만 받을 수 있는 것이 아니라 두 개의 인자를 넘겨 줄 수 있다.

- 식을 세워보면 pageSize가 10이면 페이지당 10개의 배열이 있는 것이며

(0번째 배열부터 10개, 10번 배열부터 10개, 20번 배열부터 10개...) 이다.

그렇기에  pageNum을 -1 해주고 pageSize를 곱해주면 모든 조건에 만족한다.

pageNum pageNum -1 pageSize result
1 0 10 0 * 10 = 0
2 1 10 1 * 10 = 10
....
1 0 15 0 * 15 = 0
2 1 15 1 * 15 = 15 
  @Override
    public List<ScheduleResponseDto> findPageSchedules(int pageNum, int pageSize) {
        return jdbcTemplate
        .query("SELECT * FROM schedule ORDER BY update_date DESC, id LIMIT ?,?", 
        scheduleRowMapper(), (pageNum - 1) * pageSize, pageSize);        
    }
    
	// SELECT * FROM schedule ORDER BY update_date DESC, id LIMIT 0,10 (0번째부터 10개) 0~9
	// SELECT * FROM schedule ORDER BY update_date DESC, id LIMIT 10,10 (10번째부터 10개) 10 ~19

 

4) 결론

- SQL limit를 통해 쉽게 페이지네이션을 구현가능하다. 실제로는 paging 객체를 활용하는것이 쉽다고는 하는데 시간이 부족해서 활용을 못해보았다.

 


3. 예외처리?

1) 개요

- @ExceptionHandler 를 도저히 어떻게 활용하는지 감이 안왔다. 컨트롤러단에서 예외처리하는 것이라곤 하는데 실제로  예외처리할 곳은 서비스단에 있기 때문이다. 또한, 구글링해도 이해가 가지 않는 예제와 코드들 뿐이였다.

 

2) 문제 상황

- 그래도 일단은 서비스 단에서 예외처리를 해볼려고 throw할 예외를 찾는데 BadRequest, NotFound, Forbidden은 던져줄 수 가 없었다. 던져 줄 수 있는 것들은 밑에 예제와 같은게 있었으나 저런 예외로 처리하기에는 착오를 일으킬 수 있다.

throw new IllegalArgumentException
throw new ArithmeticException

 

실제 예외처리 해야 할 부분

  • 일정 삭제/수정 시, 일정의 주인인 지 판별 (예외처리: Forbidden)
  • 필수값 미전달/입력 (예외처리: BadRequest)
  • 데이터가 잘 들어가지 않거나 없음 (예외처리: NotFound)

3) 해결

- 실제 예외처리 해야 할 부분은 사용자 정의 클래스로 정의해 예외처리를 해주어야한다. 그러면 여기서 

 

@ControllerAdvice
@ExceptionHandler

 

2가지 방법으로 처리 가능할 것이다. @ExceptionHandler를 컨트롤러에 놓고 해당 예외처리를 처리하느냐 @ControllerAdvice로 여러 컨트롤러에서 발생하는 에러를 공통적으로 처리 할 것 이냐를 결정할 수 있다. 물론 @ExceptionHandler를 컨트롤러에도 넣고 @ControllerAdvice 안에도 넣는 다면 비교적 안전한 처리를 할 수 있을 것이다. 물론 때에 따라 글로벌하게 처리해야하고, 컨트롤에서만 예외처리를 따로 처리할 필요도 있을 것이다.

일단 컨트롤러가 두개라서

예외처리 패키지

글로벌하게 처리하는 방식으로 진행했다.

 

각 사용자 정의 클래스를 정의하고

package com.example.schedule.exception;

//FobiddenException.java
public class ForbiddenException extends RuntimeException {
    public ForbiddenException() {
        super("해당 유저가 아님");
    }
}

//BadRequestException.java
public class BadRequestException extends RuntimeException {
    public BadRequestException() {
        super("빈값이 있음");
    }
}

//NotFoundException.java
public class NotFoundException extends RuntimeException {
    public NotFoundException() {
        super("없는 정보");
    }
}

 

 

글로벌 예외처리 핸들러 구현

package com.example.schedule.exception;

import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;

import java.util.List;

@ControllerAdvice
public class GlobalExceptionHandler extends RuntimeException {

    @ExceptionHandler(BadRequestException.class)
    public ResponseEntity<String> handleCustomException(BadRequestException e) {
        return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(e.getMessage());
    }

    @ExceptionHandler(ForbiddenException.class)
    public ResponseEntity<String> handleCustomException(ForbiddenException e) {
        return ResponseEntity.status(HttpStatus.FORBIDDEN).body(e.getMessage());
    }

    @ExceptionHandler(NotFoundException.class)
    public ResponseEntity<String> NotFoundException(ForbiddenException e) {
        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(e.getMessage());
    }

    // @Valid 에 따른 예외 처리 출력
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<String> handleValidationExceptions(MethodArgumentNotValidException ex) {

        List<FieldError> errors = ex.getBindingResult().getFieldErrors();

        StringBuilder errorString = new StringBuilder();

        for (FieldError fe : errors) {
            errorString.append(fe.getField()).append(": ")
                    .append(fe.getDefaultMessage())
                    .append("\n");
        }

        return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorString.toString());
    }


}

 

handleValidationExceptions 은 @Valid 어노테이션을 적용한 dto에 대한 오류들을 전부 보여주기 위해 저렇게 구현했다.

 

4) 결론

- 예외처리는 정말 방식이 다양하다. 완벽하게 적용을 시키는 것이 정말 헷갈린다. 물론 현 프로젝트에서는 컨트롤러가 2개 밖에 없기 때문에 컨트롤러에서 예외처리를  해주어도 무방할 것이다. 컨트롤러가 많아질수록 글로벌하게 처리하는것이 중요할 것이다. 글로벌하게 예외처리를 한뒤, 예외처리가 날 수 있는 컨트롤러에 구체적으로 다시 @ExceptionHandler를 구현하여 자세하게 예외를 처리할 수도 있을 것이다.


4. 레포지토리 예외처리?

1) 개요

- 예외처리를 글로벌하게 처리할 때, 서비스 단에서 모든 예외처리를 하였다. 그렇기에 레포지토리 단에서 예외를 처리하는게 착오를 일으킬 수도 있는 부분이고 모든 예외처리를 서비스 단에서 처리하고 싶었다.

 

2) 문제 상황

    @Override
    public Scheudle findMemoByIdOrElseThrow(int id) {

        List<Scheudle> result = jdbcTemplate
        .query("select * from schedule where id = ?", memoRowMapperV2(), id);

        return result.stream().findAny()
        .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "아이디가 없음"));
    }

 

- 레포지토리 단에서 지금 예외처리가 되어 있다. 이것을 어떻게 옮겨야할지 고민 중에 있었다. 만약 예외처리를 하지 않는다면 저렇게 Stream 처리를 할때 null 값이 들어와 오류가 난다. Optional 하게 처리해야하는 것이다.

 

- Optional 로 처리하면 isPresent(), isEmpty()를 통해 null 값 검증을 해줘야 해 서비스 단에서 가독성이 매우 떨어진다. 

    @Transactional
    @Override
    public ScheduleResponseDto updateSchedulePart(int id, ScheduleRequestDto dto) {
        Optional<Schedule> beforeSchedule = scheduleRepository.findScheduleById(id);

		if( beforeSchedule.isEmpty()) {
        	throw new NotFoundException();
        }

        // 패스워드 대신 uid 로 구분
        if (!beforeSchedule.getUser_uid().equals(dto.getUser_uid())) {
            throw new ForbiddenException();
        }

        int updatedRow = scheduleRepository.updateScheduleTitle(id, dto);

        if (updatedRow == 0) {
            throw new NotFoundException();
        }
        Schedule afterSchedule = scheduleRepository.findScheduleById(id);
        
        if( afterSchedule.isEmpty()) {
        	throw new NotFoundException();
        }

        return new ScheduleResponseDto(afterSchedule);
    }

 

- 문제 정리

  • 레포지토리 예외처리를 서비스단으로 옮기니 Optional 처리 해주어야함
  • Optional 처리에 따른 null 값 검증이 필요
  •  null 값 검증에 따른 가독성 저하

3) 해결

- 정답은 가까이 있었다. 그냥.. 받아와서 orElseThrow()해서 예외처리를 날려주면 되는것이다.. 그럼 Optional도 필요가 없다.

    @Transactional
    @Override
    public ScheduleResponseDto updateSchedulePart(int id, ScheduleRequestDto dto) {
        Schedule beforeSchedule = scheduleRepository.findScheduleById(id)
                .orElseThrow(NotFoundException::new);

        // 패스워드 대신 uid 로 구분
        if (!beforeSchedule.getUser_uid().equals(dto.getUser_uid())) {
            throw new ForbiddenException();
        }

        int updatedRow = scheduleRepository.updateScheduleTitle(id, dto);

        if (updatedRow == 0) {
            throw new NotFoundException();
        }
        Schedule afterSchedule = scheduleRepository.findScheduleById(id).orElseThrow(NotFoundException::new);

        return new ScheduleResponseDto(afterSchedule);
    }

 

4) 결론

- 물론 람다와 스트림에 대해서 완벽히 이해하지 못하고 있지만 람다와 스트림을 활용하면 콜렉션 처리에 매우 편리하고 가독성도 좋게 구현할 수 있다. 이에 대해서 공부를 추가로 해보아야 할 것 이다.