Spring

REST API 기반 일정 관리 서버 시스템 구축하기(트러블 슈팅)

윤승 2025. 3. 25. 20:09

스프링 입문 강의를 완강한 후 바로 일정 관리 과제를 진행했다.
과제를 수행하면서 강의에서 놓쳤던 부분을 복습하며 계속해서 개발을 이어갔다.

 

💡 개발 전, 공통 조건

1. 일정 작성, 수정, 조회 시 반환받은 일정 정보에 비밀번호는 제외해야 합니다.
2. 일정 수정, 삭제 시 선택한 일정의 비밀번호와 요청할 때 함께 보낸 비밀번호가 일치할 경우에만 가능합니다.
3. 비밀번호가 일치하지 않을 경우 적절한 오류 코드 및 메시지를 반환해야 합니다.
4. 3 Layer Architecture 에 따라 각 Layer의 목적에 맞게 개발해야 합니다.
5. CRUD 필수 기능은 모두 데이터베이스 연결 및 JDBC를 사용해서 개발해야 합니다.

 


🔥 프로젝트 목표

  • API 명세서, ERD, SQL을 작성할 수 있다.
  • Spring Boot를 기반으로 CRUD 기능을 포함한 REST API를 개발할 수 있다.

 


 

📝 기능 요구사항

 

Lv 1. 일정 생성 및 조회 필수

일정 생성 (일정 작성하기)

  • 포함 데이터:
  • 할일, 작성자명, 비밀번호, 작성/수정일
  • 작성/수정일: 날짜와 시간을 모두 포함한 형태
  • 기능:
  • 일정의 고유 식별자(ID)를 자동 생성하여 관리
  • 최초 입력 시, 수정일은 작성일과 동일

전체 일정 조회 (등록된 일정 불러오기)

  • 조건:
  • 수정일 (형식: YYYY-MM-DD)
  • 작성자명
  • 기능:
  • 조건 중 하나 또는 둘 다 충족할 수 있음
  • 수정일 기준 내림차순 정렬하여 조회

선택 일정 조회 (선택한 일정 정보 불러오기)

  • 기능:
  • 일정의 고유 식별자(ID)를 사용하여 단건 조회

Lv 2. 일정 수정 및 삭제 필수

선택한 일정 수정

  • 수정 가능한 항목: 할 일, 작성자명
  • 기능:
  • 서버에 일정 수정 시 비밀번호를 함께 전달
  • 작성일은 변경 불가, 수정일은 수정 시점으로 업데이트

선택한 일정 삭제

  • 기능:
  • 서버에 일정 삭제 요청 시 비밀번호를 함께 전달

 

1️⃣  API 명세 및 ERD 작성

API 명세서

 

ERD

 

 

 

3 Layer Architecture에 따라 각 레이어를 분리하여 각 기능들을 분리하였다.

 

 

Controller → 프레젠테이션 계층: 사용자 요청을 받아서 응답을 반환
Service → 비즈니스 로직 계층: 실제 비즈니스 로직을 처리
Repository → 데이터 계층: 데이터베이스와 상호작용하여 데이터를 처리

 

Controller (프레젠테이션 계층)

package org.example.schedule.controller;


import org.example.schedule.dto.ScheduleRequestDto;
import org.example.schedule.dto.ScheduleResponseDto;
import org.example.schedule.service.ScheduleService;
import org.springframework.http.HttpStatus;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.List;

@RestController
@RequestMapping("/schedule")
public class ScheduleController {

    private final ScheduleService scheduleService;

    public ScheduleController(ScheduleService scheduleService) {
        this.scheduleService = scheduleService;
    }

    // 1. 일정 생성
    @PostMapping
    public ResponseEntity<ScheduleResponseDto> createSchedule(@RequestBody ScheduleRequestDto dto){
        return new ResponseEntity<>(scheduleService.saveSchedule(dto), HttpStatus.CREATED);
    }
    // 2. 전체 일정 조회
    @GetMapping
    public List<ScheduleResponseDto> findAll(){
        return scheduleService.findAll();
    }
    // 3. 선택 일정 조회
    @GetMapping("/{id}")
    public ResponseEntity<ScheduleResponseDto> findById(@PathVariable Long id){
        return new ResponseEntity<>(scheduleService.findById(id),HttpStatus.OK);
    }
    // 4. 선택한 일정 수정
    @PatchMapping("/{id}")
    public ResponseEntity<ScheduleResponseDto> updateSchedule(@PathVariable Long id, @RequestBody ScheduleRequestDto dto) {
        return new ResponseEntity<>(scheduleService.updateSchedule(id, dto), HttpStatus.OK);
    }
    // 5. 선택한 일정 삭제
    @DeleteMapping("/{id}")
    // 비밀번호를 @RequestParam으로 받아서 서비스로 전달
    public ResponseEntity<Void> delet(@PathVariable Long id, @RequestParam String password) {
        scheduleService.delete(id, password);
        return new ResponseEntity<>(HttpStatus.OK);
    }
}

 

 

