개요
CH 3 일정 관리 앱 만들기 필수/도전 기능을 구현하면서 고민했던 부분과 트러블 슈팅 과정에 대해 작성해보겠습니다.
📌 필수/도전 기능 가이드
Lv 1. 일정 생성 및 조회
일정 생성하기: Default 값이 왜 적용이 안되지? 😵
일정을 저장할 테이블을 다음과 같은 구조로 생성했습니다.
CREATE TABLE schedule
(
id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '일정 식별자',
-- 생략
created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '작성일',
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '수정일'
);
created_at과 update_at은 처음 생성될 때 Default 값으로 현재 시간을 갖도록 설정했습니다.
일정 생성 API를 구현하기 위해서 Repository단을 다음과 같이 구성했습니다.
public ScheduleResponseDto saveSchedule(Schedule schedule) {
SimpleJdbcInsert jdbcInsert = new SimpleJdbcInsert(jdbcTemplate)
.withTableName("schedule")
.usingGeneratedKeyColumns("id");
Map<String, Object> parameters = new HashMap<>();
parameters.put("author_name", schedule.getAuthorName());
parameters.put("password", schedule.getPassword());
parameters.put("task", schedule.getTask());
Number key = jdbcInsert.executeAndReturnKey(new MapSqlParameterSource(parameters));
return new ScheduleResponseDto(key.longValue(), schedule.getAuthorName(), schedule.getTask());
}
Postman에서 일정 생성 API를 테스트를 했습니다.
created_at과 updated_at은 default값을 설정해놔서 작성자명, 비밀번호, 할일만 JSON 데이터로 requestBody에 넣어 전달했습니다.
그런데 실행 결과를 확인해보니 default값이 안들어가고 null로 삽입이 되어있었습니다.
콘솔에서 직접 INSERT하면 DEFAULT값이 잘 들어갔기 때문에, DB가 아니라 로직 문제로 판단했습니다.
SQL이 어떻게 작동하나 확인하기 위해서 SQL 실행 로그를 확인할 수 있도록 application.properties에 설정을 추가했습니다.
# SQL 로그 출력
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true
logging.level.org.springframework.jdbc.core=DEBUG
logging.level.org.hibernate.SQL=DEBUG
logging.level.org.hibernate.type.descriptor.sql.BasicBinder=TRACE
참고: https://zzang9ha.tistory.com/399
SQL 실행 로그를 보니, created_at과 updated_at 컬럼도 null값으로 insert가 되고있는걸 확인했습니다.
SimpleJdbcInsert 객체로 executeAndReturnKey를 실행할 때, 파라미터로 created_at, updated_at 값을 전달하지 않아도, insert문을 실행할 때는 null 값을 전달하고 있었습니다.
jdbcInsert.usingColumns("author_name", "password", "task");
usingColumns 메서드로 값을 전달할 컬럼만 지정해 주었더니 문제가 해결됐습니다.
(반환값에 생성일,수정일은 아직 가져오도록 설정안해서 null로 반환되는게 맞음)
수업 실습 코드만 참고하느라 SimpleJdbcInsert 객체 사용법은 확인하지 않아서 발생한 문제였습니다.
원인을 못찾아서 헤맸는데, 확인하고보니 별거 아니라서 허무합니다 흑흑
그래도 다음번에 이런 문제가 발생하면 객체에 대한 검색부터 하면 된다는걸 알았으니 다행입니다 ^^...
Lv 2. 일정 수정 및 삭제
수정/삭제를 위해, id로 비밀번호 확인하기
비밀번호를 전달받아 비밀번호가 저장된 비밀번호와 일치할 때만 수정/삭제가 가능하도록 비밀번호 검증이 필요했습니다.
저장된 비밀번호를 조회할 때 queryForObjectr를 사용했습니다.
public String findPasswordById(Long id) {
String sql = "SELECT password FROM schedule WHERE id = ?";
return jdbcTemplate.queryForObject(sql, String.class, id);
}
https://stackoverflow.com/questions/29286725/using-spring-jdbctemplate-to-extract-one-string
https://stackoverflow.com/questions/69784476/how-do-i-fix-this-problem-it-says-queryforobjectjava-lang-string-java-lang-o
How do I fix this problem it say's 'queryForObject(java.lang.String, java.lang.Object[], java.lang.Class<T>)' is deprecated
I'm not sure how to fix this since I just started doing spring boot project last Thursday. Unfortunately today is the deadline of my project so frankly I'm quite panicking right now :/ Here is my s...
stackoverflow.com
수정/삭제할 때 존재하는 id인지 확인하기
테스트 과정에서 존재하지 않는 id로 요청했을 때 HTTP 상태 코드가 적절하지 않게 나온다는걸 확인했습니다.
id가 없는 경우에는 404를 반환하도록 수정했습니다.
수정 전: 비밀번호 확인하고, 삭제
public void deleteSchedule(Long id, String password) {
String savedPassword = scheduleRepository.findPasswordById(id);
if (!savedPassword.equals(password)) {
throw new IllegalArgumentException("비밀번호가 틀렸습니다.");
}
int deletedRow = scheduleRepository.deleteSchedule(id);
if (deletedRow == 0) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Dose not exist id = " + id);
}
}
수정 후: id유효한지 확인하고, 비밀번호 확인하고, 삭제
public void deleteSchedule(Long id, String password) {
if (!scheduleRepository.existById(id)) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Dose not exist id = " + id);
}
String savedPassword = scheduleRepository.findPasswordById(id);
if (!savedPassword.equals(password)) {
throw new IllegalArgumentException("비밀번호가 틀렸습니다.");
}
int deletedRow = scheduleRepository.deleteSchedule(id);
if (deletedRow == 0) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Dose not exist id = " + id);
}
}
public boolean existById(Long id) {
String sql = "SELECT COUNT(*) FROM schedule WHERE id = ?";
Integer count = jdbcTemplate.queryForObject(sql, Integer.class, id);
return count != null && count > 0;
}
ID 존재 여부 확인, 비밀번호 검증은 수정과 삭제에 모두 사용하니까 공통 검증 메서드로 분리했습니다.
private void validateScheduleAndPassword(Long id, String password) {
if (!scheduleRepository.existById(id)) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Does not exist id = " + id);
}
String savedPassword = scheduleRepository.findPasswordById(id);
if (!savedPassword.equals(password)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "비밀번호가 틀렸습니다.");
}
}
Lv 3. 연관 관계 설정
조건에 대한 고민
일정(schedule)과 작성자(author)를 연결하기 위해서 기존에 구현해뒀던 API의 수정에 대해서 고민했었고,
다음과 같이 정리한 다음 구현을 시작했습니다.
작성자 테이블 생성 및 외래키 설정 관련 작성자 테이블을 생성한 후, 해당 테이블의 작성자 id를 일정 테이블의 외래키(FK) 로 설정
➡️ 기존 일정 테이블에 존재하던 작성자명 컬럼은 삭제
➡️ 일정 생성 API에서 작성자명을 제거하고 작성자 id만 입력받도록 수정
전체 일정 조회 및 선택 일정 조회 API 수정
➡️ 쿼리에 INNER JOIN을 추가하여 작성자 테이블에서 작성자명을 조회하는 방식으로 수정
➡️ 일정 수정 API에서 작성자 테이블의 작성자명을 수정하도록 변경
Lv 4. 페이지네이션
일정 목록 조회 API에 페이징을 적용하여 page와 size를 Query Parameter로 전달받도록 구현했습니다.
`Pageable` 객체를 활용하여 요청한 페이지에 해당하는 일정이 반환되도록 했습니다.
public Page<ScheduleResponseDto> findSchedulesByFilters(Long authorId, String updatedAt, Pageable pageable) {
long totalCount = scheduleRepository.countSchedules();
List<ScheduleResponseDto> schedules = scheduleRepository.findSchedulesByFilters(authorId, updatedAt, pageable);
return new PageImpl<>(schedules, pageable, totalCount);
}
Lv 5. 예외 발생 처리
- `@ExceptionHandler`와 `@RestControllerAdvice`를 활용하여 공통 예외 처리를 구현했습니다.
- CustomException을 상속받아 InvalidPasswordException, ScheduleNotFoundException 등의 사용자 정의 예외를 정의했습니다.
- CustomErrorCode Enum을 사용하여 예외의 상태 코드와 메시지를 관리했습니다.
CustomException 부모 예외 클래스 만들기
예외 처리를 일관성 있게 적용하기 위해 CustomException이라는 부모 예외 클래스를 만들었습니다.
@Getter
public class CustomException extends RuntimeException {
private final CustomErrorCode errorCode;
private final String message;
public CustomException(CustomErrorCode errorCode) {
super(errorCode.getMessage());
this.errorCode = errorCode;
this.message = errorCode.getMessage();
}
public CustomException(CustomErrorCode errorCode, String message) {
super(errorCode.getMessage());
this.errorCode = errorCode;
this.message = message;
}
}
- 모든 사용자 정의 예외가 CustomException을 상속받도록 구현했습니다.
- CustomErrorCode Enum를 사용하여 HTTP 상태 코드와 메시지를 관리하도록 했습니다.
CustomErrorCode Enum 설계
각 예외에 대한 HTTP 상태 코드와 에러 메세지를 Enum으로 관리했습니다.
@Getter
public enum CustomErrorCode {
INVALID_REQUEST(HttpStatus.BAD_REQUEST, "잘못된 요청입니다."),
PASSWORD_MISMATCH(HttpStatus.FORBIDDEN, "비밀번호가 일치하지 않습니다."),
SCHEDULE_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 일정입니다."),
AUTHOR_NOT_FOUND(HttpStatus.NOT_FOUND, "작성자가 존재하지 않습니다."),
INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "서버 내부 오류가 발생했습니다.");
private final HttpStatus httpStatus;
private final String message;
CustomErrorCode(HttpStatus httpStatus, String message) {
this.httpStatus = httpStatus;
this.message = message;
}
}
사용자 정의 예외
사용할 예외 클래스들을 CustomException을 상속받아 생성했습니다.
ScheduleNotFoundException
public class ScheduleNotFoundException extends CustomException {
public ScheduleNotFoundException(Long id) {
super(CustomErrorCode.SCHEDULE_NOT_FOUND, "존재하지 않는 일정입니다. id = " + id);
}
}
- 일정 조회 시 존재하지 않은 ID로 요청하면 발생하도록 구현했습니다.
InvalidPasswordException
public class InvalidPasswordException extends CustomException {
public InvalidPasswordException() {
super(CustomErrorCode.PASSWORD_MISMATCH);
}
}
- 수정 또는 삭제 시 비밀번호가 틀릴 경우 발생하도록 구현했습니다.
@RestControllerAdvice를 활용한 공통 예외 처리
GlobalExceptionHandler
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(CustomException.class)
public ResponseEntity<CustomErrorResponse> handleCustomException(CustomException ex) {
return ResponseEntity
.status(ex.getErrorCode().getHttpStatus())
.body(CustomErrorResponse.of(ex.getErrorCode(), ex.getMessage()));
}
}
- 모든 CustomException에 대한 처리를 하는 handleCustomException을 구현했습니다.
- 발생한 예외에 맞는 HttpStatus와 ErrorResponse를 반환합니다.
ErrorResponse DTO
예외가 발생하여 HttpStatus와 메세지를 반환할 때 JSON 형태로 코드와 메세지, 시간을 반환해주기 위해서 DTO를 정의했습니다.
@Getter
@AllArgsConstructor
public class CustomErrorResponse {
private final int code;
private final String message;
private final LocalDateTime timestamp;
public static CustomErrorResponse of(CustomErrorCode errorCode, String customMessage) {
return new CustomErrorResponse(
errorCode.getHttpStatus().value(),
(customMessage != null) ? customMessage : errorCode.getMessage(),
LocalDateTime.now()
);
}
}
Lv 6. null 체크 및 특정 패턴에 대한 검증 수행
ScehduleRequestDto
@Getter
public class ScheduleRequestDto {
@NotNull(message = "작성자 ID는 필수 입력값입니다.")
private Long authorId;
private String authorName;
@NotNull(message = "할일은 필수 입력값입니다.")
@Size(max=200, message = "할일은 최대 200자까지 입력 가능합니다.")
private String task;
@NotNull(message = "비밀번호는 필수 입력값입니다.")
private String password;
}
ScheduleController
@Valid 어노테이션을 사용해서 dto에 대해 검증을 수행하도록 했습니다.
public ResponseEntity<ScheduleResponseDto> createSchedule(@Valid @RequestBody ScheduleRequestDto dto)
GlobalExceptionHandler
설정한 메세지가 알맞게 반환되도록 `MethodArgumentNotValidException`을 GlobalExceptionHandler에서 관리하게 구현했습니다.
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<Map<String, String>> handleValidationExceptions(MethodArgumentNotValidException ex) {
Map<String, String> errors = new HashMap<>();
ex.getBindingResult().getFieldErrors().forEach(error -> {
errors.put(error.getField(), error.getDefaultMessage());
});
return ResponseEntity
.status(HttpStatus.BAD_REQUEST)
.body(errors);
}
Swagger 적용 및 API 문서화
이번 일정 관리 앱을 개발하면서 Swagger를 적용하여 API 문서를 자동으로 생성하고, 테스트할 수 있도록 설정했습니다.
controller에 API 문서화 어노테이션 @Operation, @Parameter, @ApiResponses 등을 적용했습니다.
@Operation(
summary = "일정 생성",
description = "작성자의 ID, 할 일, 비밀번호를 입력받아 새로운 일정을 생성합니다."
)
@ApiResponses({
@ApiResponse(responseCode = "201", description = "정상 생성",
content = @Content(schema = @Schema(implementation = ScheduleResponseDto.class))),
@ApiResponse(responseCode = "400", description = "잘못된 요청 (필수 입력값 누락)",
content = @Content(schema = @Schema(hidden = true)))
})
@PostMapping
public ResponseEntity<ScheduleResponseDto> createSchedule(
@io.swagger.v3.oas.annotations.parameters.RequestBody(
description = "생성할 일정 정보",
required = true,
content = @Content(
mediaType = "application/json",
examples = @ExampleObject(value = """
{
"authorId": 1,
"task": "회의 참석",
"password": "Pass123"
}
""")
)
) @Valid @RequestBody ScheduleRequestDto dto) {
return new ResponseEntity<>(scheduleService.createSchedule(dto), HttpStatus.CREATED);
}
➡️ 과제를 다 제출하고 알게된 사실..! RequestDto는 여러개여도 되는구나..!
Dto를 생성, 수정, 삭제 Dto로 분리하면 아래 @io.swagger.v3.oas.annotations.parameters.RequestBody는 삭제할 수 있을 것 같습니다. 보기 싫었는데 잘됐다..!
마무리
API를 직접 만들어보고, 로컬이지만 서버에서 테스트까지 해보면서 구현하는 경험은 처음이었습니다.
Spring 입문 온라인 강의를 들을 때, "어노테이션이 왜 이렇게 많을까? 도대체 뭘 써야 하는 걸까?"라는 생각이 들었는데, 솔직히 지금도 여전히 헷갈립니다. 😅 하지만 과제를 하면서 하나씩 적용해 보고, 조금씩 적응해 가는 과정이 나름 재미있었습니다. 덕분에 많이 배우고 공부도 됐어요.
지금 이 마무리 글을 쓰는 시점이 과제 해설 세션을 들은 직후인데… 수정해야 할 부분이 아주 많아보이지만 그래도 공부가 됐으니 OK입니다.
https://github.com/mannaKim/spring-basic-schedule
GitHub - mannaKim/spring-basic-schedule: 일정 관리 앱 만들기 Spring JDBC 과제
일정 관리 앱 만들기 Spring JDBC 과제. Contribute to mannaKim/spring-basic-schedule development by creating an account on GitHub.
github.com
'Dev Projects' 카테고리의 다른 글
[table-now] 소셜 로그인(Kakao, Naver) 통합 구현기 (1) | 2025.04.21 |
---|---|
[Spring Data JPA_일정 관리 앱 Develop] 필수 & 도전 기능 구현 기록 🖋️ (1) | 2025.02.13 |
[Java Project_키오스크 과제] 도전 기능 구현 및 트러블 슈팅 (0) | 2025.01.20 |
[Java Project_키오스크 과제] 필수 기능 구현 및 단계별 설계 (0) | 2025.01.15 |
[Mini Project_자기소개 웹페이지 만들기] Firestore 데이터 정렬(orderBy)과 HTML 스크립트 분리로 코드 관리 효율화 (0) | 2024.12.27 |