관리 메뉴

개발공부기록

스케줄러(일정관리) API 서버 만들기 - 시작, 기본적인 일정 CRUD API 개발(Java, JDBC Template, MySQL) 본문

프로젝트/토이프로젝트

스케줄러(일정관리) API 서버 만들기 - 시작, 기본적인 일정 CRUD API 개발(Java, JDBC Template, MySQL)

소소한나구리 2025. 3. 21. 15:35
728x90

github


프로젝트 정보

스프링부트 프로젝트로 생성

 

Java: JDK 17

Group: spring.basic

Artifact: scheduler

Packaing: Jar

Dependencies

  • spring-web
  • lombok
  • mysql driver

사용 스킬

  • Java
  • Spring
  • Spring Boot
  • JDBC Template
  • MySQL

시작

기초 ERD 제작과 테이블 생성

먼저 매우 간단한 스케줄러부터 시작하기로 생각하여 단일 테이블을 설계하고 매우 간단하고 성공케이스만 동작하는 CRUD 기능만 만들어 보기로 정했다.

테이블은 scheduler라는 이름의 테이블하나로 동작하도록한 다음 점차 테이블 구조를 넓히고 기능도 넓혀가볼 예정이다

 

테이블 구조

id: 아이디

 

schedule 테이블의 고유한 Key 값이다.

간단한 토이 프로젝트이므로 Auto Increment를 사용하여 Key값을 자동으로 1씩 증가하도록 설정할 예정이다

충분한 일정을 보관할 수 있도록 BIGINT 타입을 적용했고 범위는 자바의 Long타입과 같다

 

 

content: 할일

 

실제 사용자가 스케줄러에 등록할 일정을 보관하는 컬럼이다.

VARCHAR(255)으로설정하여 최대 255자까지 보관할 수 있도록 설정했으며 입력된 데이터의 길이에 따라 용량을 효율적으로 사용하기 위해 VARCHAR 타입을 택했다

 

 

name: 작성자명

 

일정을 등록한 작성자 명을 보관한다

마찬가지로 VARCHAR를 사용하여 입력된 길이에 따라 공간을 효율적으로 사용할 수 있도록 했으며 작성자명은 길어도 10자까지만 저장할 수 있도록 했다.

 

 

password: 비밀번호

 

일정을 수정하거나 삭제하기 위한 비밀번호를 저장하기 위한 컬럼이다

동일하게 VARCHAR를 사용했으며 나중에 암호화하여 DB에 저장하는 것을 시도할 예정이므로 길이를 255로 설정했다.

 

 

create_date: 생성일

 

일정을 생성한 일자와 시간을 보관하는 컬럼이다

날짜정보와 시간을 보관할 수 있도록 DATETIME으로 타입을 결정했다

 

 

update_date: 수정일

 

처음 생성하면 생성일과 동일하게 날짜가 등록되지만 등록되어있는 일정을 수정하면 해당 컬럼의 날짜 정보가 수정된 날짜로 갱신되어 보관되는 컬럼이다.

마찬가지로 DATETIME으로 설계 했다.

 

테이블 생성 SQL

create table schedule (
    id bigint auto_increment primary key comment '일정 식별자',
    content varchar(255) comment '할일',
    name varchar(10) comment '일정 작성자',
    password varchar(40) comment '비밀번호-UUID',
    create_date datetime comment '생성일',
    update_date datetime comment '수정일'
)

필수 CRUD API 명세서

PostMan API 명세서

 

PostMan으로 필수로 구현할 일정 등록, 수정, 삭제, 조회 API 명세서를 작성해 보았다.

필수로 구현하는 단계에서는 예외처리가 없이 성공 케이스만 다루었으며 추후 예외처리와 기능확장을 도전할 때 더 완성도 있게 다뤄볼 예정이다.

 

https://documenter.getpostman.com/view/32918270/2sAYkGJyMR


API 개발 회고

API를 개발하기 위해서는 가장 먼저 패키지 구조를 잡은 후 생성한 테이블에 일정을 저장하기 위한 entity를 설계 했다

Entity

Schedule

