프로젝트/TALKAK

[팀 프로젝트] TALKAK 레퍼런스 기능 구현 2편(Youtube Data API 사용하기)

yjhan1999 2025. 5. 20. 17:07

저번 글에 이어서 레퍼런스 기능 구현기를 적어보겠다.

사실 원래 설계는 레퍼런스 기능에 인스타그램, 틱톡, 유튜브 총 세 가지 인기 플랫폼의 영상을 가져오는 것이었다. 하지만 인스타그램과 틱톡의 영상을 가져오고 보기 위해서 사용자가 이중 로그인을 해야 할 필요가 있었고, 이에 두 플랫폼은 계획에서 제외가 되었다.

따라서 이번에는 유튜브 숏츠 영상을 가져올 수 있는지, 어떻게 가져올지 알아보도록 하겠다!

 

1. 로그인 이슈 확인

틱톡, 인스타그램 둘 다 사용자 로그인이 필수였기 때문에... 이것부터 확인했다.

https://developers.google.com/youtube/v3/docs/videos/list?apix=true&apix_params=%7B%22part%22%3A%22snippet%22%2C%22chart%22%3A%22mostPopular%22%2C%22regionCode%22%3A%22es%22%2C%22videoCategoryId%22%3A%2217%22%7D&hl=ko

 

Videos: list  |  YouTube Data API  |  Google for Developers

YouTube에서 Shorts 동영상의 조회수를 집계하는 방식에 맞게 Data API를 업데이트하고 있습니다. 자세히 알아보기 이 페이지는 Cloud Translation API를 통해 번역되었습니다. Videos: list 컬렉션을 사용해 정

developers.google.com

우리 프로젝트에서는 영상을 가져올 것이므로 Videos 부분의 자료를 보면 된다.

보시다시피 필수 매개변수에 인증 관련 필드가 보이지 않는 것을 확인할 수 있다. 당연하긴 하다. 지금도 유튜브를 로그인하지 않고 들어가면 영상을 볼 수 있으니까. 그래도 어찌됐든 유튜브 최고!

따라서 우리 메인 홈페이지에 유튜브 영상 정보를 바탕으로 영상을 띄웠을 때, 사용자가 아무런 로그인 등의 추가 단계 없이 바로 영상을 시청할 수 있다. 역시 개발을 어떻게 하느냐가 서비스의 성과로 이어질 수 있다는 것을 직접적으로 느끼고 있다.

 

 

2. Youtube API 키 발급하기

우선 유튜브 API를 사용하기 위해 키를 발급해야 한다. 자세한 과정은 제가 참고했던 블로그 링크를 첨부합니다

https://velog.io/@hanni/Spring-Boot-%EC%B4%88%EA%B0%84%EB%8B%A8-Youtube-Api-%EC%82%AC%EC%9A%A9%ED%95%98%EA%B8%B0

 

[Spring Boot3] 초간단 Youtube Api 사용법 (Feat. 검색 기능을 구현해보자)

1. Youtube API 키 발급하기 Youtube API키를 발급받기 위해서는 먼저 Google Cloud에서 새 프로젝트를 생성해야한다. 프로젝트 생성 - Google Cloud 플랫폼 바로가기

velog.io

여기서 중요한 부분은 발급한 API 키는 보안이 잘되어야한다. 따라서 properties에 키를 보관하고 @PropertySource 어노테이션을 통해서 키를 직접적으로 드러내지 않고 불러와서 사용하도록 해야한다. 또한 깃허브에 올릴 때 api 키가 기재되어있는application.properties 파일이 올라가지 않게 gitignore를 잘 설정해줘야 한다.

@Service
@PropertySource("classpath:application.properties")
public class YoutubeService {}

 

// .gitignore 파일 설정
HELP.md
.gradle
build/
!gradle/wrapper/gradle-wrapper.jar
!**/src/main/**/build/
!**/src/test/**/build/

