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
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package app.bottlenote.curation.config

import app.bottlenote.curation.service.CurationFeedPayloadRefreshService
import app.bottlenote.curation.service.CurationSpecResourceSyncService
import org.slf4j.LoggerFactory
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty
Expand All @@ -15,7 +16,8 @@ import org.springframework.stereotype.Component
matchIfMissing = true
)
class CurationSpecResourceSyncRunner(
private val curationSpecResourceSyncService: CurationSpecResourceSyncService
private val curationSpecResourceSyncService: CurationSpecResourceSyncService,
private val curationFeedPayloadRefreshService: CurationFeedPayloadRefreshService
) {
private val log = LoggerFactory.getLogger(javaClass)

Expand All @@ -28,5 +30,6 @@ class CurationSpecResourceSyncRunner(
result.updatedCount(),
result.totalCount()
)
curationFeedPayloadRefreshService.refresh(result.changedSpecIds())
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.http.MediaType
import java.time.LocalDate
import java.util.LinkedHashMap

@Tag("admin_integration")
@DisplayName("[integration] Admin Spec Based Curation API 통합 테스트")
Expand All @@ -26,6 +27,13 @@ class AdminSpecBasedCurationIntegrationTest : IntegrationTestSupport() {
@Autowired
private lateinit var curationExtensionRepository: CurationExtensionRepository

@Autowired
private lateinit var curationRepository: app.bottlenote.curation.domain.CurationRepository

@Autowired
private lateinit var curationFeedPayloadRegenerationService:
app.bottlenote.curation.service.CurationFeedPayloadRegenerationService

private lateinit var accessToken: String

@BeforeEach
Expand Down Expand Up @@ -332,6 +340,123 @@ class AdminSpecBasedCurationIntegrationTest : IntegrationTestSupport() {
.isEqualTo(400)
}

@Nested
@DisplayName("스펙 변경에 따른 feed_payload 재생성")
inner class FeedPayloadRegeneration {

@Test
@DisplayName("responseSpec이 그대로면 재동기화해도 변경 스펙이 없어 재생성하지 않는다")
fun sync_whenResponseSpecUnchanged_reportsNoChangedSpec() {
val result = curationSpecResourceSyncService.sync()

assertThat(result.updatedCount()).isPositive()
assertThat(result.changedSpecIds()).isEmpty()
assertThat(result.hasChangedSpecs()).isFalse()
}

@Test
@DisplayName("responseSpec을 바꾸면 그 스펙의 큐레이션만 feed_payload가 재생성되고 NULL 행도 채워진다")
fun regenerate_whenResponseSpecChanged_refreshesOnlyThatSpec() {
val curationId = createCurationViaApi()
val otherSpecId = curationSpecRepository.findByCode("WHISKY_TASTING_EVENT").orElseThrow().id
// backfill 이전 레거시 행을 재현한다.
curationExtensionRepository.findByCurationId(curationId).orElseThrow()
.also { it.updateFeedPayload(null) }
.let { curationExtensionRepository.save(it) }
val otherBefore = tamperOtherSpecCuration(otherSpecId)

tamperResponseSpec(recommendedSpecId())
val syncResult = curationSpecResourceSyncService.sync()
curationFeedPayloadRegenerationService.regenerate(syncResult.changedSpecIds())

assertThat(syncResult.changedSpecIds()).containsExactly(recommendedSpecId())
val regenerated = curationExtensionRepository.findByCurationId(curationId).orElseThrow()
assertThat(regenerated.feedPayload).isNotNull()
assertThat(mapper.valueToTree<com.fasterxml.jackson.databind.JsonNode>(regenerated.feedPayload)[0].path("alcohol").path("korName").asText())
.isEqualTo("검증 위스키")
// 원본 payload는 SSOT이므로 그대로여야 한다.
assertThat(mapper.valueToTree<com.fasterxml.jackson.databind.JsonNode>(regenerated.payload)[0].has("source")).isTrue()
assertThat(curationExtensionRepository.findByCurationId(otherBefore).orElseThrow().feedPayload)
.isNull()
}

@Test
@DisplayName("Admin 피드 프리뷰는 feed_payload가 NULL이어도 원본으로 fallback해 같은 결과를 낸다")
fun searchFeed_whenFeedPayloadIsNull_fallsBackToSource() {
val migratedId = createCurationViaApi()
val legacyId = createCurationViaApi()
curationExtensionRepository.findByCurationId(legacyId).orElseThrow()
.also { it.updateFeedPayload(null) }
.let { curationExtensionRepository.save(it) }

val result = mockMvcTester
.get()
.uri("/v2/curations/feed?code=RECOMMENDED_WHISKY&size=10")
.header("Authorization", "Bearer $accessToken")
.exchange()

assertThat(result).hasStatusOk()
// fromPage는 data 자체가 배열이다.
val items = dataNode(result)
val migrated = items.first { it.path("id").asLong() == migratedId }
val legacy = items.first { it.path("id").asLong() == legacyId }
assertThat(legacy.path("feedFields")).isEqualTo(migrated.path("feedFields"))
assertThat(migrated.path("feedFields")).isNotEmpty()
}

private fun createCurationViaApi(): Long {
val result = mockMvcTester
.post()
.uri("/v2/curations")
.header("Authorization", "Bearer $accessToken")
.contentType(MediaType.APPLICATION_JSON)
.content(mapper.writeValueAsString(createRequest(validPayload())))
.exchange()
return dataNode(result).path("targetId").asLong()
}

// 다른 스펙의 큐레이션을 NULL 상태로 만들어 재생성 범위 밖임을 확인할 수 있게 한다.
private fun tamperOtherSpecCuration(otherSpecId: Long): Long {
val curation = curationRepository.save(
app.bottlenote.curation.domain.Curation.builder()
.specId(otherSpecId)
.name("다른 스펙 큐레이션")
.description("재생성 범위 검증")
.coverImageUrl("https://cdn.example.com/cover.jpg")
.exposureStartDate(LocalDate.now().minusDays(1))
.exposureEndDate(LocalDate.now().plusDays(30))
.displayOrder(1)
.isActive(true)
.build()
)
curationExtensionRepository.save(
app.bottlenote.curation.domain.CurationExtension.builder()
.curationId(curation.id)
.specId(otherSpecId)
.payload(mapOf("eventDate" to "2026-06-21"))
.build()
)
return curation.id
}

// x-feed를 하나 꺼서 responseSpec 지문이 실제로 달라지게 만든다.
private fun tamperResponseSpec(specId: Long) {
val spec = curationSpecRepository.findById(specId).orElseThrow()
val tampered = LinkedHashMap(spec.responseSpec)
tampered["x-regeneration-test"] = System.nanoTime()
spec.update(
spec.name,
spec.description,
spec.requestSpec,
tampered,
spec.hydratorKey,
spec.version,
true
)
curationSpecRepository.save(spec)
}
}

private fun assertNotFound(result: org.springframework.test.web.servlet.assertj.MvcTestResult) {
assertThat(result).hasStatus(404)
.bodyJson()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,11 @@
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.hibernate.annotations.Comment;
import org.hibernate.annotations.DynamicUpdate;
import org.hibernate.annotations.Type;

// 재생성은 feed_payload만 바꾼다. 전체 컬럼 UPDATE면 로드 시점의 stale payload가 어드민 저장분을 덮는다.
@DynamicUpdate
@Comment("spec 기반 큐레이션 payload")
@Entity(name = "curation_extension")
@Table(name = "curation_extension")
Expand Down Expand Up @@ -47,4 +50,15 @@ public void update(Long specId, Object payload, Object feedPayload) {
this.payload = payload;
this.feedPayload = feedPayload;
}

// 스펙 변경에 따른 재생성용. 원본 payload는 SSOT이므로 건드리지 않는다.
public void updateFeedPayload(Object feedPayload) {
this.feedPayload = feedPayload;
}

// 피드 조회 소스. NULL은 backfill 이전 레거시 행이므로 원본으로 되돌아간다.
// 빈 결과는 []/{}로 저장되므로 NULL만 fallback 조건이다.
public Object feedSource() {
return feedPayload != null ? feedPayload : payload;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ public interface CurationExtensionRepository {

List<CurationExtension> findAllByCurationIdIn(Collection<Long> curationIds);

List<CurationExtension> findAllBySpecIdIn(Collection<Long> specIds);

CurationExtension save(CurationExtension curationExtension);

void deleteByCurationId(Long curationId);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package app.bottlenote.curation.domain;

// 인스턴스가 여럿이면 기동 시 재생성이 중복 실행된다. JVM 로컬 상태로는 막을 수 없어 공유 저장소로 잠근다.
public interface CurationFeedRegenerationLock {

boolean tryAcquire();

void release();
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,21 @@
package app.bottlenote.curation.dto.response;

public record CurationSpecSyncResponse(int createdCount, int updatedCount) {
import java.util.List;

// changedSpecIds: responseSpec이 실제로 바뀐 기존 스펙. 신규 생성 스펙은 큐레이션이 없어 제외한다.
public record CurationSpecSyncResponse(
int createdCount, int updatedCount, List<Long> changedSpecIds) {

// null을 빈 목록으로 뭉개면 재생성 누락이 "변경 없음"으로 위장된다. NPE로 즉시 드러나게 둔다.
public CurationSpecSyncResponse {
changedSpecIds = List.copyOf(changedSpecIds);
}

public int totalCount() {
return createdCount + updatedCount;
}

public boolean hasChangedSpecs() {
return !changedSpecIds.isEmpty();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,5 +16,7 @@ public interface JpaCurationExtensionRepository

List<CurationExtension> findAllByCurationIdIn(Collection<Long> curationIds);

List<CurationExtension> findAllBySpecIdIn(Collection<Long> specIds);

void deleteByCurationId(Long curationId);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package app.bottlenote.curation.repository;

import app.bottlenote.curation.domain.CurationFeedRegenerationLock;
import java.time.Duration;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicReference;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;

@Component
@RequiredArgsConstructor
@Slf4j
public class RedisCurationFeedRegenerationLock implements CurationFeedRegenerationLock {

private static final String LOCK_KEY = "curation:feed-payload:regeneration:lock";
// 재생성이 끝나면 해제하므로 TTL은 프로세스가 죽었을 때의 안전장치다.
private static final Duration LOCK_TTL = Duration.ofMinutes(10);

private final RedisTemplate<String, Object> redisTemplate;

// 획득할 때마다 다른 토큰을 남긴다. TTL이 만료돼 다른 인스턴스가 락을 잡았을 때 남의 락을 지우지 않기 위해서다.
private final AtomicReference<String> ownedToken = new AtomicReference<>();

@Override
public boolean tryAcquire() {
String candidate = UUID.randomUUID().toString();
boolean acquired =
Boolean.TRUE.equals(redisTemplate.opsForValue().setIfAbsent(LOCK_KEY, candidate, LOCK_TTL));
if (acquired) {
ownedToken.set(candidate);
}
return acquired;
}

@Override
public void release() {
String owned = ownedToken.getAndSet(null);
if (owned == null) {
return;
}
if (owned.equals(redisTemplate.opsForValue().get(LOCK_KEY))) {
redisTemplate.delete(LOCK_KEY);
return;
}
log.warn("재생성 락 소유권이 이미 넘어가 해제를 건너뜁니다. 남은 락은 TTL({})로 만료됩니다.", LOCK_TTL);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -243,7 +243,7 @@ private CurationFeedItemResponse toFeedResponse(Curation curation, CurationSpec
CurationExtension extension = getExtension(curation.getId());
Object materialized =
responseMaterializer.materializeFeed(
curation.getId(), spec.getCode(), spec.getResponseSpec(), extension.getPayload());
curation.getId(), spec.getCode(), spec.getResponseSpec(), extension.feedSource());
return new CurationFeedItemResponse(
curation.getId(),
curation.getSpecId(),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package app.bottlenote.curation.service;

import app.bottlenote.curation.domain.CurationFeedRegenerationLock;
import java.util.Collection;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;

// 스펙 변경 후 Read Model을 되살리는 절차를 소유한다. 잠금·순서·실패 정책이 여기 모인다.
@Service
@RequiredArgsConstructor
@Slf4j
public class CurationFeedPayloadRefreshService {

private final CurationFeedPayloadRegenerationService regenerationService;
private final CurationFeedRegenerationLock lock;

// feed_payload는 파생 데이터고 NULL fallback이 있다. 어떤 실패도 호출자(기동 경로)로 새지 않는다.
// 무효화와 재생성이 각자 커밋되어야 하므로 이 메서드 자체는 트랜잭션을 열지 않는다.
@Transactional(propagation = Propagation.NOT_SUPPORTED)
public void refresh(Collection<Long> specIds) {
if (specIds == null || specIds.isEmpty()) {
return;
}
boolean acquired = false;
try {
acquired = lock.tryAcquire();
if (!acquired) {
log.info("다른 인스턴스가 재생성 중이라 건너뜁니다: specIds={}", specIds);
return;
}
// 무효화를 먼저 커밋한다. 뒤가 실패해도 낡은 값이 아니라 NULL이 남아 원본으로 fallback된다.
regenerationService.invalidate(specIds);
log.info(
"feed_payload 재생성 완료: specIds={}, curations={}",
specIds,
regenerationService.regenerate(specIds));
} catch (Exception e) {
log.warn("feed_payload 재생성 실패. 조회는 원본 payload로 대체됩니다: specIds={}", specIds, e);
} finally {
releaseQuietly(acquired);
}
}

private void releaseQuietly(boolean acquired) {
if (!acquired) {
return;
}
try {
lock.release();
} catch (Exception e) {
log.warn("재생성 락 해제에 실패했습니다. TTL로 만료됩니다.", e);
}
}
}
Loading
Loading