Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion docs/architecture/backend-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,8 +99,9 @@
- **동시성 캡:** `@Async` 무제한 금지, `ThreadPoolTaskExecutor`로 동시 디코딩 수를 인스턴스 자원에 맞춰 제한(6.7 참조).
- **인프로세스 워커:** 별도 프로세스/인스턴스가 아니라 API와 같은 프로세스의 바운드 풀. 프로세스 분리가 없으므로 릴레이도 불필요.
- **스윕:** 재시작으로 인메모리 큐가 날아가도 원본은 안전하므로, 주기적 스윕이 `PENDING`/`FAILED`를 재처리.
- **큐 초과 시 안전 처리:** 바운드 풀의 대기열이 가득 차면 제출 시점에 `RejectedExecutionException`이 난다. 업로드 완료 등록의 커밋 후 콜백(`afterCommit`)에서 이 예외를 못 잡으면, 사진은 이미 커밋됐는데 응답만 500으로 깨지는 상태 불일치가 생긴다(#86). 제출 실패는 로그만 남기고 삼키며, 사진은 기본값인 `PENDING`에 남아 위 스윕이 재수거한다 — 최초 제출 경로(`PhotoUploadService`)와 스윕 재제출 경로(`PhotoSweepScheduler`) 모두 동일하게 처리한다.

**근거.** 썸네일 잡은 유실돼도 원본으로 재생성 가능하다. 원본이 이미 영속화돼 있다는 사실을 지렛대 삼아, Redis Queue 없이 DB 상태만으로 "재시작 견딤"을 얻는다.
**근거.** 썸네일 잡은 유실돼도 원본으로 재생성 가능하다. 원본이 이미 영속화돼 있다는 사실을 지렛대 삼아, Redis Queue 없이 DB 상태만으로 "재시작 견딤"을 얻는다. 큐 용량 자체를 실측 기반으로 얼마로 잡을지는 별도 과제다(#107).

### 4.4 실시간 미도입 — 전면 폴링 + 푸시

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.concurrent.RejectedExecutionException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
Expand Down Expand Up @@ -64,7 +65,20 @@ public void resubmitStaleThumbnailJobs() {
photoRepository.findByDeletedAtIsNullAndThumbnailStatusInAndUpdatedAtLessThanEqual(
List.of(PhotoThumbnailStatus.PENDING, PhotoThumbnailStatus.FAILED),
staleBefore);
staleJobs.forEach(photo -> thumbnailProcessingService.process(photo.getId()));

// thumbnailExecutor 큐가 가득 차면 여기서도 RejectedExecutionException이 날 수 있다. 한 사진에서
// 터진다고 나머지를 포기하면 이번 스윕 사이클에서 재수거하려던 사진들이 통째로 다음 사이클로 밀리므로,
// 사진 단위로 잡고 넘어가 나머지는 계속 재제출한다(다음 사이클에도 여전히 stale하면 다시 시도된다).
for (Photo photo : staleJobs) {
try {
thumbnailProcessingService.process(photo.getId());
} catch (RejectedExecutionException exception) {
log.warn(
"[PhotoSweep] 썸네일 작업 큐가 가득 차 재제출에 실패했습니다. photoId={}",
photo.getId(),
exception);
}
}
}

@Scheduled(fixedDelayString = "${photo.sweep.photo-purge-interval:PT1H}")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,10 @@
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.Executors;
import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.Semaphore;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronization;
Expand Down Expand Up @@ -43,6 +45,7 @@
import org.zipzip.zipzipserver.global.exception.BusinessException;
import org.zipzip.zipzipserver.global.idempotency.IdempotencyService;

@Slf4j
@Service
@RequiredArgsConstructor
public class PhotoUploadService {
Expand Down Expand Up @@ -228,11 +231,23 @@ private void submitThumbnailJobsAfterCommit(List<UUID> photoIds) {
new TransactionSynchronization() {
@Override
public void afterCommit() {
photoIds.forEach(thumbnailProcessingService::process);
photoIds.forEach(PhotoUploadService.this::submitThumbnailJob);
}
});
}

// afterCommit() 콜백에서 던진 예외는 이미 커밋된 트랜잭션의 호출자(컨트롤러)에게까지 그대로 전파된다(Spring
// TransactionSynchronization 명세). thumbnailExecutor 큐가 가득 차면 여기서 RejectedExecutionException이
// 나는데, 이미 사진은 정상 커밋된 상태라 응답만 500으로 깨지면 상태 불일치가 된다. 제출 실패 시 사진은 기본값인
// PENDING에 머물러 있으므로 로그만 남기고 넘기면 PhotoSweepScheduler가 나중에 재수거한다.
private void submitThumbnailJob(UUID photoId) {
try {
thumbnailProcessingService.process(photoId);
} catch (RejectedExecutionException exception) {
log.warn("[PhotoUpload] 썸네일 작업 큐가 가득 차 제출에 실패했습니다. photoId={}", photoId, exception);
}
}

private List<PhotoUploadUrlRequest.UploadUrlFileSpec> validateFileSpecs(
List<PhotoUploadUrlRequest.UploadUrlFileSpec> files) {
if (files == null || files.isEmpty()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
Expand All @@ -20,22 +22,27 @@
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.core.task.TaskRejectedException;
import org.springframework.http.MediaType;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.context.transaction.TestTransaction;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.transaction.annotation.Transactional;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.zipzip.zipzipserver.domain.album.entity.SharedAlbum;
import org.zipzip.zipzipserver.domain.auth.jwt.JwtTokenProvider;
import org.zipzip.zipzipserver.domain.photo.entity.PhotoUploadReservation;
import org.zipzip.zipzipserver.domain.photo.service.ThumbnailProcessingService;
import org.zipzip.zipzipserver.domain.sharedgroup.entity.InviteCodeReservation;
import org.zipzip.zipzipserver.domain.sharedgroup.entity.SharedGroup;
import org.zipzip.zipzipserver.domain.sharedgroup.entity.SharedGroupMembership;
import org.zipzip.zipzipserver.domain.sharedgroup.entity.SharedGroupRole;
import org.zipzip.zipzipserver.domain.storage.ObjectStorageService;
import org.zipzip.zipzipserver.domain.storage.PresignedDownload;
import org.zipzip.zipzipserver.domain.storage.PresignedUpload;
import org.zipzip.zipzipserver.domain.user.entity.AppUser;

Expand Down Expand Up @@ -63,6 +70,7 @@ static void configureDataSource(DynamicPropertyRegistry registry) {
@Autowired private JwtTokenProvider jwtTokenProvider;

@MockitoBean private ObjectStorageService objectStorageService;
@MockitoBean private ThumbnailProcessingService thumbnailProcessingService;

private AppUser uploader;
private SharedAlbum sharedAlbum;
Expand Down Expand Up @@ -150,6 +158,44 @@ void setUp() {
.andExpect(jsonPath("$.code").value("INVALID_UPLOAD_METADATA"));
}

@Test
void 완료_등록_시_썸네일_큐가_가득_차도_사진_생성은_201로_성공한다() throws Exception {
String objectKey = "object-key-" + UUID.randomUUID();
persist(
PhotoUploadReservation.create(
objectKey, sharedAlbum, uploader, Instant.now().plusSeconds(900)));
entityManager.flush();
entityManager.clear();
// afterCommit() 콜백은 실제 커밋이 일어나야 발화한다. 테스트 기본 트랜잭션(롤백 전제)에 얹힌 채로
// MockMvc를 호출하면 완료등록 서비스의 트랜잭션이 테스트 메서드가 끝날 때까지 실제로 커밋되지 않아, 응답을
// 검증하는 시점엔 afterCommit()이 아직 실행되기 전이라 버그를 재현하지 못한다. 지금까지의 픽스처(uploader,
// sharedAlbum, 예약)를 실제로 커밋하고 이후 요청은 별도의 진짜 트랜잭션에서 처리되도록 한다.
TestTransaction.flagForCommit();
TestTransaction.end();
when(objectStorageService.exists(objectKey)).thenReturn(true);
when(objectStorageService.issueDownloadUrl(eq(objectKey), any()))
.thenReturn(
new PresignedDownload(
"https://original-url", Instant.now().plusSeconds(600)));
doThrow(new TaskRejectedException("thumbnailExecutor 큐가 가득 찼습니다"))
.when(thumbnailProcessingService)
.process(any());
String requestBody =
objectMapper.writeValueAsString(
Map.of("files", List.of(Map.of("objectKey", objectKey))));

mockMvc.perform(
post(
"/api/v1/shared-albums/{sharedAlbumId}/photos/complete",
sharedAlbum.getId())
.header("Authorization", bearerToken(uploader))
.header("Idempotency-Key", UUID.randomUUID().toString())
.contentType(MediaType.APPLICATION_JSON)
.content(requestBody))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.data.items.length()").value(1));
}

private Map<String, Object> uploadUrlRequestBody(int fileCount, long sizeBytes) {
List<Map<String, Object>> files = new ArrayList<>();
for (int i = 0; i < fileCount; i++) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package org.zipzip.zipzipserver.domain.photo.scheduler;

import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;

import java.time.Instant;
import java.util.List;
import java.util.UUID;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.core.task.TaskRejectedException;
import org.zipzip.zipzipserver.domain.photo.entity.Photo;
import org.zipzip.zipzipserver.domain.photo.entity.PhotoThumbnailStatus;
import org.zipzip.zipzipserver.domain.photo.repository.PhotoRepository;
import org.zipzip.zipzipserver.domain.photo.repository.PhotoUploadReservationRepository;
import org.zipzip.zipzipserver.domain.photo.service.PhotoPurgeService;
import org.zipzip.zipzipserver.domain.photo.service.ThumbnailProcessingService;
import org.zipzip.zipzipserver.domain.storage.ObjectStorageService;
import org.zipzip.zipzipserver.domain.user.entity.AppUser;

@ExtendWith(MockitoExtension.class)
class PhotoSweepSchedulerTest {

@Mock private PhotoRepository photoRepository;
@Mock private PhotoUploadReservationRepository photoUploadReservationRepository;
@Mock private ObjectStorageService objectStorageService;
@Mock private ThumbnailProcessingService thumbnailProcessingService;
@Mock private PhotoPurgeService photoPurgeService;

@InjectMocks private PhotoSweepScheduler photoSweepScheduler;

@Test
void 썸네일_재제출_한_사진의_큐가_가득_차도_나머지_사진은_계속_재제출한다() {
AppUser uploader = AppUser.create("apple-subject-" + UUID.randomUUID(), "업로더");
Photo rejectedPhoto = staleThumbnailPhoto(uploader);
Photo recoveredPhoto = staleThumbnailPhoto(uploader);
when(photoRepository.findByDeletedAtIsNullAndThumbnailStatusInAndUpdatedAtLessThanEqual(
eq(List.of(PhotoThumbnailStatus.PENDING, PhotoThumbnailStatus.FAILED)),
any(Instant.class)))
.thenReturn(List.of(rejectedPhoto, recoveredPhoto));
doThrow(new TaskRejectedException("thumbnailExecutor 큐가 가득 찼습니다"))
.when(thumbnailProcessingService)
.process(rejectedPhoto.getId());

photoSweepScheduler.resubmitStaleThumbnailJobs();

verify(thumbnailProcessingService).process(rejectedPhoto.getId());
verify(thumbnailProcessingService).process(recoveredPhoto.getId());
}

private Photo staleThumbnailPhoto(AppUser uploader) {
return Photo.create(
uploader, "iPhone 15", "photos/" + UUID.randomUUID() + ".jpg", null, null, null);
}
}
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
package org.zipzip.zipzipserver.domain.photo.service;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
Expand All @@ -28,6 +30,8 @@
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.core.task.TaskRejectedException;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import org.zipzip.zipzipserver.domain.album.entity.SharedAlbum;
import org.zipzip.zipzipserver.domain.album.repository.SharedAlbumPhotoRepository;
Expand Down Expand Up @@ -400,6 +404,41 @@ albumId, userId, new PhotoUploadUrlRequest(files)))
.complete(any(ApiIdempotencyRecord.class), any(), eq(result.response()));
}

@Test
void 완료등록_썸네일_큐가_가득_차도_커밋_후_콜백에서_예외가_전파되지_않는다() {
UUID albumId = UUID.randomUUID();
UUID userId = UUID.randomUUID();
String idempotencyKeyHeader = UUID.randomUUID().toString();
PhotoUploadCompleteRequest request = completeRequestFor("object-key");

givenFreshIdempotencyStart(albumId, userId, idempotencyKeyHeader, request);
when(appUserRepository.getReferenceById(userId)).thenReturn(uploader);
PhotoUploadReservation usableReservation =
PhotoUploadReservation.create(
"object-key", sharedAlbum, uploader, Instant.now().plusSeconds(900));
when(photoUploadReservationRepository.findById("object-key"))
.thenReturn(Optional.of(usableReservation));
when(objectStorageService.exists("object-key")).thenReturn(true);
when(photoRepository.save(any(Photo.class)))
.thenAnswer(invocation -> invocation.getArgument(0));
when(objectStorageService.issueDownloadUrl(eq("object-key"), any(Duration.class)))
.thenReturn(
new PresignedDownload(
"https://original-url", Instant.now().plusSeconds(600)));
doThrow(new TaskRejectedException("thumbnailExecutor 큐가 가득 찼습니다"))
.when(thumbnailProcessingService)
.process(any(UUID.class));

photoUploadService.completeUpload(albumId, userId, idempotencyKeyHeader, request);

List<TransactionSynchronization> synchronizations =
TransactionSynchronizationManager.getSynchronizations();
assertThat(synchronizations).isNotEmpty();
assertThatCode(() -> synchronizations.forEach(TransactionSynchronization::afterCommit))
.doesNotThrowAnyException();
verify(thumbnailProcessingService).process(any(UUID.class));
}

@Test
void 완료등록_파일_목록이_비어있으면_INVALID_UPLOAD_METADATA() {
UUID albumId = UUID.randomUUID();
Expand Down
Loading