관리 메뉴

개발공부기록

스케줄러(일정관리) API 서버 만들기 - API 고도화 도전, 테이블 분리하기, 페이지네이션 도입, 예외 처리와 null 및 특정 패턴 검증 수행(스프링 시큐리티, Validation, Pageable 적용) 본문

프로젝트/토이프로젝트

스케줄러(일정관리) API 서버 만들기 - API 고도화 도전, 테이블 분리하기, 페이지네이션 도입, 예외 처리와 null 및 특정 패턴 검증 수행(스프링 시큐리티, Validation, Pageable 적용)

소소한나구리 2025. 3. 25. 14:22
728x90

github

이전 글


API 고도화 도전

기존에 단일 테이블로 단순히 일정을 생성, 수정, 삭제, 조회만 되던 API를 조금 더 고도화 시켜보고자 한다.

 

중복된 작성자명으로도 일정이 조회되도록 테이블을 분리하고, 조회시 페이지네이션을 도입하여 필요한 부분만 반환한다.

또한 null체크나 잘못된 요청이 왔을 경우 예외가 발생하여 사용자에게 안내해주고, 검증 로직도 추가하여 데이터의 무결성을 방지한다.

DB에 비밀번호가 그대로 저장되는 로직도 암호화하여 저장하도록 수정했다.

 

테이블 분리하기

먼저 schuduel 테이블 하나로 동작하던 방식을 작성자인 writer와 일정인 schdule로 관리하여 작성자가 중복되더라도 작성자의 id를 가지고 일정을 조회할 수 있도록 테이블 설계를 변경했다.

 

추가적으로 애플리케이션 로직상 필수적으로 필요한 컬럼들에는 Not Nul l제약 조건을 적용했다.

최종적으로 아래와 같이 매우 간단한 토이프로젝트의 테이블 구조가 결정 되었다.

 

테이블 분리 ERD

 

변경된 테이블 생성문

create table writer
(
    id          bigint auto_increment primary key comment '작성자 식별자',
    name        varchar(10) not null comment '작성자',
    email       varchar(50) not null comment 'email',
    create_date datetime comment '생성일',
    update_date datetime comment '수정일'
)

create table schedule
(
    id        bigint auto_increment primary key comment '일정 식별자',
    writer_id bigint      not null comment '작성자 외래키',
    content   varchar(255) not null comment '할일',
    password  varchar(255) not null comment '비밀번호',
    foreign key (writer_id) references writer (id)
)

API 수정

일정 생성 API

테이블이 분리가 되었으므로 일정을 생성할 때 writer 테이블에 한번 저장하고, 저장된 ID값을 가지고 schedule 테이블에 데이터를 저장해야 하기 때문에 2번 DB에 쿼리를 전송할 수 밖에 없다.

 

다만, 이렇게 DB에 2번 전송하더라도 하나의 단일 트랜잭션(유의미한 최소 단위의 비즈니스 로직)으로 묶어야 둘 중 하나에서 실패하더라도 둘 다 실패하고 둘 다 성공해야만 성공이 되는 데이터 원자성을 지킬 수 있다.

 

SchedulerServiceImplV2

@Override
@Transactional
public SchedulerCommonResponseDto saveSchedule(SchedulerCreateRequestDto createRequestDto) {

    Writer writer = createWriter(createRequestDto);                    // Writer 생성
    Long savedWriterId = schedulerRepository.saveWriter(writer);       // 작성자 정보부터 DB에 저장하고 key 반환

    Schedule schedule = createSchedule(createRequestDto, savedWriterId); // Schedule 생성
    Long savedScheduleId = schedulerRepository.saveSchedule(schedule);  // 일정 DB 저장하고 Key 반환

    return new SchedulerCommonResponseDto(savedScheduleId);
}

 

일정을 저장하는 서비스 로직에서 saveWriter(), saveSchedule() 메서드를 통해 2번 저장하지만 @Transactional로 묶어있으므로 하나의 원자적인 로직으로 동작할 수 있다.

 

테이블을 분리했기 때문에 Writer를 생성하는 로직과 Schedule을 생성하는 로직을 각각 메서드화하여 분리하였고, Schedule을 생성할 때 Writer를 저장하고 반환된 Key(Id)값을 포함하여 저장하도록 변경했다.

private Writer createWriter(SchedulerCreateRequestDto createRequestDto) {
    return Writer.builder()
            .name(createRequestDto.getName())
            .email(createRequestDto.getEmail())
            .create_date(LocalDateTime.now())
            .update_date(LocalDateTime.now())
            .build();
}

