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

캔버스에서 보면 대충은 보이는데 자세하게 설명하면 , 검정색 선은 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가 필요한 이유
- 소유자 검증
- 단순 경로만 노출하면, 누구나 URL 조작으로 다른 사람 이미지에 접근 가능
- Helper에서 URL에
userId,signature,expiry파라미터를 붙여 “이 파일이 진짜 내 이미지 맞나요?” 검증
- 캐시 무효화(Cache Busting)
- 이미지 업데이트(덮어쓰기)나 삭제 후에도 Nginx에 남은 캐시가 그대로 노출될 수 있음
- URL에 버전 번호나 타임스탬프 쿼리스트링(
?v=20250701_1030)을 추가해, 변경 시 새로운 URL을 발급
- 만료 정책
- 무작정 공개된 URL이 영구 노출되지 않도록, 일정 시간이 지나면 만료되게 설정
- Helper에서 만료 시간(
expires)을 계산해, 지나면 403 응답 발생
4. 삭제 처리와 정합성 검증
- DB 플래그 방식: 이미지 삭제 시
isDeleted=true로 마킹만 → 실제 파일은 보존 - Outbox 이벤트: 삭제 이벤트 발행 스케줄러가 Nginx 캐시를 API나 script로 “purge”
- 조회 시점 검증: Nginx 리버스 프록시 설정에서
/image/*요청이 들어올 때마다 백엔드에 ID·소유자 확인 →X-Accel-Redirect로 실제 파일 반환- 이중 체크를 통해, “이미 삭제된 이미지인데 캐시가 남아 노출되는” 사고 방지
5. 트래픽 분리 전략
- 쓰기 흐름:
- 클라이언트 → 컨트롤러 → Service에서 DB 저장 + Outbox 이벤트
- Outbox 스케줄러가 Kafka나 Message Queue로 발송 (비동기)
- 읽기 흐름:
- 클라이언트 → Nginx
- Nginx가 URL 파라미터(서명·만료·버전) 검증
- 조건 충족하면 스태틱 파일 서빙
이렇게 하면 읽기 부하가 Nginx로 대부분 빠져나가고, 애플리케이션 서버는 핵심 비즈니스 로직에 집중할 수 있다.
6. 배운 점 & 앞으로
- 단순해 보이는 기능일수록, 고려해야 할 엣지 케이스가 많다
- URL 헬퍼의 역할: 보안·정합성·캐시 무효화를 한 곳에서 관리 → 코드 중복·실수 방지
지금 이렇게 글을 적으니까... 예전엔 그냥 받은거 저장하고 올리고 했는대... 이제 리사이징, webp 변환등등 엄청 당연하게 이런것들을 처리하는 내가 보여서 좀 뿌듯했다..?
앞으로는 나머지 기능들을 구현하고, 명세나 시스템에맞게 캐싱 전략이나, 분산락같은 기법도배워서 써봐야겠다 .
'BindProject' 카테고리의 다른 글
| 2025-07-19 업체/룸 요구사항 정리 회의 (2) | 2025.07.19 |
|---|---|
| BIND PROJECT WEEKLY REPORT(7-1st) (0) | 2025.07.07 |
| 진행 사항 보고 (진행된 ERD) (8) | 2025.06.30 |
| 현제까지 프로젝트 진행 상황 (8) | 2025.06.29 |
| [Backend] bff 구현하기 2편. DTO 관리 전략과 API 계약 모듈 도입 (1) | 2025.06.26 |