스프링 입문 강의를 완강한 후 바로 일정 관리 과제를 진행했다.
과제를 수행하면서 강의에서 놓쳤던 부분을 복습하며 계속해서 개발을 이어갔다.
💡 개발 전, 공통 조건
1. 일정 작성, 수정, 조회 시 반환받은 일정 정보에 비밀번호는 제외해야 합니다.
2. 일정 수정, 삭제 시 선택한 일정의 비밀번호와 요청할 때 함께 보낸 비밀번호가 일치할 경우에만 가능합니다.
3. 비밀번호가 일치하지 않을 경우 적절한 오류 코드 및 메시지를 반환해야 합니다.
4. 3 Layer Architecture 에 따라 각 Layer의 목적에 맞게 개발해야 합니다.
5. CRUD 필수 기능은 모두 데이터베이스 연결 및 JDBC를 사용해서 개발해야 합니다.
🔥 프로젝트 목표
- API 명세서, ERD, SQL을 작성할 수 있다.
- Spring Boot를 기반으로 CRUD 기능을 포함한 REST API를 개발할 수 있다.
📝 기능 요구사항
- 포함 데이터:
- 할일, 작성자명, 비밀번호, 작성/수정일
- 작성/수정일: 날짜와 시간을 모두 포함한 형태
- 기능:
- 일정의 고유 식별자(ID)를 자동 생성하여 관리
- 최초 입력 시, 수정일은 작성일과 동일
- 조건:
- 수정일 (형식: YYYY-MM-DD)
- 작성자명
- 기능:
- 조건 중 하나 또는 둘 다 충족할 수 있음
- 수정일 기준 내림차순 정렬하여 조회
- 기능:
- 일정의 고유 식별자(ID)를 사용하여 단건 조회
- 수정 가능한 항목: 할 일, 작성자명
- 기능:
- 서버에 일정 수정 시 비밀번호를 함께 전달
- 작성일은 변경 불가, 수정일은 수정 시점으로 업데이트
- 기능:
- 서버에 일정 삭제 요청 시 비밀번호를 함께 전달
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 |