private Schedule createSchedule(SchedulerCreateRequestDto createRequestDto, Long savedWriterId) {
    String encodedPassword = passwordEncoder.encode(createRequestDto.getPassword());
    return Schedule.builder()
            .writerId(savedWriterId)    // 반환된 작성자 key 세팅
            .content(createRequestDto.getContent())
            .password(encodedPassword)
            .build();
}

 

비밀번호 암호화를 위한 Spring Security 적용

 

여기서 일정을 생성하는 createSchedule()메서드를 보면 passwordEncoder.encode()메서드를 통해서 createRequestDto에서 요청된 비밀번호를 암호화 하여 Schedule에 저장하는 로직을 추가한 것을 확인할 수 있다.

 

비밀번호를 암호화할 수 있는 방법은 다양하지만 여기서는 스프링으로 로그인을 구현할 때 자주 사용하는 Spring Security의 PasswordEncoder를 활용했다

 

먼저 스프링 시큐리티를 사용하기 위해서는 build.gradle에 의존성을 추가해야한다.

나는 빌드 툴을 gradle로 했기 때문에 build.gradle로 했지만 maven으로 했다면 그에맞게 의존성을 추가해주어야 한다.

implementation 'org.springframework.boot:spring-boot-starter-security'

 

SecurityConfig

@Configuration
public class SecurityConfig {

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
                .authorizeHttpRequests(auth -> auth.anyRequest().permitAll())
                .csrf(AbstractHttpConfigurer::disable);
        return http.build();
    }
}

 

의존성 등록을 하고나면 스프링 시큐리티 설정을 해주어야 하는데 우선 여기서는 비밀번호를 암호화하고 해시값을 활용하여 비밀번호를 검증할 수 있는 BCryptPasswordEncoder()를 빈으로 등록해주었다.

 

스프링 시큐리티를 도입하면 기본적으로 모든 요청을 login을 해야만 접근이가능하도록 다 막아버린다.

나는 위의 암호화기능만 사용하길 원하기 때문에 SecurityFilterChain filterChain(HttpSecurity http) 메서드를 통해서 모든 요청을 permitAll() 메서드로 허용했다.

 

추가적으로 CSRF 공격(사이트 간 요청 위조)를 막는 csrf()도 비활성화 했는데 보통 화면을 직접 뿌려줄 때는 활성화 해주는 것이 좋지만 API를 뿌려주는 서버일 경우 활성화하면 동작이 비정상적으로 동작할 수 있어서 비활성화 하여 사용한다

 

이후 사용할 PasswordEncoder를 의존관계 주입하여 사용하면 되는데 보통 비밀번호 암호화는 서비스 로직에서 많이 사용하므로 여기에서도 Service로직에서 사용하여 Schedule 객체를 생성할 때 해당 코드를 적용한 것이다.

@Service
public class SchedulerServiceImplV2 implements SchedulerService {

    private final SchedulerRepository schedulerRepository;
    private final PasswordEncoder passwordEncoder;
    
    // 기타 등등
}

 

 

SchedulerRepositoryImplV2

@Repository
public class SchedulerRepositoryImplV2 implements SchedulerRepository {

    private final NamedParameterJdbcTemplate jdbcTemplate;
    private final SimpleJdbcInsert schedulerJdbcInsert;
    private final SimpleJdbcInsert writerJdbcInsert;

    public SchedulerRepositoryImplV2(DataSource dataSource) {
        // 바인딩 순서로 쿼리하면 버그가 생길 수 있으므로 파라미터 이름으로 쿼리를 할 수 있는 JdbcTemplate
        this.jdbcTemplate = new NamedParameterJdbcTemplate(dataSource);
        this.schedulerJdbcInsert = new SimpleJdbcInsert(dataSource)  // Insert 편의 기능 활용
                .withTableName("schedule")
                .usingGeneratedKeyColumns("id");

        this.writerJdbcInsert = new SimpleJdbcInsert(dataSource)  // Insert 편의 기능 활용
                .withTableName("writer")
                .usingGeneratedKeyColumns("id");
    }
    
    // 기타 메서드들
    
}

 

writer를 저장하는 로직과 schedule을 저장하는 로직을 분리했으므로 SimpleJdbcInsert도 schedule 테이블과 writer 테이블로 각각 생성해주었다

 

이렇게 생성한 각각의 SimpleJdbcInsert를 활용하여 writer와 schedule을 Service의 saveSchedule()메서드에서 각각 호출하여 일정을 저장하고 저장된 일정의 id값을 반환한다.

@Override
public Long saveWriter(Writer writer) {
    SqlParameterSource writerParam = new BeanPropertySqlParameterSource(writer);
    Number writerKey = writerJdbcInsert.executeAndReturnKey(writerParam);
    return writerKey.longValue();
}

