GitHub - taeung515/jpa-schedule: 사용자는 회원가입, 로그인 후 일정을 CRUD할 수 있습니다. 이 프로젝트
사용자는 회원가입, 로그인 후 일정을 CRUD할 수 있습니다. 이 프로젝트는 JPA 기반으로 동작합니다. - taeung515/jpa-schedule
github.com
요구사항에 맞게 어떤 생각을 하며 구현 하였는지 학습용으로 정리하며 커밋하였고, 그에 맞추어 다시 복습하며 정리하려고 합니다.
이번 프로젝트의 목표는 JPA 사용에 익숙해지는 것과, 유지보수성!!!을 높이기 위해 서비스 계층에서 반복되는 코드를 최소화하면서도 전체 흐름을 명확히 이해할 수 있는 구조로 작성하는 것이었습니다. 우선 전체 단계를 먼저 설계한 뒤 코딩하는 것이 바람직하지만, 학습을 위해 Lv별로 단계적으로 구현했습니다.
목차
Lv1 구현할때 생각한 것들

- JPA Auditing을 활용해 생성일과 수정일을 관리하는 BaseEntity 추상 클래스로 만들고, 생성일,수정일 필드가 필요한 엔티티에 상속했습니다. (코드중복 방지)
- 내용의 경우 기본길이를 초과할 수 있기에 해당 컬럼은 columnDefinition = “longtext”로 설정했습니다.
- 안전성, 가독성, 유지보수성을 위해 요청과 응답에 사용되는 DTO는 모두 불변 객체로 구현했습니다.
- 우선 ddl-auto : create-drop으로 설정 후 추후 none으로 변경 후 .sql 생성하도록 하였습니다. (학습을 위함)
- 쿼리 메서드인 findById가 Optional을 반환하면서 서비스 계층에 중복된 예외 처리 코드가 늘어났기 때문에, 이를 공통 메서드인 findByIdOrElseThrow로 추출하여 중복을 방지했습니다.
- 사용자가 이름, 제목, 내용을 선택하여 수정하게 하기위하여 서비스 계층의 복잡한 조건문을 줄이고 가독성을 높이기 위해, Schedule 엔티티에 다음과 같은 editSchedule 메서드를 추가했습니다.
public void editSchedule(String username, String title, String contents) {
if (Strings.isNotBlank(username)) {
this.username = username;
}
if (Strings.isNotBlank(title)) {
this.title = title;
}
if (Strings.isNotBlank(contents)) {
this.contents = contents;
}
}
Lv2 구현할때 생각한 것들

- 일정 entity에 작성유저명이 사라짐에 따라 필드로 유저 entity를 갖게 하고 user_id를 FK로 연관관계를 맺어주었습니다.
- email은 유니크로 설정하여 일정을 생성할때 유니크인 email을 입력하도록 하여 유저를 식별하게 하였습니다
- 일정 생성 시 정확한 사용자 식별을 위해 사용자의 고유 키인 이메일을 요청받아 해당 이메일을 기준으로 사용자를 조회한 후 일정을 생성하도록 구현했습니다. schedule_id를 활용할 수도 있지만, 사용자가 직접 입력한 이메일 값이 요청에 더 적절하다고 판단했습니다.
- 사용자를 삭제할 때 일정도 함께 삭제되도록 구현하였습니다. orphanRemoval을 엔티티의 @Column에 적용할 수도 있지만, 서비스 계층에서 전체적인 흐름을 명확히 하기 위해 UserService가 ScheduleRepository를 의존하도록 구성했습니다. 이로 인해 사용자가 삭제될 때 해당 일정도 함께 제거되도록 처리했습니다.
- UserController의 @DeleteMapping에서 HttpSession을 활용해 사용자를 식별한 후 삭제할 수 있도록 구현했습니다.
- 이 외 1단계와 동일
Lv3, Lv4 구현할때 생각한 것들


- DispatchServlet 실행 전에 로그인 없이 접근 가능한 URL 목록을 배열로 관리하고 요청 URI가 해당 배열에 포함되지 않는 경우 세션 존재여부를 검사하여 접근을 제어하는 Filter를 구현하였습니다. 다음은 예시 코드입니다.
public class LoginFilter implements Filter {
private static final String[] WHITE_LIST = {"/api/users/signup", "/api/users/login"};
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain filterChain) throws IOException, ServletException {
HttpServletRequest httpRequest = (HttpServletRequest) request;
String requestURI = httpRequest.getRequestURI();
HttpServletResponse httpResponse = (HttpServletResponse) response;
if (!isWhiteList(requestURI)) {
HttpSession session = httpRequest.getSession(false);
if (session == null || session.getAttribute(SESSION_USER_ID) == null) {
throw new LoginRequiredException("로그인 해주세요");
}
}
filterChain.doFilter(request, response);
}
private boolean isWhiteList(String requestURI) {
return PatternMatchUtils.simpleMatch(WHITE_LIST, requestURI);
}
}
- Login전용 Dto를 만들어 이메일, 비밀번호를 요청 받고 이메일로 User를 찾은 후 비밀번호를 확인하는 login 로직을 구현하였습니다.
Lv5 구현할때 생각한 것들

1. @Validated를 사용하는 이유 중 가장 큰 이유는 Group Validation 라고 생각이 되나, groups 기능을 사용하기 보단, @Valid를 사용하고 Dto로 분리하여 하는 것이 Dto이름으로 어떤 역할을 하는지 확실히 인지가 되어 유지보수에 유리할 것 같아 ScheduleRequestDto를 <CreateScheduleRequestDto> 와 <UpdateScheduleRequestDto>로 분리하여 각각 유효성 검증 어노테이션을 달아주었습니다.
2. @Valid 는 MethodArgumentNotValidException 예외를 발생시킵니다 따라서 다음과 같이 사용자에게 defalut message를 보여주기 위해 코드를 @RestControllerAdvice를 활용하여 전역예외처리를 담당하는 클래스를 만들고, 예외발생 시 defalut message가 출력되도록 하였습니다.

