Spring Calendar 트러블슈팅

SJ.CHO·2024년 9월 30일

1. SQL, JAVA Data Type matching

  1. 문제발생 : 일정을 생성하며 날짜 형태 컬럼들인 생성일, 수정일 및 일정 시작날짜, 종료날짜를 DateTime, Date 형식으로 지정하면서 기존의 String으로 지정되있던 DATA 저장형 객체들의 오류가발생.

  2. 가설 : APP을 작성하면서 DB가 매치가 안되는 데이터타입이 존재할리없음. JAVA에서 따로 시간형에 대한 지원라이브러리가 존재하지않을까?

  3. 문제해결 : 해당 문제유형에는 내가 아는 2가지 문제해결방식이 존재함 (물론 수많은 해결책이 존재할것이다.)

    1. 그냥 String 타입으로 선언하되 패턴을 매치해서 사용하면되지않을까?
    • 장점 : 내가 작성하기 편하다. 타입에 대한 고민을 할필요가없다.
    • 단점 : 해당 String 사용하기위한 자원의 소모가 발생한다. 설계에 대한 데이터 규칙이 깨짐
      (Ex : 타입캐스팅, 패턴매칭, 날짜형식으로 사용하기 위한 가공)
    1. JDBC와 JAVA내에서 매치되는 타입을 찾아 지정한다.
    • 장점 : 설계 데이터 규칙 보존, 패턴매칭 불필요
    • 단점 : 데이터 가공작업이 필요 (필요할 때)
  • 그래도 최우선은 데이터에대한 안정성이 보장되는것이 제일이라 생각해 2번을 골라서 해결
@Getter
@AllArgsConstructor
@NoArgsConstructor
public class EventResponseDto {
    private Long id;
    private String creator;
    private String todo;
    private String password;
    private java.sql.Timestamp createddate;
    private java.sql.Timestamp modifieddate;
    private java.sql.Date startday;
    private java.sql.Date endday;
java.sql.Timestamp createddate = rs.getTimestamp("createddate");
                java.sql.Timestamp modifieddate = rs.getTimestamp("modifieddate");
                java.sql.Date startday = rs.getDate("startday");
                java.sql.Date endday = rs.getDate("endday");

참조 :
https://m.blog.naver.com/heeju_99/222217060845
https://adjh54.tistory.com/500

2. 입력값에 따른 jdbcTemplate.query 문 조절

 // Client 에서 보내온 파라미터값에 따른 DB 조회 메소드
    // 조건에 따른 WHERE 절 변화
    public List<EventResponseDto> findAll(String creator, String modifieddate) {
        // 둘다 기입하지 않았을시 모든 일정 조회
        String sql = "SELECT * FROM event";
        // 작성자, 수정일을 둘다 기입했을시.
        if (creator != null && modifieddate != null) {
            sql += " WHERE creator = " + "'" + creator + "'" + " OR date_format(modifieddate,'%Y-%m-%d') = " + "'" + modifieddate + "'";
        }
        // 수정일만 기입시 수정일 기준으로 DB 조회.
        else if (modifieddate != null) {
            sql += " WHERE date_format(modifieddate,'%Y-%m-%d') = " + "'" + modifieddate + "'";
        }
        // 작성자만 기입시 작성자 기준으로 DB 조회.
        else if (creator != null) {
            sql += " WHERE creator = " + "'" + creator + "'";
        }
        sql += " ORDER BY modifieddate DESC";
        return jdbcTemplate.query(sql, new RowMapper<EventResponseDto>() {
            @Override
            public EventResponseDto mapRow(ResultSet rs, int rowNum) throws SQLException {
                // SQL 의 결과로 받아온 Memo 데이터들을 EventResponseDto 타입으로 변환해줄 메서드
                Long id = rs.getLong("id");
                String todo = rs.getString("todo");
                java.sql.Timestamp createddate = rs.getTimestamp("createddate");
                java.sql.Timestamp modifieddate = rs.getTimestamp("modifieddate");
                java.sql.Date startday = rs.getDate("startday");
                java.sql.Date endday = rs.getDate("endday");
                String creator = rs.getString("creator");
                return new EventResponseDto(id, todo, createddate, modifieddate, startday, endday, creator);
            }
        });
    }
  1. 문제발생 : DB에 존재하는 일정들의 검색조건은 작성자명/수정일 의 검색입력에 대한 값을 가짐. 조건 중 한 가지만을 충족하거나, 둘 다 충족을 하지 않을 수도, 두 가지를 모두 충족할 수 있음. 즉 WHERE 절의 변화가 일어남.

