BindProject

BindPorject : SpringBoot 를 이용한 비동기이미지 처리 Flow 설명

dding-shark 2025. 7. 1. 11:18
728x90

냅다 그림부터 박으면...

BindProject 이미지 처리 시퀀스 다이어그램

캔버스에서 보면 대충은 보이는데 자세하게 설명하면 , 검정색 선은 REST API 통신이고, 파랑선은 KAFKA 기반 메세지 통신이다.

1~2) 클라이언트는 이미지 픽커 등으로 이미지를 선택함과 동시에 이미지가 Image모듈로 전달후 ImageId 수신

@RestController  
@RequiredArgsConstructor  
@RequestMapping(value = "/api/image/v1", consumes = MediaType.MULTIPART\_FORM\_DATA\_VALUE)  
public class ImageStoreController {  
private final ImageUploadService imageUploadService;

@PostMapping("/image")
public ResponseEntity<BaseResponse<?>> imageStore(
        @RequestPart("file") MultipartFile file,
        @RequestParam("category") ResourceCategory category,
        @RequestParam("uploaderId") Long uploaderId,
        @RequestParam("visibility") ImageVisibility visibility,
        @RequestParam(value = "isThumbnail", defaultValue = "false") Boolean isThumbnail
) {
    try {
        var response = imageUploadService.upload(file, category, uploaderId, visibility, isThumbnail);
        return ResponseEntity.ok(BaseResponse.success(response));
    } catch (ImageException e) {
        return ResponseEntity.badRequest().body(BaseResponse.fail(e.getErrorCode()));
    }
}

@PostMapping("/images")
public ResponseEntity<BaseResponse<?>> imagesStore(
        @RequestPart("files") List<MultipartFile> files,
        @RequestParam("category") ResourceCategory category,
        @RequestParam("uploaderId") Long uploaderId,
        @RequestParam("visibility") ImageVisibility visibility
) {
    try {
        var response = imageUploadService.uploadImages(files, category, uploaderId, visibility);
        return ResponseEntity.ok(BaseResponse.success(response));
    } catch (ImageException e) {
        return ResponseEntity.badRequest().body(BaseResponse.fail(e.getErrorCode()));
    }
}

대충 요로코롬 전달하면,

이미지 모듈에서 이미지를 Temp 상태로 저장하고, 할당된 이미지 ID를 클라이언트한테 반환한다.

(이때 클라이언트는, 이미지를 보내면서 , 다른 프로필, 글 등을 작성할 수 있다. 비동기 처리의 핵심! )

3) 수정/ 작성 사항을 모두 작성하고 클라이언트는 BFF(GATEWAY) 모듈 에게 이미지 아이디 배열 객체가 포함된 Request를 전달

public class UserProfileUpdateRequestFromClient {
    private Long userId;
    private List<Instrument> instruments;
    private List<Genre> genres;
    private String nickname;
    private String introduction;
    private String gender;
    private String location;
    private Long phoneNumber;
    private Long thumbnailId;

}

이런식으로 전달하여(여기선, 이미지 프로필이기에 Long 으로 주었지만, BFF내부에서 List 객체로 변환한다) 넘긴다.... 이미지 모듈에서 처리하는... 방식을... 통일 하기위해..? 이부분은 나중에 테스트나.. 멘토님한테 한번 물어봐야할듯..?)

동시에 이미지를 저장해야하는 객체(해당 프로필, 해당 글 또한 BFF에서 조합/처리하여 해당모듈로 넘긴다)

public class ImageConfirmRequest {
    private ResourceCategory category;
    private Long uploaderId;
    private Long referenceId;
    private List<Long> imageIds;
}

public class UserProfileController {

    private final UserProfileClient userProfileClient;
    private final ImageClient imageClient;