Repository (데이터 계층)

package org.example.schedule.repository;

import org.example.schedule.dto.ScheduleResponseDto;
import org.example.schedule.entity.Schedule;
import org.springframework.http.HttpStatus;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
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.ResultSet;
import java.sql.SQLException;
import java.sql.Timestamp;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;

@Repository
public class JdbcTemplateScheduleRepository implements ScheduleRepository{
    private final JdbcTemplate jdbcTemplate;

    public JdbcTemplateScheduleRepository(DataSource dataSource) {
        this.jdbcTemplate = new JdbcTemplate(dataSource);
    }

    @Override
    public ScheduleResponseDto saveSchedule(Schedule schedule){
        SimpleJdbcInsert jdbcInsert = new SimpleJdbcInsert(jdbcTemplate);
        jdbcInsert.withTableName("schedule").usingGeneratedKeyColumns("id");

        Map<String, Object> parameters = new HashMap<>();
        parameters.put("todo", schedule.getTodo());
        parameters.put("username", schedule.getUsername());
        parameters.put("password", schedule.getPassword());
        parameters.put("createtime", Timestamp.valueOf(schedule.getCreatetime()));  // LocalDateTime -> Timestamp 변환
        parameters.put("updatetime", Timestamp.valueOf(schedule.getUpdatetime()));

        Number key = jdbcInsert.executeAndReturnKey(new MapSqlParameterSource(parameters));

        return new ScheduleResponseDto(
                key.longValue(),
                schedule.getTodo(),
                schedule.getUsername(),
                schedule.getCreatetime(),
                schedule.getUpdatetime()
        );
    }

    @Override
    public List<ScheduleResponseDto> findAll() {
        return jdbcTemplate.query("select * from schedule order by updatetime desc ", scheduleRowMapper());
    }

    @Override
    public Optional<Schedule> findById(Long id){
        List<Schedule> result = jdbcTemplate.query("select * from schedule where id = ?", scheduleRowMapperV2(), id);
        return result.stream().findAny();
    }

    @Override
    public Schedule findByIdOrElseThrows(Long id) {
        List<Schedule> result = jdbcTemplate.query("select * from schedule where id = ?", scheduleRowMapperV2(), id);
        return result.stream().findAny().orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND,"id값이 없습니다"+id));
    }

    @Override
    public int updateSchedule(Long id, String todo, String username, String password, LocalDateTime updatetime) {
        return jdbcTemplate.update(
                "UPDATE schedule SET todo=?, username=?, password=?, updatetime=? WHERE id=?",
                todo, username, password, Timestamp.valueOf(updatetime), id
        );
    }

    @Override
    public int delete(Long id) {
        return jdbcTemplate.update("delete  from schedule where id = ?", id);
    }

    private RowMapper<Schedule> scheduleRowMapperV2() {
        return new RowMapper<Schedule>() {
            @Override
            public Schedule mapRow(ResultSet rs, int rowNum) throws SQLException {
                return new Schedule(
                        rs.getLong("id"),
                        rs.getString("todo"),
                        rs.getString("username"),
                        rs.getString("password"), // 패스워드 추가
                        rs.getTimestamp("createtime").toLocalDateTime(),
                        rs.getTimestamp("updatetime").toLocalDateTime()
                );
            }
        };
    }

    /**
     * rs.getString("createtime"),
     * rs.getString("updatetime")
     * 문제: createtime, updatetime은 TIMESTAMP 타입이므로, getTimestamp("createtime")을 사용하고 LocalDateTime으로 변환해야 함.
     * **/

    private RowMapper<ScheduleResponseDto> scheduleRowMapper() {
        return new RowMapper<ScheduleResponseDto>() {
            @Override
            public ScheduleResponseDto mapRow(ResultSet rs, int rowNum) throws SQLException {
                return new ScheduleResponseDto(
                        rs.getLong("id"),
                        rs.getString("todo"),
                        rs.getString("username"),
                        rs.getTimestamp("createtime").toLocalDateTime(),
                        rs.getTimestamp("updatetime").toLocalDateTime()
                );
            }
        };
    }

}

 

 

Service (비즈니스 로직 계층)

package org.example.schedule.service;


import org.example.schedule.dto.ScheduleRequestDto;
import org.example.schedule.dto.ScheduleResponseDto;
import org.example.schedule.entity.Schedule;
import org.example.schedule.repository.ScheduleRepository;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;

import java.util.List;