MethodArgumentNotValidException를 전역예외처리 클래스에서 defalut message를 출력하게 처리한 코드
// @Valid 가 MethodArgumentNotValidException를 던져 @RestControllerAdvice
// 전역예외처리 클래스에서 StringBuilder와 bindingResult를 통해 fielderror의 defalut message를 출력
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
public String processValidationError(MethodArgumentNotValidException exception) {
BindingResult bindingResult = exception.getBindingResult();
StringBuilder builder = new StringBuilder();
for (FieldError fieldError : bindingResult.getFieldErrors()) {
builder.append(fieldError.getDefaultMessage());
builder.append(" 입력된 값: ");
builder.append(fieldError.getRejectedValue());
builder.append(" \n");
}
return builder.toString();
defalut message 입력한 dto 코드
// Vaildation annotation예시
@Getter
public class UserSignUpRequestDto {
@NotNull(message = "이름을 입력해주세요!")
@Size(max = 4, message = "이름은 4자까지 입력 가능합니다.")
private final String username;
@NotEmpty(message = "이메일을 입력해주세요!")
@Pattern(regexp = "^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+$", message = "올바른 이메일 형식이 아닙니다.")
private final String email;
@NotNull(message = "비밀번호를 입력해주세요!")
private final String password;
public UserSignUpRequestDto(String username, String email, String password) {
this.username = username;
this.email = email;
this.password = password;
}
}
3. 전역 예외 처리 클래스를 구현했기 때문에, 발생하는 모든 예외에 대해 ErrorResponseDto를 사용하여 응답 형식을 통일했습니다. 또한, 예외를 커스텀하여 명확한 이름을 부여함으로써 개발자와 사용자 모두가 예외의 의미를 쉽게 이해할 수 있도록 했습니다.
다음은 예시 코드입니다. ErrorResponseDto를 생성하여 예외들의 응답이 통일되도록 하였고, 커스텀예외의 이름만으로 개발자는 어떤 예외인지 인지할 수 있도록 하였습니다.
// 에러 응답 통일 위한 DTO
@Getter
@AllArgsConstructor
public class ErrorResponseDto {
private int status;
private String message;
}
// 유지보수를 위한 전역예외처리 클래스
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
public String processValidationError(MethodArgumentNotValidException exception) {
BindingResult bindingResult = exception.getBindingResult();
StringBuilder builder = new StringBuilder();
for (FieldError fieldError : bindingResult.getFieldErrors()) {
builder.append(fieldError.getDefaultMessage());
builder.append(" 입력된 값: ");
builder.append(fieldError.getRejectedValue());
builder.append("\n");
}
return builder.toString();
}
@ExceptionHandler(UserNotFoundException.class)
public ResponseEntity<ErrorResponseDto> handleUserNotFound(UserNotFoundException exception) {
ErrorResponseDto response = new ErrorResponseDto(
HttpStatus.NOT_FOUND.value(),
exception.getMessage()
);
return new ResponseEntity<>(response, HttpStatus.NOT_FOUND);
}
@ExceptionHandler(ScheduleNotFoundException.class)
public ResponseEntity<ErrorResponseDto> handleScheduleNotFoundException(ScheduleNotFoundException exception) {
ErrorResponseDto response = new ErrorResponseDto(
HttpStatus.NOT_FOUND.value(),
exception.getMessage()
);
return new ResponseEntity<>(response, HttpStatus.NOT_FOUND);
}
@ExceptionHandler(PasswordMismatchException.class)
public ResponseEntity<ErrorResponseDto> handlePasswordMismatchException(PasswordMismatchException exception) {
ErrorResponseDto response = new ErrorResponseDto(
HttpStatus.UNAUTHORIZED.value(),
exception.getMessage()
);
return new ResponseEntity<>(response, HttpStatus.UNAUTHORIZED);
}
@ExceptionHandler(LoginRequiredException.class)
public ResponseEntity<ErrorResponseDto> handleLoginRequiredException(LoginRequiredException exception) {
ErrorResponseDto response = new ErrorResponseDto(
HttpStatus.UNAUTHORIZED.value(),
exception.getMessage()
);
return new ResponseEntity<>(response, HttpStatus.UNAUTHORIZED);
}
@ExceptionHandler(UnauthorizedSessionException.class)
public ResponseEntity<ErrorResponseDto> handleUnauthorizedSessionException(UnauthorizedSessionException exception) {
ErrorResponseDto response = new ErrorResponseDto(
HttpStatus.FORBIDDEN.value(),
exception.getMessage()
);
return new ResponseEntity<>(response, HttpStatus.FORBIDDEN);
}
}

'프로젝트 회고' 카테고리의 다른 글
| 캐시 사용전략 : 왜 Redis인가? 캐시 설계부터 동기화 (2) | 2025.08.24 |
|---|---|
| 코드 refactor 과제: 리팩토링하며 생각했던 것들과 배운 것을 기록 (0) | 2025.07.04 |
| 아웃소싱 팀 프로젝트 회고: Docker로 프론트 연동 (0) | 2025.06.23 |
| JWT 인증 기반 뉴스피드 프로젝트: 회고 (1) | 2025.06.05 |
| JPA Schedule 프로젝트: 구현 중 발생한 트러블슈팅 (0) | 2025.05.23 |