    @PutMapping("/update")
    public Mono<ResponseEntity<?>> userProfileUpdate(
            Authentication authentication,
            @RequestBody UserProfileUpdateRequestFromClient req) {

        // 1) JWT 에서 추출한 userId 검증
        String userId = authentication.getName();
        if (!userId.equals(req.getUserId().toString())) {
            throw new BffException(BffErrorCode.NOT_MATCHED_TOKEN);
        }

        // 2) 두 API 호출을 동시에 날리고, 둘 다 ResponseEntity<BaseResponse<...>> 을 받음
        Mono<ResponseEntity<BaseResponse<?>>> profileMono =
                userProfileClient.updateProfile(req.toUserProfileUpdateRequest(req));

// 요기서 변환후 넘겨버린다!
        Mono<ResponseEntity<BaseResponse<?>>> imageMono =
                imageClient.confirmImages(req.toImageConfirmRequest(req));

        // 3) zip → flatMap 으로 결과 꺼내서, 실패 케이스가 있으면 그 JSON(body) 그대로 리턴
        return Mono
                .zip(profileMono, imageMono)
                .flatMap(tuple -> {
                    BaseResponse<?> profBody = tuple.getT1().getBody();
                    BaseResponse<?> imgBody  = tuple.getT2().getBody();

                    // 프로필 호출이 실패(success==false) 했으면 그 body 그대로 리턴
                    if (profBody != null && !profBody.isSuccess()) {
                        return Mono.just(ResponseEntity.ok(profBody));
                    }
                    // 이미지 확인 호출이 실패했으면 그 body 그대로 리턴
                    if (imgBody != null && !imgBody.isSuccess()) {
                        return Mono.just(ResponseEntity.ok(imgBody));
                    }
                    // 둘 다 성공했으면 빈 결과
                    return Mono.just(ResponseEntity.ok(BaseResponse.success()));
                });
    }
}

이미지만 보면 이런식으로 넘긴다. 기본적인 BFF 에서의 할일(인증/인가/WebFlex) 를통한 병렬 호출등등 잘 짠거같은데... 이제와서 보니까 req.to~(req)..? 이건 수정해야겠다...

4) 수신된 ID 를통해 상태를 CONFIRM 으로 바꾸고, 카프카를통해 해당 모듈로 이미지 접근 URL 전달

@RestController
@RequiredArgsConstructor
@RequestMapping("/api/image/v1")
public class ImageConfirmController {
    private final ImageConfirmationService  imageConfirmationService;

    @PostMapping("/confirms")
    public ResponseEntity<BaseResponse<?>> confirmImage(
            @RequestBody ImageConfirmRequest req
    ) {
        try {

            imageConfirmationService.changeImages(req);
            return ResponseEntity.ok(BaseResponse.success());
        }
        catch (ImageException e)
        {
            return ResponseEntity.badRequest().body(BaseResponse.fail(e.getErrorCode()));
        }
    }
}

수신받은 Image모듈은 changes( )를 실행.

    @Transactional
    public void confirmImages(ImageConfirmRequest request) {
        List<Image> images = imageRepository.findAllById(request.getImageIds());
        if (images.size() != request.getImageIds().size()) {
            throw new ImageException(IMAGE_NOT_FOUND);
        }

        for (Image image : images) {
            validator.validateUser(image, request.getUploaderId());
            validator.validateTempStatus(image);
            validator.validateCategory(image, request.getCategory());

            image.setReferenceId(request.getReferenceId());
            statusChanger.changeStatus(image, ImageStatus.TEMP, ImageStatus.CONFIRMED, IMAGE_NOT_TEMP);
        }

        imageRepository.saveAll(images);
    }




    @Transactional
    public void changeImages(ImageConfirmRequest request)
    {
        List<Image> oldImages = imageRepository.findAllById(request.getImageIds());
        if(!oldImages.isEmpty())
        {
            oldImages.forEach(image -> {
                if(request.getImageIds().stream().noneMatch((imageId -> imageId.equals(image.getId()))))
                {
                    imageLifecycleService.deleteImage(image.getId());
                }
            });
        }
        confirmImages(request);
        List<ImageResponse>response = imageQueryService.getImages(request.getCategory(), request.getReferenceId());
        eventPublish.EventUpdateSelector(response);
    }
@Service
@RequiredArgsConstructor
public class EventPublishIImpl implements EventPublish {
    OutboxEventPublisher  outboxEventPublisher;


    @Override
    public void EventUpdateSelector(List<ImageResponse> req) {
        ResourceCategory category = req.getFirst().getCategory();

        if(category.equals(ResourceCategory.PROFILE))
        {
            ProfileImageUpdate(req);
            return ;
        }

        if(category.equals(ResourceCategory.BAND_ROOM))
        {
            BandRoomImageUpdate(req);
            return;
        }

        if(category.equals(ResourceCategory.STUDIO))
        {
            StudioImageUpdate(req);
        }


    }


    private void ProfileImageUpdate(List<ImageResponse> req) {
        outboxEventPublisher.publish(
                UserProfileImageUpdateEvent.builder()
                        .userId(req.getFirst().getReferenceId())
                        .profileImageUrl(req.getFirst().getUrl())
                        .build()
        );


    }
    private void BandRoomImageUpdate(List<ImageResponse> req)
    {
        outboxEventPublisher.publish(
                BandRoomImageUpdateEvent.builder()
                        .referenceId(req.getFirst().getReferenceId())
                        .images(req)
                        .build()
        );
    }
    private void StudioImageUpdate(List<ImageResponse> req)
    {
        outboxEventPublisher.publish(
                StudioImageUpdateEvent.builder()
                        .referenceId(req.getFirst().getReferenceId())
                        .referenceId(req.getFirst().getReferenceId())
                        .build()
        );

    }
}