@Service
public class ScheduleServiceImpl implements ScheduleService{

    private final ScheduleRepository scheduleRepository;


    public ScheduleServiceImpl(ScheduleRepository scheduleRepository) {
        this.scheduleRepository = scheduleRepository;
    }



    @Override
    public ScheduleResponseDto saveSchedule(ScheduleRequestDto dto) {
        Schedule schedule = new Schedule(dto.getTodo(), dto.getUsername(), dto.getPassword());

        return scheduleRepository.saveSchedule(schedule);
    }

    @Override
    public List<ScheduleResponseDto> findAll() {
        List<ScheduleResponseDto> allSchedule = scheduleRepository.findAll();
        return allSchedule;
    }

    @Override
    public ScheduleResponseDto findById(Long id) {
        Schedule schedule = scheduleRepository.findByIdOrElseThrows(id);
        return new ScheduleResponseDto(schedule);

    }

    @Transactional
    @Override
    public ScheduleResponseDto updateSchedule(Long id, ScheduleRequestDto dto) {
        if (dto.getTodo() == null || dto.getUsername() == null || dto.getPassword() == null) {
            throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "입력 값이 유효하지 않습니다. id = " + id);
        }

        // 기존 데이터 가져오기 (null 체크 추가)
        Schedule schedule = scheduleRepository.findByIdOrElseThrows(id);
        if (schedule == null) {
            throw new ResponseStatusException(HttpStatus.NOT_FOUND, "해당 ID의 데이터가 존재하지 않습니다. id = " + id);
        }
        // 현재 저장된 비밀번호 로그 출력
        System.out.println("schedule.getPassword(): " + schedule.getPassword());
        System.out.println("dto.getPassword(): " + dto.getPassword());

        // 비밀번호 검증 (schedule.getPassword()가 null이면 예외 발생)
        if (schedule.getPassword() == null || !schedule.getPassword().equals(dto.getPassword())) {
            throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "비밀번호가 일치하지 않습니다. id = " + id);
        }

        // 업데이트된 정보 적용 (비밀번호는 기존 값 유지)
        schedule.updateTodo(dto.getTodo(), dto.getUsername(), schedule.getPassword());

        // DB 업데이트 수행 (비밀번호 제외)
        int updateRow = scheduleRepository.updateSchedule(
                id,
                schedule.getTodo(),
                schedule.getUsername(),
                schedule.getPassword(), // 기존 비밀번호 유지
                schedule.getUpdatetime()
        );

        if (updateRow == 0) {
            throw new ResponseStatusException(HttpStatus.NOT_FOUND, "해당 id의 데이터가 존재하지 않습니다. id = " + id);
        }

        return new ScheduleResponseDto(schedule);
    }

    @Transactional
    @Override
    public void delete(Long id, String password) {
        // ID에 해당하는 스케줄을 찾기
        Schedule schedule = scheduleRepository.findByIdOrElseThrows(id);
        // 비밀번호 검증 (schedule.getPassword()가 null이면 예외 발생)
        if (schedule.getPassword() == null || !schedule.getPassword().equals(password)) {
            throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "비밀번호가 일치하지 않습니다. id = " + id);
        }
        // 비밀번호가 일치하면 삭제
        int deleteRow = scheduleRepository.delete(id);

        if (deleteRow == 0) {
            throw new ResponseStatusException(HttpStatus.NOT_FOUND, "해당 id의 데이터가 존재하지 않습니다. id = " + id);
        }
    }
}

 

 

Entity

package org.example.schedule.entity;

import lombok.AllArgsConstructor;
import lombok.Getter;

import java.time.LocalDateTime;

@Getter
@AllArgsConstructor
public class Schedule {

    private Long id;
    private String todo;
    private String username;
    private String password;
    private LocalDateTime createtime;
    private LocalDateTime updatetime;



    public Schedule(String todo, String username, String password) {
        this.todo = todo;
        this.username = username;
        this.password = password;
        this.createtime = LocalDateTime.now();
        this.updatetime = this.createtime;  // 초기 생성 시 수정일 = 생성일
    }

    public Schedule(long id, String todo, String username, String password, LocalDateTime createtime, LocalDateTime updatetime) {
        this.id = id;
        this.todo = todo;
        this.username = username;
        this.password = password;
        this.createtime = createtime;
        this.updatetime = updatetime;
    }

    public void updateTodo(String todo, String username, String password) {
        this.todo = todo;
        this.username = username;
        this.password = password;
        this.updatetime = LocalDateTime.now(); // updatetime만 갱신
    }
}

 

 


 

🔨  트러블 슈팅

 

