Project

[Project] Lv_3 스케줄 프로젝트

kimyongjun0129 2025. 5. 12. 15:20

 


 

요구사항

작성자와 일정의 연결

  • 설명
    • ✅ 동명이인의 작성자가 있어 어떤 작성자가 등록한 ‘할 일’인지 구별할 수 없음
    • ✅ 작성자를 식별하기 위해 이름으로만 관리하던 작성자에게 고유 식별자를 부여합니다.
    • ✅ 작성자를 할 일과 분리해서 관리합니다.
    • ✅ 작성자 테이블을 생성하고 일정 테이블에 FK를 생성해 연관관계를 설정해 봅니다.
  • 조건
    • ✅ 작성자는 이름 외에 이메일, 등록일, 수정일 정보를 가지고 있습니다.
      • ✅ 작성자의 정보는 추가로 받을 수 있습니다.(조건만 만족한다면 다른 데이터 추가 가능)
    • ✅ 작성자의 고유 식별자를 통해 일정이 검색이 될 수 있도록 전체 일정 조회 코드 수정.
    • ✅ 작성자의 고유 식별자가 일정 테이블의 외래키가 될 수 있도록 합니다.

 

✨ 요구사항에 따른 ERD를 재설계하였습니다.

 


 

문제풀이

UserController

더보기
@RestController
@RequestMapping("/users")
public class UserController {

    private final UserService userService;

    public UserController(UserService userService) {
        this.userService = userService;
    }

    @PostMapping
    public ResponseEntity<UserResponseDto> createUser(@RequestBody UserRequestDto requestDto) {
        return new ResponseEntity<>(userService.saveUser(requestDto), HttpStatus.CREATED);
    }
}
  • `@RequestBody userRequestDto requestDto` :
    • `@RequestBody`가 붙은 파라미터가 있으면, 클라이언트가 요청한 JSON 데이터를  자바 객체(UserRequestDto)로 자동 변환해줍니다.
    • `HttpMessageConverter`가 작동 : `HttpMessageConverter` 중 하나인 `MappingJackson2HttpMessageConverter`가 이 JSON 문자열을 `userRequestDto`객체로 자동 변환해 줍니다.
    • UserRequestDto에는 email과 userName만 속성으로 갖는다. (클라이언트로 부터 이 2가지를 받을 수 있습니다.)

 

 

UserService

더보기
public interface UserService {

    UserResponseDto saveUser(UserRequestDto requestDto);
}

 

 

UserServiceImpl

더보기
@Service
public class UserServiceImpl implements UserService {

    private UserRepository userRepository;

    public UserServiceImpl(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    @Override
    public UserResponseDto saveUser(UserRequestDto requestDto) {
        User user = new User(requestDto.getEmail(), requestDto.getUserName());
        return userRepository.saveUser(user);
    }
}

 

 

UserRepository

더보기
public interface UserRepository {

    UserResponseDto saveUser(User user);
}

 

 

JdbcTemplateUserRepository

더보기
@Repository
public class JdbcTemplateUserRepository implements UserRepository {

    private final JdbcTemplate jdbcTemplate;

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

    @Override
    public UserResponseDto saveUser(User user) {
        SimpleJdbcInsert jdbcInsert = new SimpleJdbcInsert(jdbcTemplate)
                .withTableName("`user`") // 테이블명
                .usingGeneratedKeyColumns("userId") // 자동 생성된 키
                .usingColumns("email", "userName");

        Map<String, Object> parameters = new HashMap<>();
        parameters.put("email", user.getEmail());
        parameters.put("userName", user.getUserName());

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

        String sql = "SELECT userId, email, userName, updateAt FROM user WHERE userId = ?";
        return jdbcTemplate.queryForObject(sql, (rs, rowNum) ->
                        new UserResponseDto(
                                rs.getLong("userId"),
                                rs.getString("email"),
                                rs.getString("userName"),
                                rs.getDate("updateAt")
                        )
                ,userId
        );
    }
}
  • 클라이언트로 보내고자하는 값 : `userId`, `email`, `userName`, `updateAt`

 

 

UserRequestDto

더보기
@Getter
public class UserRequestDto {

