Troubleshooting: 무엇이 문제였는가?/본캠프 3주 차: 일정 관리 앱 만들기

2단계: "PATCH를 두 번 눌러야 수정이 돼요. 왜 이럴까요?"

writingforever162 2024. 12. 9. 15:20

[문제]

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) 해결 후 결과

오류로 빨간 줄이 뜨거나 오류 메시지가 뜰 때보다 의도와 다르게 성공했을 때 보통 원인을 찾기가 더 어렵다. 왠지 앞으로 다른 프로젝트를 진행하면서 몇 번 더 마주칠 듯해서 더 꼼꼼하게 기록하려고 했다. 틈틈이 복습해서 그 횟수를 줄이도록 해야겠다.