@Override
public Long saveSchedule(Schedule schedule) {
    SqlParameterSource scheduleParam = new BeanPropertySqlParameterSource(schedule);
    Number schedulerKey = schedulerJdbcInsert.executeAndReturnKey(scheduleParam);
    return schedulerKey.longValue(); // 생성한 key 값을 long 타입으로 변환해서 반환
}

 

SchedulerControllerV2와 DTO의 검증

@PostMapping
public ResponseEntity<SchedulerCommonResponseDto> addSchedule(@Validated @RequestBody SchedulerCreateRequestDto createRequestDto) {
    return new ResponseEntity<>(schedulerService.saveSchedule(createRequestDto), HttpStatus.CREATED);
}

 

일정을 추가하는 addSchedule()의 메서드를 보면 클라이언트에서 요청값으로 전달되는 SchedulerCreateRequestDto 앞에 @Validated 애노테이션이 붙어있는데 이것이 스프링에서 제공하는 Bean Validation을 활용하여 클라이언트의 요청값을 편하게 검증할 수 있도록 해준다.

 

또한 기존에는 일정 생성과 수정이 같은 요청값을 받았기 때문에 CommonRequestDto로 같이 사용했지만 이제는 생성할 때와 수정할 때 요청값이 다르기 때문에 각각의 DTO 객체로 요청값을 받도록 수정했다.

 

검증을 하기 위해선 스프링 시큐리티와 마찬가지로 추가적인 의존관계를 설정해주어야 하기 때문에 build.gradle에 아래처럼 의존관계를 추가해 주었다.

implementation 'org.springframework.boot:spring-boot-starter-validation'

 

SchedulerCreateRequestDto

@Getter
@AllArgsConstructor
public class SchedulerCreateRequestDto {

    @Size(max = 200, message = "할일은 200자 미만이여야 합니다.")
    @NotNull(message = "할일은 필수 입력값 입니다.")
    @NotBlank(message = "할일은 공백만 입력할 수 없습니다.")
    private final String content;

    @NotNull(message = "사용자 이름은 필수 입력값 입니다.")
    @NotBlank(message = "사용자 이름은 공백만 입력할 수 없습니다.")
    private final String name;

    @Email
    @NotNull(message = "이메일은 필수 입력값 입니다.")
    @NotBlank(message = "이메일은 공백만 입력할 수 없습니다.")
    private final String email;

    @NotNull(message = "비밀번호는 필수 입력값 입니다.")
    @NotBlank(message = "비밀번호는 공백만 입력할 수 없습니다.")
    private final String password;
}

 

그 다음 클라이언트의 요청값을 검증하기 위해 검증 애노테이션으로 각각의 필드를 검증하는 코드를 작성하고, 검증에 실패할 경우 message를 통해 클라이언트에게 메시지를 전달한다.

 

기본적으로 일정을 생성할 때에는 일정, 이름, 이메일, 비밀번호 모두 null이거나 빈 문자열이거나 공백만 있는 문자열은 예외가 발생하도록 했다.

추가적으로 일정은 200자 이하, 이메일은 형식에 맞지 않으면 예외가 발생하도록하여 일정을 생성할 때는 빡빡한 검증 로직을 통과해야만 일정이 생성된다.

 

검증에 실패하면 MethodArgumentNotValidException 에러가 발생하고 스프링이 자동으로 400 (Bad Request)요청을 반환해주지만 에러 메시지는 반환되지 않기 때문에 예외를 API로 반환하는 로직도 필요하여 예외를 처리하는 GlobalExceptionHandler를 만들어서 발생하는 예외를 공통으로 처리했다.

API 예외 처리

ErrorCode

@Getter
@AllArgsConstructor
public enum ErrorCode {
    VALID_BAD_REQUEST("400", HttpStatus.BAD_REQUEST, "잘못된 입력값 입니다."),

    // password
    UNAUTHORIZED_ACCESS("401", HttpStatus.UNAUTHORIZED, "비밀번호가 틀립니다. 다시 입력해 주세요"),

    // noSuch
    NOT_FOUND_SCHEDULE("404", HttpStatus.NOT_FOUND, "일정이 존재하지 않습니다."),
    ;

    private final String code;
    private final HttpStatus httpStatus;
    private final String message;

}

 

우선 애플리케이션에서 발생할 수 있는 에러코드와 메시지를 상수로 관리하기 위해 Enum으로 정의해 두었고, 애플리케이션에서 사용할 예외를 Runtime 예외로 새로 생성했다.

 

기존에 정의된 예외를 사용해도 되지만 애플리케이션에서 명확하게 어떤 이유로 사용되는 예외인지 명확하게하기 위해서 새로 생성했으며, 체크 예외는 계층에서 처리되지 않으면 던지는 코드를 작성해주어야 하기 때문에 RuntimeException으로 생성했다.