  2. 원인 : 해당 문제에 대해서 jdbcTemplate.query(sql, new RowMapper<EventResponseDto>() { ... }) 메소드내에서 SQL 값이 유동적일경우 ? 식 와일드카드가 들어가 파라미터값이 항상 변해야하는 구조가 발생.

    EX)

   public Event findId(Long id) {
        String sql = "SELECT * FROM event WHERE id=?";
        return jdbcTemplate.query(sql, resultSet -> {
            if (resultSet.next()) {
                Event event = new Event();
                event.setId(resultSet.getLong("id"));
                event.setTodo(resultSet.getString("todo"));
                event.setPassword(resultSet.getString("password"));
                event.setCreateddate(resultSet.getTimestamp("createddate"));
                event.setModifieddate(resultSet.getTimestamp("modifieddate"));
                event.setStartday(resultSet.getDate("startday"));
                event.setEndday(resultSet.getDate("endday"));
                event.setCreator(resultSet.getString("creator"));
                return event;
            } else {
                return null;
            }
        }, id); // 해당 부분
    }
  1. 가설 : if문을 통해 SQL 문도 결국 String 문자열이기에 조절이 가능하지않을까?

  2. 문제해결 : String을 조절하는식으로 SQL문을 조절하여 해결. String 불변성에 의해 성능이 나빠지면 StringBuffer 혹은 Heap 내에서 사용되는 데이터를 캐싱해서 사용하면 성능개선이 충분할거라 판단됌.

  3. 생각해볼점 : 결국 String 값으로 SQL문을 사용하기에 SQL Injection 에 취약한 형태가 아닐까 생각됌.

참조 : 6조 팀원들.
SQL Injection 이란?

3. Custom Exception DTO를 통한 Client 에러전송

  1. 문제발생 : Exception 객체 throw 통해 예외를 발생시키고는 있지만 기존의 예외처리처럼 서버 Log로만 발생하는게 아닌 Client 에게 명시적으로 해당예외를 구체적으로 전송이 있으면 좋겠다고 생각. (이후 @Valid 를 추가, Service 단 에서의 값 검증을 최소화 했다.)
    (물론 HTTP 내부적으로 예외 JSON을 전달은 하지만 조금더 구체적으로 봤을때)

  2. 원인 : 예외발생시 따로 Respones 객체를 생성, 전달하는게 아닌 에러 로그로만 발생 시키고 있음.

  3. 가설 : 서버가 정보를 DTO화해서 객체로 보내는거 처럼 예외 객체도 전송이 가능한것 아닐까?

  4. 문제해결 : Spring의 커스텀 예외들을 찾아보며 기존의 자바 예외와 크게 달라진것 없이 형태만 바뀌었다고 느낌.

@NoArgsConstructor
@Getter
public enum CustomErrorCode {
    // 예외 처리를 위한 eunm 객체 속성 생성.
    USER_INFO_MISMATCH("유저정보가 일치하지않습니다."),
    END_DATE_BEFORE_START_DATE("일정 종료일자가 시작일자보다 빠릅니다."),
    NULL_BLANK_INPUT("입력을 하지 않은 값이 존재합니다. 모든값을 입력하세요."),
    OUT_OF_RANGE("선택한 ID 값이 존재하지 않습니다."),
    DATE_PARSE_ERROR("날짜 형태가 맞지않거나 해당날짜가 유효하지 않습니다. Ex) 2024-02-30 , YYYY-MM-DD)"),
    INVALID_EMAIL_FORMAT("이메일 형태가 올바르지 않습니다."),
    USER_NOT_FOUND("해당 유저가 존재하지 않습니다."),
    INPUT_OUT_OF_BOUNDS("입력 조건이 최소/최대값 과 맞지않습니다.");