🔥 문제 발단

  • 단 건 일정 수정을 할 때 비밀번호 검증 기능에서 문제가 생겼다. 
  • 기존에 password 값과 요청된 password 값을. equals() 함수로 비교하여 정상적으로 작동했음에도 불구하고, 여전히 UNAUTHORIZED 에러가 발생하는 상황이다.

Service.class 

  @Transactional
    @Override
    public ScheduleResponseDto updateSchedule(Long id, String todo, String username, String password) {
        if (todo == null || username == null || password == null) {
            throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "입력 값이 유효하지 않습니다. id = " + id);
        }
        // 기존 데이터 가져오기
        Schedule schedule = scheduleRepository.findByIdOrElseThrows(id);

        // 비밀번호 검증
        if (!schedule.getPassword().equals(dto.getPassword())) {
            throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "비밀번호가 일치하지 않습니다. id = " + id);
        }

        // 업데이트된 정보 적용
        schedule.updateTodo(dto.getTodo(), dto.getUsername(), dto.getPassword());

        // DB 업데이트 수행
        int updateRow = scheduleRepository.updateSchedule(id, schedule.getTodo(), schedule.getUsername(), schedule.getPassword(), schedule.getUpdatetime());

        if (updateRow == 0) {
            throw new ResponseStatusException(HttpStatus.NOT_FOUND, "해당 id의 데이터가 존재하지 않습니다. id = " + id);
        }

        return new ScheduleResponseDto(schedule);
    }

 

🚑 전개

[Request processing failed: java.lang.NullPointerException: Cannot invoke "String.equals(Object)" because the return value of "org.example.schedule.entity.Schedule.getPassword()" is null] with root cause

 

포스트맨에서 JSON 형태로 응답을 보내도록 했지만, 계속 500 에러가 발생했다.

IntelliJ 실행 화면을 확인해보니 NullPointerException 이 발생했다.

이에 따라, 혹시 DB에서 조회한 schedule 객체가 null인지 확인해 보았다.

비밀번호는 디비에 있는 그대로 작성했지만, 계속 오류가 발생했다.

 
 
먼저 현재 저장된 비밀번호와 요청한 비밀번호에 NULL값이 있는지 출력(log)해봤다.
 System.out.println("schedule.getPassword(): " + schedule.getPassword());
 System.out.println("dto.getPassword(): " + dto.getPassword());
 
헉 로그를 확인해 보니, DB에 저장된 비밀번호 값이 null로 저장되고 있었다.

 

🚀  문제 원인 분석

 

원인을 찾아보니, 현재 Schedule 엔티티를 조회하는 findByIdOrElseThrows 메서드에서 password 필드가 매핑되지 않고 있었다...
즉, DB에서 가져올 때 password 필드 값을 읽어오지 않기 때문에, updateSchedule 메서드에서 schedule.getPassword()를 출력하면 null이 되는 것이었다.

 

 

✨ 문제 해결

먼저 scheduleRowMapperV2()에서 password 필드를 추가했다.

 

수정 전

private RowMapper<Schedule> scheduleRowMapperV2() {
    return new RowMapper<Schedule>() {
        @Override
        public Schedule mapRow(ResultSet rs, int rowNum) throws SQLException {
            return new Schedule(
                    rs.getLong("id"),
                    rs.getString("todo"),
                    rs.getString("username"),
                    rs.getTimestamp("createtime").toLocalDateTime(),
                    rs.getTimestamp("updatetime").toLocalDateTime()
            );
        }
    };
}

 

수정 후

public class Schedule {
    private Long id;
    private String todo;
    private String username;
    private String password; // 추가 필요
    private LocalDateTime createtime;
    private LocalDateTime updatetime;
}

 

마지막으로 엔티티에도 password 포함하는 생성자 추가 하였다.

public Schedule(long id, String todo, String username, String password, LocalDateTime createtime, LocalDateTime updatetime) {
    this.id = id;
    this.todo = todo;
    this.username = username;
    this.password = password;
    this.createtime = createtime;
    this.updatetime = updatetime;
}

 

 

마지막으로 수정 후 다시 코드 실행

  // 비밀번호 검증 (schedule.getPassword()가 null이면 예외 발생)
        if (schedule.getPassword() == null || !schedule.getPassword().equals(dto.getPassword())) {
            throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "비밀번호가 일치하지 않습니다. id = " + id);
        }

 

실행화면

 

이제 findByIdOrElseThrows()를 호출할 때, password 값이 null이 아닌 실제 저장된 값으로 채워질 것이다.

 

'Spring' 카테고리의 다른 글

Spring Container와 Spring Bean  (0) 2025.03.27
SOLID 원칙이란?  (0) 2025.03.26
JDBC  (0) 2025.03.24
롬복(Lombok)이란?  (0) 2025.03.21
데이터베이스와 트랜잭션의 개념  (0) 2025.03.20