public class NoSuchScheduleException extends RuntimeException {
    public NoSuchScheduleException(String message) {
        super(message);
    }
}

public class PasswordValidationException extends RuntimeException {
    public PasswordValidationException(String message) {
        super(message);
    }
}

 

NoSuchScheduleException은 DB에서 찾고자 하는 일정을 찾지 못할 때 발생하는 예외이고 PasswordValidationException은 요청한 비밀번호화 DB의 비밀번호를 해시값으로 비교했을 때 일치하지 않으면 발생하는 예외이다.

 

체크 예외와 언체크 예외(런타임 예외)에 대한 내용은 별도로 정리해둔 아래의 글을 참고하면 좋다.

ErrorDto

@Data
@AllArgsConstructor
public class ErrorDto {
    private String code;
    private String message;
}

 

발생된 예외는 해당 DTO 객체로 반환되어 어떤 예외든 공통된 양식으로 반환 되도록 했다.

상태코드와 예외 메시지를 가지고 있다.

 

GlobalExceptionHandler

@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler
    public ResponseEntity<ErrorDto> passwordException(PasswordValidationException e) {
        log.error("[passwordException] ex: ", e);
        ErrorDto errorDto = new ErrorDto(ErrorCode.UNAUTHORIZED_ACCESS.getCode(), e.getMessage());
        return new ResponseEntity<>(errorDto, ErrorCode.UNAUTHORIZED_ACCESS.getHttpStatus());
    }

    @ExceptionHandler
    public ResponseEntity<ErrorDto> noSuchScheduleException(NoSuchScheduleException e) {
        log.error("[noSuchScheduleException] ex: ", e);
        ErrorDto errorDto = new ErrorDto(ErrorCode.NOT_FOUND_SCHEDULE.getCode(), e.getMessage());
        return new ResponseEntity<>(errorDto, ErrorCode.NOT_FOUND_SCHEDULE.getHttpStatus());
    }

    @ExceptionHandler
    public ResponseEntity<ErrorDto> inputValidException(MethodArgumentNotValidException e) {
        log.error("[inputValidException] ex: ", e);

        // @Validated 에서 발생한 에러만 꺼내서 스트림을 통해 에러의 필드와 메시지만 꺼내는 코드, GPT 도움 받았음
        Map<String, String> errors = e.getBindingResult().getFieldErrors().stream()
                .collect(Collectors.toMap(
                        FieldError::getField,
                        FieldError::getDefaultMessage,
                        (msg1, msg2) -> msg1
                ));

        ErrorDto errorDto = new ErrorDto(ErrorCode.VALID_BAD_REQUEST.getCode(), errors.toString());
        return new ResponseEntity<>(errorDto, ErrorCode.VALID_BAD_REQUEST.getHttpStatus());
    }
}

 

@RestControllerAdvice를 사용하여 성공 했을 때 API와 실패 했을 때 API를 각각 분리하여 관리할 수 있도록 했으며 GlobalExceptionHandler는 실패했을 때의 응답해야할 로직을 모두 모아서 관리한다.

 

각각의 예외 상황에 처리해야할 핸들러를 @ExceptionHandler를 통해서 빈으로 등록하고 관리하며 응답값은 ResonseEntity<ErrorDto>로 모두 일관된 형식으로 응답하도록 작성했다.

@ExceptionHandler를 작성할 때 예외를 지정해주지 않으면 파라미터 타입에 따라 예외 종류가 결정되며 여러개의 예외를 { }로 묶어서 등록할 수도 있다.

 

직젒 작성해둔 비밀번고 검증 예외가 발생하면 401 상태코드와 함께 상수에서 관리되는 메시지를 반환해주고, 일정을 찾지 못하여 예외가 발생하면 404 상태코드와 함께 예외 메시지를 반환해준다.

 

여기서 검증이 실패하면 발생하는 MethodArgumentNotValidException을 관리하는 inputValidException()메서드는 @Validated로 검증이 실패한 데이터만 동적으로 조회해야 하기 때문에 추가적인 로직이 들어갔다.

 

해당 로직은 잘 모르는 내용이기도 하고 잘 생각이 안나서 GPT의 도움을 통해 발생한 error만 Map으로 변환하는 코드를 작성했다

  • e.getBindingResult()를 호출하면 클라이언트에서 요청한 데이터를 자바 객에 매핑한 결과를 가져오는데 여기에 getFieldErrors()를 하면 에러가 발생한 필드의 목록을 List<FieldError>로 반환한다
  • List로 반환하기 때문에 stream()을 통해 FieldError를 Map 자료 구조로 변환시켰는데 key를 에러가 발생한 필드, value를 에러 메시지로 변환했다
  • 마지막으로 동일한 검증 메시지가 발생할 수 있으므로 중복을 제거하기 위해 간단한 람다식으로 중복 발생 시 1개만 출력되도록 설정했다

