# 서버 사이드

# 1. Crawling

SBS K-POP 뉴스 빌보드코리아 뉴스 멜론 차트 등의 사이트를 크롤링 하는 과정에 대해 소개합니다.

# Jsoup

Crawling은 Jsoup을 활용했습니다. Jsoupjava로 만들어지는 HTML Parser입니다.

Document doc = Jsoup.connect(url).userAgent(agent).get();

이렇게 URL에 해당하는 DOM을 Parsing할 수 있으며, interface가 jQuery와 매우 유사합니다.

# Flow Chart

uml diagram

Crawling한 Data는 Caching 하여 재사용하여 1분 동안 저장합니다.

Caching 후 1분 동안 요청이 오면 Cache에 저장된 data를 반환하고, 그 이후에는 다시 Jsoup을 통하여 크롤링을 수행합니다.

크롤링 해온 음원에 대해 Youtube에 검색해서 동영상을 가져오는 과정에 대해 소개합니다.

# API Request Cost

Youtube Data API를 통하여 Youtube에 있는 동영상 채널 리소스 등에 접근할 수 있습니다.

그런데 Youtube Data API는 Youtube Service의 자원을 사용하기 때문에, 요청에 대한 제한이 있습니다.

하루에 10000의 할당량을 사용할 수 있으며, Request 종류별로 할당량에 대한 cost가 다릅니다.

공식 문서에서 요청에 대한 cost를 확인해볼 수 있는데, 정확한 수치가 아니라 근삿값입니다.

cost

이렇듯 Search 요청은 기본적으로 100 이상의 Cost를 지불해야합니다.

cost2

한 개의 Search 요청을 보낸 후 API 관리자에서 확인해본 결과, 실제로 102의 Cost를 지불합니다. 한도가 10000 이므로, 하루에 98회의 Search 요청을 보낼 수 있습니다.

# Youtube API Client Package

Youtube는 API를 Client에서 사용하기 쉽게 Client Package를 제공합니다.

import com.google.api.client.http.HttpRequest;
import com.google.api.client.http.HttpRequestInitializer;
import com.google.api.client.http.HttpTransport;
import com.google.api.client.http.javanet.NetHttpTransport;
import com.google.api.client.json.JsonFactory;
import com.google.api.client.json.jackson2.JacksonFactory;
import com.google.api.services.youtube.YouTube;
import com.google.api.services.youtube.model.SearchResultSnippet;

// { NetHttpTransprot, JacksonFactory } --> 구글에서 제공하는 Client API
// NetHttpTransport: java.net 패키지 기반의 Thread-safe http low-level Transport
// JacksonFactory: Jackson2 기반의 low-level JSON library
private HttpTransport HTTP_TRANSPORT = new NetHttpTransport();
private JsonFactory JSON_FACTORY = new JacksonFactory();

// API 사용에 필요한 Instance 생성
YouTube youtube = new YouTube.Builder(HTTP_TRANSPORT, JSON_FACTORY, new HttpRequestInitializer() {
  public void initialize(HttpRequest request) throws IOException { }
}).setApplicationName("youtube-cmdline-search-sample").build();

그리고 Snippet에서 필요한 것들만 선택하여 가져오면 됩니다.

search
  .setKey(API_KEY) // 검색에 사용할 API KEY
  .setQ(searchQuery) // 검색어. 제목+가수 형태의 문자열을 넘김
  .setType("video") // 기본값: chnnel,playlist,video. 현재 필요한 것은 video
  .setMaxResults(1) // 검색된 목록에서 가져올 데이터의 수
  .setFields("items(id/videoId,snippet(title,thumbnails/default/url))") // 결과로 가져올 필드
  .execute() // Search 실행 후 결과를 Video List에 Mapping
  .getItems()
  .forEach(v -> {
    SearchResultSnippet snippet = v.getSnippet();
    result.add(
      Video.builder()
        .title(snippet.getTitle())
        .videoId(v.getId().getVideoId())
        .thumbnail(snippet.getThumbnails().getDefault().getUrl())
        .searchTitle(searchQuery)
        .build()
    );
  });

# Search Result Save

VideoService의 일부 코드입니다.

/*
 * 검색어(제목+가수) 기반으로 Video 정보를 가져옴
 * @param q : 검색어(제목+가수)
 * @return : Video Entity
 * @throws VideoNotFoundException : 동영상을 가져오는 과정에 오류가 발생했을 때 예외 처리
 */
@Cacheable(cacheNames = "VideoCache", key="#q")
public Video getBySearch (String q) throws VideoNotFoundException {
  // 일단 DB에 video가 있는지 탐색
  Video video = Optional.ofNullable(videoRepository.findBySearchTitle(q)).orElseGet(() -> {
    // DB에 없다면 Youtube Search
    Video v = Optional.ofNullable(youtubeSearch.execute(q))
                      .orElseThrow(VideoNotFoundException::new);
    videoRepository.save(v);
    return v;
  });
  // 탐색해온 Video 정보 반환
  return video;
}

앞서 언급했듯이 API 요청에는 cost가 필요합니다. 그래서 중복 요청을 방지하기위해 이미 결과로 가져온 데이터는 DB에 저장하고, Caching 처리 합니다. 따라서 API 요청을 하기 위해선 일단 cache와 db를 거쳐야 합니다.

# Flow Chart

Youtube Search를 위한 과정은 다음과 같습니다.

uml diagram

음원의 제목을 통해서 Youtube에 검색합니다. 검색 후 DB에 결과를 저장하고, 캐싱까지 합니다.