/**
 * 일정 엔티티 - 필요한 필드만 생성할 수 있도록 빌더 패턴 사용
 */
@Builder
@Getter
@AllArgsConstructor
public class Schedule {
    private Long id;
    private String content;
    private String name;
    private String password;
    private LocalDateTime createDate;
    private LocalDateTime updateDate;

}

 

테이블에 생성한 컬럼을 필드로 모두 선언했고 생성일과 수정일은 시,분,초 단위까지 모두 받을 수 있는 LocalDateTime으로 설정했다.

 

여기서 처음에는 롬복의 @Getter와 @AllArgsConstructor 애노테이션만 사용하여 개발을 진행하고 있었다.

그러나 추후 DB에 데이터를 집어 넣기 위해 Schedule 객체를 생성하고 값을 집어넣으려고 하는데 필드가 많기도하고 id에는 null을 집어넣어야 하는 코드가 별로 가독성이 좋아 보이지 않아서 @Builder를 사용했다.

 

@Builder를 사용했으므로 @AllArgsConstructor를 제거해도 되지만 추후 확장시 필요할 수 있으므로 아직은 그대로 두었다

 

컨트롤러 계층과 DTO

SchedulerController

@RestController
@RequiredArgsConstructor
@RequestMapping("/required/scheduler")
public class SchedulerController {

    private final SchedulerService schedulerService;

    // API 메서드들 따로 설명 (생성, 수정, 삭제, 조회)
}

롬복의 RequiredArgsConstructor를 사용하여 private final로 선언한 SchedulerService schedulerService를 자동으로 생성자 주입 받도록 적용했다.

@Autowired 없이 생성자 주입을 간결하게 표현할 수 있어서 사용하는 것을 권장한다

 

그리고 우리의 목적은 API를 전송해주는 서버를 개발한 것이기 때문에 @RestController를 사용하여 응답 메시지를 전송하는 컨트롤러로 사용했다.

@RestController에는 @ResponseBody가 있기 때문에 메시지 바디에 응답을 담아서 전송할 수 있다.

 

추가적으로 @RequestMapping을 통해 필수 기능을 구현한 스케줄러 도메인이라는 뜻으로 URL 매핑을 /required/scheduler가 기본경로가 되도록 매핑했다.

이 경로는 도전할 기능까지 모두 완성되고 나면 /api/scheduler 라는 기본 경로로 다시 매핑할 예정이다.

 

 

addSchedule() - 일정 생성, updateSchedule() - 일정 수정

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


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

 

일정을 생성하는 addSchedule()과 수정하는 updateSchedule()은 같은 요청 정보를 필요로 하고 있기 때문에 SchedulerCommomRequestDto라는 DTO를 매개변수로 가지고 있다.

 

일정 생성은 PostMapping을 사용하고 수정은 전체를 수정하기 위해 PutMapping을 사용했으며 수정할 대상을 조회해야 하기 때문에 id값을 추가로 매핑한 다음 @PathVariable로 매핑된 id값을 매개변수로 가져와서 사용한다.

 

두 메서드 모두 JSON 형태로 일관된 응답을 줄 수 있도록 ResponseEntity를 사용하였고 여기에 일정 생성이 성공 하면 상태코드 201(Created)를 응답해주고 일정 수정이 성공하면 200(OK)를 응답해주도록 설정했다.

 

반환되는 객체는 일정 생성과 수정에서 공통된 응답값을 반환하므로 SchedulerCommonResponseDto를 사용하며 해당 객체는 일정의 ID값만 반환한다.

 

SchedulerCommomRequestDto

@Getter
@AllArgsConstructor
public class SchedulerCommonRequestDto {

    private final String content;
    private final String name;
    private final String password;
}

 

사용자가 일정을 생성하기 위해 필수로 입력해서 서버로 전송해야 할 기본 데이터이다.

일정, 사용자 이름, 비밀번호를 필드로 가지고 있으며 이 필드들의 값을 초기화하기 위한 @AllArgsConstructor와 값들을 꺼내기 위한 @Getter가 있다.

 

SchedulerCommomResponseDto

