이번 과제는 일정 관리 기능을 제공하는 API를 Postman
을 사용해 테스트하며 구현하는 것이 목표다. 일정 조회, 생성, 수정, 삭제 기능을 제공하며, 3계층 구조(3 Layer Architecture)를 적용하고, JDBC
를 통해 MySQL 데이터베이스와 연동하는 것이 기본 요구사항이다.
구현하면서 있었던 트러블슈팅과 리팩토링 과정을 작성해보려고 한다.
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
생성일과 수정일을 DB에서 처리하도록 위와 같이 작성한 쿼리문을 기준으로 로직을 구현했다. 그래서 created_at
, updated_at
을 제외하고 DB에 Insert를 하면 null
값이 들어가는 문제가 발생했다.
기존 코드
@Override
public ScheduleResponseDto createSchedule(Schedule schedule) {
SimpleJdbcInsert jdbcInsert = new SimpleJdbcInsert(jdbcTemplate);
jdbcInsert.withTableName("schedule").usingGeneratedKeyColumns("schedule_id");
Map<String, Object> parameters = new HashMap<>();
parameters.put("writer_id", schedule.getWriter().getWriterId());
parameters.put("task", schedule.getTask());
parameters.put("password", schedule.getPassword());
Number key = jdbcInsert.executeAndReturnKey(parameters);
return getScheduleByIdOrElseThrow(key.longValue());
}
수정 코드
@Override
public Schedule createSchedule(Schedule schedule) {
String sql = "INSERT INTO schedule (writer_id, task, password) VALUES (?, ?, ?)";
// SQL 쿼리 실행 후 DB에서 자동으로 생성된 기본 키 값을 가져오는 데 사용
KeyHolder keyHolder = new GeneratedKeyHolder();
jdbcTemplate.update(connection -> {
PreparedStatement ps = connection.prepareStatement(sql, new String[] {"schedule_id"});
ps.setLong(1, schedule.getWriter().getWriterId());
ps.setString(2, schedule.getTask());
ps.setString(3, schedule.getPassword());
return ps;
}, keyHolder);
// 삽입된 id를 가지고 단건 조회
Long key = keyHolder.getKey().longValue();
return getScheduleByIdOrElseThrow(key);
}
이렇게 하니 정상적으로 DEFAULT
적용된다.
💡 왜 이런 문제가 발생했을까?
이 이유를 몇 시간 째 찾고 있었는데, 준모님이 한 방에 찾아주셨다....
실행 메소드를 타고 내부로 가면 values.add((Object) null);
부분이 있다. 이게 문제였다.
SimpleJdbcInsert
는 명시적으로 모든 값을 넣기 때문에 DB의 DEFAULT
값을 자동으로 처리하지 못한다.
해결 방법으로는 세 가지를 고려할 수 있었다.
1. 시간을 LocalDateTime.now()
로 삽입 (DB의 값과 일관성이 깨질 수 있는 위험)
2. 테이블을 DEFAULT
옵션 없이 새로 만들기
3. jdbcTemplate
로직으로 대체
나는 세 번째 방법을 선택했고, 위에 작성한 수정 코드로 변경했다.
@DeleteMapping("/{scheduleId}")
public ResponseEntity<Void> deleteSchedule(@PathVariable Long scheduleId,
@RequestParam @Valid String password) {
scheduleService.deleteSchedule(scheduleId, password);
return new ResponseEntity<>(HttpStatus.NO_CONTENT);
}
파라미터로 값을 받는 password
의 유효성 검사가 제대로 이루어지지 않았다.
분명 유효성 검증 예외 처리를 하는 핸들러를 만들었는데도 말이다.
콘솔 창을 잘 살펴보니 MissingServletRequestParameterException
예외가 발생한다.
이 예외가 무엇인가 알아보니 @RequestParam
에 필수 파라미터인 password
가 요청에 포함되지 않아서 발생하는 오류이다.
기존에 만든 핸들러는 MethodArgumentNotValidException
을 처리하는 핸들러였다.
// MissingServletRequestParameterException 처리
@ExceptionHandler(MissingServletRequestParameterException.class)
public ResponseEntity<String> handleMissingParamExceptions(MissingServletRequestParameterException ex) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body("필수 값 " + ex.getParameterName() + "을(를) 입력하세요.");
}
그래서 위와 같이 MissingServletRequestParameterException
을 처리하는 핸들러를 추가해 해결했다.
/**
* 일정 삭제 API (소프트 딜리트 적용)
* 일정 ID를 받아 해당 일정 삭제
* 비밀번호가 일치해야 삭제 가능
* 'deleted_at' 컬럼을 업데이트하여 논리적으로 삭제 처리
* 향후 복구 가능하도록 데이터는 유지
*/
@DeleteMapping("/{scheduleId}")
public ResponseEntity<Void> deleteSchedule(@PathVariable Long scheduleId,
@RequestParam @Valid String password) {
scheduleService.deleteSchedule(scheduleId, password);
return new ResponseEntity<>(HttpStatus.NO_CONTENT);
}
@Transactional
@Override
public void deleteSchedule(Long scheduleId, String password) {
Schedule schedule = scheduleRepository.getScheduleByIdOrElseThrow(scheduleId);
// 입력된 비밀번호와 실제 비밀번호가 일치하지 않을 경우 예외 발생
if (schedule.verifyPassword(password)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "비밀번호가 일치하지 않습니다.");
}
// 일정 삭제
scheduleRepository.deleteSchedule(scheduleId);
}
@Override
public void deleteSchedule(Long scheduleId) {
String sql = "UPDATE schedule SET deleted_at = ? WHERE schedule_id = ?";
jdbcTemplate.update(sql, LocalDateTime.now(), scheduleId);
}
소프트 딜리트라는 개념을 알게 돼서 적용해봤다.
실생활 예시로는 회원 탈퇴 후 7일 이내 취소 가능한 경우에 소프트 딜리트를 사용한다.
기존 코드
public Schedule(Long scheduleId, String task, String password, Long writerId,
String name, String email, LocalDateTime createdAt, LocalDateTime updatedAt) {
this.scheduleId = scheduleId;
this.task = task;
this.password = password;
this.createdAt = createdAt;
this.updatedAt = updatedAt;
this.writer = new Writer(writerId, name, email);
}
@Override
public Schedule mapRow(ResultSet rs, int rowNum) throws SQLException {
return new Schedule(
rs.getLong("schedule_id"),
rs.getString("task"),
rs.getString("password"),
rs.getLong("writer_id"),
rs.getString("name"),
rs.getString("email"),
rs.getTimestamp("a.created_at").toLocalDateTime(),
rs.getTimestamp("a.updated_at").toLocalDateTime()
);
}
기존 코드에서는 Schedule
클래스의 생성자 내에서 Writer
객체를 직접 생성하고 있었다.
이로 인해 Schedule
클래스는 Writer
객체 생성에 대한 직접적인 책임을 가지고 있다는 문제점이 있었다.
수정 코드
public Schedule(Long scheduleId, String task, String password, LocalDateTime createdAt,
LocalDateTime updatedAt, Writer writer) {
this.scheduleId = scheduleId;
this.task = task;
this.password = password;
this.createdAt = createdAt;
this.updatedAt = updatedAt;
this.writer = writer;
}
@Override
public Schedule mapRow(ResultSet rs, int rowNum) throws SQLException {
// Writer 객체는 외부에서 주입받도록 수정
Writer writer = new Writer(rs.getLong("writer_id"), rs.getString("name"), rs.getString("email"));
return new Schedule(
rs.getLong("schedule_id"),
rs.getString("task"),
rs.getString("password"),
rs.getTimestamp("a.created_at").toLocalDateTime(),
rs.getTimestamp("a.updated_at").toLocalDateTime(),
writer // 외부에서 생성된 Writer 객체를 사용
);
Schedule
클래스는 Writer
객체 생성에 대한 책임을 지지 않게 되었고, 테스트와 유지보수 측면에서도 더 유연한 구조가 되었다.
기존 코드
@Override
public Schedule getScheduleByIdOrElseThrow(Long scheduleId) {
String sql = "SELECT schedule_id, a.writer_id, name, email, task, password, a.created_at,a.updated_at "
+ "FROM schedule a JOIN writer b ON a.writer_id = b.writer_id WHERE schedule_id = ? "
+ "AND a.deleted_at IS NULL";
List<Schedule> result = jdbcTemplate.query(sql, new ScheduleRowMapper(), scheduleId);
return result.stream().findAny().orElse(null);
}
기존 코드에서는 getScheduleByIdOrElseThrow
메서드가 Schedule
을 조회한 후, 결과가 없으면 null
을 반환했다. 이 경우 서비스 레벨에서 null
체크를 해야 하고, null
값에 대한 예외 처리가 따로 필요했다. 즉, 예외 상황을 메서드가 아닌 서비스 계층에서 처리하도록 하는 방식이였다.
수정 코드
@Override
public Schedule getScheduleByIdOrElseThrow(Long scheduleId) {
String sql = "SELECT schedule_id, a.writer_id, name, email, task, password, a.created_at,a.updated_at "
+ "FROM schedule a JOIN writer b ON a.writer_id = b.writer_id WHERE schedule_id = ? "
+ "AND a.deleted_at IS NULL";
try {
return jdbcTemplate.queryForObject(sql, new ScheduleRowMapper(), scheduleId);
} catch (EmptyResultDataAccessException e) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "id가 " + scheduleId + "인 일정이 존재하지 않습니다.");
}
}
수정된 코드에서는 queryForObject
를 사용해 결과를 바로 반환하며, 결과가 없을 경우 EmptyResultDataAccessException
을 발생시킨다. 이를 catch
하여, 해당 일정이 존재하지 않으면 ResponseStatusException
을 던져서 클라이언트에게 404 Not Found
응답을 반환하게 변경했다. 예외가 발생하면 바로 처리되기 때문에, 서비스 레벨에서 추가적인 예외 처리가 필요하지 않게 된다.
/**
* 검증을 위해 scheduleId 값으로 단건 조회를 할 경우 생성자가 초기화
* 그 값을 이용해 입력된 패스워드와 비교하는 메서드
*/
public boolean verifyPassword(String password) {
return !this.password.equals(password);
}
Schedule schedule = scheduleRepository.getScheduleByIdOrElseThrow(scheduleId);
if (schedule.verifyPassword(password)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "비밀번호가 일치하지 않습니다.");
}
기존 코드에서는 getPassword()
메서드를 통해 비밀번호를 불러와서 비교하는 방식이었다. 이 방식은 비밀번호를 외부로 노출시키고, 객체에 대해 불필요한 메서드를 호출하는 방식으로 코드가 복잡하고 비효율적이였다.
수정된 코드에서는 Schedule
객체 내에 직접 비밀번호 검증 로직을 추가해 코드의 복잡성을 줄이고, 불필요한 메서드 호출을 제거해 메모리 낭비를 방지하도록 개선했다.
객체지향에 대해 깊이 들어갈수록 어렵다 ㅠㅠ
객체가 가지는 역할, 의존성, 불필요한 호출 등에 초점을 맞춰 리팩토링을 진행했는데
튜터님의 코드리뷰가 없었다면 발견하지도 못 했을 문제점들이였다.
예외 처리가 세분화되면서 신경 써야 할 부분이 많아져 한 번에 기능을 구현하는 것이 힘들었지만,
계속 수정하고 개선하다 보면 한결 더 나은 나만의 코드 스타일을 완성할 수 있을 거라고 생각한다.