    private String statusMessage;

    // 예외 발생시 Message 출력을 위한 생성자
    CustomErrorCode(String statusMessage) {
        this.statusMessage = statusMessage;
    }
}
@Getter
@Setter
@AllArgsConstructor
@Builder
public class ErrorResponse {
    private CustomErrorCode code;
    private String description;
    private String detail;

    public ErrorResponse(CustomErrorCode code, String description) {
        this.code = code;
        this.description = description;
    }
}
@Getter
@AllArgsConstructor
@NoArgsConstructor
// 사용자예외를 발생시키기 위한 예외 클래스
public class CustomException extends RuntimeException {
    private CustomErrorCode customErrorCode;
    private String detailMessage;

    public CustomException(CustomErrorCode customErrorCode) {
        super(customErrorCode.getStatusMessage());
        this.customErrorCode = customErrorCode;
        this.detailMessage = customErrorCode.getStatusMessage();
    }

}
@Slf4j
@RestControllerAdvice
// 사용자 예외 발생시 예외를 처리해주는 Handler Class
public class CustomExceptionHandler {
    @ExceptionHandler(CustomException.class)
    // 예외 발생시 예외에 대한 DTO 객체를 생성해준다
    public ErrorResponse handleCustomException(CustomException e, HttpServletRequest request) {
        log.error("errorCode : {}, url : {}, message: {}", e.getCustomErrorCode(), request.getRequestURL(), e.getDetailMessage());
        return new ErrorResponse(e.getCustomErrorCode(), e.getDetailMessage(), e.getMessage());
    }

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ErrorResponse> methodValidException(MethodArgumentNotValidException e, HttpServletRequest request) {
        log.warn("MethodArgumentNotValidException 발생!!! url:{}, trace:{}", request.getRequestURI(), e.getStackTrace());
        ErrorResponse errorResponse = makeErrorResponse(e.getBindingResult());
        return new ResponseEntity<ErrorResponse>(errorResponse, HttpStatus.BAD_REQUEST);
    }

    private ErrorResponse makeErrorResponse(BindingResult bindingResult) {
        CustomErrorCode code = null;
        String description = "";
        String detail = "";
        if (bindingResult.hasErrors()) {
            //DTO에 설정한 meaasge값을 가져온다
            detail = bindingResult.getFieldError().getDefaultMessage();

            //DTO에 유효성체크를 걸어놓은 어노테이션명을 가져온다.
            String bindResultCode = bindingResult.getFieldError().getCode();

            switch (bindResultCode) {
                case "NotBlank":
                    code = CustomErrorCode.NULL_BLANK_INPUT;
                    description = CustomErrorCode.NULL_BLANK_INPUT.getStatusMessage();
                    break;
                case "NotNull":
                    code = CustomErrorCode.NULL_BLANK_INPUT;
                    description = CustomErrorCode.NULL_BLANK_INPUT.getStatusMessage();
                    break;
                case "Email":
                    code = CustomErrorCode.INVALID_EMAIL_FORMAT;
                    description = CustomErrorCode.INVALID_EMAIL_FORMAT.getStatusMessage();
                    break;
                case "Size":
                    code = CustomErrorCode.INPUT_OUT_OF_BOUNDS;
                    description = CustomErrorCode.INPUT_OUT_OF_BOUNDS.getStatusMessage();
                    break;
            }
        }
        return new ErrorResponse(code, description, detail);
    }
}
  • ENUM Class CustomErrorCode 에서 예외상태를 정의해준 후 ExceptionHandler 를 통해 해당예외가 발생시 메소드로 유도 및 예외정보를 가지고 ErrorResponse 객체를 만들어 Client 에게 전달한다.