@Getter
@AllArgsConstructor
public class SchedulerCommonResponseDto {
    private Long id;
}

 

일정 생성과 수정에서 공통 응답으로 사용되는 DTO이며 일정의 ID값을 반환한다.

 

 

findSchedules() - 일정 전체 조회(검색 포함), findSchedule() - 일정 단건 조회

@GetMapping
public List<SchedulerFindResponseDto> findSchedules(SchedulerSearchCond searchCond) {
    return schedulerService.findAllSchedules(searchCond);
}


@GetMapping("/{id}")
public ResponseEntity<SchedulerFindResponseDto> findSchedule(@PathVariable Long id) {
    return new ResponseEntity<>(schedulerService.findScheduleById(id), HttpStatus.OK);
}

 

조회를 위한 API이므로 GetMapping()을 사용한다.

 

일정 전체 조회를 하는 findSchedules에서는 검색 조건을 입력할 수 있기 때문에 매개변수에 SchedulerSearchCond라는 DTO를 사용한다

 

여러개의 Schedule을 조회한다는 뜻에서 findSchedules로 복수를 사용했으며 schedulerServe.findAllSchedulers()메서드를 호출할 때 검색조건을 넘기고 실행된 결과를 List<SchdedulerFindResponseDto>로 반환하여 사용자에게 제공할 정보만 반환하도록 했다.

이때 정상 반환되면 200 상태코드가 반환된다

 

단건 조회에서는 일정 수정과 마찬가지로 URL에 일정의 id를 추가로 매핑되야하기 때문에 @GetMapping("/{id}")를 적용시킨다음 findSchedule 메서드에서 @Pathvariable을 사용하여 id의 값을 사용한다.

마찬가지로 schedulerService.findSchedulerById()메서드를 호출할 때 id값을 넘겨서 호출하고 정상 응답이 되면 200 상태코드가 반환된다.

 

SchedulerSearchCond

@Getter
@AllArgsConstructor
public class SchedulerSearchCond {

    // 요구 조건에 따라 날짜 포맷을 연-월-일 정보만 가지고 있음
    @DateTimeFormat(pattern = "yyyy-MM-dd")
    private LocalDate condDate;
    private String condName;

}

 

사용자가 일정을 조회할 때 검색 조건을 전달할 수 있는 DTO로 검색 조건을 위한 객체라는 뜻으로 Condition의 Cond를 사용했다.

년-월-일 정보와 사용자 이름을 검색조건으로 전달할 수 있다.

 

deleteSchedule() - 일정 삭제

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

 

일정을 삭제하는 API로 DeleteMapping을 사용하며 삭제할 일정의 id가 필요하기 때문에 id를 URL에 매핑하고 @PathVariable로 매핑한 id를 사용한다.

이때 삭제를 하려면 사용자가 입력한 비밀번호를 검증해야 하므로 SchedulerDeleteRequestDto를 매개변수로 사용한다.

 

schedulerService.deleteSchedule()을 호출할 때 id와 해당 DTO를 전달하고 이때 반환값은 필요 없으므로 삭제가 성공하면 200 상태코드만 반환한다.

 

SchedulerDeleteRequestDto

@Getter
@AllArgsConstructor
public class SchedulerDeleteRequestDto {
    private String password;
}

 

일정 삭제 시 비밀번호 검증이 필요하므로 비밀번호 필드 하나만 가지고 있는 DTO이다

 

서비스 계층

ShcedulerService 인터페이스

public interface SchedulerService {
    SchedulerCommonResponseDto saveSchedule(SchedulerCommonRequestDto commonRequestDto);

    List<SchedulerFindResponseDto> findAllSchedules(SchedulerSearchCond searchCond);

    SchedulerFindResponseDto findScheduleById(Long id);

    SchedulerCommonResponseDto updateSchedule(Long id, SchedulerCommonRequestDto commonRequestDto);

    void deleteSchedule(Long id, SchedulerDeleteRequestDto deleteDto);
}

 

실제 비즈니스의 로직이 수행되는 service 레이어의 SchedulerService 인터페이스이다.

