fix(comment-cards): YouTube API 오류를 reason별로 구분 처리

403을 무조건 '댓글 비활성화'로 표시하던 문제 수정. error.errors[0].reason을 파싱해
commentsDisabled/quotaExceeded/기타(keyInvalid 등)를 각각 안내하고, 비-403 오류가
opaque 500으로 새던 것도 의미있는 메시지로 노출. 단위 테스트 추가.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
hehihoho3@gmail.com 2026-06-29 17:54:50 +09:00
parent 0ee343ceca
commit 6f59554b6d
2 changed files with 103 additions and 6 deletions

View File

@ -1,12 +1,13 @@
package com.hlab.yanalyst.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.hlab.yanalyst.global.schedule.YoutubeQuotaGuard;
import com.hlab.yanalyst.web.dto.CommentCardDto;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.web.client.HttpClientErrorException;
import org.springframework.web.client.RestClientResponseException;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.util.UriComponentsBuilder;
@ -30,6 +31,8 @@ public class YoutubeCommentService {
/** 응답 크기/쿼터 보호용 최대 페이지 수 (페이지당 최대 100개). */
private static final int MAX_PAGES = 5;
private static final ObjectMapper MAPPER = new ObjectMapper();
public List<CommentCardDto> fetchComments(String urlOrId) {
String videoId = YoutubeVideoIdParser.parse(urlOrId);
if (videoId == null) {
@ -56,11 +59,10 @@ public class YoutubeCommentService {
JsonNode root;
try {
root = restTemplate.getForObject(b.build().encode().toUri(), JsonNode.class);
} catch (HttpClientErrorException.Forbidden e) {
if (result.isEmpty()) {
throw new IllegalArgumentException("이 영상은 댓글이 비활성화되어 있거나 접근할 수 없습니다.");
}
break;
} catch (RestClientResponseException e) {
// 일부라도 모았으면 그걸로 반환(다음 페이지 실패는 무시)
if (!result.isEmpty()) break;
throw new IllegalArgumentException(messageFor(extractReason(e.getResponseBodyAsString())));
}
if (root == null || !root.has("items")) break;
@ -84,4 +86,30 @@ public class YoutubeCommentService {
return result;
}
/** YouTube 오류 응답 본문에서 error.errors[0].reason 추출. 실패 시 null. */
private static String extractReason(String body) {
if (body == null || body.isBlank()) return null;
try {
JsonNode errs = MAPPER.readTree(body).path("error").path("errors");
if (errs.isArray() && errs.size() > 0) {
String reason = errs.get(0).path("reason").asText(null);
if (reason != null && !reason.isBlank()) return reason;
}
} catch (Exception ignore) {
// 본문이 JSON이 아니면 reason 없음
}
return null;
}
/** YouTube 오류 reason → 사용자용 안내 메시지. */
private static String messageFor(String reason) {
if ("commentsDisabled".equals(reason)) {
return "이 영상은 댓글이 비활성화되어 있습니다.";
}
if ("quotaExceeded".equals(reason) || "dailyLimitExceeded".equals(reason) || "rateLimitExceeded".equals(reason)) {
return "YouTube API 일일 쿼터를 초과했습니다. 잠시 후 다시 시도해주세요.";
}
return "YouTube API 호출에 실패했습니다" + (reason != null ? " (" + reason + ")" : "") + ".";
}
}

View File

@ -0,0 +1,69 @@
package com.hlab.yanalyst.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.hlab.yanalyst.global.schedule.YoutubeQuotaGuard;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.client.HttpClientErrorException;
import org.springframework.web.client.RestTemplate;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.*;
class YoutubeCommentServiceTest {
private YoutubeCommentService newService(RestTemplate rt) {
YoutubeQuotaGuard guard = mock(YoutubeQuotaGuard.class);
when(guard.tryConsume(anyLong())).thenReturn(true);
YoutubeCommentService svc = new YoutubeCommentService(rt, guard);
ReflectionTestUtils.setField(svc, "youtubeApiKey", "TEST_KEY");
return svc;
}
private HttpClientErrorException apiError(HttpStatus status, String reason) {
String body = "{\"error\":{\"code\":" + status.value()
+ ",\"message\":\"x\",\"errors\":[{\"reason\":\"" + reason + "\"}]}}";
return HttpClientErrorException.create(status, status.getReasonPhrase(),
HttpHeaders.EMPTY, body.getBytes(StandardCharsets.UTF_8), StandardCharsets.UTF_8);
}
@Test
void commentsDisabled_throwsDisabledMessage() {
RestTemplate rt = mock(RestTemplate.class);
when(rt.getForObject(any(URI.class), eq(JsonNode.class)))
.thenThrow(apiError(HttpStatus.FORBIDDEN, "commentsDisabled"));
IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
() -> newService(rt).fetchComments("dQw4w9WgXcQ"));
assertTrue(ex.getMessage().contains("비활성화"), ex.getMessage());
}
@Test
void quotaExceeded_throwsQuotaMessage_notDisabled() {
RestTemplate rt = mock(RestTemplate.class);
when(rt.getForObject(any(URI.class), eq(JsonNode.class)))
.thenThrow(apiError(HttpStatus.FORBIDDEN, "quotaExceeded"));
IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
() -> newService(rt).fetchComments("dQw4w9WgXcQ"));
assertTrue(ex.getMessage().contains("쿼터"), ex.getMessage());
assertFalse(ex.getMessage().contains("비활성화"), ex.getMessage());
}
@Test
void invalidKey_throwsMeaningfulMessage_notDisabled() {
RestTemplate rt = mock(RestTemplate.class);
when(rt.getForObject(any(URI.class), eq(JsonNode.class)))
.thenThrow(apiError(HttpStatus.BAD_REQUEST, "keyInvalid"));
IllegalArgumentException ex = assertThrows(IllegalArgumentException.class,
() -> newService(rt).fetchComments("dQw4w9WgXcQ"));
assertFalse(ex.getMessage().contains("비활성화"), ex.getMessage());
assertTrue(ex.getMessage().contains("keyInvalid"), ex.getMessage());
}
}