이후 Map으로 매핑된 검증 에러 메시지들을 new ErrorDto를 생성할 때 상태코드 와함께 인자로 넘겨주고 ResponseEntity로 반환한다.

이를 통해서, 단건 조회, 수정, 삭제 API에서 발생한 예외들을 공통적으로 한번에 처리하도록 했다.

일정 전체 조회 API

클라이언트의 요청값으로 page와 size를 받을 수 있도록 컨트롤러 코드에 Pageable을 사용했다.

Pageable은 스프링 데이터가 제공하는 기능이기 때문에 ORM기술을 사용하지 않더라도 페이징 객체를 사용하기 위해선 의존관계를 등록해야 한다

implementation 'org.springframework.data:spring-data-commons'

 

SchedulerControllerV2, SchedulerServiceImplV2

// 컨트롤러
@GetMapping
public ResponseEntity<Page<SchedulerFindResponseDto>> findSchedules(@Validated SchedulerSearchCond searchCond, Pageable pageable) {
    return new ResponseEntity<>(schedulerService.findAllSchedules(searchCond, pageable), HttpStatus.OK);
}

// 서비스
@Override
public Page<SchedulerFindResponseDto> findAllSchedules(SchedulerSearchCond searchCond, Pageable pageable) {
    return schedulerRepository.findAllSchedules(searchCond, pageable);    // 조회한 일정을 리스트로 반환
}

 

컨트롤러의 메서드에서 Pageable을 파라미터로 적용하면 요청 파라미터 값으로 page, size의 값을 전송할 수 있다.

page는 현재 페이지, size는 현재 페이지에서 보여줄 개수이며 Pageable을 사용하면 page의 시작은 0부터 시작하기 때문에 클라이언트 개발자와 이 내용을 공유하는 것이 좋다.

 

요청받은 Pageable 값을 서비스 계층으로 넘기고 서비스 계층에서도 실제 DB에서 조회할 수 있도록 리포지토리 계층으로 넘긴다

이때 각 메서드의 반환값을 List가 아닌 Page로 변경해주었는데 이부분은 Repository 계층의 메서드를 설명할 때 설명하겠다.

 

SchedulerRepositoryImplV2

@Override
public Page<SchedulerFindResponseDto> findAllSchedules(SchedulerSearchCond searchCond, Pageable pageable) {

    LocalDate condDate = searchCond.getCondDate();  // 날짜 검색 조건
    String condName = searchCond.getCondName();     // 이름 검색 조건

    SqlParameterSource param = new BeanPropertySqlParameterSource(searchCond);

    // 동적 쿼리 시작
    String query = "select s.id, w.id, s.content, w.name, w.update_date" +
            " from schedule as s" +
            " join writer as w" +
            " on s.id = w.id";

    String allCountQuery = "select count(*) from schedule as s join writer as w on s.id = w.id";

    // 동적 쿼리 작성하기
    // 날짜가 null이 아니거나 이름이 null, 길이 0, 공백 문자만으로 구성되어있지 않으면 -> 즉 동적 쿼리 조건이 있으면 where 붙이기
    if (condDate != null || StringUtils.hasText(condName)) {
        query += " where";
        allCountQuery += " where";
    }

    boolean andFlag = false;    // and 조건 붙이기 위한 플래그
    if (condDate != null) {
        // DB의 update_date 컬럼의 타입이 시, 분, 초가 있으므로 날짜 조건만 맞추기 위해 like 문법 사용
        query += " update_date like concat(:condDate, '%')";
        allCountQuery += " update_date like concat(:condDate, '%')";
        andFlag = true;         // and 플래그 true 설정
    }

    if (StringUtils.hasText(condName)) {
        // 날짜 조건이 null이 아니라서 쿼리가 추가 되면 쿼리에 and 추가
        if (andFlag) {
            query += " and";
            allCountQuery += " and";

        }
        query += " name = :condName";   // 작성자 이름 같은 일정 조회
        allCountQuery += " name = :condName";

    }

    int offset = pageable.getPageNumber() * pageable.getPageSize();
    int limit = pageable.getPageSize();

    query += " order by update_date desc limit " + limit + " offset " + offset;

    List<SchedulerFindResponseDto> findAllSchedules = jdbcTemplate.query(query, param, scheduleRowMapper());

    Integer totalCount = jdbcTemplate.queryForObject(allCountQuery, param, Integer.class);

    // 쿼리 결과가 없으면 0으로 반환, todo: 이후에 예외발생으로 변경
    if (totalCount == null) {
        totalCount = 0;
    }

    return new PageImpl<>(findAllSchedules, pageable, totalCount);
}

 

