사흘 전 올린 글에서 만든 프로젝트를 도전과제 요구사항에 맞게 확?장시키기로 했다.
설계를 하기 전 미리 요구사항에 대해 파악 할 필요가 있어 요구사항을 확인했다.
테이블부터 싹다 고쳐야하는 상당히 머리 아픈 요구사항이라 할 수 있겠다.
작성자 테이블에 대한 이유는 매우 타당하지만, 일정 관리 앱에 왜 이메일이 필요한지는 둘째 치더라도 이메일을 입력해야 하는 상황이 없다는 것이 문제이다.
등록일과 수정일이 존재하는 것을 보아, 회원을 등록하고 로그인 하는 기능이 나중에 생길 것을 가정하고 방향을 잡으면 될 것으로 보인다.결과적으로 해야 할 일을 정리해보자면 테이블 추가 및 연관관계 설정 후, 그 테이블에 맞게 기존 조회 코드 수정, 페이지네이션, 그리고 이 모든 것을 회원가입이 추가될 것을 상정하고 진행한다면 5레벨은 따라오게 된다. 다만, 6레벨의 담당자의 이메일 정보가 형식에 맞는지 확인하라는 요구사항은 무엇을 의미하는 지 도무지 알 수 없기에, 일단 이메일이 형식을 따르는지를 확인하는 기능만 임의로 만들어놓기로 했다.
일정 생성
- Method: POST
- URL:
/api/schedules- Request Body:
{ "title": "일정 제목", "task": "일정 내용", "userName": "사용자이름", "password": "비밀번호" }
- Response:
201 Created전체 일정 조회
- Method: GET
- URL:
/api/schedules- Response:
{ "id": 1, "title": "일정 제목", "task": "일정 내용", "userName": "사용자이름", "createdAt": "2024-03-21 15:30", "updatedAt": "2024-03-21 15:30" }
특정 일정 조회
- Method: GET
- URL:
/api/schedules/{id}- Response:
{ "id": 1, "title": "일정 제목", "task": "일정 내용", "userName": "사용자이름", "createdAt": "2024-03-21 15:30", "updatedAt": "2024-03-21 15:30" }
일정 검색
- Method: GET
- URL:
/api/schedules/search- Query Parameters:
userId: (선택) 사용자 IDdate: (선택) 날짜 (형식: YYYY-MM-DD)- Example:
/api/schedules/search?userId=1&date=2024-03-21- Response:
{ "id": 1, "title": "일정 제목", "task": "일정 내용", "userName": "사용자이름", "createdAt": "2024-03-21 15:30", "updatedAt": "2024-03-21 15:30" }
일정 수정
- Method: PATCH
- URL:
/api/schedules/{id}- Request Body:
{ "title": "수정된 제목", // 선택적 "task": "수정된 내용", // 선택적 "password": "비밀번호" // 필수 }Response:
204 No Content
일정 삭제
- Method: DELETE
- URL:
/api/schedules/{id}- Request Body:
{ "password": "비밀번호" }
- Response:
204 No Content오류 응답
{ "message": "오류 메시지" }주요 HTTP 상태 코드
200: 성공201: 생성 성공204: 성공 (응답 본문 없음)400: 잘못된 요청404: 리소스를 찾을 수 없음500: 서버 내부 오류
우선 요구사항에 맞게 Users 테이블을 추가하고, 그에 따른 전체적인 구조의 확장이 있었다.
schedules 테이블에 있던 username과 password를 새로 만든 users 테이블로 옮겼다.
테이블을 만들 때 테이블 내부에 있던 모든 데이터를 지워야 한다는 작은 문제가 있었지만 요구사항이 우선이기 때문에 전혀 문제되지 않는다.
진짜 문제는 따로 있는데 그것은 새로 추가된 테이블에 따라 전체적인 구조를 바꿔야 한다는 점이다.우선 테이블이 두 개이기 때문에 Repository도 나눌 필요가 있다.
당연 Repository가 나뉘면 Service도 나뉘어야 한다.
이 고통스러웠던 과정을 정리하며 다시 고통을 느껴보도록 하자.
User 엔티티 추가
@Getter @Setter public class User { private Long id; private String username; private String password; private LocalDate registerDate; private LocalDate updateDate; }
Schedule 엔티티 수정
@Getter @Setter public class Schedule { private Long id; private String title; private String task; private String userName; private LocalDateTime postedTime; private LocalDateTime updatedTime; }우선 User 테이블의 속성들이 담긴 Entity 클래스를 추가했다.
Schedule 엔티티에 있던 password 는 지워졌지만 username은 반환할 때를 위해 그대로 뒀다. (사실 지웠었으나 모든 수정이 끝난 뒤, 조회할 때 이름이 보이지 않는 것이 매우 불편해 다시 추가했다.)
UserRepository 추가
public interface UserRepository { Long findUserId(User user); void save(User user); } @Repository public class UserRepositoryImpl implements UserRepository { private final JdbcTemplate jdbcTemplate; @Override public Long findUserId(User user) { String sql = "SELECT id FROM users WHERE username = ? AND password = ?"; try { return jdbcTemplate.queryForObject(sql, Long.class, user.getUsername(), user.getPassword()); } catch (EmptyResultDataAccessException e) { return null; } } @Override public void save(User user) { String sql = "INSERT INTO users (username, password, registerdate, updatedate) VALUES (?, ?, ?, ?)"; jdbcTemplate.update(sql, user.getUsername(), user.getPassword(), user.getRegisterDate(), user.getUpdateDate() ); } } }User 테이블과 직접 데이터를 주고받기 위한 UserRepository 를 추가했다.
기본적인 저장 기능과 외래 키인 ID를 가져오는 기능을 가지고있다.
ScheduleRepository 수정
public interface ScheduleRepository { List<Schedule> findAll(); Schedule findById(Long id); void save(Schedule schedule, Long userId); // userId 파라미터 추가 void update(Schedule schedule); void deleteById(Long id); Optional<Schedule> findByIdAndPassword(Long id, String password); // 인증용 메서드 추가 List<Schedule> findByUserIdOrDate(ScheduleSearchRequest request); } @Repository public class ScheduleRepositoryImpl implements ScheduleRepository { // 조회 쿼리 수정 - user 정보 JOIN @Override public List<Schedule> findAll() { String sql = "SELECT s.*, u.username FROM schedules s " + "JOIN users u ON s.user_id = u.id"; return jdbcTemplate.query(sql, this::mapRowWithUsername); } // 저장 로직 수정 - userId 포함 @Override public void save(Schedule schedule, Long userId) { String sql = "INSERT INTO schedules (title, task, user_id, posted_time, updated_time) " + "VALUES (?, ?, ?, ?, ?)"; jdbcTemplate.update(sql, schedule.getTitle(), schedule.getTask(), userId, schedule.getPostedTime(), schedule.getUpdatedTime() ); } // 인증 조회 메서드 추가 @Override public Optional<Schedule> findByIdAndPassword(Long scheduleId, String password) { String sql = "SELECT s.*, u.username FROM schedules s " + "JOIN users u ON s.user_id = u.id " + "WHERE s.id = ? AND u.password = ?"; try { Schedule schedule = jdbcTemplate.queryForObject( sql, new Object[]{scheduleId, password}, this::mapRowWithUsername ); return Optional.ofNullable(schedule); } catch (EmptyResultDataAccessException e) { return Optional.empty(); } } // ResultSet 매핑 로직 수정 private Schedule mapRowWithUsername(ResultSet rs, int rowNum) throws SQLException { Schedule schedule = new Schedule(); schedule.setId(rs.getLong("id")); schedule.setTitle(rs.getString("title")); schedule.setTask(rs.getString("task")); schedule.setUserName(rs.getString("username")); // username 매핑 추가 schedule.setPostedTime(rs.getTimestamp("posted_time").toLocalDateTime()); schedule.setUpdatedTime(rs.getTimestamp("updated_time").toLocalDateTime()); return schedule; } }아무래도 이미 작업이 끝난 걸 모두 기억하며 적을 순 없고, 변화가 있던 부분에 적당히 주석을 적었다.
UserService 추가
public interface UserService { void updateUser(UserUpdateRequest request); Long findUserId(UserUpdateRequest request); } @Service @Transactional public class UserServiceImpl implements UserService { private final UserRepository userRepository; @Override public void updateUser(UserUpdateRequest request) { User user = new User(); user.setUsername(request.getUserName()); user.setPassword(request.getPassword()); user.setRegisterDate(LocalDate.now()); user.setUpdateDate(LocalDate.now()); userRepository.save(user); } @Override public Long findUserId(UserUpdateRequest request) { User user = new User(); user.setUsername(request.getUserName()); user.setPassword(request.getPassword()); return userRepository.findUserId(user); } }마찬가지로 users 테이블의 비즈니스 로직을 구현하기 위해 UserService 를 추가한 뒤 UserRepository 가 가진 메서드에 대응되는 메서드를 구현해줬다.
ScheduleService 변경
@Service @Transactional public class ScheduleServiceImpl implements ScheduleService { private final ScheduleRepository scheduleRepository; private final UserService userService; // UserService 의존성 추가 // 생성 로직 수정 @Override @Transactional public void createSchedule(ScheduleRequest request) { // 사용자 처리 로직 추가 UserUpdateRequest userRequest = new UserUpdateRequest( request.getUserName(), request.getPassword() ); userService.updateUser(userRequest); Long userId = userService.findUserId(userRequest); if (userId == null) { throw new IllegalStateException("사용자 생성/조회 실패"); } Schedule schedule = new Schedule(); schedule.setTitle(request.getTitle()); schedule.setTask(request.getTask()); schedule.setPostedTime(LocalDateTime.now()); schedule.setUpdatedTime(LocalDateTime.now()); scheduleRepository.save(schedule, userId); } // 수정 로직 수정 @Override @Transactional public void updateSchedule(Long id, ScheduleUpdateRequest request) { Optional<Schedule> scheduleOp = scheduleRepository.findByIdAndPassword( id, request.getPassword() ); Schedule schedule = scheduleOp.orElseThrow(() -> new IllegalArgumentException("일정을 찾을 수 없거나 비밀번호가 일치하지 않습니다.") ); if (request.getTitle() != null) { schedule.setTitle(request.getTitle()); } if (request.getTask() != null) { schedule.setTask(request.getTask()); } schedule.setUpdatedTime(LocalDateTime.now()); scheduleRepository.update(schedule); } // 삭제 로직 수정 @Override @Transactional public void deleteSchedule(Long id, ScheduleDeleteRequest request) { Optional<Schedule> scheduleOp = scheduleRepository.findByIdAndPassword( id, request.getPassword() ); Schedule schedule = scheduleOp.orElseThrow(() -> new IllegalArgumentException("일정을 찾을 수 없거나 비밀번호가 일치하지 않습니다.") ); scheduleRepository.deleteById(schedule.getId()); } }전체적으로 유저와 관련된 리소스는 UserService를 통해 얻을 수 있게끔 수정한 뒤, 그에 맞게 로직들을 수정해줬다.
DTO 추가
// 사용자 정보 업데이트용 @Getter @AllArgsConstructor public class UserUpdateRequest { private String userName; private String password; }사용자의 정보를 추가하고 업데이트하기 위한 DTO를 추가해줬다.
기존 DTO 변경
// 일정 생성 요청 수정 @Getter @Setter public class ScheduleRequest { private String title; private String task; private String userName; // 추가 private String password; // 추가 } // 일정 수정 요청 수정 @Getter @Setter public class ScheduleUpdateRequest { private String title; private String task; private String password; // 추가 } // 일정 응답 수정 @Getter @Setter public class ScheduleResponse { private Long id; private String title; private String task; private String userName; // 추가 private LocalDateTime createdAt; private LocalDateTime updatedAt; }바뀐 로직에 맞게 기존의 DTO를 수정해줬다.
응답 형식 통일
@RestController @RequestMapping("api/schedules") public class ScheduleController { @PostMapping public ResponseEntity<Void> createSchedule(@RequestBody ScheduleRequest request) { scheduleService.createSchedule(request); return ResponseEntity.status(HttpStatus.CREATED).build(); } @PatchMapping("/{id}") public ResponseEntity<Void> updateSchedule( @PathVariable Long id, @RequestBody ScheduleUpdateRequest request) { scheduleService.updateSchedule(id, request); return ResponseEntity.noContent().build(); } @DeleteMapping("/{id}") public ResponseEntity<Void> deleteSchedule( @PathVariable Long id, @RequestBody ScheduleDeleteRequest request) { scheduleService.deleteSchedule(id, request); return ResponseEntity.noContent().build(); } }Controller 계층에서는 크게 변경사항은 없었지만 에러처리를 하며 응답형식도 함께 통일시켰다.
GlobalExceptionHandler 추가
@RestControllerAdvice public class GlobalExceptionHandler { @ExceptionHandler(IllegalArgumentException.class) public ResponseEntity<ErrorResponse> handleIllegalArgumentException(IllegalArgumentException e) { ErrorResponse response = new ErrorResponse(e.getMessage()); return ResponseEntity.badRequest().body(response); } @ExceptionHandler(Exception.class) public ResponseEntity<ErrorResponse> handleException(Exception e) { ErrorResponse response = new ErrorResponse("내부 서버 오류가 발생했습니다."); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response); } } @Getter @AllArgsConstructor class ErrorResponse { private String message; }전역의 예외를 편하게 관리하기 위해 GlobalExceptionHandler를 추가했다.
- 사용자 정보 관리
- User 엔티티와 관련 Repository/Service 추가
- 사용자 생성 및 조회 로직 구현
- 일정-사용자 연동
- Schedule 엔티티에 userName 필드 추가
- 저장 시 userId 연동
- 조회 시 username 포함
- 인증 로직 추가
- 비밀번호 기반 인증 구현
- 수정/삭제 시 권한 검증
- 트랜잭션 관리
- 사용자 생성과 일정 생성의 트랜잭션 통합
- 실패 시 롤백 처리
- 에러 처리
- 구체적인 예외 메시지 추가
- 인증 실패 시 명확한 피드백