Skip to content

Conversation

@bladnoch
Copy link
Collaborator

@bladnoch bladnoch commented Sep 1, 2025

기본 요구 사항

Spring Event - 파일 업로드 로직 분리하기

  • 디스코드잇은 BinaryContent의 메타 데이터(DB)와 바이너리 데이터(FileSystem/S3)를 분리해 저장합니다.

  • 만약 지금처럼 두 로직이 하나의 트랜잭션으로 묶인 경우 트랜잭션을 과도하게 오래 점유할 수 있는 문제가 있습니다.

    • 바이너리 데이터 저장 연산은 오래 걸릴 수 있는 연산이며, 해당 연산이 끝날 때까지 트랜잭션이 대기해야합니다.
  • 따라서 Spring Event를 활용해 메타 데이터 저장 트랜잭션으로부터 바이너리 데이터 저장 로직을 분리하여, 메타데이터 저장 트랜잭션이 종료되면 바이너리 데이터를 저장하도록 변경합니다.

  • BinaryContentStorage.put을 직접 호출하는 대신 BinaryContentCreatedEvent를 발행하세요.

    • BinaryContentCreatedEvent를 정의하세요.

      • BinaryContent 메타 정보가 DB에 잘 저장되었다는 사실을 의미하는 이벤트입니다.
    • 다음의 메소드에서 BinaryContentStorage를 호출하는 대신 BinaryContentCreatedEvent를 발행하세요.

      • UserService.create/update
      • MessageService.create
      • BinaryContentService.create
    • ApplicationEventPublisher를 활용하세요.

  • 이벤트를 받아 실제 바이너리 데이터를 저장하는 리스너를 구현하세요.

    • 이벤트를 발행한 메인 서비스의 트랜잭션이 커밋되었을 때 리스너가 실행되도록 설정하세요.
    • BinaryContentStorage를 통해 바이너리 데이터를 저장하세요.
  • 바이너리 데이터 저장 성공 여부를 알 수 있도록 메타 데이터를 리팩토링하세요.
    [w6750l1ce-image.png](https://bakey-api.codeit.kr/api/files/resource?root=static&seqId=14493&version=1&directory=/w6750l1ce-image.png&name=w6750l1ce-image.png)

  • 바이너리 데이터 저장 성공 여부를 메타 데이터에 반영하세요.

    • 성공 시 BinaryContent의 status를 SUCCESS로 업데이트하세요.
    • 실패 시 BinaryContent의 status를 FAIL로 업데이트하세요.

Spring Event - 알림 기능 추가하기

비동기 적용하기

  • 비동기를 적용하기 위한 설정(AsyncConfig) 클래스를 구현하세요.

    • @EanbleAsync 어노테이션을 활용하세요.
    • TaskExecutor를 Bean으로 등록하세요.
    • TaskDecorator를 활용해 MDC의 Request ID, SecurityContext의 인정 정보가 비동기 스레드에서도 유지되도록 구현하세요.
  • 앞서 구현한 Event Listener를 비동기적으로 처리하세요.

    • @Async 어노테이션을 활용하세요.
  • 동기 처리와 비동기 처리 간 성능 차이를 비교해보세요.

    • 파일 업로드 로직에 의도적인 지연(Thread.sleep(…))을 발생시키세요.

      // LocalBinaryContentStorage
      public UUID put(UUID binaryContentId, byte[] bytes) {
          try {
            Thread.sleep(3000);
          } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new RuntimeException("Thread interrupted while simulating delay", e);
          }
          ...
      }
    • 메시지 생성 API의 실행 시간을 측정해보세요.

      • @Timed 어노테이션을 메소드에 추가합니다.
        // MessageController
          @Timed("message.create.async")@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
          public ResponseEntity<MessageDto> create(...) {...}
      • Actuator 설정을 추가합니다.
        # application.yaml
        management:
            ...
          observations:
            annotations:
              enabled: true
      • /actuator/metrics/message.create.async 에서 측정된 시간을 확인할 수 있습니다.
    • @EnableAsync를 활성화 / 비활성화 해보면서 동기 / 비동기 처리 간 응답 속도의 차이를 확인해보세요.

      • 동기: MAX ≈ 3.095 s
      • 비동기: MAX ≈ 0.0506 s

      클라이언트 응답 시간 기준 약 61배 빨라짐 (3.095 ÷ 0.0506 ≈ 61.2)

