[Packy] Spring Boot로 유튜브 영상의 유효성 검사하기

반응형

 

1. 들어가며

 

이번에 만들고 있는 서비스에서 유튜브 영상을 임베드할 수 있는 기능을 제공한다.

  1. iOS에서 지금 사용하는 라이브러리에서는 유효하지 않은 빈 동영상 링크를 삽입해도 빈 동영상으로 나옴
  2. 유튜브 API 할당량을 아끼기 위해 URL 캐싱

위 두 가지 이유 때문에 유튜브 영상의 유효성 검사를 서버 단에서 진행하기로 결정하였다.

 

이번 포스트에서는 Youtube API를 연동하여 유튜브 영상의 정보를 가져오고 유효성을 검사하는 로직을 만들어보자.

 

2. 유효성 검증 로직에 대해 알아보자

1. 유튜브 URL에 정규 표현식을 사용하여 Video ID 추출
2. Video ID를 유튜브 API에게 전달하여 Video 정보를 가져옴
3. Embeddable, PrivacyStatus 여부를 확인하여 유효한 영상인지 판단

 

먼저 유튜브 URL을 분석해본 결과 총 6가지 타입이 있었고, 각각의 URL에서 Video ID를 추출하는 정규 표현식은 아래와 같았다.

유튜브 URL 분석은 수기, 정규 표현식 생성은 Chat GPT로 진행하였다.

URL 타입 URL 예시 정규 표현식
긴 URL https://www.youtube.com/watch?v=FFdHMm9dHbQ (?:https?:\\/\\/)?(?:www\\.)?youtube\\.com\\/watch\\?v=([a-zA-Z0-9_-]+)
짧은 URL https://youtu.be/FFdHMm9dHbQ (?:https?:\\/\\/)?(?:www\\.)?youtu\\.be\\/([a-zA-Z0-9_-]+)
영상 재생 시점을 지정한 URL https://youtu.be/FFdHMm9dHbQ?t=2 (?:https?:\\/\\/)?(?:www\\.)?youtu\\.be\\/([a-zA-Z0-9_-]+)\\?t=\\d+
재생 목록에 있는 영상 URL https://www.youtube.com/watch?v=ds-FowwO9Lg&list=PLUud9rWUXUuWLmZy_ykyuaqrkfTymPjpP (?:https?:\\/\\/)?(?:www\\.)?youtube\\.com\\/watch\\?v=([a-zA-Z0-9_-]+)&list=([a-zA-Z0-9_-]+)
쇼츠 URL https://www.youtube.com/shorts/n2DcpqDK8C0?feature=share (?:https?:\\/\\/)?(?:www\\.)?youtube\\.com\\/shorts\\/([a-zA-Z0-9_-]+)\\??[a-zA-Z0-9_=&-]*
쇼츠 우클릭 후 복사한 URL https://www.youtube.com/shorts/n2DcpqDK8C0 (?:https?:\\/\\/)?(?:www\\.)?youtube\\.com\\/shorts\\/([a-zA-Z0-9_-]+)

 

3. API Key 발급 및 application.yml에 추가

유튜브 API를 사용하기 위해서는 Google Cloud Console에서 API Key를 발급 받아야 한다.

API 라이브러리에서 YouTube Data API v3를 찾아 사용 버튼을 눌러준다.

 

그러면 아래와 같이 사용 설정된 API 및 서비스 탭에서 사용량을 확인할 수 있다.

 

유튜브 API에서는 일간 10,000cost를 기본으로 할당해주는데 사용하는 요청(채널 영상 조회, 플레이리스트 조회, 인기 급상승 목록 조회 등 ...)에 따라 횟수 당 깎이는 cost가 다르다. 각 요청별 할당량은 아래 링크에서 확인할  수 있다.

 

 

YouTube Data API (v3) - 할당량 계산기  |  Google for Developers

이 페이지는 Cloud Translation API를 통해 번역되었습니다. Switch to English YouTube Data API (v3) - 할당량 계산기 컬렉션을 사용해 정리하기 내 환경설정을 기준으로 콘텐츠를 저장하고 분류하세요. 아래 표

developers.google.com

 

그 다음 사용자 인증 정보 탭에 들어가 사용자 인증 정보 만들기를 클릭하여 API 키를 만들자.

 

추후 API 호출 시 사용할 수 있도록 설정 파일에 키를 작성해준다.

youtube:
  api:
    key: AIzaSyA30x588Evmyo3PQFslLAxQoPOK_jpOUUI

 

4. 의존성 추가

build.gradle에 아래 의존성을 추가해준다.

implementation 'com.google.api-client:google-api-client:2.2.0'
implementation 'com.google.oauth-client:google-oauth-client-jetty:1.34.1'
implementation 'com.google.apis:google-api-services-youtube:v3-rev20230816-2.0.0'
implementation 'com.google.http-client:google-http-client-jackson2:1.43.1'

 

5. YoutubeService 클래스 구현

각 메서드의 기능은 아래와 같다.

 

  1. validateYoutubeUrl - Video 객체에 담긴 정보를 가져와 영상이 유효한지 판단
  2. extractVideoId - 인자로 들어온 URL과 정규 표현식을 비교하여 videoId 추출
  3. getVideoInfo - Youtube API를 호출하여 videoId를 사용하여 Video 객체를 추출
  4. getYouTubeService - Youtube Client와 연동
  5. getPatterns - 정규 표현식 리스트를 반환