    private String email;
    private String userName;
}

 

 

UserResponse

더보기
@Getter
@AllArgsConstructor
public class UserResponseDto {

    private Long userId;
    private String email;
    private String userName;
    private Date updateAt;
}
  • 클라이언트로 응답하고자하는 데이터들입니다.

 

 

User

더보기
@Getter
@AllArgsConstructor
public class User {

    private Long userId;
    private String email;
    private String userName;
    private Date createAt;
    private Date updateAt;

    public User(String email, String userName) {
        this.email = email;
        this.userName = userName;
    }
}

 

 

ScheduleController

더보기
@RestController
@RequestMapping("/schedules")
public class ScheduleController {

    private final ScheduleService scheduleService;

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

    /**
     * 스케쥴 생성 API
     * param : {@link ScheduleRequestDto} 스케쥴 생성 요청 객체
     * @return : {@link ResponseEntity<ScheduleResponseDto>} JSON 응답
     */
    @PostMapping
    public ResponseEntity<ScheduleResponseDto> createSchedule(@RequestBody ScheduleRequestDto requestDto) {
        return new ResponseEntity<>(scheduleService.saveSchedule(requestDto), HttpStatus.CREATED);
    }

    /**
     * 스케쥴 다건 조회 API
     * @return : {@link  List<ScheduleResponseDto>} JSON 응답
     */
    @GetMapping
    public List<ScheduleResponseDto> findAllSchedule() {
        return scheduleService.findAllSchedules();
    }

    /**
     * 스케쥴 단건 조회 API
     * @param id 식별자
     * @return : {@link ResponseEntity<ScheduleResponseDto>} JSON 응답
     * @exception ResponseStatusException 식별자로 조회된 Schedule이 없는 경우 404 Not Found
     */
    @GetMapping("/{id}")
    public ResponseEntity<ScheduleResponseDto> findScheduleById(@PathVariable Long id) {
        return new ResponseEntity<>(scheduleService.findScheduleById(id), HttpStatus.OK);
    }

    /**
     * 스케줄 단건 수정 API
     * Param id 식별자
     * Return : {@link ResponseEntity<ScheduleResponseDto>} JSON 응답
     * @exception ResponseStatusException 식별자로 조회된 Schedule이 없는 경우 404 Not Found
     */
    @PatchMapping("/{id}")
    public ResponseEntity<ScheduleResponseDto> updatetoDoContent(
            @PathVariable long id,
            @RequestBody ScheduleRequestDto requestDto
    ) {
        return new ResponseEntity<>(scheduleService.updateToDoContent(id, requestDto.getUserId(), requestDto.getToDoContent()), HttpStatus.OK);
    }

    /**
     * 스케줄 단건 수정 API
     * Param id 식별자
     * return {@link ResponseEntity<Void>} 성공시 Data 없이 200OK 상태코드만 응답.
     * @exception ResponseStatusException 식별자로 조회된 Schedule이 없는 경우 404 Not Found
     */

    @DeleteMapping("/{id}")
    public ResponseEntity<Void> deleteSchedule(
            @PathVariable long id,
            @RequestBody ScheduleRequestDto requestDto
    ) {
        scheduleService.deleteSchedule(id, requestDto.getUserId());
        // 성공한 경우
        return new ResponseEntity<>(HttpStatus.OK);
    }
}

 

 

ScheduleService

더보기
public interface ScheduleService {

    ScheduleResponseDto saveSchedule(ScheduleRequestDto requestDto);

    List<ScheduleResponseDto> findAllSchedules();

    ScheduleResponseDto findScheduleById(long id);

    ScheduleResponseDto updateToDoContent(long id, long userId, String toDoContent);

    void deleteSchedule(long id, long password);
}

 

 

ScheduleServiceImpl

더보기
@Service
public class ScheduleServiceImpl implements ScheduleService{

    private final ScheduleRepository scheduleRepository;

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