### IntelliJ IDEA ###
src/main/resources/*.properties

 

3. API 살펴보기

내가 맡은 기능은 인기 있는 '쇼츠만' 가져오는 것이 중요하다. 

youtube data API의 videos.list 문서를 보면 다음과 같은 API를 제공한다

  1. 인기 영상 리스트 조회
  2. 특정 영상 ID의 정보 조회
  3. 좋아요 누른 영상 정보

더 많겠지만 이 정도만 제공해줘도 정말 감사하다. 내가 구현해야 할 기능이 인기 있는 쇼츠를 가져오는 것이므로 1번의 인기 영상 리스트 조회를 이용하면 잘 될 것이라고 생각했다.

 

API 문서의 예제를 보면 

GET https://youtube.googleapis.com/youtube/v3/videos
  ?part=snippet // 어떤 정보를 받을지 지정(제목, 설명, 채널 등)
  &chart=mostPopular // 인기 있는 영상 리스트
  &regionCode=kr // 지역 : 한국
  &videoCategoryId=17 // 카테고리
  &key=[발급받은 API 키] 

뭐 이런 식으로 사용을 하면 된다. 그리고 내가 구현해야 할 기능 중에 카테고리별 인기 쇼츠를 가져오는 것도 있으므로 videoCategoryId를 잘 사용해서 하면 기능 구현이 정말 쉬울 것 같았다.

 

하지만! 문제 발견

일단 이런 식으로 API 요청을 했을 때 응답이 오는 걸 보자

{
  "kind": "youtube#videoListResponse",
  "etag": "Je8KlbOP0-rMI6S0igGd1Eb1xE4",
  "items": [
    {
      "kind": "youtube#video",
      "etag": "8EEpjaDodWT4_iPGY9efDlAveXg",
      "id": "EudBvwmfTbA",
      "snippet": {
        "publishedAt": "2025-05-17T13:23:10Z",
        "channelId": "UCuEpHb8hXkV-qXBp1uBC2pA",
        "title": "스피드의 스피드ㄷㄷ",
        "description": "",
        "thumbnails": {
          "default": {
            "url": "https://i.ytimg.com/vi/EudBvwmfTbA/default.jpg",
            "width": 120,
            "height": 90
          },
          "medium": {
            "url": "https://i.ytimg.com/vi/EudBvwmfTbA/mqdefault.jpg",
            "width": 320,
            "height": 180
          },
          "high": {
            "url": "https://i.ytimg.com/vi/EudBvwmfTbA/hqdefault.jpg",
            "width": 480,
            "height": 360
          },
          "standard": {
            "url": "https://i.ytimg.com/vi/EudBvwmfTbA/sddefault.jpg",
            "width": 640,
            "height": 480
          },
          "maxres": {
            "url": "https://i.ytimg.com/vi/EudBvwmfTbA/maxresdefault.jpg",
            "width": 1280,
            "height": 720
          }
        },
        "channelTitle": "아이쇼스피드 백과사전",
        "categoryId": "22",
        "liveBroadcastContent": "none",
        "localized": {
          "title": "스피드의 스피드ㄷㄷ",
          "description": ""
        },
        "defaultAudioLanguage": "ko"
      }
    }

 

보시다시피 쇼츠가 아니라 긴 영상으로 온다. 그래서 쇼츠만 제공하는 필터링 기능을 제공하나 싶어서 찾아봤다. 하지만 쇼츠만 제공하는 API는 제공하지 않는다...

그래서 해결책을 두 가지 정도 생각을 했다.

 

  1. 받아온 영상들 중에서 영상 길이가 1분 이내인 영상만 필터링 하는 메서드를 직접 구현하자
  2. 검색 기능을 활용한 API를 이용해 쇼츠를 불러오자

1번 방법의 한계점은 영상 길이를 통해 필터링을 하더라도 쇼츠라는 확신을 할 수 없다. 1분 이내의 일반 영상일수도 있기 때문이다. 그래서 2번을 통해 해결해보고자 했다.

 

4. 검색어를 통해 인기 있는 쇼츠 가져오기

https://developers.google.com/youtube/v3/docs/search/list?hl=ko

 

Search: list  |  YouTube Data API  |  Google for Developers

YouTube에서 Shorts 동영상의 조회수를 집계하는 방식에 맞게 Data API를 업데이트하고 있습니다. 자세히 알아보기 이 페이지는 Cloud Translation API를 통해 번역되었습니다. Search: list 컬렉션을 사용해 정

developers.google.com

해당 API는 Search:list이다. 말 그대로 검색어를 통해 해당하는 영상 리스트를 불러올 수 있는 API이다. 참 다양하게 유튜브가 지원해줘서 다행인 것 같다. 

 

문서를 살펴보면 어떻게 사용해야 하는지 알 수 있다. 기본적인 요청 구조는

GET https://youtube.googleapis.com/youtube/v3/search

 

여기에 파라미터를 더해서 찾고자 하는 영상 정보들을 가져올 수 있다.

 

- 필수 파라미터

파라미터 설명
part 어떤 정보를 포함할지
q 검색어
key 발급받은 유튜브 API 키
type 검색 대상 유형(video, channel, playlist) 중 하나 또는 복수

 

 

이걸 활용해서 예시를 만들어보자면

GET https://youtube.googleapis.com/youtube/v3/search?part=snippet&q=golf+highlight&type=video&maxResults=5&key=YOUR_API_KEY

 

이런 식으로 사용할 수 있다.

 

그래서 이걸 사용해서 어떻게 쇼츠만 가져오지?

 

검색어 기능이 좋긴하지만 사용할 수 있는 파라미터 중에 regionCode도 없고, categoryId도 없어서 어찌보면 더 어려울 수도 있다.

그래서 직접 유튜브에 들어가서 검색을 많이 해봤다. 보통 한국 쇼츠는 #쇼츠 #shorts라고 해시태그가 되어있다. 따라서 한국 인기 있는 쇼츠만 걸러오기 위해 q=쇼츠를 활용해서 성공적으로 불러올 수 있었다.

 

- 요청

https://youtube.googleapis.com/v3/search?part=snippet&fields=items(id(videoId),snippet(publishedAt,%20title,%20channelId,%20thumbnails(default(url))))&q=%EC%87%BC%EC%B8%A0&type=video&key=발급받은 키

조금 더 복잡하게 보이긴한데, 우리 서비스에 필요한 영상 정보만 가져오기 위해서 이렇게 작성했다. 안그러면 성능적으로 손해니까

 

- 응답

{
  "items": [
    {
      "id": {
        "videoId": "HP2sCkEwC_c"
      },
      "snippet": {
        "publishedAt": "2024-08-29T10:57:07Z",
        "channelId": "UCs4wUolAHK6DONFmUBJIOHA",
        "title": "오늘부터 스스로 요리해먹...아..네... #shorts",
        "thumbnails": {
          "default": {
            "url": "https://i.ytimg.com/vi/HP2sCkEwC_c/default.jpg"
          }
        }
      }
    },
    {
      "id": {
        "videoId": "T8q72BgvTjo"
      },
      "snippet": {
        "publishedAt": "2024-08-25T08:50:25Z",
        "channelId": "UCs4wUolAHK6DONFmUBJIOHA",
        "title": "정말 똑똑한(?) 내친구 #shorts",
        "thumbnails": {
          "default": {
            "url": "https://i.ytimg.com/vi/T8q72BgvTjo/default.jpg"
          }
        }
      }
    },
    {
      "id": {
        "videoId": "u1pUlpX-7_0"
      },
      "snippet": {
        "publishedAt": "2025-02-23T11:47:42Z",
        "channelId": "UCseRtCpOFnf7_R1N7gsL5wQ",
        "title": "요즘 미국에서 사탕 팔아서 돈 버는 방법.    #쇼츠 #shorts #사업",
        "thumbnails": {
          "default": {
            "url": "https://i.ytimg.com/vi/u1pUlpX-7_0/default.jpg"
          }
        }
      }

 

응답을 보면 한국 쇼츠만 불러오는 것을 알 수 있다. 그리고 조회 수도 몇십만, 몇백만을 달성하고 있는 인기 있는 쇼츠들로 불러올 수 있다.

해당 요청과 응답을 바탕으로 코드를 작성했다. 

 

public List<YoutubeApiResponse> getPopularShorts() throws IOException {
        // 필요한 값들만 불러오도록 최소한의 필드 요청
        String url = YOUTUBE_API_URL + "?part=snippet&fields=items(id(videoId),snippet(publishedAt, title, channelId, thumbnails(default(url))))&q=쇼츠&type=video&key="
            +apiKey+"&maxResults=10";

        String response = webClientUtil.get(url, String.class);
        return parseYoutubeData(response);
    }

 

 

그리고 응답값은 전부 JSON 형식이므로 List 형식으로 전달하기로 API 문서에서 정했을 뿐만 아니라, 그렇게 해야 프론트 측에서 데이터를 쓰기 쉬울 것 같아서 List 형식으로 파싱하는 함수도 작성했다.

 

private List<YoutubeApiResponse> parseYoutubeData(String json) throws IOException {
        ObjectMapper objectMapper = new ObjectMapper();
        JsonNode rootNode = objectMapper.readTree(json);
        List<YoutubeApiResponse> responses = new ArrayList<>();

        for (JsonNode item : rootNode.path("items")) {
            String videoId = item.path("id").path("videoId").asText();
            String date = item.path("snippet").path("publishedAt").asText();
            String channelId = item.path("snippet").path("channelId").asText();
            String title = item.path("snippet").path("title").asText();
            String thumnailUrl = item.path("snippet").path("thumbnails")
                .path("default").path("url").asText();

            DateTimeFormatter formatter = DateTimeFormatter.ISO_DATE_TIME;
            LocalDateTime dateTime = LocalDateTime.parse(date, formatter);
            responses.add(new YoutubeApiResponse(dateTime, videoId, channelId, title, thumnailUrl));
        }

        return responses;
    }

 

 

5. 카테고리별 인기 쇼츠 가져오기

q 검색어를 이용해서 불러오고 싶은 카테고리를 이어서 적어주면 된다

q=쇼츠+<카테고리명>

 

그럼 우리가 정해둔 카테고리 중 하나인 '스포츠' 카테고리의 인기 쇼츠를 응답으로 받아보겠다.

 

- 요청

https://youtube.googleapis.com/v3/search?part=snippet&fields=items(id(videoId),snippet(publishedAt,%20title,%20channelId,%20thumbnails(default(url))))&q=%EC%87%BC%EC%B8%A0+%EC%8A%A4%ED%8F%AC%EC%B8%A0&type=video&key=발급받은 키

 

- 응답

{
  "items": [
    {
      "id": {
        "videoId": "m05-9aeXzEY"
      },
      "snippet": {
        "publishedAt": "2023-07-25T16:45:19Z",
        "channelId": "UCbPf-II9lNXJeiZY5_k8GPw",
        "title": "오타니의 소년 만화",
        "thumbnails": {
          "default": {
            "url": "https://i.ytimg.com/vi/m05-9aeXzEY/default.jpg"
          }
        }
      }
    },
    {
      "id": {
        "videoId": "Z2wkp4IzTvg"
      },
      "snippet": {
        "publishedAt": "2023-06-17T12:19:22Z",
        "channelId": "UCbPf-II9lNXJeiZY5_k8GPw",
        "title": "챔스 5번 우승한 레전드가 평가하는 세리에 A 최고 수비수",
        "thumbnails": {
          "default": {
            "url": "https://i.ytimg.com/vi/Z2wkp4IzTvg/default.jpg"
          }
        }
      }
    },
    {
      "id": {
        "videoId": "K8_gRRqgnqg"
      },
      "snippet": {
        "publishedAt": "2022-12-30T12:31:26Z",
        "channelId": "UCnbQutaGtu6EOKYnwT35Etg",
        "title": "숨겨왔던 축구 실력 공개합니다. #쇼츠 #축구 #미국 #유학생 #윗유하우스 #shorts",
        "thumbnails": {
          "default": {
            "url": "https://i.ytimg.com/vi/K8_gRRqgnqg/default.jpg"
          }
        }
      }
    }

 

보시다시피 스포츠 분야 쇼츠를 잘 불러오는 것을 볼 수 있다.

 

마찬가지로 해당 요청과 응답을 바탕으로 코드를 작성했다. 우리 서비스는 카테고리가 5개로 지정이 되어있어서 enum으로 카테고리를 관리하고 있다.

@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Getter
@Table(name = "category")
public class Category extends BaseEntity {

    public static final Integer ALLOWED_CATEGORY_SELECT_COUNT = 3;
    
    @Enumerated(EnumType.STRING)
    private CategoryType categoryType;
}

@RequiredArgsConstructor
@Getter
public enum CategoryType {
    FOOD("음식"), JOURNEY("여행"), GAME("게임"), MUSIC("음악"), SPORT("스포츠");

    private final String name;

    public static CategoryType fromName(String name) {
        return Arrays.stream(CategoryType.values())
            .filter(categoryType -> categoryType.getName().equals(name))
            .findFirst()
            .orElse(null);
    }

}

 

해당 entity를 바탕으로 서비스 코드를 작성했다.

public List<YoutubeApiResponse> getShortsByCategory(YoutubeCategoryRequest youtubeCategoryRequest) throws IOException {
        // CategoryId로 카테고리명 가져오기
        CategoryType categoryType = categoryRepository.findCategoryTypeById(youtubeCategoryRequest.categoryId())
            .orElseThrow(() -> TalKakException.of(CategoryError.NOT_EXISTING_CATEGORY));
        String categoryName = categoryType.getName();

        // 필요한 값들만 불러오도록 최소한의 필드 요청
        String url = YOUTUBE_API_URL + "?part=snippet&fields=items(id(videoId),snippet(publishedAt, title, channelId, thumbnails(default(url))))&q=쇼츠, "
            + categoryName + "&type=video&key=" + apiKey + "&maxResults=10";
        String response = webClientUtil.get(url, String.class);
        return parseYoutubeData(response);
    }

 

YoutubeCategoryRequest를 통해 요청이 들어오면 CategoryId 값에 맞는 Enum의 카테고리를 String 형식으로 들고와서 q 검색어에 넣는 방식으로 구현을 했다.

 

 

 

6. 테스트 코드 작성

테스트 할 때마다 실제 API 키를 통해 검색을 하면 비용이 많이 든다는 점을 생각해 WireMock을 통해 구현을 했다.

 

SpringBootTest + WireMock : 실제 유튜브 API를 호출하지 않고, 가짜 서버(WireMock)를 통해 HTTP 요청/응답을 시뮬레이션

 

우선 가짜 API 키와 가짜 url을 작성한 후 불러온다.

@ActiveProfiles("test")
@AutoConfigureWireMock(port = 0)
@TestPropertySource(properties = {
    "youtube.api-key=test-youtube-api-key",
    "youtube.api-url=http://localhost:${wiremock.server.port}"
})

 

test/resource 폴더 하위에 성공과 실패 응답을 작성한다.

{
  "items": [
    {
      "id": { "videoId": "12345" },
      "snippet": {
        "publishedAt": "2024-07-24T11:48:50Z",
        "title": "Test Video",
        "channelId": "UC123",
        "thumbnails": {
          "default": {
            "url": "http://example.com/thumbnail.jpg"
          }
        }
      }
    }
  ]
}

 

그리고 이제 서비스 코드 메서드를 테스트 진행해주면 된다.

public class YoutubeServiceTest {

    @Value("${youtube.api-key}")
    private String YOUTUBE_API_KEY;

    @Mock
    private CategoryRepository categoryRepository;

    @Mock
    private WebClientUtil webClientUtil;

    @InjectMocks
    private YoutubeService youtubeService;

    @Test
    @DisplayName("유튜브 인기 쇼츠 정보 가져오기 성공 테스트")
    void getPopularShortsSuccessTest() throws IOException {
        String expectedResponse = getMockResponseByPath("payload/get-youtube-popular-shorts-success-response.json");

        when(webClientUtil.get(anyString(), eq(String.class))).thenReturn(expectedResponse);

        stubFor(get(urlPathEqualTo("/youtube/v3/search"))
            .withQueryParam("part", equalTo("snippet"))
            .withQueryParam("q", equalTo("쇼츠"))
            .withQueryParam("key", equalTo(YOUTUBE_API_KEY))
            .willReturn(aResponse()
                .withStatus(HttpStatus.OK.value())
                .withHeader("Content-Type", MediaType.APPLICATION_JSON_VALUE)
                .withBody(expectedResponse)
            )
        );

        List<YoutubeApiResponse> response = youtubeService.getPopularShorts();

        assertAll(
            () -> assertThat(response).hasSize(1),
            () -> assertThat(response.get(0).videoId()).isEqualTo("12345"),
            () -> assertThat(response.get(0).title()).isEqualTo("Test Video"),
            () -> assertThat(response.get(0).channelId()).isEqualTo("UC123"),
            () -> assertThat(response.get(0).thumbnailUrl()).isEqualTo("http://example.com/thumbnail.jpg")
        );
    }

    @Test
    @DisplayName("유튜브 카테고리별 쇼츠 정보 가져오기 성공 테스트")
    void getShortsByCategorySuccessTest() throws IOException {
        YoutubeCategoryRequest request = new YoutubeCategoryRequest(1L);
        when(categoryRepository.findCategoryTypeById(1L)).thenReturn(Optional.of(CategoryType.MUSIC));

        String expectedResponse = getMockResponseByPath("payload/get-youtube-category-shorts-success-response.json");
        when(webClientUtil.get(anyString(), eq(String.class))).thenReturn(expectedResponse);

        stubFor(get(urlPathEqualTo("/youtube/v3/search"))
            .withQueryParam("part", equalTo("snippet"))
            .withQueryParam("q", equalTo("쇼츠, 음악"))
            .withQueryParam("key", equalTo(YOUTUBE_API_KEY))
            .willReturn(aResponse()
                .withStatus(HttpStatus.OK.value())
                .withHeader("Content-Type", MediaType.APPLICATION_JSON_VALUE)
                .withBody(expectedResponse)
            )
        );

        List<YoutubeApiResponse> response = youtubeService.getShortsByCategory(request);

        assertAll(
            () -> assertThat(response).hasSize(1),
            () -> assertThat(response.get(0).videoId()).isEqualTo("67890"),
            () -> assertThat(response.get(0).title()).isEqualTo("Music Video"),
            () -> assertThat(response.get(0).channelId()).isEqualTo("UC678"),
            () -> assertThat(response.get(0).thumbnailUrl()).isEqualTo("http://example.com/music-thumbnail.jpg")
        );
    }

    @Test
    @DisplayName("잘못된 categoryId로 예외 발생 테스트")
    void getShortsByCategoryInvalidCategoryIdTest() {
        // Given
        YoutubeCategoryRequest request = new YoutubeCategoryRequest(999L); // 존재하지 않는 categoryId 사용
        when(categoryRepository.findCategoryTypeById(999L)).thenReturn(Optional.empty());

        // When & Then
        ExceptionAssertions.assertErrorCode(
            CategoryError.NOT_EXISTING_CATEGORY,
            () -> youtubeService.getShortsByCategory(request)
        );
    }

    private static String getMockResponseByPath(String path) throws IOException {
        ClassLoader classLoader = YoutubeServiceTest.class.getClassLoader();
        try (InputStream inputStream = classLoader.getResourceAsStream(path)) {
            if (inputStream == null) {
                throw new IOException("File not found: " + path);
            }
            return new String(inputStream.readAllBytes());
        }
    }
}

 

이런 식으로 진행하면 실제 API를 사용하지 않고 테스트를 진행할 수 있다!!

 

7. 마치며

인기 쇼츠 불러오기 기능을 구현하며 설계와 개발을 진행할 때 여러 가지 고려할 점이 많다는 것을 느낄 수 있었다. 우리가 어떻게 설계하느냐에 따라 개발이 힘들어질 수도 있고, 개발을 어떻게 진행하느냐에 따라 사용자에게 직접적으로 영향을 줄 수 있다는 점을 뼈저리게 느꼈다. 역시 개발은 어렵다...

 

그리고 현재 구현한 기능이 메인페이지에 있는 것이므로 사용자가 우리의 서비스에 들어올 때마다 유튜브 API를 사용하게 된다. 그래서 비용과 서버 측면에서 굉장히 비효율적이라고 판단했다. 해당 부분의 해결 방법은 다음 편에 적어보도록 하겠다!