구현체에 의존하지 않고 추상화에 의존하도록 하고 유연하게 확장할 수 있도록 인터페이스에 추상메서드를 작성하고 실제 구현은 별도의 구현체를 통해 작성했다.

 

SchedulerServiceImpl

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class SchedulerServiceImpl implements SchedulerService {

    private final SchedulerRepository schedulerRepository;

    // 메서드들은 별도로 설명

}

 

실제 Service계층의 구현 로직을 담당하고있는 구현체 클래스로 @Service애노테이션을 적용하여 스프링의 컴포넌트스캔의 대상이 되도록 한다.

컨트롤러와 마찬가지로 @RequiredArgsConstructor를 사용하여 ShcedulerRepository를 생성자 주입을 받도록 설정했다

 

클래스 레벨에 @Transactional(readOnly = true)를 적용시켜 기본적으로 읽기 전용으로 서비스 로직의 메서드를 하나의 트랜잭션 단위로 묶어 안전하게 수행되도록 지정했으며 이후 생성, 수정, 삭제 등의 쓰기가 필요한 로직에만 @Transactional을 추가로 붙여 쓰기가 가능하도록 할 예정이다.

 

 

saveSchedule() - 일정 저장

@Override
@Transactional
public SchedulerCommonResponseDto saveSchedule(SchedulerCommonRequestDto commonRequestDto) {

    Schedule schedule = Schedule.builder()
            .content(commonRequestDto.getContent())
            .name(commonRequestDto.getName())
            .password(commonRequestDto.getPassword())
            .createDate(LocalDateTime.now())
            .updateDate(LocalDateTime.now())
            .build();

    // 생성된 일정의 ID값 반환
    Long savedScheduleId = schedulerRepository.saveSchedule(schedule);
    return new SchedulerCommonResponseDto(savedScheduleId);
}

 

실제 일정을 생성하는 메서드로 쓰기전용의 트랜잭션이 필요하여 @Transactional이 적용되었고 컨트롤러에서 전달받은 SchedulerCommonRequestDto의 값을 꺼내서 Shedule을 생성한다

 

이때 Schedule의 id는 DB에서 자동으로 고유 값으로 생성되어 부여되기 때문에 id값을 제외한 나머지의 값만 추가하여 생성하는데, 이 생성하는 코드를 편하고 가독성있게 작성하기 위해 Entity에 @Builder를 사용했던 것이다.

 

이렇게 빌더패턴을 이용해 생성한 Schedule을 schedulerRepository.saveSchedule()메서드를 호출하여 리포지토리의 메서드에 전달하여 실제 DB에 저장하도록 하며 이때 반환값은 생성된 일정의 ID값이 반환된다.

 

반환된 일정의 ID를 new SchedulerCommonResponseDto(SavedScheduleId)로 응답을 위한 DTO를 생성하여 메서드를 반환한다.

 

 

findAllSchedules() - 전체조회, findScheduleById() - 단건 조회(ID로 조회)

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

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

    // 필수 예외 처리 없음 -> 도전에서 예외처리 강화하면서 상태코드 반환 예정
    if (optionalFindSchedule.isEmpty()) {
        return null;
    }

    return optionalFindSchedule.get();  // 조회한 일정을 반환, 검증로직을 거쳤으므로 .get()으로 바로 반환
}

 

전체 일정을 조회하는 findAllSchedules()는 매개변수의 검색 조건을 schedulerRepostiory.findAllSchedules()를 호출할 때 인자로 넘겨서 반환된 결과를 그대로 반환한다.

 

현재 필수로 CRUD를 구현하는 단계에서는 딱히 검증로직이나 추가로직을 추가할 필요가 없기 때문에 그대로 repository의 메서드에 검색조건만 넘기는 역할을 한다.

 

일정을 단건 조회하는 findScheduleById()메서드는 schedulerRepository.findScheduleById()로 찾은 검색 결과가 null에 안전하게 반환되기 위해 Optional<SchedulerFindResponseDto>로 반환된다.

 