    @Override
    public ScheduleResponseDto saveSchedule(ScheduleRequestDto requestDto) {
        Schedule schedule = new Schedule(requestDto.getUserId(), requestDto.getToDoContent());
        return scheduleRepository.saveSchedule(schedule);
    }

    @Override
    public List<ScheduleResponseDto> findAllSchedules() {
        // 전체 조회
        return scheduleRepository.findAllSchedules();
    }

    @Override
    public ScheduleResponseDto findScheduleById(long id) {
        Schedule schedule = scheduleRepository.findScheduleByIdElseThrow(id);
        return new ScheduleResponseDto(schedule);
    }

    @Override
    public ScheduleResponseDto updateToDoContent(long id, long userId, String toDoContent) {
        // 필수값 검증
        if (toDoContent == null) {
            throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "The userName and toDoContent are required values.");
        }

        // 비밀번호 검증
        if (userId != scheduleRepository.findScheduleUserIdByIdElseThrow(id).getUserId()) {
            throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "The password is not the same as the password for this schedule");
        }

        int updatedRow = scheduleRepository.updateUserNameOrToDoContent(id, toDoContent);

        // NPE 방지
        if (updatedRow == 0) {
            throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Does not exist id = " + id);
        }

        // 식별자의 schedule 없다면?
        Schedule schedule = scheduleRepository.findScheduleByIdElseThrow(id);

        return new ScheduleResponseDto(schedule);
    }

    @Override
    public void deleteSchedule(long id, long userId) {
        // 비밀번호 검증
        if (userId != scheduleRepository.findScheduleUserIdByIdElseThrow(id).getUserId()) {
            throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "The password is not the same as the password for this schedule");
        }

        int deletedRow = scheduleRepository.deleteSchedule(id);

        // NPE 방지
        if(deletedRow == 0) {
            throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Does not exist id = " + id);
        }
    }
}

 

 

ScheduleRepository

더보기
public interface ScheduleRepository {

    ScheduleResponseDto saveSchedule(Schedule schedule);

    List<ScheduleResponseDto> findAllSchedules();

    Schedule findScheduleByIdElseThrow(long id);

    Schedule findScheduleUserIdByIdElseThrow(long id);

    int updateUserNameOrToDoContent(long id, String toDoContent);

    int deleteSchedule (long id);
}

 

 

JdbcTemplateScheduleRepository

더보기
@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)
                .withTableName("schedule") // 테이블명
                .usingGeneratedKeyColumns("scheduleId") // 자동 생성된 키
                .usingColumns("userId", "userName", "toDoContent");

        String userCheckSql = "SELECT COUNT(*) FROM user WHERE userId = ?";
        Integer userCount = jdbcTemplate.queryForObject(userCheckSql, Integer.class, schedule.getUserId());

        if (userCount == null || userCount == 0) {
            throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "This user doesn't exist.");
        }

        String userNameSql = "SELECT userName FROM user WHERE userId = ?";
        String userName = jdbcTemplate.queryForObject(userNameSql, String.class, schedule.getUserId());

        Map<String, Object> parameters = new HashMap<>();
        parameters.put("userId", schedule.getUserId());
        parameters.put("userName", userName);
        parameters.put("toDoContent", schedule.getTodoContent());

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

        String sql = "SELECT userName, toDoContent, updateAt FROM schedule WHERE scheduleId = ?";
        return jdbcTemplate.queryForObject(sql, (rs, rowNum) ->
                new ScheduleResponseDto(
                        rs.getString("userName"),
                        rs.getString("toDoContent"),
                        rs.getDate("updateAt")
                )
                ,scheduleId
        );
    }

    @Override
    public List<ScheduleResponseDto> findAllSchedules() {
        return jdbcTemplate.query("select * from schedule ORDER BY updateAt DESC", scheduleRowMapper());
    }

    @Override
    public Schedule findScheduleByIdElseThrow(long id) {
        List<Schedule> result = jdbcTemplate.query("select * from schedule where scheduleId = ?", scheduleRowMapper2(), id);
        return result.stream().findAny().orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Does not exists id = " + id));
    }

    @Override
    public Schedule findScheduleUserIdByIdElseThrow(long id) {
        List<Schedule> result = jdbcTemplate.query("select * from schedule where scheduleId = ?", scheduleRowMapper3(), id);
        return result.stream().findAny().orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Does not exists id = " + id));
    }

    @Override
    public int updateUserNameOrToDoContent(long id, String toDoContent) {
        return jdbcTemplate.update("update schedule set toDoContent = ? where scheduleId = ?", toDoContent, id);
    }

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

    private RowMapper<ScheduleResponseDto> scheduleRowMapper() {
        return (rs, rowNum) -> new ScheduleResponseDto(
                rs.getString("userName"),
                rs.getString("todoContent"),
                rs.getDate("updateAt")
        );
    }

    private RowMapper<Schedule> scheduleRowMapper2() {
        return (rs, rowNum) -> new Schedule(
                rs.getString("userName"),
                rs.getString("todoContent"),
                rs.getDate("updateAt")
        );
    }

    private RowMapper<Schedule> scheduleRowMapper3() {
        return (rs, rowNum) -> new Schedule(
                rs.getLong("userId")
        );
    }
}
  • String userNameSql = "SELECT userName FROM user WHERE userId = ?";
    String userName = jdbcTemplate.queryForObject(userNameSql, String.class, schedule.getUserId());
    parameters.put("userName", userName) :

    클라이언트로부터 넘겨받은 userId를 통해, 해당 userId의 userName을 가져와서 응답 데이터에 넣어줍니다.

 

 