후에 사전에 작성된 EventUpdateSelector -> 현제 ImageUpdateSelector 로 명칭 바꿈;;;;;;;

을 통해 아웃박스로 들어가고 아웃박스가 알아서 잘~ 이벤트를 발행해줄것이다.

<회고>

회고: 이미지 업로드에서 URL 발급, 그리고 Nginx 서빙까지

예전엔 모놀리스 Spring Boot 프로젝트에서 게시물 하나 등록할 때

  • DB에 이미지 저장
  • 바로 URL 생성
  • 리턴된 URL로 클라이언트에서 곧바로 조회
    이렇게 한 방에 처리했는데, 이번엔 Nginx를 앞단에 두고 정적 파일 서빙, 캐시, 보안, 삭제 정합성 등 고려할 게 한두 가지가 아니었다.

2. Nginx 도입의 이유

  • 트래픽 분리: 읽기(read) 트래픽은 Nginx가, 쓰기(write) 트래픽은 애플리케이션 서버가 담당 → 서비스 과부하 방지
  • 정적 파일 최적화: gzip, cache-control 헤더 설정, HTTP/2 푸시 등으로 이미지 성능 극대화
  • 보안 레이어: 직접 애플리케이션을 경유하지 않고 스태틱으로 서빙할 때도, 권한·정합성 검증이 필요

3. ImageUrlHelper가 필요한 이유

  1. 소유자 검증
    • 단순 경로만 노출하면, 누구나 URL 조작으로 다른 사람 이미지에 접근 가능
    • Helper에서 URL에 userId, signature, expiry 파라미터를 붙여 “이 파일이 진짜 내 이미지 맞나요?” 검증
  2. 캐시 무효화(Cache Busting)
    • 이미지 업데이트(덮어쓰기)나 삭제 후에도 Nginx에 남은 캐시가 그대로 노출될 수 있음
    • URL에 버전 번호나 타임스탬프 쿼리스트링(?v=20250701_1030)을 추가해, 변경 시 새로운 URL을 발급
  3. 만료 정책
    • 무작정 공개된 URL이 영구 노출되지 않도록, 일정 시간이 지나면 만료되게 설정
    • Helper에서 만료 시간(expires)을 계산해, 지나면 403 응답 발생

4. 삭제 처리와 정합성 검증

  • DB 플래그 방식: 이미지 삭제 시 isDeleted=true로 마킹만 → 실제 파일은 보존
  • Outbox 이벤트: 삭제 이벤트 발행 스케줄러가 Nginx 캐시를 API나 scrip­t로 “purge”
  • 조회 시점 검증: Nginx 리버스 프록시 설정에서 /image/* 요청이 들어올 때마다 백엔드에 ID·소유자 확인 → X-Accel-Redirect로 실제 파일 반환
    • 이중 체크를 통해, “이미 삭제된 이미지인데 캐시가 남아 노출되는” 사고 방지

5. 트래픽 분리 전략

  • 쓰기 흐름:
    1. 클라이언트 → 컨트롤러 → Service에서 DB 저장 + Outbox 이벤트
    2. Outbox 스케줄러가 Kafka나 Message Queue로 발송 (비동기)
  • 읽기 흐름:
    1. 클라이언트 → Nginx
    2. Nginx가 URL 파라미터(서명·만료·버전) 검증
    3. 조건 충족하면 스태틱 파일 서빙

이렇게 하면 읽기 부하가 Nginx로 대부분 빠져나가고, 애플리케이션 서버는 핵심 비즈니스 로직에 집중할 수 있다.

6. 배운 점 & 앞으로

  • 단순해 보이는 기능일수록, 고려해야 할 엣지 케이스가 많다
  • URL 헬퍼의 역할: 보안·정합성·캐시 무효화를 한 곳에서 관리 → 코드 중복·실수 방지

지금 이렇게 글을 적으니까... 예전엔 그냥 받은거 저장하고 올리고 했는대... 이제 리사이징, webp 변환등등 엄청 당연하게 이런것들을 처리하는 내가 보여서 좀 뿌듯했다..?

앞으로는 나머지 기능들을 구현하고, 명세나 시스템에맞게 캐싱 전략이나, 분산락같은 기법도배워서 써봐야겠다 .


728x90