Skip to content

[여운정] sprint mission 11 - #142

Open
novafterg1ow wants to merge 11 commits into
codeit-bootcamp-spring:여운정from
novafterg1ow:여운정-sprint-mission-11

Hidden character warning

The head ref may contain hidden characters: "\uc5ec\uc6b4\uc815-sprint-mission-11"
Open

novafterg1ow wants to merge 11 commits into
codeit-bootcamp-spring:여운정from
novafterg1ow:여운정-sprint-mission-11

Conversation

@novafterg1ow

@novafterg1ow novafterg1ow commented Aug 18, 2026

Copy link
Copy Markdown
Collaborator

요구사항

Spring Event - 파일 업로드 분리

  • BinaryContentStorage.put 대신 BinaryContentCreatedEvent 발행, 커밋 이후 리스너가 저장
  • BinaryContent.status 추가 및 성공/실패 반영

Spring Event - 알림

  • MessageCreatedEvent / RoleUpdatedEvent 정의·발행, ReadStatus.notificationEnabled 추가
  • 알림 API (GET /api/notifications, DELETE /api/notifications/{id})
  • NotificationRequiredEventListener 구현 (작성자 제외)

비동기

  • AsyncConfig (@EnableAsync, TaskExecutor, TaskDecorator), 리스너 @Async 처리
  • 동기/비동기 성능 비교

비동기 실패 처리

  • Spring Retry @Retryable 정책 설정, @Recover로 관리자 통지

캐시

  • Caffeine 구성, @Cacheable 3곳 적용, 변경 시 무효화
  • 적용 전후 SQL 비교, Actuator 통계 확인

분산 환경 대비

  • Kafka 구동·설정, KafkaProduceRequiredEventListener 구현, 기존 리스너 비활성화
  • Kafka Console 확인, NotificationRequiredTopicListener 구현
  • Redis 구동, 전역 캐시 구성 및 직렬화 설정

주요 변경사항

파일 업로드를 트랜잭션 밖으로 위치

파일 I/O가 끝날 때까지 DB 커넥션을 붙잡고 있던 구조를, 이벤트 발행 + 커밋 이후 저장으로 분리했습니다. 첨부 1건당 이벤트 1개를 발행해 파일 하나가 실패해도 나머지는 SUCCESS로 남습니다.

알림

  • notificationEnabled생성자에서 채널 타입으로 결정 (PRIVATE=true / PUBLIC=false). 생성 경로가 둘이라 호출부마다 넘기면 빼먹기 쉽습니다.
  • ReadStatusUpdateRequest의 두 필드는 null = 변경 없음. 프론트가 한쪽만 보내므로 덮어쓰면 알림 토글마다 읽음 시각이 사라집니다.
  • 조회·삭제 대상자는 @AuthenticationPrincipal에서 가져옵니다. 삭제는 조회 → 404 → 403 순서를 지켜야 두 코드가 모두 나옵니다.
  • PRIVATE 채널은 name이 null이라 #null 대신 보낸사람 (DM)으로 표기했습니다.

비동기 — 스레드풀 분리

eventTaskExecutor fileUploadTaskExecutor
용도 알림 (수 ms) 바이너리 저장 (수 초)
core/max, 큐 4/8, 500 4/8, 100
거부 정책 CallerRunsPolicy CallerRunsPolicy

같은 풀이면 3초짜리 업로드가 수 ms짜리 알림을 통째로 막습니다(head-of-line blocking).

큐를 유한하게 잡은 것이 핵심입니다. queueCapacity 기본값이 Integer.MAX_VALUE인데, ThreadPoolExecutor는 큐가 가득 차야 코어 이상으로 스레드를 늘리므로 그대로 두면 maxPoolSize에 영원히 도달하지 않습니다.

TaskDecorator는 복사보다 정리가 중요합니다. finally에서 지우지 않으면 풀 스레드 재사용 시 다음 작업이 앞 요청 사용자의 인증 정보를 물려받습니다. @PreAuthorize 메소드를 남의 권한으로 통과하는 실제 보안 구멍이라, 누수 검증 테스트를 넣었습니다.