그러므로 검색된 조건이 없는 경우 그에 따른 예외 처리를 진행해주여야 하지만 여기서는 성공 케이스만 우선 구현할 것이기 때문에 Optional의 값이 비어있다면 그대로 null을 반환하도록 하고 조회에 성공하면 Optional객체에서 .get()으로 실제 객체를 꺼내서 반환한다.

 

 

updateSchedule() - 일정 수정

@Override
@Transactional
public SchedulerCommonResponseDto updateSchedule(Long id, SchedulerCommonRequestDto commonRequestDto) {

    // 패스워드 검증
    String findPassword = schedulerRepository.findPasswordById(id);

    // 필수 구현에서는 별도 예외 처리 없이 비밀번호를 못찾거나 비밀번호가 안맞으면 null을 반환함 -> 도전에서 예외처리하면서 상태코드 반환 예정
    if (!StringUtils.hasText(findPassword) || !findPassword.equals(commonRequestDto.getPassword())) {
        return null;
    }

    int updatedRow = schedulerRepository.updateSchedule(id, commonRequestDto.getContent(), commonRequestDto.getName());

    // 필수 구현에서는 일단 수정된 값이 없으면 null로 반환
    if (updatedRow == 0) {
        return null;
    }
    // 수정완료 되면 id값 반환
    return new SchedulerCommonResponseDto(id);
}

 

실제 수정을 위한 메서드이므로 생성과 마찬가지로 @Transactional을 사용하여 하나의 수행 단위로 묶었다.

수정은 아무나 할 수 없도록 DB에서 id값으로 비밀번호를 조회하여 요청으로 입력한 비밀번호가 다르면 아무런 동작을 수행하지 않도록 null을 반환했다

 

만약 비밀번호가 DB에서 조회한 비밀번호와 같다면 실제 DB의 일정을 수정하기 위해 DTO로 전달받은 데이터와 id값을 schedulerRepository.updateSchedule()메서를 호출하는 인자로 전달한다.

 

반환값은 update가 수행된 결과를 반환해주는데 반환값이 0이면 업데이트가 된 것이 없으므로 잘못된 요청이 온 것이다

마찬가지로 이런 예외처리 로직은 향후에 작성하기 위해 지금은 단순히 null로 반환한다

 

수정이 완료되면 수정이 완료된 일정의 id를 new SchedulerCommonResponseDto(id)로 DTO를 생성하여 반환한다.

 

 

deleteSchedule() - 일정 삭제

@Override
@Transactional
public void deleteSchedule(Long id, SchedulerDeleteRequestDto deleteDto) {
    // 패스워드 검증
    String findPassword = schedulerRepository.findPasswordById(id);

    // 필수 구현에서는 별도 예외 처리 없이 비밀번호를 못찾거나 비밀번호가 안맞으면 그냥 리턴 -> 도전에서 예외처리하면서 상태코드 반환 예정
    if (!StringUtils.hasText(findPassword) || !findPassword.equals(deleteDto.getPassword())) {
        return ;
    }
    schedulerRepository.deleteSchedule(id);
}

 

리포지토리 계층

ShedulerRepository 인터페이스

public interface SchedulerRepository {
    Long saveSchedule(Schedule schedule);

    List<SchedulerFindResponseDto> findAllSchedules(SchedulerSearchCond searchCond);

    Optional<SchedulerFindResponseDto> findScheduleById(Long id);

    String findPasswordById(Long id);

    int updateSchedule(Long id, String content, String name);

    int deleteSchedule(Long id);
}

 

서비스와 마찬가지로 추상화에 의존하고 확장성을 고려하여 인터페이스를 설계했다

 

 

SchedulerRepositoryImpl

@Repository
public class SchedulerRepositoryImpl implements SchedulerRepository {

    private final NamedParameterJdbcTemplate jdbcTemplate;
    private final SimpleJdbcInsert jdbcInsert;
    
    public SchedulerRepositoryImpl(DataSource dataSource) {
    // 바인딩 순서로 쿼리하면 버그가 생길 수 있으므로 파라미터 이름으로 쿼리를 할 수 있는 JdbcTemplate
    this.jdbcTemplate = new NamedParameterJdbcTemplate(dataSource);
    this.jdbcInsert = new SimpleJdbcInsert(dataSource)  // Insert 편의 기능 활용
            .withTableName("schedule")
            .usingGeneratedKeyColumns("id");
    }
    