그래서 동영상 정보를 재요청시 Cache나 DB에서 가져오게 됩니다.

# 3. Authorization

회원가입 후 로그인을 하면 다음과같은 과정으로 JWT(Json Web Token)을 발행합니다.

/**
* 토큰 생성
* @param userId : user의 id
* @param roles : user의 역할. 현재는 ROLE_USER 만 존재
* @return 토큰 값 반환
*/
public String createToken(String userId, List<String> roles) {
  Claims claims = Jwts.claims().setSubject(userId); // claim 생성
  claims.put("roles", roles); // role 지정
  Date now = new Date();
  return Jwts.builder()
          .setClaims(claims) // claim 지정
          .setIssuedAt(now) // 토큰 발행 일자 지정
          .setExpiration(new Date(now.getTime() + tokenValidMS)) // 유효 시간 지정
          .signWith(SignatureAlgorithm.HS256, secretKey) // 암호화 알고리즘, secret 값 지정
          .compact(); // 위의 내용을 압축 후 반환
}

uml diagram

# 4. Authentication

Authentication은 Spring Security의 Filter와 JWT를 이용합니다.

먼저 spring security에서 filter를 정의합니다.

/**
* http 요청에 대해 처리하는 내용을 정의함
* @param http
* @throws Exception
*/
@Override
protected void configure (HttpSecurity http) throws Exception {
http
  .httpBasic().disable() // spring-security에서 제공하는 /login 과 같은 페이지 비활성
  .csrf().disable() // Cross site request forgery 비활성
  .sessionManagement()
    .sessionCreationPolicy(SessionCreationPolicy.STATELESS) // session을 stateless 형태로 관리
  .and()
     // jwt를 이용하는 filter 추가
    .addFilterBefore(
      new JwtAuthenticationFilter(jwtTokenProvider),
      UsernamePasswordAuthenticationFilter.class
    );
}

이렇게 모든 요청에 대해 jwtAuthenticationFilter를 통해 사전 검증을 합니다.

@Override
public void doFilter(
  ServletRequest request,
  ServletResponse response,
  FilterChain chain
) throws IOException, ServletException {
  // request의 header에 포함된 Token 정보를 가져온다.
  String token = jwtTokenProvider.resolveToken((HttpServletRequest) request);
  
  // token 정보가 존재할 때만 token을 검증
  if (token != null && jwtTokenProvider.validateToken(token)) {
    // token에서 값을 추출하여
    Authentication auth = jwtTokenProvider.getAuthentication(token);
    // context에 저장한다.
    SecurityContextHolder.getContext().setAuthentication(auth);
  }
  
  chain.doFilter(request, response);
}

이렇게 Request Header에 Token 정보가 있을 때만 Token 검증 후 Token에 담긴 Authentication을 Security Context에 저장합니다. 그리고 다음과 같이 사용됩니다.

/**
 * AuthenticationCheck
 * @return
 * @throws AuthException
 */
public String AuthenticationCheck () throws AuthException {
  // Security Context 에 저장한 authentication 정보 가져오기
  Authentication auth = SecurityContextHolder.getContext().getAuthentication();
  // Token 에서 가져온 User Id가 익명의 사용자일 경우 예외처리
  String userId = auth.getName();
  if (userId.equals("anonymousUser")) {
     throw new AuthException();
  }
  return userId;
}

즉, Security의 Authentication 정보는 기본 값이 항상 anoymousUser입니다. 이런식으로 User 권한이 필요할 때 Token 정보를 통해서 검증이 가능합니다.

uml diagram

# 5. Exception

Optional과 Spring의 RestControllerAdvice을 이용하여 예외에 대한 Response를 만들었습니다.

ExceptionAdvice.java의 일부입니다.













 







@Slf4j
@RequiredArgsConstructor
@RestControllerAdvice
public class ExceptionAdvice {
  private final ResponseService responseService;

  /**
   * User select 에 대한 response 예외 처리
   * @param request
   * @param e
   * @return USER_FAIL
   */
  @ExceptionHandler(UserIdNotFoundException.class)
  @ResponseStatus(HttpStatus.OK)
  protected CommonResult userIdNotFoundException(HttpServletRequest request, Exception e) {
    return responseService.failResult(CommonResponse.USER_FAIL);
  }
  // 나머지 생략
}

아래의 코드에서 예외가 발생하면 ExceptionAdvice에서 처리됩니다.









 


/**
 * 유저 정보가 Null 이면 Exception 처리, 아니면 유저 정보 반환
 * @param userId : User의 login ID
 * @return
 * @throws UserIdNotFoundException : 유저 정보 탐색에 대한 실패 처리
 */
@Override
public User loadUserByUsername(String userId) throws UserIdNotFoundException {
  return Optional.ofNullable(get(userId)).orElseThrow(UserIdNotFoundException::new);
}

즉, ExceptionAdvice.java에 정의된 Exception 이 발생하면 ExceptionAdvice가 바로 관련 Response 데이터를 만들고 바로 브라우저에 return 합니다.

uml diagram

POST /api/video-like 요청에 대한 예시입니다.

response01

이런 응답을 반환합니다. 여기에 request-body를 추가하면 다음과 같습니다.

response02

header에 JWT가 생략되었기 때문에 로그인이 필요하다는 응답을 반환합니다. 다시 header에 access token 정보를 담아서 요청하면

response03

이렇게 정상적인 내용을 반환합니다.