측정 결과 (첨부 1개, 업로드 3초 지연)

[동기]   3076 ms      [비동기]   44 ms

@EnableAsync를 끄는 대신 executor를 SyncTaskExecutor로 갈아끼워 비교군을 만들었습니다. 실제 기동에서도 /actuator/metrics/message.create.async MAX 0.0384s, 프론트 폴링에서 응답 110ms → 3초 PROCESSINGSUCCESS를 확인했습니다.

참고로 기존 통합 테스트는 @Transactional이라 롤백되어 AFTER_COMMIT 리스너가 아예 실행되지 않습니다. 그래서 3초 지연에도 느려지지 않았고, 측정용 테스트만 트랜잭션을 뺐습니다.

재시도 — 무엇을 다시 시도할 것인가

AWS SDK는 자격증명 오류든 5xx든 모두 S3Exception을 던져 어노테이션만으로는 거를 수 없습니다. put() 안에서 분류해 다시 던집니다.

재시도 SdkClientException, 5xx, 429
재시도 안 함 403(자격증명), 404(버킷 없음) — 몇 번을 해도 같은 결과

요구사항 예시의 The AWS Access Key Id ... does not exist 403이 정확히 재시도하면 안 되는 케이스입니다.

  • @Recover는 예외를 다시 던집니다. 조용히 복구하면 리스너가 성공으로 보고 statusSUCCESS로 기록해버립니다.
  • S3UploadFailedEvent@EventListener로 받아야 합니다. 비동기 스레드에서 트랜잭션 밖으로 발행되므로 @TransactionalEventListener면 영영 실행되지 않습니다.
  • 통지의 requestIdTaskDecorator가 없으면 null입니다. 두 요구사항이 하나로 이어져 있습니다.

캐시 — online은 캐시하면 안 됩니다

UserDto.online은 컬럼이 아니라 매 매핑마다 JwtRegistry에 묻는 런타임 값입니다. 그대로 캐시하면 엑세스 토큰이 그냥 만료된 경우(무효화를 걸 이벤트가 없음) 이미 나간 사람이 TTL 내내 온라인으로 보입니다.

UserDirectory.findAllWithoutOnline()이라는 별도 빈에만 @Cacheable을 걸고 online은 조회 시 다시 채웁니다. 별도 빈인 이유는 자기 호출이 프록시를 타지 않아 @Cacheable이 조용히 무시되기 때문입니다. Redis 저장값에서도 "online":null로 확인됩니다.

무효화는 키 단위가 가능한 곳만 키 단위로 했습니다(알림=수신자별, 채널=PUBLIC 영향 범위 때문에 allEntries).

요구사항 예시 중 하나는 일부러 뺐습니다. "로그인/로그아웃 → 사용자 목록 캐시 무효화"는 넣지 않았습니다. 두 동작은 DB 목록을 바꾸지 않고 online만 바꾸는데 그 값이 이제 캐시에 없어서, 넣으면 히트율만 떨어집니다.

GET /api/channels 3회 → SELECT 2회 (1 miss + 2 hit) → 채널 생성(evict) → 다시 miss
Actuator 누적: users hit=2/miss=1, channels hit=4/miss=3

Kafka

discodeit.event.transport(spring|kafka)로 전환합니다. 코드를 지우지 않아 브로커 없이도 앱이 동작하고 두 방식을 비교할 수 있습니다. 알림 생성 규칙은 NotificationDispatcher로 분리해 두 리스너가 공유합니다.

파티션 key = channelId. 순서는 파티션 안에서만 보장되므로 key가 없으면 파티션을 늘리는 순간 알림 순서가 어긋납니다. 이벤트에 엔티티 대신 값만 담아둔 덕에 직렬화도 그대로 통과했습니다.

역직렬화 실패는 예외를 던지지 않고 건너뜁니다. 요구사항 예시(throw new RuntimeException)대로면 offset이 진행되지 않아 깨진 메시지 하나가 그 파티션 뒤의 모든 알림을 막습니다(poison pill). 깨진 JSON을 실제로 주입해 확인했습니다 — ERROR 한 줄 남기고 스킵, 뒤이은 정상 메시지 처리, LAG=0.

