오랜만에 스프링을 해서 그런가 과제 시작하고 하루에서 이틀 동안은 손에 잘 안잡혔던 것 같다. 특히 생각이 잘 안나는 JDBC 만을 사용해서 구현해야 하다보니 더 안잡혔던 것 같다.
그래도 과제를 하면서 사용해본 적 없던 MapStruct나 Swagger를 사용해보려고 시도해봤다. MapStruct 같은 경우 어찌저찌 사용을 했지만 Swagger의 경우엔 공통 응답 클래스를 따로 만들어서 그런가 원하는대로 응답 구조가 만들어지지 않아서 결국 Excel에 API 명세서를 작성했다.
나중에 좀 더 찾아보고 적용시켜보는 것을 목표로 해봐야겠다.
Zero or One
Zero or Many
One or Many (1 또는 N)
One Only (정확히 1)
Many (N)
One (1)
Jackson은 자바 진영의 다양한 형식의 데이터를 지원하는 Data processing 툴이다. 스트림 방식으로 속도가 빠르고 유연하며 다양한 서드 파티 데이터 타입을 지원한다. 또한 어노테이션 방식으로 메타 데이터를 기술할 수 있다.
@JsonIgnoreProperties: 직렬화, 역직렬화 시 제외할 속성을 지정.@JsonIgnore: 멤버 변수 위에 선언해서 제외 처리@JsonProperty: Json으로 변환 시 사용할 이름 (DB 컬럼과 이름이 다르거나 API 응답과 이름이 다르지만 매핑해야 하는 경우)@JsonInclude: 값 존재 유무에 따라 직렬화 시 동작을 지정default)Optional, AtomicReference)을 제외하고 포함ERD 등의 사진 파일을 올려야 할 경우 따로 image 폴더를 만들어서 두지 않고도 사진 파일을 사용할 수 있다.
Issues 탭으로 들어간다.New Issue를 누른다.사실 페이지네이션은 해본 적이 없어서 알게된 내용으로 가야하지 않을까 싶지만.. 일단 적어본다.
페이지네이션은 DB 상으론 limit와 offset을 사용하면 되는데 limit는 결과 중 처음부터 얼마나 가져올 것인지를 정하는 것이고, offset은 어디서부터 가져올 것인지를 정하는 것이다.
이때 offset의 경우 입력받은 page 값에서 1만큼 빼준 뒤 입력받은 size를 곱해주면 된다. (int offset = (page - 1) * size)
-1을 하는 이유는 사용자가 볼 땐1 페이지지만 실제로는0 페이지이기 때문이다.
전체 일정의 개수를 알려주기 위해서 repository에 count 메서드를 추가했다. 그리고 전체 일정 개수를 활용해서 전체 페이지 수 또한 계산 후 넘겨줬다.
@Override
public PageDto<ScheduleWithAuthor> findAll(GetSchedulesRequest request) {
List<ScheduleWithAuthor> resultList = scheduleRepository.findAll(request.getAuthorId(), request.getSelectedDate(), request.getPage(), request.getSize());
long totalCount = scheduleRepository.count();
int totalPages = (int) Math.ceil((double) totalCount / request.getSize());
return new PageDto<>(resultList, request.getPage(), request.getSize(), totalCount, totalPages);
}
따로 PageDto 객체를 만들어서 반환하도록 했다. 또한 다른 메서드와는 달리 작성자의 이름이 포함되어야 하기 때문에 작성자의 이름을 반환받기 위해 repository 폴더에 ScheduleWithAuthor를 만들어서 repository의 반환값으로 사용했다.
repository에서 RowMapper를 사용할 때 컴파일 에러가 났었었다.
private RowMapper<Schedule> scheduleRowMapper() {
return (rs, rowNum) -> {
Schedule.builder()
.id(rs.getLong("schedule_id"))
.authorId(rs.getLong("author_id"))
.todo(rs.getString("todo"))
.password(rs.getString("password"))
.createdAt(rs.getTimestamp("created_at").toLocalDateTime())
.lastUpdated(rs.getTimestamp("last_updated").toLocalDateTime())
.build();
} // error!: return 문 누락
}
알고보니 @Builder 사용 시 리턴이 없기 때문에 람다 본문을 만들 필요가 없었었다.
private RowMapper<Schedule> scheduleRowMapper() {
return (rs, rowNum) ->
Schedule.builder()
.id(rs.getLong("schedule_id"))
.authorId(rs.getLong("author_id"))
.todo(rs.getString("todo"))
.password(rs.getString("password"))
.createdAt(rs.getTimestamp("created_at").toLocalDateTime())
.lastUpdated(rs.getTimestamp("last_updated").toLocalDateTime())
.build();
}
일정 전체 조회 시 QueryParameter로 작성자 ID, 선택한 날짜, 페이지, 사이즈 값을 받아오게 되는데 이것들을 Request 객체로 만들어서 한 번에 가져오려고 했고, 별 생각 없이 @RequestParam을 사용했다.
@GetMapping
@Override
public ApiResponse<PageDto<ScheduleWithAuthor>> findAllSchedules(@Valid @RequestParam GetSchedulesRequest request) { // error!
PageDto<ScheduleWithAuthor> authorDtoPageDto = scheduleService.findAll(request);
return ApiResponse.success(OK, authorDtoPageDto, "일정 전체 조회 성공");
}
하지만 요청 전송 시 MissingServletRequestParameterException이 발생했고 파라미터 request를 찾을 수 없다고 나왔다.
찾아보니 @RequestParam은 단일 파라미터를 매핑하기 때문에 GetSchedulesRequest 타입의 request라는 이름을 찾는 것이었다. 당연히 그런 타입은 없고, QueryParameter에도 request라는 이름이 없으니 예외가 발생하는 것이었다.
그래서 여러 파라미터를 읽는 경우에 사용하는 @ModelAttribute를 사용해서 해결하게 되었다.
@GetMapping
@Override
public ApiResponse<PageDto<ScheduleWithAuthor>> findAllSchedules(@Valid @ModelAttribute GetSchedulesRequest request) {
PageDto<ScheduleWithAuthor> authorDtoPageDto = scheduleService.findAll(request);
return ApiResponse.success(OK, authorDtoPageDto, "일정 전체 조회 성공");
}
@Getter
@RequiredArgsConstructor
public class DeleteScheduleRequest {
@NotBlank
private final String password;
}
일정 삭제 시 Password 필드 하나만을 가지고 있는 Request를 보내게 되는데, 이때 JSON -> Object로의 역직렬화가 제대로 되지 않아서 예외가 발생했다. 찾아보니 필드가 하나인 경우 Delegating 방식과 Properties 방식 중 어떤 것을 사용해야 할 지 몰라서 발생한 에러라고 한다.
@Getter
public class DeleteScheduleRequest {
@NotBlank
private final String password;
@JsonCreator
public DeleteScheduleRequest(String password) {
this.password = password;
}
}
여러 해결 방법이 있었지만 나는 @JsonCreator를 사용해서 문제를 해결했다. 기본 생성자를 사용하지 않기 때문에 final을 제거할 필요가 없어서 선택했다.
이건 조금 다른 얘긴데 DTO에 대해서 튜터님께 여쭤보러 갔었을 때였는데 튜터님이 DELETE 코드를 잠깐 보시더니 리소스 삭제는 정말 신중하게 해야하고, 삭제를 한 뒤에도 tracking이 가능해야 한다고 말씀을 해주셨다.
그래서 내 생각에 가장 간단한 방법이라고 느낀 soft-delete 방식을 사용하게 되었다.
# 기존 삭제 구문
delete from schedules where schedule_id = 1
# soft-delete 구문
update schedules set is_active = false where schedule_id = 1
repository에서 기존 삭제 구문처럼 실제로 삭제하는 것이 아니라 상태를 나타내는 is_active를 추가한 뒤에 삭제 시 false로 바꿔주고, 다른 조회 구문에서 where is_active = true를 추가하게 되면 실제로 삭제가 된 것처럼 is_active가 false인 row를 제외하고 조회하게 된다.
로그 파일을 따로 만들어서 tracking을 하는 방법도 있다.
일정 등록 시 Password를 등록하고, 일정 수정이나 삭제 시 Password를 확인해서 일치할 경우 기능이 수행되도록 한다. 이때 Password 암호화를 하지 않을 경우 DB에서 조회 시 사용자의 password가 저장된 상태 그대로 전부 보이기 때문에 보안에 좋지 않다.
따라서 PasswordEncoder를 사용하려고 했다. 실제로 spring-boot-starter-security 의존성 사용 시 PasswordEncoder 인터페이스를 빈으로 등록해서 구현체를 사용할 수 있다는 것을 알고 있었지만 security 사용 시 filterChain을 설정해줘야 하는 번거로움이 있어서 고민을 했었다.
하지만 spring-security-crypto를 사용하게 되면 별도의 filterChain 설정 없이 PasswordEncoder만 사용할 수 있다는 것을 알게 되었다.
implementation 'org.springframework.security:spring-security-crypto'
추가로 비밀번호 검증 시 단순하게 passwordEncoder.encode(request.getPassword()).equals(schedule.getPassword())를 사용하면 안된다. 입력한 값이 실제로 같더라도 암호화하는 과정에서 값이 달라지기 때문에 비밀번호 검증 시에는 passwordEncoder.matches(request.getPassword(), schedule.getPassword())를 사용해야 한다.
메서드 추출 단축키(ctrl + alt + m)가 갑자기 안먹혔을 때가 있었다. 처음엔 메서드 추출이 안되는 구문인 줄 알았지만 다른 구문도 마찬가지로 메서드 추출 단축키가 안먹혔었다.
왜 그런지 알아보니 최근에 그래픽 카드 업데이트를 하면서 Geforce Experience도 같이 다운받았는데 해당 기능 중 게임 내 오버레이 기능 중 메서드 추출 단축키와 겹치는 것이 있어서 먹히지 않았던 것이었다.
해당 기능을 비활성화해서 해결했다.
객체 단건 조회 시 null을 반환받지 않도록 하기 위해서 Optional로 반환 받으려고 했다.
하지만 JDBC에서 Optional로 반환받아본 적이 없어서 조금 찾아보게 되었고, 알게 된 방법이 있다.
@Override
public Optional<Schedule> findById(Long scheduleId) {
String sql = "select * from schedules where schedule_id = :scheduleId and is_active = true";
SqlParameterSource param = new MapSqlParameterSource("scheduleId", scheduleId);
List<Schedule> schedules = jdbcTemplate.query(sql, param, scheduleRowMapper());
return schedules.stream().findFirst();
}
바로 단건 조회여도 query()를 사용해서 List로 값을 가져오도록 하고, return schedules.stream().findFirst()를 통해 Optional로 반환하도록 했다.
findFirst()는 Optional을 반환한다
데이터베이스 연결 정보를 숨기기 위해서 프로필을 분리했다.
spring:
config:
import:
- classpath:application-dev.yml
사실 다른 방법도 있지만 단순 과제였기 때문에 프로필을 분리해서 데이터베이스 정보가 있는 파일을 .gitignore에 추가하는 방식으로 선택했다.
https://coding-business.tistory.com/34 (오류 메시지)
https://jiwondev.tistory.com/251 (DTO는 어떤 레이어에 포함되어야 하는가)
https://jong-bae.tistory.com/80 ([MapStruct] @Mapping 활용하기)
https://rk1993.tistory.com/196 (MySQL 날짜 더하기)
https://phpschool.com/gnuboard4/bbs/board.php?bo_table=qna_db&wr_id=224393&sca=&sfl=wr_subject%7C%7Cwr_content&stx=date&sop=and (날짜 범위 설정)
https://insight-bgh.tistory.com/246 (MySQL 타임존 설정)
https://worthpreading.tistory.com/83 (깃허브 리드미에 이미지 올릴 때 꼼수)
https://velog.io/@westreed/Spring-application.yml-%EA%B4%80%EB%A6%AC%ED%95%98%EA%B8%B0 (application.yml 관리하기)
https://kong-dev.tistory.com/236 (Json 필드 매핑 관련)
https://promisingmoon.tistory.com/215 (security 없이 PasswordEncoder 추가)
https://rainbow-flavor.tistory.com/12 (메서드 추출 안될 때)