기존의 동적 쿼리에 페이징을 위한 코드와 테이블이 분리 되었으므로 두 테이블을 join하는 쿼리가 추가 적용되었다.

먼저 페이징을 적용하려면 조회할 DB에 전체 개수가 필요하기 때문에 schedule과 writer를 join하여 개수를 반환하는 쿼리를 추가로 선언해둔다. 

 

그다음 count 쿼리도 기존의 동적 쿼리 로직과 동일하게 적용되야하기 때문에 각 조건에 따라 where, and 조건이 붙도록 추가했다.

그리고 기존에 동적쿼리로 작성해둔 실제 데이터를 조회하는 조회 쿼리의 마지막에 limit과 offset을 적용하여 offset의 데이터를 limit의 개수만큼 가져오도록 쿼리를 붙인다.

 

이때 offset은 Pageable에서 넘어온 페이지 넘버와 한페이지에 출력한 개수를 곱셈연산하여 구할 수 있으며 limit은 그냥 클라이언트에서 요청파라미터로 넘어온 size의 값을 그대로 매핑해주면 된다.

 

동적 쿼리로직이 모두 지나가면 검색 조건이 적용된 조인된 테이블의 전체 개수를 구하는 쿼리와 실제 구할 데이터를 각각 실행 시키고 해당 결과를 PageImpl객체를 생성할 때 인자로 전달한다.

이렇게 하면 반환값을 Page로 반환할 수 있는데, 여기에 PageImpl을 생성할 때 전달한 pageable과 전체 개수를 가지고 페이지네이션 값을 자동으로 적용해준다.

일정 단건 조회 API

SchedulerServiceImplV2

@Override
public SchedulerFindResponseDto findScheduleById(Long id) {
    Optional<SchedulerFindResponseDto> optionalFindSchedule = schedulerRepository.findScheduleById(id);

    // 찾을 ID가 없으면 예외 반환
    if (optionalFindSchedule.isEmpty()) {
        throw new NoSuchScheduleException("해당 id로 찾을 수 없습니다.");
    }

    return optionalFindSchedule.get();
}

 

단건 조회의 로직에서는 Repository에서 조회한 일정이 비어있다면 예외가 발생하도록 처리하는 로직만 추가 되었다.

 

SchedulerRepositoryImplV2

@Override
public Optional<SchedulerFindResponseDto> findScheduleById(Long id) {
    String query = "select s.id, w.id, s.content, w.name, w.update_date" +
            " from schedule as s" +
            " join writer as w" +
            " on s.id = w.id" +
            " where s.id = :id";

    Map<String, Long> param = Map.of("id", id);
    List<SchedulerFindResponseDto> findScheduleDto = jdbcTemplate.query(query, param, scheduleRowMapper());

    // Optional 반환
    return findScheduleDto.stream().findAny();
}

 

단건 조회도 테이블이 분리 되었으므로 join 쿼리를 적용했다.

일정 수정 API

SchedulerControllerV2와 UpdateDTO

@PutMapping("/{id}")
public ResponseEntity<SchedulerCommonResponseDto> updateSchedule(@PathVariable Long id,
                                                                 @Validated @RequestBody SchedulerUpdateRequestDto updateRequestDto) {
    return new ResponseEntity<>(schedulerService.updateSchedule(id, updateRequestDto), HttpStatus.OK);
}

 

일정을 추가할 때 공통을 사용되던 SchedulerCommonRequestDto를 각각 분리하여 SchedulerUpdateRequestDto를 사용하도록 변경 적용했다.

분리 적용한 가장 주된 이유는 Email은 수정할 수 없으므로 수정할 때 요청값에서 Email을 필요가 없으며, 생성할 때는 일정과 이름은 필수값이지만 수정할 때는 둘 중 하나만 수정할 수도 있기 때문에 검증로직을 분리해야하기 때문이다.

@Getter
@AllArgsConstructor
public class SchedulerUpdateRequestDto {

    @Size(max = 200)
    private final String content;   // 수정은 필수 X

    private final String name;      // 수정은 필수 X

    @NotNull(message = "비밀번호는 필수 입력값 입니다.")
    @NotBlank(message = "비밀번호는 필수 입력값 입니다.")
    private final String password;
}

 

SchedulerServiceImplV2