Kafka는 at-least-once라 알림이 중복 생성될 수 있습니다. 막으려면 이벤트 고유 ID로 소비 측에서 멱등 처리해야 하며, 이번 범위에서는 다루지 않았습니다.

Redis

spring.cache.type(caffeine|redis)로 전환합니다. 재기동 후 첫 조회에 SQL이 나가지 않는 것으로 전역 캐시임을 확인했습니다(Caffeine이면 프로세스와 함께 사라져 반드시 미스).

직렬화는 요구사항 코드를 그대로 썼습니다. 다만 LaissezFaireSubTypeValidator + DefaultTyping.EVERYTHING은 JSON에 적힌 클래스 이름을 그대로 믿고 인스턴스화하므로, Redis에 임의 값을 넣을 수 있으면 원격 코드 실행으로 이어질 수 있는 조합입니다. Jackson도 EVERYTHING을 deprecated로 표시해 두었습니다. 외부에 노출되지 않는다는 전제에서만 안전하다는 점을 주석에 남겼습니다.


멘토에게

  • 유예 창이 재사용 감지의 실효성을 얼마나 낮추나요? 정상 사용자의 강제 로그아웃은 사라졌지만, 이제 탈취된 토큰이 창 안에 오면 감지되지 않습니다. 탈취 직후 사용이 가장 흔한 시나리오라 "가장 위험한 순간에만 탐지가 꺼지는" 구조로도 보입니다. 실무에서는 IP·User-Agent 같은 신호로 보완하는지, 아니면 재사용 감지를 그 정도 정밀도로 기대하지 않는 것인지 궁금합니다.

  • 쓰기가 잦은 데이터에 캐시를 두는 것이 의미가 있을까요? 알림은 메시지 하나마다 구독자 전원의 캐시가 무효화되어 히트율이 매우 낮을 것으로 보입니다. 캐시를 걷어내는 편이 나은지, 무효화 대신 짧은 TTL로 stale을 허용하고 히트율을 얻는 쪽이 일반적인지 궁금합니다.

api-doc 2.2 기준으로 버전을 맞추고, 알림/업로드 상태 폴링/채널별 알림 설정이
추가된 프론트엔드 정적 리소스로 교체한다.
메타 데이터(DB) 저장과 바이너리 데이터(FileSystem/S3) 저장이 한 트랜잭션에 묶여 있어
느린 파일 I/O가 끝날 때까지 트랜잭션이 커넥션을 점유하는 문제가 있었다.

- BinaryContentCreatedEvent를 첨부 1건당 1개 발행하고, 커밋 이후 리스너가 저장한다.
  파일 단위로 이벤트를 나눠 하나가 실패해도 나머지는 성공으로 남는다.
- BinaryContent에 status(PROCESSING/SUCCESS/FAIL)를 두어 업로드 결과를 기록한다.
  프론트엔드가 이 값을 폴링해 업로드 진행/실패를 표시한다.
- updateStatus는 REQUIRES_NEW로 연다. AFTER_COMMIT 리스너는 이미 커밋된 트랜잭션의
  동기화 콜백에서 실행되므로, 기본 전파로 열면 변경이 flush되지 않고 조용히 사라진다.
- UserService.create/update, MessageService.create, BinaryContentService.create에서
  BinaryContentStorage 의존을 제거했다.

알려진 한계: 이벤트 발행과 리스너 실행 사이에 프로세스가 죽으면 PROCESSING 상태가
남는다. 근본 해결은 Transactional Outbox.
채널에 메시지가 등록되거나 사용자 권한이 변경되면 이벤트를 발행하고,
리스너가 알림을 생성한다.

- ReadStatus에 notificationEnabled를 추가한다. 채널 타입으로 초기값을 정해
  PRIVATE은 true, PUBLIC은 false가 된다. ReadStatus 생성 경로가 채널 생성과
  채널 최초 진입 두 곳이라 호출부가 아닌 생성자에서 한 번에 결정한다.
