![image-20240207001441527](https://private-user-images.githubusercontent.com/61932809/310031635-031f1149-7c12-48d5-9522-e8d7346a596e.png?jwt=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJnaXRodWIuY29tIiwiYXVkIjoicmF3LmdpdGh1YnVzZXJjb250ZW50LmNvbSIsImtleSI6ImtleTUiLCJleHAiOjE3MjE5NjY3OTgsIm5iZiI6MTcyMTk2NjQ5OCwicGF0aCI6Ii82MTkzMjgwOS8zMTAwMzE2MzUtMDMxZjExNDktN2MxMi00OGQ1LTk1MjItZThkNzM0NmE1OTZlLnBuZz9YLUFtei1BbGdvcml0aG09QVdTNC1ITUFDLVNIQTI1NiZYLUFtei1DcmVkZW50aWFsPUFLSUFWQ09EWUxTQTUzUFFLNFpBJTJGMjAyNDA3MjYlMkZ1cy1lYXN0LTElMkZzMyUyRmF3czRfcmVxdWVzdCZYLUFtei1EYXRlPTIwMjQwNzI2VDA0MDEzOFomWC1BbXotRXhwaXJlcz0zMDAmWC1BbXotU2lnbmF0dXJlPTg1Mjg0NjQyNjM1NmQwMzQzNWY2ZTJkZTNlOTY1OTA3ZTllODQ4NjM5ZjA1NWY4ZGNjNGFiYjVhMWY3MDU2ZWEmWC1BbXotU2lnbmVkSGVhZGVycz1ob3N0JmFjdG9yX2lkPTAma2V5X2lkPTAmcmVwb19pZD0wIn0.ybfYPSyWj32y66KuodkkWB_nVTQ7BVTv2fleqroWfaI)
last updated at: 2024.02.12
-
웹사이트 주소 : https://today.worklog.shop (임시 계정 ID: 3, PW: 3)
-
API 서버: https://worklog.shop
-
API 명세서 : 구글 스프레드 시트
-
개발 기간: 2023.11.10 ~ 진행중
- 언어 : Java17
- 빌드 툴 : Gradle
- 프레임워크 : Springboot(3.1.x), Spring Framework, Spring Data JPA, Spring Security, Spring AOP, Spring Validation
- 라이브러리: Quartz Scheduler(알림 기능), Quarydsl
- ORM : JPA
- DB: MySQL
- 프록시서버: nginx
- 캐시 서버(리프레시 토큰, 알림 확인 Flag): Redis
- Infra Structure: AWS(EC2, RDS, CodeDeploy, S3), GIthub Actions
![image](https://private-user-images.githubusercontent.com/61932809/310037323-ddeddec0-fc75-4e12-8d12-7e1537f7feea.png?jwt=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJnaXRodWIuY29tIiwiYXVkIjoicmF3LmdpdGh1YnVzZXJjb250ZW50LmNvbSIsImtleSI6ImtleTUiLCJleHAiOjE3MjE5NjY3OTgsIm5iZiI6MTcyMTk2NjQ5OCwicGF0aCI6Ii82MTkzMjgwOS8zMTAwMzczMjMtZGRlZGRlYzAtZmM3NS00ZTEyLThkMTItN2UxNTM3ZjdmZWVhLnBuZz9YLUFtei1BbGdvcml0aG09QVdTNC1ITUFDLVNIQTI1NiZYLUFtei1DcmVkZW50aWFsPUFLSUFWQ09EWUxTQTUzUFFLNFpBJTJGMjAyNDA3MjYlMkZ1cy1lYXN0LTElMkZzMyUyRmF3czRfcmVxdWVzdCZYLUFtei1EYXRlPTIwMjQwNzI2VDA0MDEzOFomWC1BbXotRXhwaXJlcz0zMDAmWC1BbXotU2lnbmF0dXJlPTkzYjY5ZmNhMTY1NGFiODFiZTY3ZGUyYzY1ZjUzNjllMzE4ODlhM2NhNzBmZGFmNjQ3MjQ1OGFhNmUxMGZjZDgmWC1BbXotU2lnbmVkSGVhZGVycz1ob3N0JmFjdG9yX2lkPTAma2V5X2lkPTAmcmVwb19pZD0wIn0.h3rkeMmPC-xJ-vKE_cDJI_oQGvIYwY4Ea42htgNrghM)
![image-20240206200557127](https://private-user-images.githubusercontent.com/61932809/310031447-9c5dbb99-aeca-4deb-a078-4e49ac28fddd.png?jwt=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJnaXRodWIuY29tIiwiYXVkIjoicmF3LmdpdGh1YnVzZXJjb250ZW50LmNvbSIsImtleSI6ImtleTUiLCJleHAiOjE3MjE5NjY3OTgsIm5iZiI6MTcyMTk2NjQ5OCwicGF0aCI6Ii82MTkzMjgwOS8zMTAwMzE0NDctOWM1ZGJiOTktYWVjYS00ZGViLWEwNzgtNGU0OWFjMjhmZGRkLnBuZz9YLUFtei1BbGdvcml0aG09QVdTNC1ITUFDLVNIQTI1NiZYLUFtei1DcmVkZW50aWFsPUFLSUFWQ09EWUxTQTUzUFFLNFpBJTJGMjAyNDA3MjYlMkZ1cy1lYXN0LTElMkZzMyUyRmF3czRfcmVxdWVzdCZYLUFtei1EYXRlPTIwMjQwNzI2VDA0MDEzOFomWC1BbXotRXhwaXJlcz0zMDAmWC1BbXotU2lnbmF0dXJlPTdmMjRmNjRiMjJlODFiMTBlMWMxM2YwMzVlZTViNGM0Y2YxMTRhM2Q1ZGRhZjA2MWFhMDZiMGRlNjBjYjNlMTUmWC1BbXotU2lnbmVkSGVhZGVycz1ob3N0JmFjdG9yX2lkPTAma2V5X2lkPTAmcmVwb19pZD0wIn0.nBAJflXkmE3oBTxJh3v-yhMrnVNl7OPg7ddCRbXxSBM)
![image-20240206200639822](https://private-user-images.githubusercontent.com/61932809/310031288-1a68df4e-515e-4bae-bb23-f25fee53e458.png?jwt=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJnaXRodWIuY29tIiwiYXVkIjoicmF3LmdpdGh1YnVzZXJjb250ZW50LmNvbSIsImtleSI6ImtleTUiLCJleHAiOjE3MjE5NjY3OTgsIm5iZiI6MTcyMTk2NjQ5OCwicGF0aCI6Ii82MTkzMjgwOS8zMTAwMzEyODgtMWE2OGRmNGUtNTE1ZS00YmFlLWJiMjMtZjI1ZmVlNTNlNDU4LnBuZz9YLUFtei1BbGdvcml0aG09QVdTNC1ITUFDLVNIQTI1NiZYLUFtei1DcmVkZW50aWFsPUFLSUFWQ09EWUxTQTUzUFFLNFpBJTJGMjAyNDA3MjYlMkZ1cy1lYXN0LTElMkZzMyUyRmF3czRfcmVxdWVzdCZYLUFtei1EYXRlPTIwMjQwNzI2VDA0MDEzOFomWC1BbXotRXhwaXJlcz0zMDAmWC1BbXotU2lnbmF0dXJlPWIxYjM4ZjYzMzFiZDYxNmVhZWQxZjBjZjY4N2FjNDhlOTg5ODIxYmEyODdmMDg4MGRhMGRjMjVmOTBmNTEyODcmWC1BbXotU2lnbmVkSGVhZGVycz1ob3N0JmFjdG9yX2lkPTAma2V5X2lkPTAmcmVwb19pZD0wIn0.LL23WhX_-ll3TrTbBSmuFf4vIrKehUNooYvo9wulQnM)
로컬 실행시
-
IntelliJ, MySQL 8.0 설치
-
redis 클라이언트 설치
- Windows 사용자: https://github.com/microsoftarchive/redis/releases에서 .msi 파일 설치
- Mac 사용자:
brew install redis
-
환경변수 설정
- JWT_EXPIRATION : 엑세스 토큰 유효시간(초)
- JWT_REFRESH_EXPIRATION : 리프레시토큰 유효시간(초)
- JWT_SECRET : JWT 시크릿 키(임의 문자열)
- LOCAL_DB_URL : MySQL 스키마 URL
- LOCAL_DB_USERNAME : MySQL 사용자 이름
- LOCAL_DB_PASSWORD : MySQL 비밀번호
-
프로필 설정
- Active Profiles: dev
-
실행
-
업무 마감 임박 알림(프론트 미구현)
- 생성된 업무가 알림을 보낼 시간이 지났다면 바로 알림을 전송합니다.
- 생성된 업무의 알림을 보낼 시간이 24시간 이내라면 알림을 예약합니다.
- 업무의 생성 또는 수정이 발생하면 알림을 동작할지 확인합니다.
-
회원
- 로그인 및 회원가입을 할 수 있습니다.
- 로그인된 사용자에 한해 서비스를 이용할 수 있습니다.
-
업무
- 사용자는 해당일의 업무를 생성/조회/수정/삭제 할 수 있습니다.
- 업무에는 제목, 내용, 업무유형, 진행상태를 포함합니다.
- 날짜별로 업무가 표시되는 순서를 저장할 수 있습니다.
- 제목, 내용을 검색할 수 있습니다.
-
메모
- 사용자는 해당일의 메모를 생성/조회/수정/삭제 할 수 있습니다.
- 메모에는 내용만 기록할 수 있습니다.
- 날짜별로 메모가 표시되는 순서를 저장할 수 있습니다.
- 내용을 검색할 수 있습니다.
-
달력
- 업무 또는 메모가 존재하는 년월일에 한해 날짜를 제공합니다.
- 날짜는 년월일 순으로 제공됩니다.
-
Redis를 인터페이스로 활용해 DB의 접근을 줄이고 기능간 결합도 낮추기
-
EventPublisher/Listener를 이용해 트랜젝션 분리 및 기능간 결합도 낮추기
-
업무 생성·수정·삭제 시 알림 조건 확인 및 전송 로직에 EventPublisher·Listener 적용
public class WorkService{ // ... @Transactional public void createWork(WorkPostDto dto, CustomUserDetails userDetails) { Work work = workRepository.save( Work.builder()//... .build() ); // JPA에서 Flush 후 이벤트 발행 applicationEventPublisher.publishEvent( WorkChangeEvent.builder().work(work).build() ); } // ... }
public class EventHandler { private final NotificationService notificationService; @TransactionalEventListener // 이벤트 받아 로직 실행 public void onWorkChanged(WorkChangeEvent workChangeEvent) { Work work = workChangeEvent.getWork(); Long userId = work.getUser().getId(); //... 조건 확인 후 전송하거나 알림 예약 Notification notification = notificationService.createNotificationFrom(work); notificationService.sendNotification(notification); }
-
-
JWT에 userId를 담아 user 테이블과 조인하는 모든 쿼리 FK(userId)로만 조회
public class WorkServiceImpl { // ... 생략 private Work getValidatedWorkByUserIdAndWorkId(Long userId, Long workId) { Work work = workRepository.findById(workId) .orElseThrow(() -> new CustomException(ErrorCode.WORK_NOT_FOUND)); // Work Select 쿼리 발생 if (!work.getUser().getId().equals(userId)) { // userId는 Work의 FK이므로 추가 조회 불필요 // 만약 getId() 대신 getUsername() 호출시 지연로딩에 의해 User SELECT 쿼리 발생 throw new CustomException(ErrorCode.WORK_USER_NOT_MATCHED); } else { return work; } } }
-
연관된 엔티티의 필드가 함께 사용되는 경우 지연로딩에 의한 추가 쿼리 방지를 위해 Fetch Join 적용
public interface NotificationRepository extends JpaRepository<Notification, Long> { // ... 생략 @Query( "SELECT n FROM Notification n " + "JOIN FETCH n.receiver " + // Notification의 User receiver; 필드 "WHERE n.id = :id" ) // 이미 receiver가 로딩된 상태이므로 notification.getReceiver().getUsername()을 호출해도 추가 쿼리 미발생 Optional<Notification> findByIdFetchReceiver(@Param("id") Long id);
-
로그인, 회원가입, 비밀번호 변경 시 ID/PW가 형식에 맞지 않으면 DB 조회하지 않고 로그인 실패 처리
public class UserServiceImpl { // ... public JwtDto login(UserLoginDto dto, HttpServletRequest request) { Pattern usernamePattern = Pattern.compile(Constant.USERNAME_REGEX); Pattern passwordPattern = Pattern.compile(Constant.PASSWORD_REGEX); if ( !usernamePattern.matcher(dto.getUsername()).matches() || !passwordPattern.matcher(dto.getPassword()).matches() ) { throw new CustomException(ErrorCode.LOGIN_FAILED); } // 이후 ID/PW 확인 및 토큰 발급... } // ... }
-
Spring Security에서 인증 객체를 Controller계층에 전달, 요청별 권한 설정, PasswordEncoder의 기능만 제한적으로 사용
-
Login, Logout, 엑세스 토큰 재발급 등의 API를 다른 API와 통일성을 위해 Controller, Service 레이어에서 구현
-
UserDetails와 UserDetailsService를 구현하지 않고 기존의 User 엔티티와 UserService만 사용하도록 구현
public class CustomAuthenticationToken extends AbstractAuthenticationToken { private final User user; private final Object credentials; // ... }
public class JwtValidationFilter extends OncePerRequestFilter { // ... @Override protected void doFilterInternal(*/ ... */) throws ServletException, IOException { // ... Claims claims = jwtTokenUtils.parseClaims(token); // request header에서 token 찾아 파싱 User user = jwtTokenUtils.generateUserFromClaims(claims); // 파싱한 정보로 User 객체 생성 Authentication authentication // 인증 객체에 User 저장 = new CustomAuthenticationToken(user, token, user.getAuthorities()); SecurityContext context = SecurityContextHolder.createEmptyContext(); context.setAuthentication(authentication); SecurityContextHolder.setContext(context); filterChain.doFilter(request, response); // 다음 필터 실행 }
-
특정 타입으로 역직렬화가 불가능한 형식의 값이 요청으로 오면 유효성 검사 이전인 객체가 생성 전에 예외가 발생, 어떤 상황에서도 유효성 검사가 가능하도록 DTO에 String으로 1차 저장 후 검사
public class WorkCategoryPatchDto { @NotNull(message = Constants.CATEGORY_NOT_BLANK) @EnumValueCheck(enumClass = Category.class) // Enum으로 변환시 예외 발생여부 확인 private String category; }
public class ValueOfEnumValidator implements ConstraintValidator<EnumValueCheck, String> { private EnumValueCheck enumValueCheck; @Override public void initialize(EnumValueCheck constraintAnnotation) { this.enumValueCheck = constraintAnnotation; } @Override public boolean isValid(String value, ConstraintValidatorContext context) { try { Method fromMethod = this.enumValueCheck.enumClass().getMethod("from", String.class); fromMethod.invoke(null, value); } catch (NoSuchMethodException | IllegalAccessException | InvocationTargetException e) { log.error("Error validating enum value: {}", e.getMessage()); return false; } return true; } }
-
유효성 검사 후 Controller -> Service 계층 이동시 필요한 타입으로 변환
public class WorkController{ // ... @PatchMapping("/{workId}/category") public ResponseEntity<ResponseDto> updateWorkCategory( @PathVariable("workId") Long workId, @Valid @RequestBody WorkCategoryPatchDto dto, // DTO 내부에서 유효성 검사 @AuthenticationPrincipal User user ) { // 원하는 타입으로 변환 후 Service 계층 전달 workService.updateWorkCategory(Category.from(dto.getCategory()), workId, user.getId()); return ResponseEntity .status(HttpStatus.OK) .body(ResponseDto.fromSuccessCode(SuccessCode.WORK_EDIT_SUCCESS)); } // ... }
-
응답 형식
-
자원을 반환하는 응답
"status": 200, "count": 0, "data": []
-
자원을 반환하지 않는 모든 응답(예외 포함)
"status": 201, "code": "CREATED", "message": "업무일지가 생성되었습니다."
-
-
예외 처리
-
DispatcherSevlet 안에서 발생하는 예외 : @RestControllerAdvice으로 처리
-
사용자 지정 예외 : CustomException 클래스 생성해 사용
@ExceptionHandler(CustomException.class) protected ResponseEntity handleCustomException(CustomException ex) { return new ResponseEntity( ResponseDto.fromErrorCode( ex.getErrorCode()), HttpStatus.valueOf(ex.getErrorCode().getStatus())); }
-
Validation에서 발생하는 예외
@ExceptionHandler(MethodArgumentNotValidException.class) @ResponseStatus(HttpStatus.BAD_REQUEST) protected ResponseDto handleValidationException( MethodArgumentNotValidException exception ) { return ResponseDto.fromValidationException(exception); }
-
그 외의 예외
@ExceptionHandler(Exception.class) protected ResponseEntity handleServerException(Exception ex) { return new ResponseEntity( ResponseDto.fromErrorCode(INTERNAL_SERVER_ERROR), HttpStatus.INTERNAL_SERVER_ERROR); }
-
-
DispatcherServlet 밖에서 발생하는 예외
-
필터 실행중 발생하는 예외: ObjectMapper 사용 ex) JWT 파싱중 예외 응답해 401 응답시 프론트에서 토큰 재발급 요청
public class FilterExceptionHandler { public static void jwtExceptionHandler( HttpServletResponse response, ErrorCode error ) { response.setStatus(error.getStatus()); response.setContentType(MediaType.APPLICATION_JSON_VALUE); response.setCharacterEncoding("UTF-8"); try { ObjectMapper objectMapper = new ObjectMapper(); objectMapper.writeValue(response.getWriter(), ResponseDto.fromErrorCode(error)); } catch (Exception e) { log.error(e.getMessage()); } } }
-
그 외의 예외 : DefaultErrorAttributes ex) 맵핑되지 않은 Request URI
@Component public class CustomErrorAttributes extends DefaultErrorAttributes { @Override public Map<String, Object> getErrorAttributes( WebRequest webRequest, ErrorAttributeOptions options ) { ResponseDto responseDto = ResponseDto.fromErrorAttributes( super.getErrorAttributes(webRequest, options) ); return BeanMap.create(responseDto); } }
-
-