    // 메서드들은 별도로 설명
}

 

SchedulerRepository 인터페이스를 구현한 구현체이며 @Repository를 사용하여 컴포넌트스캔의 대상이 되도록 했다.

@Repository는 또 하나의 기능을 추가적으로 하는데 다양한 DB의 예외를 스프링이 제공하는 일관된 예외로 변환해주어 하위 기술에 종속되지 않고 일관되게 예외를 처리할 수 있게 해준다.

 

여기서는 @RequiredArgsConstructor 애노테이션을 사용하지 않고 직접 생성자를 작성해주었는데 jdbcTemplate과 Insert기능을 편하게 사용하는 SimpleJdbcInsert를 직접 생성해주기 위함이다.

 

생성자의 코드로 DB 커넥션 풀을 관리하는 DataSource를 외부에서 주입받아 NamedParameterJdbcTemplate(dataSource)로 파라미터이름으로 매핑하는 jdbcTemplate을 생성하고, Insert 기능을 쉽게 사용할 수 있는 SimpleJdbcInsert(dataSource)도 테이블이름과 PK컬럼을 매핑하여 함께 생성한다.

 

일반 jdbcTemplate은 파라미터 순서로 쿼리를 입력받는데, 매우 간단한 쿼리를 작성할 때는 괜찮지만 파라미터 순서가 변경되거나 의도치 않게 코드가 수정되어 DB에 데이터가 잘못 입력되는 치명적인 버그가 생길 수 있으므로 가급적 사용하지 않고 파라미터 이름을 사용하는 NamedParameterJdbcTemplate을 사용하는 것을 권장한다고한다.

 

jdbcTemplate 사용법이 하나도 기억이 안나서 기존에 강의에서 들었던 김영한님 강의를 정리해놓은 글을 많이 참고했다.

https://nagul2.tistory.com/313

 

 

SaveSchedule() - 일정 저장

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

 

Insert기능을 쉽게 사용하도록 생성한 SimpleJdbcInsert를 활용하여 매개변수로 넘어온 schedule을 DB에 저장하고 반환된 키값을 Long으로 꺼내서 반환한다.

 

NamedParameterJdbcTemplate을 사용하여 쿼리를 하기위해선 파라미터를 매핑을 해야하는데 이때 빈 프로퍼티 규약을 기반으로 파라미터객체를 생성하는 BeanPropertySqlParameterSource()를 사용했다.

 

생성된 파라미터를 인자로하여 jdbcInsert의 executeAndReturnKey(param) 메서드를 호출하면 insert SQL을 실행하고 생성된 키를 Number로 받을 수 있다

 

findAllSchedules() - 일정 전체 조회, 검색

@Override
public List<SchedulerFindResponseDto> findAllSchedules(SchedulerSearchCond searchCond) {
    LocalDate condDate = searchCond.getCondDate();  // 날짜 검색 조건
    String condName = searchCond.getCondName();     // 이름 검색 조

    SqlParameterSource param = new BeanPropertySqlParameterSource(searchCond);

    // 동적 쿼리 시작
    String query = "select id, content, name, update_date from schedule";

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

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

    if (StringUtils.hasText(condName)) {
        // 날짜 조건이 null이 아니라서 쿼리가 추가 되면 쿼리에 and 추가
        if (andFlag) {
            query += " and";
        }
        query += " name = :condName";   // 작성자 이름 같은 일정 조회
    }

    query += " order by update_date desc";

    return jdbcTemplate.query(query, param, scheduleRowMapper());
}

 

일정을 전체 조회할때도 파라미터를 생성하는 객체는 BeanPropertySqlParameterSource()를 사용하여 검색 조건을 파라미터로 생성했다.

 

그 다음 검색조건의 유무에 따라 동적쿼리를 작성해야 하는데, 문자열이므로 조건을 생각해서 + 연산으로 붙이면 된다.