- ReadStatusUpdateRequest는 두 필드 모두 null을 '변경 없음'으로 다룬다.
  프론트엔드가 읽음 갱신과 알림 토글을 각각 한쪽만 담아 보내기 때문이다.
- 알림 조회/확인 API를 추가한다. 대상자는 요청 파라미터가 아니라 인증 주체에서
  얻는다. 확인 시 존재 여부를 먼저 검사해 없으면 404, 남의 알림이면 403이 나간다.
- 알림 대상 ReadStatus는 EntityGraph로 user까지 한 번에 읽고, 수신자 User는
  getReferenceById로 프록시만 잡아 수신자 수만큼 SELECT가 나가지 않게 한다.

PRIVATE 채널은 이름이 없어 알림 제목을 '작성자 (DM)'으로 표기한다.
파일 저장을 트랜잭션 밖으로 뺐어도 같은 스레드에서 처리하면 응답은 여전히 막힌다.
리스너를 별도 스레드풀로 넘겨 요청 스레드를 즉시 반환한다.

- 성격이 다른 두 작업을 별도 풀로 격리한다(bulkhead). 건당 수 초가 걸리는 파일 저장이
  풀을 점유하면 수 ms짜리 알림 생성이 큐 뒤에서 통째로 밀리기 때문이다.
- 두 풀 모두 큐를 유한하게 두고 CallerRunsPolicy를 쓴다. 큐가 차면 제출한 스레드가
  직접 실행해 유입 속도가 느려질 뿐 작업이 버려지지 않는다. 큐가 무한하면
  maxPoolSize에는 영영 도달하지 않고 메모리에 byte[]만 쌓인다.
- TaskDecorator로 MDC와 SecurityContext를 옮겨 심고, 작업이 끝나면 반드시 지운다.
  지우지 않으면 풀 스레드가 재사용될 때 다음 작업이 앞 요청의 인증 정보를 물려받는다.
- 반환 타입이 void인 @async 예외는 전파될 곳이 없어 사라지므로 로그로 남긴다.

측정: LocalBinaryContentStorage.put에 3초 지연을 심고 메시지 생성 응답 시간 비교
  동기   3076 ms
  비동기   44 ms
비교군은 @EnableAsync를 끄는 대신 executor를 SyncTaskExecutor로 교체해
프로덕션 코드를 건드리지 않고 같은 효과를 낸다.

기존 통합 테스트는 @transactional이라 롤백되어 AFTER_COMMIT 리스너가 실행되지
않으므로, 측정 테스트만 클래스 레벨 트랜잭션을 뺐다.
비동기로 처리하는 업로드는 실패해도 사용자 응답에 실리지 않는다.
일시적 장애는 자동으로 회복하고, 회복 불가능한 실패는 관리자에게 남긴다.

- 재시도 대상을 예외 타입으로 나눈다. AWS SDK는 자격증명 오류든 5xx든 같은
  S3Exception을 던지므로, put 안에서 한 번 분류해 다시 던진다.
  네트워크 오류/5xx/429만 재시도하고, 403·404 같은 4xx는 재시도하지 않는다.
  요구사항 예시의 'AWS Access Key Id does not exist'(403)가 바로 몇 번을 시도해도
  결과가 같은 실패이며, 재시도하면 스레드만 붙잡고 실패 인지가 늦어진다.
- @recover는 실패 이벤트를 발행한 뒤 예외를 다시 던진다. 조용히 정상 반환하면
  호출한 리스너가 업로드 성공으로 보고 status를 SUCCESS로 기록해버린다.
- S3UploadFailedEvent는 트랜잭션 밖에서 발행되므로 @eventlistener로 받는다.
  @TransactionalEventListener로 받으면 커밋될 트랜잭션이 없어 실행되지 않는다.
- 통지 대상은 Role이 ADMIN인 사용자 전원이고, 내용에 작업 이름과 MDC의 Request ID,
  예외 메시지를 담는다. Request ID는 TaskDecorator가 옮겨 심어준 값이다.

