diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 7d9ca93..7606011 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -80,6 +80,7 @@ jobs: printf 'DB_USERNAME=%s\n' "$V_DB_USERNAME" >> ~/compose/be.env printf 'DB_PASSWORD=%s\n' "$V_DB_PASSWORD" >> ~/compose/be.env printf 'JWT_SECRET=%s\n' "$V_JWT_SECRET" >> ~/compose/be.env + printf 'TZ=Asia/Seoul\n' >> ~/compose/be.env printf 'REDIS_HOST=redis\n' >> ~/compose/be.env printf 'REDIS_PORT=6379\n' >> ~/compose/be.env printf 'REDIS_PASSWORD=%s\n' "$V_REDIS_PASSWORD" >> ~/compose/be.env diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4f009e2..956a7e9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,6 +7,7 @@ on: jobs: test: runs-on: ubuntu-latest + timeout-minutes: 10 services: postgres: diff --git a/Dockerfile b/Dockerfile index 52f4d29..18be4a5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,6 +8,9 @@ COPY src src RUN ./gradlew bootJar --no-daemon -x test FROM eclipse-temurin:21-jre-jammy +RUN apt-get update \ + && apt-get install -y --no-install-recommends tzdata \ + && rm -rf /var/lib/apt/lists/* WORKDIR /app COPY --from=build /workspace/build/libs/*.jar app.jar ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"] diff --git a/src/main/java/com/Coming/Backend/admin/client/DataPipelineClient.java b/src/main/java/com/Coming/Backend/admin/client/DataPipelineClient.java index 72e2600..124343d 100644 --- a/src/main/java/com/Coming/Backend/admin/client/DataPipelineClient.java +++ b/src/main/java/com/Coming/Backend/admin/client/DataPipelineClient.java @@ -8,6 +8,9 @@ import com.Coming.Backend.admin.exception.PipelineConflictException; import com.Coming.Backend.admin.exception.PipelineNotFoundException; import com.Coming.Backend.admin.exception.PipelineServerException; +import com.Coming.Backend.admin.exception.PipelineTimeoutException; +import io.netty.channel.ConnectTimeoutException; +import io.netty.handler.timeout.TimeoutException; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.core.ParameterizedTypeReference; @@ -44,6 +47,7 @@ public List searchArtists(String name) { .retrieve() .bodyToMono(new ParameterizedTypeReference>() {}) .doOnError(e -> log.warn("Data pipeline artist search failed: name={}, error={}", name, e.getMessage())) + .onErrorMap(DataPipelineClient::isTimeout, e -> new PipelineTimeoutException()) .block(); } @@ -56,6 +60,7 @@ public List searchConcerts(String title) { .retrieve() .bodyToMono(new ParameterizedTypeReference>() {}) .doOnError(e -> log.warn("Data pipeline concert search failed: title={}, error={}", title, e.getMessage())) + .onErrorMap(DataPipelineClient::isTimeout, e -> new PipelineTimeoutException()) .block(); } @@ -64,6 +69,7 @@ public List searchConcerts(String title) { * * @throws PipelineNotFoundException MusicBrainz에 해당 MBID가 없는 경우 * @throws PipelineConflictException 동일 MBID에 대한 수집이 이미 처리 중인 경우 + * @throws PipelineTimeoutException Data 파이프라인 응답이 설정된 시간 내에 오지 않은 경우 */ public PipelineArtistCollectResult collectArtist(String mbid) { return webClient.post() @@ -77,6 +83,7 @@ public PipelineArtistCollectResult collectArtist(String mbid) { .bodyToMono(PipelineArtistCollectResult.class) .doOnSuccess(r -> log.info("Artist collect completed: mbid={}, success={}", mbid, r.success())) .doOnError(e -> log.warn("Artist collect failed: mbid={}, error={}", mbid, e.getMessage())) + .onErrorMap(DataPipelineClient::isTimeout, e -> new PipelineTimeoutException()) .block(); } @@ -85,6 +92,7 @@ public PipelineArtistCollectResult collectArtist(String mbid) { * * @throws PipelineNotFoundException KOPIS에 해당 ID가 없는 경우 * @throws PipelineConflictException 동일 KOPIS ID에 대한 수집이 이미 처리 중인 경우 + * @throws PipelineTimeoutException Data 파이프라인 응답이 설정된 시간 내에 오지 않은 경우 */ public PipelineConcertCollectResult collectConcert(String kopisId) { return webClient.post() @@ -98,6 +106,7 @@ public PipelineConcertCollectResult collectConcert(String kopisId) { .bodyToMono(PipelineConcertCollectResult.class) .doOnSuccess(r -> log.info("Concert collect completed: kopisId={}, success={}", kopisId, r.success())) .doOnError(e -> log.warn("Concert collect failed: kopisId={}, error={}", kopisId, e.getMessage())) + .onErrorMap(DataPipelineClient::isTimeout, e -> new PipelineTimeoutException()) .block(); } @@ -111,6 +120,7 @@ public void triggerArtistReleases(Long artistId) { .toBodilessEntity() .doOnSuccess(r -> log.info("Data pipeline artist releases triggered: artistId={}", artistId)) .doOnError(e -> log.warn("Data pipeline artist releases failed: artistId={}, error={}", artistId, e.getMessage())) + .onErrorMap(DataPipelineClient::isTimeout, e -> new PipelineTimeoutException()) .block(); } @@ -119,6 +129,7 @@ public void triggerArtistReleases(Long artistId) { * * @throws PipelineNotFoundException setlist.fm에 해당 공연의 셋리스트가 없는 경우 * @throws PipelineConflictException 동일 공연에 대한 수집이 이미 처리 중인 경우 + * @throws PipelineTimeoutException Data 파이프라인 응답이 설정된 시간 내에 오지 않은 경우 */ public PipelineSetlistCollectResult collectConcertSetlist(Long concertId) { return webClient.post() @@ -130,6 +141,15 @@ public PipelineSetlistCollectResult collectConcertSetlist(Long concertId) { .bodyToMono(PipelineSetlistCollectResult.class) .doOnSuccess(r -> log.info("Setlist collect completed: concertId={}, success={}", concertId, r.success())) .doOnError(e -> log.warn("Setlist collect failed: concertId={}, error={}", concertId, e.getMessage())) + .onErrorMap(DataPipelineClient::isTimeout, e -> new PipelineTimeoutException()) .block(); } + + // WebClient의 connectTimeout/responseTimeout 초과는 각각 ConnectTimeoutException, + // ReadTimeoutException(io.netty.handler.timeout.TimeoutException)으로 전달되며, + // 둘 다 원본 예외를 감싸는 WebClientRequestException의 cause로 담겨온다. + private static boolean isTimeout(Throwable e) { + Throwable cause = e.getCause(); + return cause instanceof TimeoutException || cause instanceof ConnectTimeoutException; + } } diff --git a/src/main/java/com/Coming/Backend/admin/controller/AdminController.java b/src/main/java/com/Coming/Backend/admin/controller/AdminController.java index 1763464..218b54c 100644 --- a/src/main/java/com/Coming/Backend/admin/controller/AdminController.java +++ b/src/main/java/com/Coming/Backend/admin/controller/AdminController.java @@ -221,12 +221,14 @@ public ResponseEntity removeArtistFromConcert( } @Operation(summary = "Data 파이프라인 아티스트 검색") + @ApiResponse(responseCode = "504", description = "PIPELINE_TIMEOUT") @GetMapping("/data/search/artists") public ResponseEntity> searchArtists(@RequestParam String name) { return ResponseEntity.ok(adminService.searchArtists(name)); } @Operation(summary = "Data 파이프라인 공연 검색") + @ApiResponse(responseCode = "504", description = "PIPELINE_TIMEOUT") @GetMapping("/data/search/concerts") public ResponseEntity> searchConcerts(@RequestParam String title) { return ResponseEntity.ok(adminService.searchConcerts(title)); @@ -235,6 +237,7 @@ public ResponseEntity> searchConcerts(@RequestPara @Operation(summary = "MBID 기반 아티스트 동기 수집") @ApiResponse(responseCode = "404", description = "PIPELINE_NOT_FOUND") @ApiResponse(responseCode = "409", description = "PIPELINE_CONFLICT") + @ApiResponse(responseCode = "504", description = "PIPELINE_TIMEOUT") @PostMapping("/data/collect/artists") public ResponseEntity collectArtist(@RequestBody @Valid AdminArtistCollectRequest request) { return ResponseEntity.ok(adminService.collectArtist(request)); @@ -243,6 +246,7 @@ public ResponseEntity collectArtist(@RequestBody @V @Operation(summary = "KOPIS ID 기반 공연 동기 수집") @ApiResponse(responseCode = "404", description = "PIPELINE_NOT_FOUND") @ApiResponse(responseCode = "409", description = "PIPELINE_CONFLICT") + @ApiResponse(responseCode = "504", description = "PIPELINE_TIMEOUT") @PostMapping("/data/collect/concerts") public ResponseEntity collectConcert(@RequestBody @Valid AdminConcertCollectRequest request) { return ResponseEntity.ok(adminService.collectConcert(request)); @@ -250,6 +254,7 @@ public ResponseEntity collectConcert(@RequestBody @Operation(summary = "Data 파이프라인 아티스트 릴리즈 수집 트리거") @ApiResponse(responseCode = "404", description = "ARTIST_NOT_FOUND (Data 파이프라인 측)") + @ApiResponse(responseCode = "504", description = "PIPELINE_TIMEOUT") @PostMapping("/data/collect/artists/{id}/releases") public ResponseEntity triggerArtistReleases(@PathVariable Long id) { adminService.triggerArtistReleases(id); @@ -259,6 +264,7 @@ public ResponseEntity triggerArtistReleases(@PathVariable Long id) { @Operation(summary = "공연 셋리스트 동기 수집") @ApiResponse(responseCode = "404", description = "PIPELINE_NOT_FOUND") @ApiResponse(responseCode = "409", description = "PIPELINE_CONFLICT") + @ApiResponse(responseCode = "504", description = "PIPELINE_TIMEOUT") @PostMapping("/data/collect/concerts/{id}/setlist") public ResponseEntity triggerConcertSetlist(@PathVariable Long id) { return ResponseEntity.ok(adminService.triggerConcertSetlist(id)); diff --git a/src/main/java/com/Coming/Backend/admin/exception/PipelineTimeoutException.java b/src/main/java/com/Coming/Backend/admin/exception/PipelineTimeoutException.java new file mode 100644 index 0000000..2f2a22a --- /dev/null +++ b/src/main/java/com/Coming/Backend/admin/exception/PipelineTimeoutException.java @@ -0,0 +1,11 @@ +package com.Coming.Backend.admin.exception; + +import com.Coming.Backend.common.exception.BusinessException; +import com.Coming.Backend.common.exception.ErrorCode; + +public class PipelineTimeoutException extends BusinessException { + + public PipelineTimeoutException() { + super(ErrorCode.PIPELINE_TIMEOUT); + } +} diff --git a/src/main/java/com/Coming/Backend/admin/repository/ArtistCollectLockRepository.java b/src/main/java/com/Coming/Backend/admin/repository/ArtistCollectLockRepository.java new file mode 100644 index 0000000..d11418f --- /dev/null +++ b/src/main/java/com/Coming/Backend/admin/repository/ArtistCollectLockRepository.java @@ -0,0 +1,51 @@ +package com.Coming.Backend.admin.repository; + +import java.util.List; +import java.util.UUID; +import java.util.concurrent.TimeUnit; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.script.DefaultRedisScript; +import org.springframework.data.redis.core.script.RedisScript; +import org.springframework.stereotype.Repository; + +@Repository +public class ArtistCollectLockRepository { + + private static final String KEY_PREFIX = "ARTIST_COLLECT_LOCK:"; + // WebClient responseTimeout(120s)보다 여유 있게 잡아, 정상 흐름에서는 항상 명시적 unlock으로 + // 해제되고 TTL은 서버 재시작 등 예외 상황의 안전망 역할만 한다. + private static final long TTL_SECONDS = 150; + + // 저장된 토큰이 일치할 때만 삭제한다. TTL 만료 후 다른 요청이 같은 키를 선점했다면, + // 이전 보유자의 unlock이 그 새 락을 실수로 지우지 않도록 막는다. + private static final RedisScript UNLOCK_SCRIPT = new DefaultRedisScript<>( + "if redis.call('get', KEYS[1]) == ARGV[1] then " + + "return redis.call('del', KEYS[1]) " + + "else return 0 end", + Long.class); + + private final RedisTemplate redisTemplate; + + public ArtistCollectLockRepository(RedisTemplate redisTemplate) { + this.redisTemplate = redisTemplate; + } + + /** + * mbid에 대한 락을 선점한다. + * + * @return 선점에 성공하면 이 호출을 식별하는 토큰, 이미 다른 요청이 보유 중이면 null + */ + public String tryLock(String mbid) { + String token = UUID.randomUUID().toString(); + Boolean acquired = redisTemplate.opsForValue() + .setIfAbsent(KEY_PREFIX + mbid, token, TTL_SECONDS, TimeUnit.SECONDS); + return Boolean.TRUE.equals(acquired) ? token : null; + } + + /** + * 전달된 토큰이 현재 저장된 값과 일치할 때만 락을 해제한다. + */ + public void unlock(String mbid, String token) { + redisTemplate.execute(UNLOCK_SCRIPT, List.of(KEY_PREFIX + mbid), token); + } +} diff --git a/src/main/java/com/Coming/Backend/admin/service/AdminService.java b/src/main/java/com/Coming/Backend/admin/service/AdminService.java index 7164b20..00b5fcf 100644 --- a/src/main/java/com/Coming/Backend/admin/service/AdminService.java +++ b/src/main/java/com/Coming/Backend/admin/service/AdminService.java @@ -25,6 +25,8 @@ import com.Coming.Backend.admin.dto.AdminInquiryListItemResponse; import com.Coming.Backend.admin.dto.AdminInquiryStatusUpdateRequest; import com.Coming.Backend.admin.dto.AdminPendingConcertResponse; +import com.Coming.Backend.admin.exception.PipelineConflictException; +import com.Coming.Backend.admin.repository.ArtistCollectLockRepository; import com.Coming.Backend.artist.entity.Artist; import com.Coming.Backend.artist.entity.ArtistAlias; import com.Coming.Backend.artist.entity.ArtistUrl; @@ -61,6 +63,7 @@ import com.Coming.Backend.inquiry.exception.InvalidInquiryStatusException; import com.Coming.Backend.inquiry.repository.InquiryRepository; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; @@ -73,6 +76,7 @@ import java.util.Set; import java.util.stream.Collectors; +@Slf4j @Service @RequiredArgsConstructor @Transactional(readOnly = true) @@ -89,6 +93,7 @@ public class AdminService { private final ConcertBookingLinkRepository concertBookingLinkRepository; private final ConcertImageRepository concertImageRepository; private final DataPipelineClient dataPipelineClient; + private final ArtistCollectLockRepository artistCollectLockRepository; /** * 어드민 아티스트 단건을 조회한다. 존재하지 않는 아티스트 ID이면 ArtistNotFoundException을 던진다. @@ -645,9 +650,25 @@ public List searchConcerts(String title) { /** * MBID 기반으로 아티스트를 동기 수집한다. 수집 결과를 반환한다. + * 동일 MBID에 대한 요청이 이미 처리 중이면 Data 파이프라인까지 가지 않고 즉시 거부한다. + * + * @throws PipelineConflictException 동일 MBID에 대한 수집 요청이 이미 처리 중인 경우 */ public PipelineArtistCollectResult collectArtist(AdminArtistCollectRequest request) { - return dataPipelineClient.collectArtist(request.mbid()); + String mbid = request.mbid(); + String lockToken = artistCollectLockRepository.tryLock(mbid); + if (lockToken == null) { + throw new PipelineConflictException(); + } + try { + return dataPipelineClient.collectArtist(mbid); + } finally { + try { + artistCollectLockRepository.unlock(mbid, lockToken); + } catch (Exception e) { + log.warn("Failed to release artist collect lock: mbid={}", mbid, e); + } + } } /** diff --git a/src/main/java/com/Coming/Backend/common/config/WebClientConfig.java b/src/main/java/com/Coming/Backend/common/config/WebClientConfig.java index 694ec5a..9906da9 100644 --- a/src/main/java/com/Coming/Backend/common/config/WebClientConfig.java +++ b/src/main/java/com/Coming/Backend/common/config/WebClientConfig.java @@ -1,14 +1,27 @@ package com.Coming.Backend.common.config; +import io.netty.channel.ChannelOption; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.http.client.reactive.ReactorClientHttpConnector; import org.springframework.web.reactive.function.client.WebClient; +import reactor.netty.http.client.HttpClient; + +import java.time.Duration; @Configuration public class WebClientConfig { + private static final int CONNECT_TIMEOUT_MILLIS = 5000; + private static final Duration RESPONSE_TIMEOUT = Duration.ofSeconds(120); + @Bean public WebClient.Builder webClientBuilder() { - return WebClient.builder(); + HttpClient httpClient = HttpClient.create() + .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, CONNECT_TIMEOUT_MILLIS) + .responseTimeout(RESPONSE_TIMEOUT); + + return WebClient.builder() + .clientConnector(new ReactorClientHttpConnector(httpClient)); } } diff --git a/src/main/java/com/Coming/Backend/common/exception/ErrorCode.java b/src/main/java/com/Coming/Backend/common/exception/ErrorCode.java index 2c44c3f..dfd5f65 100644 --- a/src/main/java/com/Coming/Backend/common/exception/ErrorCode.java +++ b/src/main/java/com/Coming/Backend/common/exception/ErrorCode.java @@ -51,8 +51,9 @@ public enum ErrorCode { // Pipeline PIPELINE_NOT_FOUND(HttpStatus.NOT_FOUND, "Data 파이프라인에서 해당 리소스를 찾을 수 없습니다."), - PIPELINE_CONFLICT(HttpStatus.CONFLICT, "이미 처리 중인 수집 요청입니다."), + PIPELINE_CONFLICT(HttpStatus.CONFLICT, "이미 처리 중인 수집 요청입니다. 잠시 후 다시 확인해주세요."), PIPELINE_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "Data 파이프라인 서버 오류가 발생했습니다."), + PIPELINE_TIMEOUT(HttpStatus.GATEWAY_TIMEOUT, "Data 파이프라인 응답이 지연되고 있습니다. 잠시 후 다시 확인해주세요."), // Common RATE_LIMIT_EXCEEDED(HttpStatus.TOO_MANY_REQUESTS, "요청이 너무 많습니다. 잠시 후 다시 시도해주세요."), diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index b6ccd22..66b29c4 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -3,7 +3,7 @@ server: logging: pattern: - console: "%d{yyyy-MM-dd HH:mm:ss.SSS} [%X{traceId:- }] [%X{userId:-anonymous}] %-5level %logger{36} - %msg%n" + console: "%d{yyyy-MM-dd HH:mm:ss.SSS, Asia/Seoul} [%X{traceId:- }] [%X{userId:-anonymous}] %-5level %logger{36} - %msg%n" spring: application: diff --git a/src/main/resources/db/migration/V29__add_concert_artist_artist_id_index.sql b/src/main/resources/db/migration/V29__add_concert_artist_artist_id_index.sql new file mode 100644 index 0000000..7a2f76d --- /dev/null +++ b/src/main/resources/db/migration/V29__add_concert_artist_artist_id_index.sql @@ -0,0 +1 @@ +CREATE INDEX idx_concert_artist_artist_id ON concert_artist (artist_id); diff --git a/src/test/java/com/Coming/Backend/admin/client/DataPipelineClientTest.java b/src/test/java/com/Coming/Backend/admin/client/DataPipelineClientTest.java new file mode 100644 index 0000000..db88fdb --- /dev/null +++ b/src/test/java/com/Coming/Backend/admin/client/DataPipelineClientTest.java @@ -0,0 +1,97 @@ +package com.Coming.Backend.admin.client; + +import com.Coming.Backend.admin.exception.PipelineConflictException; +import com.Coming.Backend.admin.exception.PipelineNotFoundException; +import com.Coming.Backend.admin.exception.PipelineServerException; +import com.Coming.Backend.admin.exception.PipelineTimeoutException; +import com.sun.net.httpserver.HttpServer; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.client.reactive.ReactorClientHttpConnector; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.netty.http.client.HttpClient; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class DataPipelineClientTest { + + private HttpServer server; + private DataPipelineClient dataPipelineClient; + + @BeforeEach + void setUp() throws IOException { + // given: 지정된 응답 타임아웃(500ms)보다 오래(2000ms) 지연 응답하는 로컬 서버 + // CI처럼 부하가 있는 환경에서도 즉시 응답하는 다른 테스트들이 오탐 타임아웃에 걸리지 않도록 여유를 크게 둔다 + server = HttpServer.create(new InetSocketAddress(0), 0); + server.createContext("/search/artists", exchange -> { + try { + TimeUnit.MILLISECONDS.sleep(2000); + } catch (InterruptedException ignored) { + Thread.currentThread().interrupt(); + } + byte[] body = "[]".getBytes(); + exchange.getResponseHeaders().add("Content-Type", "application/json"); + exchange.sendResponseHeaders(200, body.length); + exchange.getResponseBody().write(body); + exchange.close(); + }); + // given: mbid에 따라 404/409/500을 돌려주는 /collect/artist 엔드포인트 + server.createContext("/collect/artist", exchange -> { + byte[] rawBody = exchange.getRequestBody().readAllBytes(); + String requestBody = new String(rawBody, StandardCharsets.UTF_8); + int status = requestBody.contains("not-found-mbid") ? 404 + : requestBody.contains("conflict-mbid") ? 409 + : 500; + exchange.sendResponseHeaders(status, -1); + exchange.close(); + }); + server.start(); + + HttpClient httpClient = HttpClient.create().responseTimeout(Duration.ofMillis(500)); + WebClient.Builder builder = WebClient.builder() + .clientConnector(new ReactorClientHttpConnector(httpClient)); + String baseUrl = "http://localhost:" + server.getAddress().getPort(); + dataPipelineClient = new DataPipelineClient(builder, baseUrl, "secret"); + } + + @AfterEach + void tearDown() { + server.stop(0); + } + + @Test + void should_throw_pipeline_timeout_exception_when_response_exceeds_configured_timeout() { + // when + // then + assertThatThrownBy(() -> dataPipelineClient.searchArtists("test")) + .isInstanceOf(PipelineTimeoutException.class); + } + + @Test + void should_throw_pipeline_not_found_exception_when_data_pipeline_returns_404() { + // when & then + assertThatThrownBy(() -> dataPipelineClient.collectArtist("not-found-mbid")) + .isInstanceOf(PipelineNotFoundException.class); + } + + @Test + void should_throw_pipeline_conflict_exception_when_data_pipeline_returns_409() { + // when & then + assertThatThrownBy(() -> dataPipelineClient.collectArtist("conflict-mbid")) + .isInstanceOf(PipelineConflictException.class); + } + + @Test + void should_throw_pipeline_server_exception_when_data_pipeline_returns_500() { + // when & then + assertThatThrownBy(() -> dataPipelineClient.collectArtist("error-mbid")) + .isInstanceOf(PipelineServerException.class); + } +} diff --git a/src/test/java/com/Coming/Backend/admin/repository/ArtistCollectLockRepositoryTest.java b/src/test/java/com/Coming/Backend/admin/repository/ArtistCollectLockRepositoryTest.java new file mode 100644 index 0000000..c83c7bf --- /dev/null +++ b/src/test/java/com/Coming/Backend/admin/repository/ArtistCollectLockRepositoryTest.java @@ -0,0 +1,83 @@ +package com.Coming.Backend.admin.repository; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; + +import java.util.List; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ValueOperations; +import org.springframework.data.redis.core.script.RedisScript; + +@ExtendWith(MockitoExtension.class) +class ArtistCollectLockRepositoryTest { + + @InjectMocks + private ArtistCollectLockRepository artistCollectLockRepository; + + @Mock + private RedisTemplate redisTemplate; + + @Mock + private ValueOperations valueOperations; + + @Test + void should_return_generated_token_when_try_lock_with_unheld_mbid() { + // given + String mbid = "some-mbid-123"; + given(redisTemplate.opsForValue()).willReturn(valueOperations); + given(valueOperations.setIfAbsent( + eq("ARTIST_COLLECT_LOCK:" + mbid), any(), eq(150L), eq(TimeUnit.SECONDS))) + .willReturn(true); + + // when + String token = artistCollectLockRepository.tryLock(mbid); + + // then + assertThat(token).isNotNull(); + ArgumentCaptor tokenCaptor = ArgumentCaptor.forClass(String.class); + verify(valueOperations).setIfAbsent( + eq("ARTIST_COLLECT_LOCK:" + mbid), tokenCaptor.capture(), + eq(150L), eq(TimeUnit.SECONDS)); + assertThat(tokenCaptor.getValue()).isEqualTo(token); + } + + @Test + void should_return_null_when_try_lock_with_already_held_mbid() { + // given + String mbid = "some-mbid-123"; + given(redisTemplate.opsForValue()).willReturn(valueOperations); + given(valueOperations.setIfAbsent( + eq("ARTIST_COLLECT_LOCK:" + mbid), any(), eq(150L), eq(TimeUnit.SECONDS))) + .willReturn(false); + + // when + String token = artistCollectLockRepository.tryLock(mbid); + + // then + assertThat(token).isNull(); + } + + @Test + void should_execute_unlock_script_with_key_and_token_when_unlock_called() { + // given + String mbid = "some-mbid-123"; + String token = "token-abc"; + + // when + artistCollectLockRepository.unlock(mbid, token); + + // then + verify(redisTemplate).execute( + any(RedisScript.class), eq(List.of("ARTIST_COLLECT_LOCK:" + mbid)), eq(token)); + } +} diff --git a/src/test/java/com/Coming/Backend/admin/service/AdminServiceTest.java b/src/test/java/com/Coming/Backend/admin/service/AdminServiceTest.java index ac34a8d..d318cec 100644 --- a/src/test/java/com/Coming/Backend/admin/service/AdminServiceTest.java +++ b/src/test/java/com/Coming/Backend/admin/service/AdminServiceTest.java @@ -22,6 +22,9 @@ import com.Coming.Backend.admin.dto.PipelineArtistCollectResult; import com.Coming.Backend.admin.dto.PipelineConcertCollectResult; import com.Coming.Backend.admin.dto.AdminArtistDetailResponse; +import com.Coming.Backend.admin.exception.PipelineConflictException; +import com.Coming.Backend.admin.exception.PipelineTimeoutException; +import com.Coming.Backend.admin.repository.ArtistCollectLockRepository; import com.Coming.Backend.artist.entity.Artist; import com.Coming.Backend.artist.entity.ArtistAlias; import com.Coming.Backend.artist.entity.ArtistUrl; @@ -80,6 +83,7 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.willThrow; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; @@ -122,6 +126,9 @@ class AdminServiceTest { @Mock private DataPipelineClient dataPipelineClient; + @Mock + private ArtistCollectLockRepository artistCollectLockRepository; + private static final Long USER_ID = 10L; private static final Long INQUIRY_ID = 1L; private static final Long TARGET_ID = 100L; @@ -1660,7 +1667,9 @@ void should_return_empty_list_when_no_concerts_match() { void should_trigger_artist_collect_when_mbid_given() { // given AdminArtistCollectRequest request = new AdminArtistCollectRequest("some-mbid-123"); - PipelineArtistCollectResult result = new PipelineArtistCollectResult(true, 1L, "some-mbid-123", "IU", null, List.of(), null); + PipelineArtistCollectResult result = new PipelineArtistCollectResult( + true, 1L, "some-mbid-123", "IU", null, List.of(), null); + given(artistCollectLockRepository.tryLock("some-mbid-123")).willReturn("lock-token"); given(dataPipelineClient.collectArtist("some-mbid-123")).willReturn(result); // when @@ -1668,9 +1677,70 @@ void should_trigger_artist_collect_when_mbid_given() { // then verify(dataPipelineClient).collectArtist("some-mbid-123"); + verify(artistCollectLockRepository).unlock("some-mbid-123", "lock-token"); + assertThat(actual.success()).isTrue(); + } + + @Test + void should_throw_pipeline_conflict_exception_when_lock_already_held_by_another_request() { + // given + AdminArtistCollectRequest request = new AdminArtistCollectRequest("some-mbid-123"); + given(artistCollectLockRepository.tryLock("some-mbid-123")).willReturn(null); + + // when & then + assertThatThrownBy(() -> adminService.collectArtist(request)) + .isInstanceOf(PipelineConflictException.class); + verify(dataPipelineClient, never()).collectArtist(any()); + verify(artistCollectLockRepository, never()).unlock(any(), any()); + } + + @Test + void should_release_lock_when_data_pipeline_client_throws() { + // given + AdminArtistCollectRequest request = new AdminArtistCollectRequest("some-mbid-123"); + given(artistCollectLockRepository.tryLock("some-mbid-123")).willReturn("lock-token"); + given(dataPipelineClient.collectArtist("some-mbid-123")) + .willThrow(new PipelineConflictException()); + + // when & then + assertThatThrownBy(() -> adminService.collectArtist(request)) + .isInstanceOf(PipelineConflictException.class); + verify(artistCollectLockRepository).unlock("some-mbid-123", "lock-token"); + } + + @Test + void should_return_pipeline_result_when_unlock_fails_after_success() { + // given + AdminArtistCollectRequest request = new AdminArtistCollectRequest("some-mbid-123"); + PipelineArtistCollectResult result = new PipelineArtistCollectResult( + true, 1L, "some-mbid-123", "IU", null, List.of(), null); + given(artistCollectLockRepository.tryLock("some-mbid-123")).willReturn("lock-token"); + given(dataPipelineClient.collectArtist("some-mbid-123")).willReturn(result); + willThrow(new RuntimeException("redis down")) + .given(artistCollectLockRepository).unlock("some-mbid-123", "lock-token"); + + // when + PipelineArtistCollectResult actual = adminService.collectArtist(request); + + // then - unlock 실패가 성공 결과를 가리지 않는다 assertThat(actual.success()).isTrue(); } + @Test + void should_keep_original_exception_when_unlock_also_fails() { + // given + AdminArtistCollectRequest request = new AdminArtistCollectRequest("some-mbid-123"); + given(artistCollectLockRepository.tryLock("some-mbid-123")).willReturn("lock-token"); + given(dataPipelineClient.collectArtist("some-mbid-123")) + .willThrow(new PipelineTimeoutException()); + willThrow(new RuntimeException("redis down")) + .given(artistCollectLockRepository).unlock("some-mbid-123", "lock-token"); + + // when & then - unlock 실패가 원래 예외(PipelineTimeoutException)를 가리지 않는다 + assertThatThrownBy(() -> adminService.collectArtist(request)) + .isInstanceOf(PipelineTimeoutException.class); + } + // ------------------------------------------------------------------------- // collectConcert // -------------------------------------------------------------------------