참조 :
https://velog.io/@hwsa1004/Spring-%EC%98%88%EC%99%B8%EC%B2%98%EB%A6%ACCustom-Exception-%EB%A7%8C%EB%93%A4%EA%B8%B0
https://tecoble.techcourse.co.kr/post/2020-08-17-custom-exception/

4. 입력값에 대한 유효성 검증

@Getter
@AllArgsConstructor
@NoArgsConstructor
@Valid
public class EventRequestDto {
    @Positive
    private Long id;
    @NotNull(message = "유저 ID를 입력해주세요.")
    @Positive
    private Long user_id;
    @NotBlank(message = "일정을 입력해주세요.")
    @Size(min = 1, max = 500, message = "일정은 최소 1글자 최대 500글자 입력가능")
    private String todo;
    @NotBlank(message = "비밀번호를 입력해주세요.")
    private String password;
    private java.sql.Timestamp createddate;
    private java.sql.Timestamp modifieddate;
    @NotNull(message = "일정시작일을 입력해주세요.")
    private java.sql.Date startday;
    @NotNull(message = "일정종료일을 입력해주세요.")
    private java.sql.Date endday;
}
    // 일정을 생성하는 컨트롤러 메소드
    @PostMapping("/schedul/")
    public EventResponseDto createEvent(@RequestBody @Valid EventRequestDto requestDto) {
        return eventService.createEvent(requestDto);
    }
  1. 문제발생 : 3번의 예외처리에서 파생되는 문제로 모든정보입력이 필수인 현재 프로젝트에서 값이 유효한 값인지에 대한 인식을 어디서 잡는지에 대한 문제 발생
  2. 원인 : 사실 프론트단이 있다면 프론트에서 입력폼이 비었는지에대한 검사가 더 효율적이지만 우린 프론트단이 없기에 잇몸으로라도 씹어야한다.
  3. 가설 : 결국 사용자가 입력하는 정보를 RequestDto 로 포장하기에 이걸 Entity로 만들기 전에 검사를 한번하면 되지않을까?
  4. 문제해결 : RequestDto 내에서 필드에서 값을 주입받으면서 해당 어노테이션 @NotNull(message = "일정시작일을 입력해주세요.") 등 유효조건에 맞는지 검사하고 해당값이 유효가 아니면 예외발생 및 처리방식을 사용했다.

참조 : https://cchoimin.tistory.com/entry/Valid-%EC%99%80-ControllerAdvice%EB%A1%9C-DTO-%EC%98%88%EC%99%B8%EC%B2%98%EB%A6%AC%ED%95%98%EA%B8%B0

5. 외래키 삭제 및 관계테이블 ROW 변경 동기화.

  1. 문제 발생 : Lv.4 를 진행하면서 User Data를 Event에서 분리한뒤 User 는 고유한 값을 가지고있어야하는 전제조건이 발생.
    일정 수정 삭제에는 크게 관계없지만 User가 삭제 혹은 변경 됄경우 FK 제약으로 인해 삭제가 불가능 한 문제가 생김.
  2. 원인 : FK 의 경우 다른 테이블에서도 데이터가 일관되고 정합성이 지켜지지만 그로 인해 다른테이블의 묶여있는 컬럼의 수정이 기본적으론 막혀있음.
  3. 가설 : 수정및 삭제구간마다 제약조건을 설정/해제 하기엔 문제발생가능성이 높아보임. 같은 제약조건중에 해당 컬럼의 상태를 따라가는 설정이 있을거라 생각.
  4. 문제해결 : ON DELETE CASCADE 의 속성을 통해 제약조건에게 해당컬럼의 변경사항을 따라가는 조건을 추가해서 해결.