한계: @retryable이 S3 구현체에만 붙어 있어 storage.type=local에서는 동작하지 않는다.
반복 조회가 잦은 목록 조회 세 곳에 캐시를 적용한다.

- 채널 목록은 userId를 키로 캐싱하고, 채널 생성/수정/삭제 시 전체를 비운다.
  PUBLIC 채널 하나가 생기면 모든 사용자의 목록이 바뀌어 키 단위로는 좁힐 수 없다.
- 알림 목록은 receiverId를 키로 캐싱한다. 생성 시에는 수신자가 여러 명이라
  어노테이션으로 표현할 수 없어 CacheManager로 해당 키만 반복해서 비운다.
- 사용자 목록은 online을 뺀 상태로 캐싱한다. online은 엔티티 컬럼이 아니라 조회
  시점에 JwtRegistry에 묻는 런타임 값이라, 캐시에 담으면 엑세스 토큰이 만료돼
  오프라인이 된 사용자가 TTL이 끝날 때까지 온라인으로 표시된다(로그아웃과 달리
  만료에는 무효화를 걸 이벤트가 없다). 캐시된 목록을 받아 online만 다시 채운다.
- 캐시 대상 메소드는 UserDirectory로 분리했다. 자기 호출은 프록시를 타지 않아
  같은 클래스 안에 두면 @Cacheable이 조용히 무시된다.

로그인/로그아웃 시의 사용자 목록 무효화는 넣지 않았다. 두 동작은 DB의 사용자
목록을 바꾸지 않고 online만 바꾸는데, 그 online이 더 이상 캐시에 없기 때문이다.

recordStats를 켜고 actuator에 caches 엔드포인트를 노출해 히트/미스를 확인한다.
index.html, favicon.ico, assets가 classpath 루트에 있어 어떤 요청으로도 서빙되지
않았다. Spring Boot의 기본 정적 경로는 classpath:/static, /public,
/META-INF/resources이며 classpath 루트는 포함되지 않는다.