이때 스트링빌더를 사용해도되는데 반복문속에서 +연산을 사용하는 것이 아니라면 + 연산도 내부적으로 StringBuilder를 사용하도록 최적화 되어있기 때문에 + 연산을 사용해도 괜찮다.

 

우선 기본적으로 반환값에 필요한 id, content, name, update_date를 조회하는 쿼리문과 update_date를 기준으로 내림차순으로 지정하는 쿼리문을 각각 준비하고 동적으로 생성할 로직을 이 두개의 쿼리 사이에 두어 조건에 따라 쿼리문을 추가해주면 된다.

  • 날짜가 null이 아니거나 이름이 빈문자열, null, 공백으로된 문자열이 아니면 where를 붙인다
  • 둘 중 하나의 조건만 만족할 수 있기 때문에 두 조건을 모두 사용하는 것을 구분하기 위해 andFlag를 false로 선언해둔다
  • 그다음 날짜가 null이 아니면 검색 날짜와 update_date 날짜가 일치하는 조건문을 이어 붙이고 andFlag를 true로 해둔다.
  • 여기서 DB에 입력된 데이터는 시,분,초 정보도 있지만 검색으로 넘어온 데이터는 연-월-일 정보만 있으므로 like 문법을 사용하여 연-월-일만 맞으면 모두 반환되도록 설정하였는데, 파라미터바인딩 문법과 문자열을 합치려면 concat 문법을 사용해야 하며 여기서 특수문자인 %를 문자열로 입력하기 위해서 '%'로 감쌌다
  • 그 다음 문자열이 null, 빈문자열, 공백으로된 문자열이 아니면 이름 검색 조건을 붙이기 위한 코드가 실행되는데 andFlag가 true이면 and를 추가한 뒤에 동적 쿼리를 추가하고, andFlag가 false이면 이름만 검색 조건에 사용되기 때문에 and없이 동적 쿼리만 추가한다.

이렇게 완성된 동적쿼리를 jdbcTemplate.query(쿼리, 매핑된 파라미터, 매퍼)정보를 통해 List로 반환받는다.

shceduleRowMapper()는 단건 일정을 조회하는 findCheduleById()에서도 사용되는데 아래 해당 메서드를 설명하고 설명하겠다.

 

findScheduleById() - 일정 단건 조회