유효하지 않은 URL이 들어올 경우 videoId나 Video 객체 추출에 실패할 수 있으므로 Optional이나 null을 반환하도록 하고, validateYoutubeUrl 메서드에서 널 체크를 진행해주었다.

(이렇게 구현하는게 클린 코드인지는 잘 모르겠다 ㅠㅠ)

@Service
@RequiredArgsConstructor
public class YoutubeService {

    @Value("${youtube.api.key}")
    private String apiKey;
    private final JsonFactory jsonFactory = GsonFactory.getDefaultInstance();

    public StatusResponse validateYoutubeUrl(String url) {
        String videoId = extractVideoId(url);
        // videoId 추출 불가능
        if (videoId == null) {
            return StatusResponse.from(false);
        }

        // video 정보 접근 불가능
        Optional<Video> video = getVideoInfo(videoId);
        if (video.isEmpty()) {
            return StatusResponse.from(false);
        }

        // 임베딩 불가능
        if (Boolean.FALSE.equals(video.get().getStatus().getEmbeddable())) {
            return StatusResponse.from(false);
        }

        // 공개되지 않은 영상
        if (video.get().getStatus().getPrivacyStatus().equals("private")) {
            return StatusResponse.from(false);
        }

        return StatusResponse.builder().status(true).build();
    }

    private String extractVideoId(String url) {
        String[] patterns = getPatterns();

        // 일치하는 정규 표현식이 있다면 videoId를 반환
        for (String pattern : patterns) {
            Pattern compiledPattern = Pattern.compile(pattern);
            Matcher matcher = compiledPattern.matcher(url);

            if (matcher.find()) {
                return matcher.group(1);
            }
        }

        return null;
    }

    private Optional<Video> getVideoInfo(String videoId) {
        try {
            YouTube youtubeService = getYouTubeService();
            YouTube.Videos.List videoList = youtubeService.videos().list(
                Collections.singletonList("status")
            );

            videoList.setKey(apiKey);
            videoList.setId(Collections.singletonList(videoId));
            
            // API 통신을 하는 코드
            VideoListResponse videoListResponse = videoList.execute();

            return Optional.ofNullable(videoListResponse.getItems().get(0));
        } catch (Exception e) {
            return Optional.empty();
        }
    }

    // YouTube 객체 생성
    private YouTube getYouTubeService() {
        try {
            return new YouTube.Builder(
                GoogleNetHttpTransport.newTrustedTransport(),
                jsonFactory,
                null
            )
                .setApplicationName("Packy")
                .build();
        } catch (GeneralSecurityException | IOException e) {
            throw new InternalServerException(ErrorCode.YOUTUBE_SERVER_ERROR);
        }
    }
    
    // 정규 표현식 모듈화
    private static String[] getPatterns() {
        String originalUrl = "(?:https?:\\/\\/)?(?:www\\.)?youtube\\.com\\/watch\\?v=([a-zA-Z0-9_-]+)";
        String shortUrl = "(?:https?:\\/\\/)?(?:www\\.)?youtu\\.be\\/([a-zA-Z0-9_-]+)";
        String shortUrlWithTime = "(?:https?:\\/\\/)?(?:www\\.)?youtu\\.be\\/([a-zA-Z0-9_-]+)\\?t=\\d+";
        String originalUrlFromPlaylist = "(?:https?:\\/\\/)?(?:www\\.)?youtube\\.com\\/watch\\?v=([a-zA-Z0-9_-]+)&list=([a-zA-Z0-9_-]+)";

        return new String[]{
            originalUrl,
            shortUrl,
            shortUrlWithTime,
            originalUrlFromPlaylist
        };
    }
}

 

 

 

extractVideoId 메서드를 실행하면 위와 같이 videoId가 추출된다. 이 videoId를 getVideoInfo 메서드에게 넘기면 아래와 같이 Video 객체를 반환해준다.

 

나는 status 안의 정보만 필요하여 status만 가져왔는데, getVideoInfo 메서드에서 아래와 같이 원하는 정보를 추가하면 영상의 다른 데이터도 가져올 수 있다.

// 예시
YouTube.Videos.List videoList = youtubeService.videos().list(
    Collections.singletonList("status, snippet, statistics")
);

 

각 항목의 의미와 자세한 내용은 아래 API 공식 문서를 확인하자.

 

Channels  |  YouTube Data API  |  Google for Developers

이 페이지는 Cloud Translation API를 통해 번역되었습니다. Switch to English Channels 컬렉션을 사용해 정리하기 내 환경설정을 기준으로 콘텐츠를 저장하고 분류하세요. 이제 API에서 채널 또는 동영상을 '

developers.google.com

 

6. 마무리

현재 사용 중인 API의 cost는 1이기 때문에 하루에 10,000회 가량 해당 API를 호출할 수 있다. 만일 유효하지 않은 URL로 호출한다면 유튜브 API까지 도달하지 않으므로 10,000회보다 조금 더 가능하므로 개발 단계와 유저가 적은 초기 단계에는 문제가 없을 것이다.

 

하지만 추후 유저가 는다면 일일 할당량을 넘길 수 있으므로 Redis 등을 활용하여 URL을 캐싱하고, 이전에 유효성 검사를 완료한 URL이라면 서버 단에서 결과를 반환해주도록 하여 API 호출을 최소화할 수 있는 방법으로 개선이 필요하다. 

반응형