ALTER TABLE event
ADD FOREIGN KEY (user_id) REFERENCES user(id) ON DELETE CASCADE
  • 업데이트의 경우 FK 가 많아질 경우 장점도 있지만 단점이 더 크다 판단. 외래키를 사용하지않고 업데이트를 시행.
    // 단일 User Data 를 DB 상에서 수정하는 메소드
    public void update(Long id, UserRequestDto requestDto) {
        // 수정시간 입력을 위한 Timestamp 생성
        Timestamp update_date = Timestamp.valueOf(LocalDateTime.now());
        // 해당 ID를 가진 UserData 수정
        String sql = "UPDATE user SET username = ?, update_date = ? WHERE id = ?";
        jdbcTemplate.update(sql, requestDto.getUsername(), update_date, id);
        // event Table 에 일정을 가지고 있을 경우 변경값으로 함께 변경
        sql = "UPDATE event SET username = ? WHERE user_id = ?";
        jdbcTemplate.update(sql, requestDto.getUsername(), id);

6. Mapper 의존성 주입.


Mapper 란?

  • MapStruct는 configuration 접근 방식의 규칙을 기반으로 한 Java 빈 타입의 매핑 구현을 단순화해주는 코드 생성기.
  • Later 간 이동을 위해 다양한 DTO가 발생. 필드간 차이가 발생시 MapStruct를 통해 객체 내 많은 필드에 대해서 쉽게 매핑
  1. 문제발생 : Lv.5 를 해결하면서 페이징 부분 중 @Mapper 을 사용시 인식하지 못하거나 서버가 실행되지않는 상황이 발생 (Mapper를 Bean으로 생성하지 못함).
  2. 원인 : MapStruct 또한 외부라이브러리기에 gradle 에 의한 외부 라이브러리 주입이 필요하다.
  3. 문제해결 : 아래와 같이 dependency를 설정하여 해결
implementation "org.mapstruct:mapstruct:1.5.2.Final"
annotationProcessor "org.mapstruct:mapstruct-processor:1.5.2.Final"
testAnnotationProcessor "org.mapstruct:mapstruct-processor:1.5.2.Final"
implementation 'org.projectlombok:lombok-mapstruct-binding:0.2.0'
annotationProcessor 'org.projectlombok:lombok-mapstruct-binding:0.2.0'
  • dependency 순서에 따라 lombok이 작동 안하는 이슈발생가능성 존재.

참조 : https://mein-figur.tistory.com/entry/mapstruct-1

7. CrudRepository, Mehtod Name to SQL

  1. 문제발생 : 페이징 문제를 해결하던 도중 지속적으로 Repository 의 SQL의 문제가 있어서 Bean 객체로 등록이 불가능해 서버를 실행할수없다는 오류문 출력

  2. 원인 : CrudRepository 를 상속받아 메소드를 작성한 경우 해당 메소드명 을 통해 DB단으로 진입해 Entity 를 작성하게 되어있음.

  3. 가설 : 메소드명이 MemberID 로 고정되어 있지않고 해당 메서드명을 파생하여서 SQL문이 작성되어지지 않을까? 생각.

  4. 문제해결 : 해당 메서드명을 Table명 포함, 컬럼명 등 계속 비교해가며 작성 프로그램에 적절하게 교체하여 문제 해결. (솔직히 메소드명을 통해서 SQL 이 파생된다고는 생각못했다...)
    (Spring의 특징으로 생략되어진 기능이 상당히 많은거 같다느낌.)

//Entity 를 Id 기준 내림차순으로 데이터를 받기위한 CrudRepository 요청 메소드
public interface EventPageRepository extends CrudRepository<Event, Long> {
    Page<Event> findAllByOrderByIdDesc(Pageable pageable);
}

참조 :
https://velog.io/@sussa3007/Spring-Spring-DATA-JDBC-Pagination-API#-repository-%EA%B5%AC%ED%98%84
https://mason-lee.tistory.com/108
https://clgitory.tistory.com/m/40

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

0개의 댓글