ScheduleRequestDto

더보기
@Getter
public class ScheduleRequestDto {

    private Long userId;
    private String toDoContent;
}
  • userId를 통해 userName을 내부적으로 받아올 거이므로, userName은 요청 데이터로 받지 않습니다.

 

 

ScheduleResponseDto

더보기
@Getter
@AllArgsConstructor
public class ScheduleResponseDto {

    private String userName;
    private String todoContent;
    private Date updateAt;

    public ScheduleResponseDto(Schedule schedule) {
        this.userName = schedule.getUserName();
        this.todoContent = schedule.getTodoContent();
        this.updateAt = schedule.getUpdateAt();
    }
}

 

 

Schedule(Entity)

더보기
@Getter
@AllArgsConstructor
public class Schedule {

    private long scheduleId;
    private long userId;
    private String userName;
    private String todoContent;
    private Date createAt;
    private Date updateAt;

    public Schedule(long userId, String todoContent) {
        this.userId = userId;
        this.todoContent = todoContent;
    }

    public Schedule(String userName, String todoContent, Date updateAt) {
        this.userName = userName;
        this.todoContent = todoContent;
        this.updateAt = updateAt;
    }

    public Schedule(long userId) {
        this.userId = userId;
    }
}

 


 

트러블 슈팅

1. HttpMediaTypeNotAcceptableException 오류 발생

설명 : 유저 생성을 위해 서버로 요청을 보낸 상황입니다.

  1. 클라이언트에서 요청 : 문제 ❌
  2. 유저 컨트롤러 처리 : 문저 ❌
  3. 유저 서비스 로직 처리 : 문제 ❌
  4. 유저 레포지토리 처리 : 문제 ❌
  5. DB에 데이터 저장 : 문제 ❌
  6. 클라이언트로 응답 : 문제 ✅ (다음과 같은 ` HttpMediaTypeNotAcceptableException` 오류가 발생합니다.)

 

 

문제

UserResponseDto를 클라이언트에 JSON 형식으로 응답이 가능해야합니다.

Spring Boot는 응답 객체(UserResponseDto)를 JSON으로 자동 변환(직렬화)하기 위해 Jackson 라이브러리를 사용합니다.

근데 UserResponseDto에 `getter`가 존재하지 않으면, Jackson은 필드 접근 방법을 찾지 못하고 응답 직렬화에 실패합니다.

  • 클라이언트는 `406 Not Acception`을 받습니다.
  • 서버에서는 `HttpMediaTypeNotAcceptableException`이 발생합니다.

 

 

해결

`getter`를 추가하거나 `@getter` 어노테이션을 추가하면 해결됩니다.