@Override
@Transactional
public SchedulerCommonResponseDto updateSchedule(Long id, SchedulerUpdateRequestDto updateRequestDto) {

    passwordValid(id, updateRequestDto.getPassword());  // 패스워드 검증
    validUpdateSchedule(id, updateRequestDto);          // 동적으로 수정하고 수정할 대상을 못찾으면 예외 발생

    // 수정완료 되면 id값 반환
    return new SchedulerCommonResponseDto(id);
}

 

일정을 수정하기 위한 서비스계층의 메서드로 패스워드를 검증하는 로직과 수정하는 로직을 메서드로 추출하여 가독성을 향상 시켰다.

검증이 통과하고 수정이 완료되면 수정이 적용된 일정의 id를 반환하는 로직은 동일하다.

 

비밀번호 검증 메서드

private void passwordValid(Long id, String password) {
    Optional<String> passwordById = schedulerRepository.findPasswordById(id);

    if (passwordById.isEmpty()) {
        throw new NoSuchScheduleException(ErrorCode.NOT_FOUND_SCHEDULE.getMessage());
    }

    if (!passwordEncoder.matches(password, passwordById.get())) {
        throw new PasswordValidationException(ErrorCode.UNAUTHORIZED_ACCESS.getMessage());
    }
}

 

인자로 전달받은 비밀번호를 findPasswordById()를 통해 DB에 암호화되어 저장된 비밀번호를 꺼내온다.

여기서도 잘못된 id가 전달 되어 비밀번호를 못찾을 수 있기 때문에 null 검증을 적용시켰다.

 

DB에서 조회된 인코딩된 암호와 요청값으로 전달한 암호를 passwordEncoder의 matches()메서드를 통해 비교하고 검증이 실패하면 작성해둔 비밀번호 검증 예외가 발생하도록 했다.

 

해당 비밀번호 검증 로직은 삭제하는 로직에서도 동일하게 사용된다.

 

동적으로 수정하는 메서드

private void validUpdateSchedule(Long id, SchedulerUpdateRequestDto updateRequestDto) {
    int updatedRow;
    if (StringUtils.hasText(updateRequestDto.getContent()) && !StringUtils.hasText(updateRequestDto.getName())) {
        // 일정만 수정
        updatedRow = schedulerRepository.updateScheduleContent(id, updateRequestDto.getContent());

    } else if (StringUtils.hasText(updateRequestDto.getName()) && !StringUtils.hasText(updateRequestDto.getContent())) {
        // 이름만 수정
        updatedRow = schedulerRepository.updateWriterName(id, updateRequestDto.getName());

    } else {
        // 전체 수정
        updatedRow = schedulerRepository.updateScheduleContentWithWriterName(id, updateRequestDto.getContent(), updateRequestDto.getName());
    }

    // 수정된 내역이 없으면 잘못된 요청으로 예외 발생
    if (updatedRow == 0) {
        throw new NoSuchScheduleException(ErrorCode.NOT_FOUND_SCHEDULE.getMessage());
    }
}

 

비밀번호 검증 로직이 성공하면 이제 동적으로 일정을 수정하는 메서드가 호출되는데, 조건문을 통해서 할일만 수정하거나, 이름만 수정하거나 둘다 수정할 수 있도록 했다.

 

이부분을 어떻게 최적화 할지 고민하다가 각 상황에 맞는 리포지토리 코드를 모두 작성하여 조건에 맞는 요청이 오면 알맞는 메서드를 호출하는 식으로 코드를 작성했다.

 

매우 직관적이게 설계하긴 했지만 이부분은 더 우아한 방법이 있을 것 같아서 이후 피드백이 온다면 개선할 여지가 있을 것 같다.

 

SchedulerRepositoryImplV2

@Override
public int updateScheduleContent(Long id, String content) {
    String query = "update schedule as s" +
            " join writer as w" +
            " on s.id = w.id" +
            " set s.content = :content, w.update_date = now()" +
            " where s.id = :id";

    SqlParameterSource param = new MapSqlParameterSource()
            .addValue("id", id)
            .addValue("content", content);

    return jdbcTemplate.update(query, param);
}

 

일정만 수정하는 쿼리를 날리는 메서드이다.

일정을 수정하지만 수정된 시간은 최신으로 반영해야하기 때문에 MySQL에서 지원하는 update join 쿼리를 적용시켰다.

update join 쿼리는 MySQL, mariaDB에서 사용할 수 있으며 SQL Server와 PostgreSQL 각각의 방법으로 비슷한 기능을 지원한다고 한다.

  • SQL Server: from 절에서 join을 사용하여 update를 수행할 수 있다
  • PostgreSQL: update ... from 구문으로 조인을 통한 업데이트가 가능하다
  • Oracle: update 구문에 직접적인 join문법을 지원하진 않지만 merge문또는 서브쿼리를 활용하여 업데이트 할 수 있다.

 

