-
Notifications
You must be signed in to change notification settings - Fork 16
[이지현] Sprint11 #157
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: 이지현
Are you sure you want to change the base?
[이지현] Sprint11 #157
Conversation
- 리스너 로그 메시지 변경
Status -> BinaryContentStatus update -> statusUpdate
Notification 관련 클래스 추가 - Controller, Service, Mapper, Repository, Entity, Exception ReadStatus에 알림 설정용 변수 추가 - notificationEnabled
알림 서비스에 이벤트 리스너 처리용 메서드 추가
- Transaction 설정 문제 해결 - GitIgnore에 env 파일 추가
- status 명 요구사항에 맞게 변경 - status가 초기화되지 않던 현상 해결 - 응답 dto에 status가 null로 표기되는 현상 해결 - 이벤트 리스너에 비동기 처리 적용
- S3 파일 업로드 실패에 대한 비동기 실패 처리 구현
- gradle 추가 - application.yml 추가
- 사용자별 채널 목록 조회, 사용자별 알림 목록 조회, 사용자 목록 조회 메서드에 캐시 적용 - 캐시 이름 변경 - userCache -> users, channelCache -> channels, notificationCache -> notifications
- 채널 추가/수정/삭제 - 알림 추가/삭제 - 사용자 추가/수정/삭제, 로그인/로그아웃
- NotificationRequiredEventListener, NotificationRequiredTopicListener 등 클래스 명 예제에 맞게 변경
- 캐싱 로직에 recevierId key를 설정하여 해당 사용자에게만 알람이 가도록 변경 - Authentication에서 recevierId를 가져오는 로직을 Controller로 이관
- CacheConfig 구현: DefaultTyping EVERYTHING - application Redis 설정 추가 - docker-compose redis 추가
…ion into sprint11
| try { | ||
| uploadService.upload(binaryContentId, bytes); | ||
| binaryContentService.updateStatus(binaryContentId, BinaryContentStatus.SUCCESS); | ||
| log.info("바이너리 데이터 저장 성공: id={}, size={}", | ||
| binaryContentId, | ||
| size | ||
| ); | ||
| } catch (Exception e) { | ||
| binaryContentService.updateStatus(binaryContentId, BinaryContentStatus.FAIL); | ||
| log.error("바이너리 데이터 저장 실패: id={}, size={}, error={}", | ||
| binaryContentId, | ||
| size, | ||
| e.getMessage(), | ||
| e |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
상태 변경 처리
- BinaryContentStatus.SUCCESS
- BinaryContentStatus.FAIL
| // 관리자에게 실패 알람 생성 | ||
| publisher.publishEvent(new S3UploadFailedEvent(binaryContentId, requestId, TASK_NAME, errorMessage)); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
실패 이벤트 발행
| ); | ||
| return binaryContentRepository.save(binaryContent); | ||
| binaryContentRepository.save(binaryContent); | ||
| eventPublisher.publishEvent(new BinaryContentCreatedEvent(binaryContent.getId(), bytes)); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
이벤트 발행
| @Async("eventTaskExecutor") | ||
| @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) | ||
| public void on(MessageCreatedEvent event) { | ||
| sendToKafka("discodeit.MessageCreatedEvent", event); | ||
| } | ||
|
|
||
| @Async("eventTaskExecutor") | ||
| @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) | ||
| public void on(RoleUpdatedEvent event) { | ||
| sendToKafka("discodeit.RoleUpdatedEvent", event); | ||
| } | ||
|
|
||
| @Async("eventTaskExecutor") | ||
| @EventListener | ||
| public void on(S3UploadFailedEvent event) { | ||
| sendToKafka("discodeit.S3UploadFailedEvent", event); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
비동기 설정 및 이벤트 소비
| @PreAuthorize("hasRole('CHANNEL_MANAGER')") | ||
| @Transactional | ||
| @Override | ||
| @CacheEvict(cacheNames = "channels", allEntries = true) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
캐시 만료
|
|
||
| @Transactional(readOnly = true) | ||
| @Override | ||
| @Cacheable("channels") |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@Cacheable 로컬 캐싱 처리
| @Bean | ||
| public RedisCacheManager cacheManager(RedisConnectionFactory redisConnectionFactory) { | ||
| ObjectMapper redisObjectMapper = new ObjectMapper(); | ||
| redisObjectMapper.registerModule(new JavaTimeModule()); | ||
| redisObjectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); | ||
|
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
로컬 캐시에서 레디스로 변경
| @Retryable( | ||
| retryFor = {S3Exception.class, RuntimeException.class}, | ||
| maxAttempts = 3, | ||
| backoff = @Backoff(delay = 1000, | ||
| multiplier = 2) | ||
| ) | ||
| @Override | ||
| public void upload(UUID binaryContentId, byte[] bytes) { | ||
| log.debug("S3파일 업로드 시작: id={}", binaryContentId); | ||
| storage.put(binaryContentId, bytes); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
비동기 요청 실패 시 재처리
| @Override | ||
| @Recover |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
모든 재시도가 실패했을 때 발행
기본 요구사항
Spring Event - 파일 업로드 로직 분리하기
디스코드잇은 BinaryContent의 메타 데이터(DB)와 바이너리 데이터(FileSystem/S3)를 분리해 저장합니다.
만약 지금처럼 두 로직이 하나의 트랜잭션으로 묶인 경우 트랜잭션을 과도하게 오래 점유할 수 있는 문제가 있습니다.
따라서 Spring Event를 활용해 메타 데이터 저장 트랜잭션으로부터 바이너리 데이터 저장 로직을 분리하여, 메타데이터 저장 트랜잭션이 종료되면 바이너리 데이터를 저장하도록 변경합니다.
BinaryContentStorage.put을 직접 호출하는 대신 BinaryContentCreatedEvent를 발행하세요.
UserService.create/updateMessageService.createBinaryContentService.createApplicationEventPublisher를 활용하세요.이벤트를 받아 실제 바이너리 데이터를 저장하는 리스너를 구현하세요.
BinaryContentStorage를 통해 바이너리 데이터를 저장하세요.바이너리 데이터 저장 성공 여부를 알 수 있도록 메타 데이터를 리팩토링하세요.
BinaryContent에 바이너리 데이터 업로드 상태 속성(status)을 추가하세요.PROCESSING: 업로드 중SUCCESS: 업로드 완료FAIL: 업로드 실패BinaryContent의 상태를 업데이트하는 메소드를 정의하세요.바이너리 데이터 저장 성공 여부를 메타 데이터에 반영하세요.
BinaryContent의status를SUCCESS로 업데이트하세요.BinaryContent의status를FAIL로 업데이트하세요.1)채널에 새로운 메시지가 등록되거나2)권한이 변경된 경우이벤트를 발행해 알림을 받을 수 있도록 구현합니다.채널에 새로운 메시지가 등록된 경우 알림을 받을 수 있도록 리팩토링하세요.
MessageCreatedEvent를 정의하고 새로운 메시지가 등록되면 이벤트를 발행하세요.notificationEnabled)을 추가하세요.true로 초기화합니다.false로 초기화합니다.ReadStatusUpdateRequest를 수정하세요.사용자의 권한(Role)이 변경된 경우 알림을 받을 수 있도록 리팩토링하세요.
RoleUpdatedEvent를 정의하고 권한이 변경되면 이벤트를 발행하세요.알림 API를 구현하세요.
NotificationDto를 정의하세요.receiverId: 알림을 수신할 User의id입니다.알림 조회
GET /api/notifications200 List<NotifcationDto>401 ErrorResponse알림 확인
DELETE /api/notifications/{notificationId}204 Void401 ErrorResponse403 ErrorResponse404 ErrorResponse알림이 필요한 이벤트가 발행되었을 때 알림을 생성하세요.
on(MessageCreatedEvent)ReadStatus를 조회합니다.ReadStatus의 사용자들에게 알림을 생성합니다.on(RoleUpdateEvent)비동기 적용하기
비동기를 적용하기 위한 설정(AsyncConfig) 클래스를 구현하세요.
@EnableAsync어노테이션을 활용하세요.TaskExecutor를 Bean으로 등록하세요.TaskDecorator를 활용해 MDC의 Request ID, SecurityContext의 인정 정보가 비동기 스레드에서도 유지되도록 구현하세요.앞서 구현한 Event Listener를 비동기적으로 처리하세요.
@Async어노테이션을 활용하세요.동기 처리와 비동기 처리 간 성능 차이를 비교해보세요.
Thread.sleep(…))을 발생시키세요.동기 처리: 3340ms
비동기 처리: 339ms
메시지 생성 API의 실행 시간을 측정해보세요.
@Timed어노테이션을 메소드에 추가합니다./actuator/metrics/message.create.async에서 측정된 시간을 확인할 수 있습니다.@EnableAsync를 활성화 / 비활성화 해보면서 동기 / 비동기 처리 간 응답 속도의 차이를 확인해보세요.비동기 실패 처리하기
비동기로 처리하는 로직이 실패하는 경우 사용자에게 즉각적인 에러 전파가 되지 않을 가능성이 높습니다.
따라서 비동기로 처리하는 로직은 자동 재시도 전략을 통해 더 견고하게 구현해야 합니다.
또, 실패하더라도 그 사실을 명확하게 기록해두어야 에러에 대응할 수 있습니다.
S3를 활용해 바이너리 데이터 저장 시 자동 재시도 매커니즘을 구축하세요.
org.springframework.retry:spring-retry의존성을 추가하세요.@EnableRetry어노테이션을 활용해 Spring Retry를 활성화 하세요.@Retryable어노테이션을 사용해 재시도 정책(횟수, 대기 시간 등)을 설정하세요.재시도가 모두 실패했을 때 대응 전략을 구축하세요.
@Recover어노테이션을 활용하세요.캐시 적용하기
Caffeine 캐시를 위한 환경을 구성하세요.
org.springframework.boot:spring-boot-starter-cache의존성을 추가하세요.com.github.ben-manes.caffeine:caffeine의존성을 추가하세요.@Cacheable어노테이션을 활용해 캐시가 필요한 메소드에 적용하세요.데이터 변경 시, 캐시를 갱신 또는 무효화하는 로직을 구현하세요.
@CacheEvict,@CachePut,CacheManager등을 활용하세요.캐시 적용 전후의 차이를 비교해보세요.
user,channel,notification의 목록이application.yml에 정의한 간격 당(10분) 1회만 조회되었다.user,channel,notification의 목록이 매번 다시 조회되어 SQL 조회가 매우 많이 이루어졌다.Spring Actuator를 활용해 캐시 관련 통계 지표를 확인해보세요.
recordStats옵션을 추가하세요./actuator/caches,/actuator/metrics/cache.*를 통해 캐시 관련 데이터를 확인해보세요.{ "cacheManagers": { "cacheManager": { "caches": { "users": { "target": "com.github.benmanes.caffeine.cache.BoundedLocalCache$BoundedLocalManualCache" }, "channels": { "target": "com.github.benmanes.caffeine.cache.BoundedLocalCache$BoundedLocalManualCache" }, "notifications": { "target": "com.github.benmanes.caffeine.cache.BoundedLocalCache$BoundedLocalManualCache" } } } } }심화 요구사항
Spring Kafka 도입하기
회원이 늘어나면서 알림 연산량이 급증해 알림 기능만 별도의 마이크로 서비스로 분리하기로 결정했다고 가정해봅시다.
이제 알림 서비스와 메인 서비스는 완전히 분리된 서버이므로 Spring Event만을 통해서 이벤트를 발행/소비할 수 없습니다.
따라서 메인 서비스에서 Kafka를 통해 서버 외부로 이벤트를 발행하고, 알림 서비스에서는 서버 외부의 이벤트를 소비할 수 있도록 해야합니다.
Kafka 환경을 구성하세요.
Docker Compose를 활용해 Kafka를 구동하세요.
Spring Kafka 의존성을 추가하고,
application.yml에 Kafka 설정을 추가하세요.Spring Event를 Kafka로 발행하는 리스너를 구현하세요.
NotificationRequiredEventListener는 비활성화하세요.KafkaProduceRequiredEventListener를 구현하세요.Kafka Console을 통해 Kafka 이벤트가 잘 발행되는지 확인해보세요.
Kafka 토픽을 구독해 알림을 생성하는 리스너를 구현하세요.
NotificationRequiredTopicListener를 구현하세요.@EventListener기반 로직을 제거하고@KafkaListener로 대체하세요.Redis Cache 도입하기
대용량 트래픽을 감당하기 위해 서버의 인스턴스를 여러 개로 늘렸다고 가정해봅시다.
Caffeine과 같은 로컬 캐시는 서로 다른 서버에서 더 이상 활용할 수 없습니다.따라서
Redis를 통해 전역 캐시 저장소를 구성합니다.Redis 환경을 구성하세요.
application.yml에 Redis 설정을 추가하세요.DataGrip을 통해 Redis에 저장된 캐시 정보를 조회해보세요.
주요 변경사항
docker-compose.yml설정 변경: 실행 프로필을prod에서dev로 변경하였습니다.스크린샷
멘토에게