[문제]
Postman으로 일정을 부분 수정하는 PATCH를 실행하면 Send 버튼을 두 번 눌러야 수정되었다. 500번 대 오류 메시지가 뜨지는 않았으나, 이는 분명 무언가에 문제가 있다는 뜻이었다. 이 문제를 확실히 해결하고자 아침 9시에 팀에서 데일리 스크럼(Daily Scrum)을 마치는 대로 튜터님을 찾아갔다.
[원인]
이번에 발생한 문제는 PATCH 기능이 두 번 이루어진 게 아니라, 수정된 일정을 제대로 반환하지 않아서 생긴 문제였다. 스프링(Spring) 내에서 쓰는 객체와 데이터베이스는 별개인데, Send 버튼을 눌렀을 때 데이터베이스에서만 일정을 수정한 탓에 API 응답에서는 수정 전 일정이 출력된 것이었다.
[해결]
(1) 데이터베이스와 한 번 더 소통하기
ⓐ 해결 방법
package com.spring.weekthree.service;
import com.spring.weekthree.dto.requestdto.CreatePlanRequestDto;
import com.spring.weekthree.dto.responsedto.PlanResponseDto;
import com.spring.weekthree.entity.Plan;
import com.spring.weekthree.repository.PlanRepository;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.web.server.ResponseStatusException;
import java.time.LocalDate;
import java.util.List;
import java.util.Objects;
// [1/1] Service Layer 수정하기
@Service
public class PlanServiceImpl implements PlanService {
// (1) 속성
private final PlanRepository planRepository;
// (2) 생성자
public PlanServiceImpl(PlanRepository planRepository) {
this.planRepository = planRepository;
}
// (3) 기능
@Override
public PlanResponseDto processSave(
CreatePlanRequestDto requestDto
) {
Plan plan = new Plan(
requestDto.getName(),
requestDto.getPassword(),
requestDto.getPlannedDate(),
requestDto.getTitle(),
requestDto.getTask()
);
return planRepository.save(plan);
}
@Override
public List<PlanResponseDto> processFetchList(
String name,
LocalDate updatedDate
) {
return planRepository.fetchAllPlans(name, updatedDate);
}
@Override
public PlanResponseDto processFetchEach(Long id) {
Plan plan;
plan = planRepository.fetchPlanById0rElseThrow(id);
return new PlanResponseDto(plan);
}
@Override
public PlanResponseDto processUpdatePatch(
Long id,
String name,
String password,
LocalDate plannedDate,
String title,
String task
) {
Plan plan;
plan = planRepository.fetchPlanById0rElseThrow(id);
if (!Objects.equals(password, plan.getPassword()))
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST,
"Password does not match"
);
planRepository.updatePatchInRepository(
id,
name,
plannedDate,
title,
task
);
plan = planRepository.fetchPlanById0rElseThrow(id);
/*
[수정 전] 없었음
[수정 후]
- 추가 ▼
plan = planRepository.fetchPlanById0rElseThrow(id);
*/
return new PlanResponseDto(plan);
}
@Override
public void processDelete(Long id, String password) {
Plan plan;
plan = planRepository.fetchPlanById0rElseThrow(id);
if (!Objects.equals(password, plan.getPassword()))
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST,
"Password does not match"
);
planRepository.deletePlan(id);
}
}
// [참고] Repository Layer에 있는 메서드(method)
@Override
public Plan fetchPlanById0rElseThrow(Long id) {
List<Plan> result = jdbcTemplate.query(
"SELECT * FROM planner WHERE id =?",
plannerRowMapperEach(),
id
);
return result.stream()
.findAny()
.orElseThrow(
() -> new ResponseStatusException(
HttpStatus.NOT_FOUND,
"Id does no exist id = " + id
)
);
}
ⓑ 장점: 다른 레이어(layer)를 건드리지 않고 서비스 레이어(Service Layer)에서 코드 한 줄만 추가하면 된다.
ⓒ 단점: 데이터베이스와 직접 소통하기 때문에 횟수가 늘어날수록 API 성능에 영향을 준다.
(2) 데이터베이스와 소통 늘리지 않기
ⓐ 해결 방법
package com.spring.weekthree.entity;
import lombok.AllArgsConstructor;
import lombok.Getter;
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.Objects;
// [1/5] Entity에 해당하는 Plan 클래스(class) 수정하기
@AllArgsConstructor
@Getter
public class Plan {
// (1) 속성
private Long id;
private String name;
private String password;
private LocalDate plannedDate;
private String title;
private String task;
private LocalDateTime createdDateTime;
private LocalDateTime updatedDateTime;
// (2) 생성자
public Plan(
String name,
String password,
LocalDate plannedDate,
String title,
String task
) {
this.name = name;
this.password = password;
this.plannedDate = plannedDate;
this.title = title;
this.task = task;
this.createdDateTime = LocalDateTime.now();
this.updatedDateTime = LocalDateTime.now();
}
/*
(3) 기능
[수정 전] 없었음
[수정 후] 두 메서드(method) 추가
*/
// 첫 번째 메서드: 작성자의 이름 및 일정 수정
public void update (
String name,
LocalDate plannedDate,
String title,
String task,
LocalDateTime updatedDateTime
) {
this.name = name;
this.plannedDate = plannedDate;
this.title = title;
this.task = task;
this.updatedDateTime = updatedDateTime;
}
// 두 번째 메서드: 비밀번호 일치 여부 검증
public void validatePassword(String password) {
if (!Objects.equals(password, this.password))
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST,
"Password does not match"
);
}
/*
[클래스에 메서드를 추가한 이유는?]
(1) 코드의 중복을 피할 수 있다.
(2) 객체가 있어야 호출할 수 있다.
즉, 객체가 없을 때에 해당하는 null을 검증할 필요가 없다.
*/
}
package com.spring.weekthree.util;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
// [2/5] 시간 출력 형식을 맡은 TimeUtil 클래스 새로 작성하기
public class TimeUtil {
public static LocalDateTime now() {
DateTimeFormatter formatter;
formatter = DateTimeFormatter.
ofPattern(
"yyyy-MM-dd'T'HH:mm:ss"
);
return LocalDateTime
.parse(
LocalDateTime
.now()
.format(formatter)
);
}
}
package com.spring.weekthree.repository;
import com.spring.weekthree.dto.responsedto.PlanResponseDto;
import com.spring.weekthree.entity.Plan;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
// [3/5] Repository Layer의 인터페이스(interface) 수정하기
public interface PlanRepository {
PlanResponseDto save(Plan plan);
List<PlanResponseDto> fetchAllPlans(
String name,
LocalDate updatedDate
);
Plan fetchPlanById0rElseThrow(Long id);
/*
[수정 전] void
[수정 후] int
*/
void updatePatchInRepository(
Long id,
String name,
LocalDate plannedDate,
String title,
String task,
LocalDateTime updatedDatetime
);
// [수정 후] 추가: LocalDateTime updatedDatetime
void deletePlan(Long id);
}
package com.spring.weekthree.repository;
import com.spring.weekthree.dto.responsedto.PlanResponseDto;
import com.spring.weekthree.entity.Plan;
import org.springframework.http.HttpStatus;
import org.springframework.jdbc.core.*;
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
import org.springframework.jdbc.core.simple.SimpleJdbcInsert;
import org.springframework.stereotype.Repository;
import org.springframework.web.server.ResponseStatusException;
import javax.sql.DataSource;
import java.sql.*;
import java.sql.Date;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.*;
// [4/5] 인터페이스를 오버라이딩한 Repository Layer 수정하기
@Repository
public class JdbcTemplatePlanRepository implements PlanRepository {
// (1) 속성
private final JdbcTemplate jdbcTemplate;
// (2) 생성자
public JdbcTemplatePlanRepository(DataSource dataSource) {
this.jdbcTemplate = new JdbcTemplate(dataSource);
}
// (3) 기능
@Override
public PlanResponseDto save(Plan plan) {
SimpleJdbcInsert jdbcInsert;
jdbcInsert = new SimpleJdbcInsert(jdbcTemplate);
jdbcInsert.withTableName("planner").
usingGeneratedKeyColumns("id");
Map<String, Object> parameters = new HashMap<>();
parameters.put("name", plan.getName());
parameters.put("password", plan.getPassword());
parameters.put("plannedDate", plan.getPlannedDate());
parameters.put("title", plan.getTitle());
parameters.put("task", plan.getTask());
parameters.put("createdDateTime", plan.getCreatedDateTime());
parameters.put("updatedDateTime", plan.getUpdatedDateTime());
Number key = jdbcInsert.executeAndReturnKey(
new MapSqlParameterSource(parameters
)
);
return new PlanResponseDto(
key.longValue(),
plan.getName(),
plan.getPlannedDate(),
plan.getTitle(),
plan.getTask(),
plan.getCreatedDateTime(),
plan.getUpdatedDateTime());
}
@Override
public List<PlanResponseDto> fetchAllPlans(
String name,
LocalDate updatedDate
) {
StringBuilder sql;
sql = new StringBuilder(" SELECT * FROM planner WHERE 1=1");
List<Object> params = new ArrayList<>();
if (name != null) {
sql.append(" AND BINARY name = ? ");
params.add(name);
}
if (updatedDate != null) {
Date updatedDateSql = Date.valueOf(updatedDate);
sql.append(" AND DATE(updatedDateTime) = ? ");
params.add(updatedDateSql);
}
sql.append(" ORDER BY updatedDateTime DESC");
List<PlanResponseDto> allPlans;
allPlans = jdbcTemplate.query(
sql.toString(),
plannerRowMapper(),
params.toArray()
);
return allPlans;
}
@Override
public Plan fetchPlanById0rElseThrow(Long id) {
List<Plan> result = jdbcTemplate.query(
"SELECT * FROM planner WHERE id =?",
plannerRowMapperEach(),
id
);
return result.stream()
.findAny()
.orElseThrow(
() -> new ResponseStatusException(
HttpStatus.NOT_FOUND,
"Id does no exist id = " + id
)
);
}
/*
[수정 전] public void updatePatchInRepository
[수정 후] public int updatePatchInRepository
*/
@Override
public int updatePatchInRepository(
Long id,
String name,
LocalDate plannedDate,
String title,
String task,
LocalDateTime updatedDateTime
// [수정 후] 추가: LocalDateTime updatedDateTime
) {
// [수정 후] int를 반환해야 하므로 추가: return
return jdbcTemplate.update(
"UPDATE planner SET " +
"name = ?, " +
"plannedDate = ?, " +
"title = ?, " +
"task = ?, " +
"updatedDateTime = ? " +
/*
[수정 전] updatedDateTime = CURRENT_TIMESTAMP
[수정 후] updatedDateTime = ?
*/
"WHERE id = ?",
name,
plannedDate,
title,
task,
updatedDateTime,
// [수정 후] 추가: updatedDateTime
id
);
}
@Override
public void deletePlan(Long id) {
jdbcTemplate.update(
"DELETE FROM planner WHERE id = ?",
id
);
}
private RowMapper<PlanResponseDto> plannerRowMapper() {
return new RowMapper<PlanResponseDto>() {
@Override
public PlanResponseDto mapRow(ResultSet rs, int rowNum) throws SQLException {
return new PlanResponseDto(
rs.getLong("id"),
rs.getString("name"),
rs.getDate("plannedDate").toLocalDate(),
rs.getString("title"),
rs.getString("task"),
rs.getTimestamp("createdDateTime").toLocalDateTime(),
rs.getTimestamp("updatedDateTime").toLocalDateTime()
);
}
};
}
private RowMapper<Plan> plannerRowMapperEach() {
return new RowMapper<Plan>() {
@Override
public Plan mapRow(ResultSet rs, int rowNum) throws SQLException {
return new Plan(
rs.getLong("id"),
rs.getString("name"),
rs.getString("password"),
rs.getDate("plannedDate").toLocalDate(),
rs.getString("title"),
rs.getString("task"),
rs.getTimestamp("createdDateTime").toLocalDateTime(),
rs.getTimestamp("updatedDateTime").toLocalDateTime()
);
}
};
}
}
package com.spring.weekthree.service;
import com.spring.weekthree.dto.requestdto.CreatePlanRequestDto;
import com.spring.weekthree.dto.responsedto.PlanResponseDto;
import com.spring.weekthree.entity.Plan;
import com.spring.weekthree.repository.PlanRepository;
import com.spring.weekthree.util.TimeUtil;
import org.springframework.stereotype.Service;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
// [5/5] Service Layer 수정하기
@Service
public class PlanServiceImpl implements PlanService {
// (1) 속성
private final PlanRepository planRepository;
// (2) 생성자
public PlanServiceImpl(PlanRepository planRepository) {
this.planRepository = planRepository;
}
// (3) 기능
@Override
public PlanResponseDto processSave(
CreatePlanRequestDto requestDto
) {
Plan plan = new Plan(
requestDto.getName(),
requestDto.getPassword(),
requestDto.getPlannedDate(),
requestDto.getTitle(),
requestDto.getTask()
);
return planRepository.save(plan);
}
@Override
public List<PlanResponseDto> processFetchList(
String name,
LocalDate updatedDate
) {
return planRepository.fetchAllPlans(name, updatedDate);
}
@Override
public PlanResponseDto processFetchEach(Long id) {
Plan plan;
plan = planRepository.fetchPlanById0rElseThrow(id);
return new PlanResponseDto(plan);
}
@Override
public PlanResponseDto processUpdatePatch(
Long id,
String name,
String password,
LocalDate plannedDate,
String title,
String task
) {
Plan plan;
plan = planRepository.fetchPlanById0rElseThrow(id);
plan.validatePassword(password);
/*
[수정 전]
if (!Objects.equals(password, plan.getPassword()))
throw new ResponseStatusException(
HttpStatus.BAD_REQUEST,
"Password does not match"
);
*/
LocalDateTime updatedDateTime = TimeUtil.now();
// [수정 후] 시간 형식을 통일해야 하므로 추가
int updatedRow = planRepository.updatePatchInRepository(
id,
name,
plannedDate,
title,
task,
updatedDateTime
// [수정 후] 추가: updatedDateTime
);
if (updatedRow >= 1) {
plan.update(
name,
plannedDate,
title,
task,
updatedDateTime
);
}
/*
[수정 후]
if문 추가
일정이 1 이상일 때만 수정해야 하므로 1 이상이라는 조건식 작성
*/
return new PlanResponseDto(plan);
}
@Override
public void processDelete(Long id, String password) {
Plan plan;
plan = planRepository.fetchPlanById0rElseThrow(id);
plan.validatePassword(password);
planRepository.deletePlan(id);
}
}
ⓑ 장점: 데이터베이스와 직접 소통하지 않으므로 API 성능에 영향을 주지 않는다.
ⓒ 단점: 데이터베이스와 직접 소통할 때보다 수정 및 작성해야 하는 코드가 늘어난다.
(3) 해결 후 결과
오류로 빨간 줄이 뜨거나 오류 메시지가 뜰 때보다 의도와 다르게 성공했을 때 보통 원인을 찾기가 더 어렵다. 왠지 앞으로 다른 프로젝트를 진행하면서 몇 번 더 마주칠 듯해서 더 꼼꼼하게 기록하려고 했다. 틈틈이 복습해서 그 횟수를 줄이도록 해야겠다.
'Troubleshooting: 무엇이 문제였는가? > 본캠프 3주 차: 일정 관리 앱 만들기' 카테고리의 다른 글
3단계: "순환 참조 오류라니, 돌아가는 각도가 예술이군요." (0) | 2024.12.12 |
---|---|
3단계: "사용자 Id를 입력했는데 왜 자꾸 0이 나오니?" (0) | 2024.12.12 |
1단계: "넌 띄어쓰기를 소중히 하지 않았어" (0) | 2024.12.09 |
1단계: "아무래도 이름(name)이란 체에 구멍이 뻥 뚫렸나 보다." (0) | 2024.12.08 |
2단계: "왜 수정 날짜를 수정하려고 하니!" (0) | 2024.12.08 |