@Override
public int updateWriterName(Long id, String name) {
    String query = "update writer set name = :name, update_date = now() where id = :id";

    SqlParameterSource param = new MapSqlParameterSource()
            .addValue("id", id)
            .addValue("name", name);

    return jdbcTemplate.update(query, param);
}

 

작성자의 정보만 수정하는 쿼리는 단일 테이블의 정보만 수정하면 되기 때문에 깔끔하게 수정할 수 있다.

@Override
public int updateScheduleContentWithWriterName(Long id, String content, String name) {
    String query = "update schedule as s" +
            " join writer as w" +
            " on s.id = w.id" +
            " set s.content = :content, w.name = :name, w.update_date = now()" +
            " where s.id = :id";

    SqlParameterSource param = new MapSqlParameterSource()
            .addValue("id", id)
            .addValue("content", content)
            .addValue("name", name);

    return jdbcTemplate.update(query, param);
}

 

둘 다 수정할 때에도 update join 쿼리를 사용했다.

일정 삭제 API

SchedulerControllerV2와 DeleteDTO

@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteSchedule(@PathVariable Long id,
                                           @Validated @RequestBody SchedulerDeleteRequestDto deleteDto) {
    schedulerService.deleteSchedule(id, deleteDto);
    return new ResponseEntity<>(HttpStatus.OK);
}

 

일정을 삭제할 때에도 요청값으로 비밀번호가 필요하여 비밀번호 필드만 가지고 있는 삭제 전용 DTO를 활용했다.

 

단일 필드인데 DTO를 사용한 이유는 @Validated를 적용하여 필수값 검증을 적용하여 공통적으로 예외를 처리할 수 있고 요청 데이터를 모두 동일한 패키지에서 묶어서 관리하기 위함이다.

@Getter
@AllArgsConstructor
public class SchedulerDeleteRequestDto {

    @NotNull(message = "비밀번호는 필수 입력값 입니다.")
    @NotBlank(message = "비밀번호는 필수 입력값 입니다.")
    private String password;
}

 

SchedulerServiceImplV2

@Override
@Transactional
public void deleteSchedule(Long id, SchedulerDeleteRequestDto deleteDto) {
    passwordValid(id, deleteDto.getPassword()); // 패스워드 검증
    validDeleteSchedule(id);                    // 삭제할 대상을 못찾으면 예외 발생
}

 

일정 삭제시에도 일정 수정할 때 사용했던 비밀번호 검증 메서드를 동일하게 재사용한다.

비밀번호 검증이 통과하면 일정을 삭제하는 메서드를 호출하는데, 이 때 삭제할 일정을 찾지 못하면 예외가 발생하는 코드를 별도의 메서드로 추출하여 삭제 로직을 직관적이고 가독성이 좋게 구성했다.

 

private void validDeleteSchedule(Long id) {
    int deleteRow = schedulerRepository.deleteSchedule(id);

    if (deleteRow == 0) {
        throw new NoSuchScheduleException(ErrorCode.NOT_FOUND_SCHEDULE.getMessage());
    }
}

 

비밀번호 검증이 통과하면 repository의 deleteSchedule()메서드를 호출하여 삭제를 실행하고, 삭제된 데이터가 없으면 삭제할 일정을 찾지 못한 것이므로 예외를 발생시킨다.


회고 정리

이로써 자바와 JDBC를 활용하여 DB에 데이터를 저장하고, 삭제하고, 수정하고, 조회하는 기본적인 API를 개발해보았다.

 

순수 JDBC 코드를 통해 연습을 해볼 수도 있지만 직접 JDBC 커넥터를 생성하고 자원을 일일히 반납하기 보다는 jdbctemplate을 사용하는 경우가 더 많기 때문에 jdbc template을 사용하여 코드를 작성해보면서 잊혀졌던 기억들을 다시한번 되살리는 계기가 되었다.

 

특히 jdbc tempalte을 사용하여 쿼리를 구성하는 부분은 너무 오랜만에 해봐서 기억이 거의 나질 않았고, 스프링 시큐리티를 설정하는 부분과 Pageable을 사용하는 부분도 자주 사용하긴 했지만 설정을 하는 부분이 기억이 안나서 검색을 통해 해결할 수 있었다.

 

확실히 나에게는 강의로 배운 내용을 나의 것으로 다지는 실전 코딩 연습이 매우 부족하다는 것을 다시한번 느끼는 계기가 되었다

아직 모든 부분의 코드가 완벽하지 않기도하지만 이부분은 피드백을 통해서 더 우아한 코드로 개선해볼 예정이다.

728x90