@Override
public Optional<SchedulerFindResponseDto> findScheduleById(Long id) {
    String query = "select id, content, name, update_date from schedule where id = :id";

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

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

 

단건을 조회하는 쿼리문을 생성하고 Map.of를 통해 파라미터를 매핑할 파라미터를 생성했다.

파라미터를 생성하는 다양한 방법이 있는데 이렇게 단순히 Map으로 파라미터를 생성하여 쿼리에 전달할 수 있어 매핑할 파라미터가 적다면 단순한 Map으로 파라미터를 생성하는 방식을 권장한다.

 

조회된 일정을 jdbcTemplate.query()를 통해 List로 반환하여 조회된 값이 없어도 빈 값으로 반환되도록 하고, 반환된 값은 List로 반환되지만 조회된 내역은 1개밖에 없으므로 Stream의 findAny()를 사용하여 조회된 값을 Optional로 반환한다.

 

이후 예외처리 구현을 할 때 이 Optional을 활용하여 API의 응답을 다양하게 응답하도록 변경할 예정이다.

 

scheduleRowMapper()

private RowMapper<SchedulerFindResponseDto> scheduleRowMapper() {
    return BeanPropertyRowMapper.newInstance(SchedulerFindResponseDto.class);
}

 

DB의 조회 결과를 객체로 편리하게 변환하기 위한 메서드이다.

직접 익명클래스나, 람다식으로 매핑을 정의할 수도 있는데, 지금처럼 BeanPropertyRowMapper를 사용하면 ResultSet의 결과를 받아서 자바빈 규약에 맞추어 리플렉션을 활용하여 객체로 변환한다.

 

이를 사용하기 위해 기본생성자와 세터가 필요하기 때문에 SchedulerFindResponseDto에 @Setter와 @NoArgsConstructor애노테이션을 적용했던 것이다

 

ResultSet에 대한 자세한 내용은 아래의 글을 참고하면 좋다.

https://nagul2.tistory.com/305

 

findPasswordById()

@Override
public String findPasswordById(Long id) {
    String query = "select password from schedule where id = :id";
    Map<String, Long> param = Map.of("id", id);

    // 단건을 조회하는 queryForObject는 못찾으면 EmptyResultDataAccessException이 발생한다고 함
    try {
        return jdbcTemplate.queryForObject(query, param, String.class);
    } catch(EmptyResultDataAccessException e) {
        return null;    // 못찾으면 null
    }
}

 

일정 수정과 삭제를 위해 비밀번호 검증을 위한 메서드이다.

마찬가지로 id 하나만 파라미터로 사용하기 때문에 Map으로 파라미터를 만들었고 이를 단건 조회하는 jdbcTemplate.queryForObject()를 사용하여 String으로 결과를 반환 받는다.

 

이때 조회되는 값이 없으면 EmptyResultDataAccessException이 발생하기 때문에 try - catch로 상황에 따라 반환받는 값이 달라지도록 했으며 여기서는 단순하게 null을 했지만 안전하게 하려면 Optional로 하는 것이 좋다.

 

하지만 이 로직은 회고를 작성하면서 깨닫게 되었는데, 패스워드만 따로 조회하면 오히려 DB 호출만 더 많아질뿐이기 때문에 애초에 수정이나, 삭제를 할 때 일정 데이터를 불러오고 비밀번호가 맞으면 수정이나 삭제를 진행하는게 더 좋을 것 같다고 생각이 들어서 이후에 변경할 예정이다.

 

updateSchedule() - 일정 수정

@Override
public int updateSchedule(Long id, String content, String name) {
    // 업데이트 후 수정일을 변경한 시간으로 업데이트
    String query = "update schedule set content = :content, name = :name, update_date = now() where id = :id";

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

    return jdbcTemplate.update(query, param);
}
  • 매개변수의 id, content, name의 정보를 가지고 업데이트를 위한 쿼리를 생성한 후 MapSqlParameterSource()를 통해 파라미터 객체를 생성했다.
  • 파라미터를 매핑하는 또다른 방법으로 MapSqlParameterSource가 있는데 Map과 유사한 형태로 파라미터객체를 생성성한다
  • 생성해야 할 파라미터 객체가 아니고 개수가 좀 많다면 메서드 체인으로 파라미터를 생성할 수 있는 MapSqlParameterSource로 편리하게 파라미터를 생성할 수 있다
  • update()메서드를 통해 업데이트 쿼리를 전송하면 쿼리의 영향을 받은 row의 수가 int로 반환된다.

deleteSchedule() - 일정 삭제

@Override
public int deleteSchedule(Long id) {
    String query = "delete from schedule where id = :id";
    SqlParameterSource param = new MapSqlParameterSource().addValue("id", id);
    return jdbcTemplate.update(query, param);
}

 

일정 삭제를 위한 메서드로 삭제 쿼리를 생성한 후 MqpSqlParameterSource()를 활용하여 파라미터를 생성했다.

jdbcTemplate을 활용한 삭제 방법이 좀 특이한데 쿼리는 delete from ... 처럼 삭제 쿼리 이지만 실제 쿼리를 전송하기위한 메서드는 update()메서드를 호출하므로 반환값으로 영향받은 row 수를 int로 반환한다.


회고

배웠던 내용이고 강의에서 한번 다루었던 내용이지만 배운지 좀 시간이 지나서 완전 백지상태에서 구현하느라 생각보다 애를 많이 먹었다.

 

작성하면서 살짝 기억이 떠오르는 부분도 있고 아닌 부분도 있었는데 그래도 이런 토이프로젝트를 통해서 한번 jdbcTemplate을 사용하기 위해 jdbc 문서도 다시 살펴보면서 자바가 DB와 소통하는 가장 기본인 JDBC를 복습하는 계기가 되었다.

 

이제 단순히 성공 로직만 포함된 API를 더 촘촘하게 설계하기 위한 도전을 시도하여 다음 회고에 작성하겠다.

728x90