SecurityConfig가 /, /index.html, /favicon.ico, /assets/**를 permitAll로 열어둔
것으로 보아 애플리케이션이 프론트엔드를 함께 서빙하려던 의도였다.
알림 연산량 증가로 알림 기능을 별도 서버로 분리한 상황을 가정한다. 발행과 구독이
같은 JVM 안에 있던 Spring Event만으로는 서버 경계를 넘을 수 없으므로, 이벤트를
Kafka 토픽으로 내보내고 별도 서비스가 구독해 알림을 생성한다.

- discodeit.event.transport(spring|kafka)로 전달 방식을 전환한다. 코드를 지우는
  대신 설정으로 끄면 브로커 없이도 애플리케이션이 온전히 동작하고, 두 방식을
  나란히 비교할 수 있다.
- 알림 생성 규칙을 NotificationDispatcher로 분리했다. 이벤트가 어느 경로로 오든
  만들어지는 알림은 같아야 하므로 전달 수단과 생성 규칙을 나눈다.
- 파티션 key로 channelId를 사용한다. 순서는 토픽 전체가 아니라 파티션 안에서만
  보장되므로, key가 없으면 파티션을 늘리는 순간 알림 순서가 전송 순서와 어긋난다.
- 역직렬화 실패는 예외를 던지지 않고 건너뛴다. 예외를 던지면 offset이 진행되지
  않아 깨진 메시지 하나가 그 파티션 뒤의 모든 알림을 막는다(poison pill).

Kafka는 at-least-once 전달이라 알림이 중복 생성될 수 있다. 막으려면 이벤트에
고유 ID를 싣고 소비 측에서 멱등하게 처리해야 한다.
인스턴스를 여러 개로 늘린 상황을 가정한다. Caffeine은 인스턴스마다 각자 캐시를
들고 있어, 한 인스턴스에서 채널을 만들어도 다른 인스턴스의 캐시는 그대로 남는다.
같은 요청이 어느 인스턴스로 가느냐에 따라 다른 결과가 나오므로 전역 저장소가 필요하다.

- spring.cache.type(caffeine|redis)으로 전환한다. Redis 없이도 기존 테스트가
  그대로 동작하고, 로컬 캐시와 전역 캐시를 나란히 비교할 수 있다.
- Redis는 값을 바이트로 직렬화해 저장하므로 직렬화 방식을 명시해야 한다. JSON만으로는
  List<UserDto>의 원소 타입이 지워져 복원할 수 없어 defaultTyping으로 타입을 함께 적는다.

직렬화 설정의 LaissezFaireSubTypeValidator + DefaultTyping.EVERYTHING 조합은 JSON에
적힌 클래스 이름을 그대로 믿고 인스턴스화하므로, Redis에 임의의 값을 넣을 수 있으면
원격 코드 실행으로 이어질 수 있다. Jackson도 EVERYTHING을 deprecated로 표시해 두었다.
애플리케이션만 쓰고 외부에 노출되지 않는다는 전제에서만 안전하다.
catch-all Exception 핸들러가 NoResourceFoundException까지 잡아서, 없는 정적 파일
요청이 INTERNAL_ERROR 500으로 나가고 있었다. 클라이언트가 서버 장애와 오타를
구분할 수 없다.

미인증 요청은 Security 필터가 MVC보다 앞에 있어 여전히 401이 된다. 미인증
사용자에게 라우팅 구조를 알려주지 않는 편이 안전하므로 그대로 둔다.
리프레시 토큰 재발급이 검사·생성·교체 3단계로 나뉘어 있고 락이 없어, 탭 두 개가
같은 토큰으로 동시에 들어오면 정상 사용자가 강제 로그아웃되거나 자기가 생성하지
않은 토큰 쌍을 응답으로 받을 수 있었다.

- TokenPair를 불변 record로 묶고 AtomicReference로 참조만 교체한다. 두 토큰을 각각의
  가변 필드로 두면 가시성이 보장되지 않고 찢어진 조합이 관측된다. ConcurrentLinkedQueue의
  happens-before는 노드 추가/제거에 대한 것이지 원소 내부 필드 변경에는 적용되지 않는다.
- 검사와 교체를 rotateIfMatches CAS 한 번으로 합치고, 결과를 ROTATED/GRACE_REPLAY/
  MISMATCH로 판정한다. 유예 창(기본 10초) 안에 직전 토큰이 다시 오면 로테이션하지 않고
  현재 토큰 쌍을 그대로 재응답해 멱등하게 처리한다.
- 레지스트리의 큐 변경 연산을 compute/computeIfPresent 안으로 옮겨 사용자 단위로
  직렬화한다. add + while(poll)이 원자적이지 않아 동시 로그인 두 건이 서로를 밀어내거나
  둘 다 제한을 넘길 수 있었다. size()가 O(1)이라는 주석도 바로잡았다.
- 필터의 레지스트리 조회를 한 번으로 합쳤다. has→find 두 단계 사이에 로테이션이 끼면
  로그 없이 익명 요청으로 통과했고, 서명 검증도 매 요청 3회 일어났다.
- 권한 변경 시 토큰 무효화를 RoleUpdatedEvent 기반 커밋 이후 처리로 옮겼다. 레지스트리는
  트랜잭션에 참여하지 않아, 커밋 전에 지우면 롤백 시 권한은 그대로인데 로그아웃만 남는다.
- application-prod.yaml에 cookie-secure=true를 고정했다. 환경변수 누락 시 리프레시 토큰이
  Secure 없이 내려가는 것은 fail-fast 원칙과 어긋난다.
- GlobalExceptionHandler의 log 호출에 예외를 인자로 넘겨 cause 스택트레이스를 살렸다.
  플레이스홀더 수와 인자 수가 같으면 SLF4J가 마지막 인자를 예외로 인식하지 않는다.

Principal이 로그인 시점 스냅샷이라는 제약과 online 판정의 한계는 주석으로 남겼다.
@novafterg1ow
novafterg1ow deleted the 여운정-sprint-mission-11 branch August 18, 2026 09:58
@novafterg1ow
novafterg1ow restored the 여운정-sprint-mission-11 branch August 18, 2026 09:58
@novafterg1ow novafterg1ow reopened this Aug 18, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant