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 오류 발생
설명 : 유저 생성을 위해 서버로 요청을 보낸 상황입니다.
- 클라이언트에서 요청 : 문제 ❌
- 유저 컨트롤러 처리 : 문저 ❌
- 유저 서비스 로직 처리 : 문제 ❌
- 유저 레포지토리 처리 : 문제 ❌
- DB에 데이터 저장 : 문제 ❌
- 클라이언트로 응답 : 문제 ✅ (다음과 같은 ` HttpMediaTypeNotAcceptableException` 오류가 발생합니다.)
문제
UserResponseDto를 클라이언트에 JSON 형식으로 응답이 가능해야합니다.
Spring Boot는 응답 객체(UserResponseDto)를 JSON으로 자동 변환(직렬화)하기 위해 Jackson 라이브러리를 사용합니다.
근데 UserResponseDto에 `getter`가 존재하지 않으면, Jackson은 필드 접근 방법을 찾지 못하고 응답 직렬화에 실패합니다.
- 클라이언트는 `406 Not Acception`을 받습니다.
- 서버에서는 `HttpMediaTypeNotAcceptableException`이 발생합니다.
해결
`getter`를 추가하거나 `@getter` 어노테이션을 추가하면 해결됩니다.