비동기 실패 처리하기

  • 비동기로 처리하는 로직이 실패하는 경우 사용자에게 즉각적인 에러 전파가 되지 않을 가능성이 높습니다.

  • 따라서 비동기로 처리하는 로직은 자동 재시도 전략을 통해 더 견고하게 구현해야 합니다.

  • 또, 실패하더라도 그 사실을 명확하게 기록해두어야 에러에 대응할 수 있습니다.

  • S3를 활용해 바이너리 데이터 저장 시 자동 재시도 매커니즘을 구축하세요.

    • Spring Retry를 위한 환경을 구성하세요.

      • org.springframework.retry:spring-retry 의존성을 추가하세요.
      • @EnableRetry 어노테이션을 활용해 Spring Retry를 활성화 하세요.
    • 바이너리 데이터를 저장하는 메소드에 @Retryable 어노테이션을 사용해 재시도 정책(횟수, 대기 시간 등)을 설정하세요.

  • 재시도가 모두 실패했을 때 대응 전략을 구축하세요.

    • @Recover 어노테이션을 활용하세요.

    • 실패 정보를 관리자에게 통지하세요.
      [1ubhvrs9g-image.png](https://bakey-api.codeit.kr/api/files/resource?root=static&seqId=14496&version=1&directory=/1ubhvrs9g-image.png&name=1ubhvrs9g-image.png)

      
      # 알림 내용 예시
      RequestId: 7641467e369e458a98033558a83321fb
      BinaryContentId: b0549c2a-014c-4761-8b21-4b77d3bd011c
      Error: The AWS Access Key Id you provided does not exist in our records. (Service: S3, Status Code: 403, Request ID: B7KCVSRCGPYJZREX, Extended Request ID: AWRVuJJJ3upwwOkCnd+yhHkgSajUxdg7L4195lbMVTIka6WnBpjZLLRTReoHbgIMf9zzH/QQM0Y5ZOVJCHF2F+l2mSyPG/+8Ee2XBS8hcqk=) (SDK Attempt Count: 1)
      
      • 실패 정보에는 추후 디버깅을 위해 필요한 정보를 포함하세요.

        • 실패한 작업 이름
        • MDC의 Request ID
        • 실패 이유 (예외 메시지)

캐시 적용하기

  • Caffeine 캐시를 위한 환경을 구성하세요.

    • org.springframework.boot:spring-boot-starter-cache 의존성을 추가하세요.
    • com.github.ben-manes.caffeine:caffeine 의존성을 추가하세요.
    • application.yaml 설정 또는 Bean을 통해 캐시 Caffeine 캐시를 설정하세요.
  • [ ] @Cacheable 어노테이션을 활용해 캐시가 필요한 메소드에 적용하세요.

    • 사용자별 채널 목록 조회
    • 사용자별 알림 목록 조회
    • 사용자 목록 조회
  • 데이터 변경 시, 캐시를 갱신 또는 무효화하는 로직을 구현하세요.

    • @CacheEvict@CachePutCacheManager 등을 활용하세요.

    • 예시:

      • 새로운 채널 추가/수정/삭제 → 채널 목록 캐시 무효화
      • 알림 추가/삭제 → 알림 목록 캐시 무효화
      • 사용자 추가/로그인/로그아웃 → 사용자 목록 캐시 무효화
  • 캐시 적용 전후의 차이를 비교해보세요.

    • 로그를 통해 SQL 실행 여부를 확인해보세요.
  • Spring Actuator를 활용해 캐시 관련 통계 지표를 확인해보세요.

    • Caffein Spec에 recordStats 옵션을 추가하세요.
        # application.yaml
        cache:
              ...
          caffeine:
            spec: >
              maximumSize=100,
              expireAfterAccess=600s,
              recordStats
    • /actuator/caches, /actuator/metrics/cache.* 를 통해 캐시 관련 데이터를 확인해보세요.
    { "name": "cache.gets", 
      "description": "The number of times cache lookup methods have returned a cached (hit) or uncached (newly loaded or null) value (miss).", 
      "measurements": [ { "statistic": "COUNT", "value": 108 } ], 
      "availableTags": [ 
        { "tag": "cache.manager", "values": [ "channel", "composite" ] }, 
    		{ "tag": "application", "values": [ "discodeit" ] },
    		{ "tag": "name", "values": [ "channelsByUser" ] } ] 
    }

심화 요구 사항

Spring Kafka 도입하기

  • 회원이 늘어나면서 알림 연산량이 급증해 알림 기능만 별도의 마이크로 서비스로 분리하기로 결정했다고 가정해봅시다.

  • 이제 알림 서비스와 메인 서비스는 완전히 분리된 서버이므로 Spring Event만을 통해서 이벤트를 발행/소비할 수 없습니다.

  • 따라서 메인 서비스에서 Kafka를 통해 서버 외부로 이벤트를 발행하고, 알림 서비스에서는 서버 외부의 이벤트를 소비할 수 있도록 해야합니다.

  • Kafka 환경을 구성하세요.

    • Docker Compose를 활용해 Kafka를 구동하세요.

      # docker-compose-kafka.yaml
      # https://developer.confluent.io/confluent-tutorials/kafka-on-docker/#the-docker-compose-file
      services:
        broker:
          image: apache/kafka:latest
          hostname: broker
          container_name: broker
          ports:
            - 9092:9092
          environment:
            KAFKA_BROKER_ID: 1
            KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: PLAINTEXT:PLAINTEXT,PLAINTEXT_HOST:PLAINTEXT,CONTROLLER:PLAINTEXT
            KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://broker:29092,PLAINTEXT_HOST://localhost:9092
            KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
            KAFKA_GROUP_INITIAL_REBALANCE_DELAY_MS: 0
            KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1
            KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1
            KAFKA_PROCESS_ROLES: broker,controller
            KAFKA_NODE_ID: 1
            KAFKA_CONTROLLER_QUORUM_VOTERS: 1@broker:29093
            KAFKA_LISTENERS: PLAINTEXT://broker:29092,CONTROLLER://broker:29093,PLAINTEXT_HOST://0.0.0.0:9092
            KAFKA_INTER_BROKER_LISTENER_NAME: PLAINTEXT
            KAFKA_CONTROLLER_LISTENER_NAMES: CONTROLLER
            KAFKA_LOG_DIRS: /tmp/kraft-combined-logs
            CLUSTER_ID: MkU3OEVBNTcwNTJENDM2Qk
      docker compose -f docker-compose-kafka.yml up -d
    • Spring Kafka 의존성을 추가하고, application.yml에 Kafka 설정을 추가하세요.

      implementation 'org.springframework.kafka:spring-kafka'
      # application.yaml
      spring:
          ...
        kafka:
          bootstrap-servers: localhost:9092
          producer:
            key-serializer: org.apache.kafka.common.serialization.StringSerializer
            value-serializer: org.apache.kafka.common.serialization.StringSerializer
          consumer:
            group-id: discodeit-group
            auto-offset-reset: earliest
            key-deserializer: org.apache.kafka.common.serialization.StringDeserializer
            value-deserializer: org.apache.kafka.common.serialization.StringDeserializer
    • Spring Event를 Kafka로 발행하는 리스너를 구현하세요.

      • NotificationRequiredEventListener는 비활성화하세요.

      • KafkaProduceRequiredEventListener를 구현하세요.

        package com.sprint.mission.discodeit.event.kafka;
        
        @Slf4j
        @RequiredArgsConstructor
        @Component
        public class KafkaProduceRequiredEventListener {
        
            private final KafkaTemplate<String, String> kafkaTemplate;
            private final ObjectMapper objectMapper;
        
          @Async("eventTaskExecutor")
          @TransactionalEventListener
          public void on(MessageCreatedEvent event) {
            ...
            String payload = objectMapper.writeValueAsString(event);
            kafkaTemplate.send("discodeit.MessageCreatedEvent", payload);
            ...
          }
        
          @Async("eventTaskExecutor")
          @TransactionalEventListener
          public void on(RoleUpdatedEvent event) {...}
        
          @Async("eventTaskExecutor")
          @EventListener
          public void on(S3UploadFailedEvent event) {...}
        }
    • Kafka Console을 통해 Kafka 이벤트가 잘 발행되는지 확인해보세요.

      1. broker 컨테이너 쉘 접속
        docker exec -it -w /opt/kafka/bin broker sh
      2. 토픽 리스트 확인 (실행위치: /opt/kafka/bin)
        ./kafka-topics.sh --list --bootstrap-server broker:29092
        
        __consumer_offsets
        discodeit.MessageCreatedEvent
        ...
      3. 특정 토픽 이벤트 구독 및 대기 (실행위치: /opt/kafka/bin)
        ./kafka-console-consumer.sh --topic discodeit.MessageCreatedEvent --from-beginning --bootstrap-server broker:29092
        
        ...
    • Kafka 토픽을 구독해 알림을 생성하는 리스너를 구현하세요.

      • 이 리스너는 메인 서비스와 별도의 서버로 구성된 알림 서비스라고 가정합니다.
      • NotificationRequiredTopicListener를 구현하세요.
        package com.sprint.mission.discodeit.event.kafka;
        
        @Slf4j
        @RequiredArgsConstructor
        @Component
        public class NotificationRequiredTopicListener {
          ...
          private final ObjectMapper objectMapper;
        
          @KafkaListener(topics = "discodeit.MessageCreatedEvent")
          public void onMessageCreatedEvent(String kafkaEvent) {
            try {
              MessageCreatedEvent event = objectMapper.readValue(kafkaEvent,
                  MessageCreatedEvent.class);
                    ...
            } catch (JsonProcessingException e) {
              throw new RuntimeException(e);
            }
          }
        
          @KafkaListener(topics = "discodeit.RoleUpdatedEvent")
          public void onRoleUpdatedEvent(String kafkaEvent) {...}
        
          @KafkaListener(topics = "discodeit.S3UploadFailedEvent")
          public void onS3UploadFailedEvent(String kafkaEvent) {...}
        }
      • 기존 @EventListener 기반 로직을 제거하고 @KafkaListener로 대체하세요.

    Redis Cache 도입하기

    • 대용량 트래픽을 감당하기 위해 서버의 인스턴스를 여러 개로 늘렸다고 가정해봅시다.

    • Caffeine과 같은 로컬 캐시는 서로 다른 서버에서 더 이상 활용할 수 없습니다.
      따라서 Redis를 통해 전역 캐시 저장소를 구성합니다.

    • Redis 환경을 구성하세요.

      • Docker Compose를 활용해 Redis를 구동하세요.

        # docker-compose-redis.yml
        # https://developer.confluent.io/confluent-tutorials/kafka-on-docker/#the-docker-compose-file
        services:
          redis:
            image: redis:7.2-alpine
            container_name: redis
            ports:
              - "6379:6379"
            volumes:
              - redis-data:/data
            command: redis-server --appendonly yes
        
        volumes:
          redis-data:
        docker compose -f docker-compose-redis.yml up -d
      • Redis 의존성을 추가하고, application.yml에 Redis 설정을 추가하세요.

        implementation 'com.github.ben-manes.caffeine:caffeine'
        implementation 'org.springframework.boot:spring-boot-starter-data-redis'
        # application.yaml
        spring:
          ...
          cache:
            type: caffeine    type: redis
            cache-names:
              - channels
              - notifications
              - users
            caffeine:
              spec: >
                maximumSize=100,
                expireAfterAccess=600s,
                recordStats
            redis:
              enable-statistics: true
          data:
            redis:
              host: ${REDIS_HOST:localhost}
              port: ${REDIS_PORT:6379}
      • 직렬화 설정을 위해 다음과 같이 Bean을 선언하세요.

        // CacheConfig
        @Bean
        public RedisCacheConfiguration redisCacheConfiguration(ObjectMapper objectMapper) {
          ObjectMapper redisObjectMapper = objectMapper.copy();
          redisObjectMapper.activateDefaultTyping(
              LaissezFaireSubTypeValidator.instance,
              DefaultTyping.EVERYTHING,
              As.PROPERTY
          );
        
          return RedisCacheConfiguration.defaultCacheConfig()
              .serializeValuesWith(
                  RedisSerializationContext.SerializationPair.fromSerializer(
                      new GenericJackson2JsonRedisSerializer(redisObjectMapper)
                  )
              )
              .prefixCacheNameWith("discodeit:")
              .entryTtl(Duration.ofSeconds(600))
              .disableCachingNullValues();
        }
    • DataGrip을 통해 Redis에 저장된 캐시 정보를 조회해보세요.

@bladnoch bladnoch self-assigned this Sep 1, 2025
@bladnoch bladnoch added 미완성🛠️ 스프린트 미션 제출일이지만 미완성했습니다. 죄송합니다. 지각제출⏰ 제출일 이후에 늦게 제출한 PR입니다. labels Sep 1, 2025
@bladnoch bladnoch requested a review from ssjf409 September 1, 2025 13:47
@ssjf409
Copy link
Collaborator

ssjf409 commented Sep 2, 2025

비동기 적용하는 부분은 아직 못 하셨네요.
그 위에 요구사항들과 관련된 코드 부분들에선 아주 잘 작하셔서 어색한 부분은 못 찾았습니다.

고생하셨습니다. 동욱님

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

미완성🛠️ 스프린트 미션 제출일이지만 미완성했습니다. 죄송합니다. 지각제출⏰ 제출일 이후에 늦게 제출한 PR입니다.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants