CodeBuild로 크롤링 자동화 + 슬랙 알림

konu·2024년 12월 12일
1

AWS

목록 보기
1/2
post-thumbnail

 

 

0. 배경

얼마 전 올해 4월부터 꾸준히 (느리게) 진행중이던 프로젝트를 드디어 세상에 출시하게 되었다.
가사 기반 소셜링을 지원하는 아티스트 팬 커뮤니티라고 이해하면 될 것 같다.

누군가 그랬다. 아무리 개떡같이 만들어도 서비스를 세상에 내보이고,
사람들이 유입되면 애정을 가지게 된다고.

나도 마찬가지였다. 4월부터 지금까지 꾸준히 많은 팀원들이 오고 가며 지치기도 했지만
막상 세상에 나오고 나니까 이것도 고치고 싶고 저것도 고치고 싶었다.

그래서 그 중 하나 계속 벼르던 것이 자동화된 곡 크롤링이었다.
우리 서비스에 등록된 아티스트들이 꾸준히 신보를 낼 테고,
우리는 그런 곡들을 빠르게 지원하기 위해 크롤링을 자동화하기로 했다.

 

 

1. Gradle Task

크롤링 과정은 다음과 같은 순서를 따른다.

-> spotify API에 해당 아티스트의 곡 리스트 조회
-> DB에 존재하지 않는 곡에 대해서만 DB에 저장

 

따라서 기존 데이터 모델링을 지원하면서 JPA를 통해 DB와의 연결까지 가능한
우리 프로젝트를 사용할 수밖에 없었다.

그런데 웹서버를 실행하고 싶지는 않았다.
크롤링 작업을 위해 API를 열어야 할 필요는 없었기 때문이다.

정리하면, 스프링은 쓰고 싶지만 웹 서버를 열고 싶진 않아.

 

package com.projectlyrics.server.global.dev.cron;

import com.projectlyrics.server.ServerApplication;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.builder.SpringApplicationBuilder;
import org.springframework.context.ConfigurableApplicationContext;

import static org.springframework.boot.WebApplicationType.NONE;

@Slf4j
public class SongCollectorRunner {

    private static ConfigurableApplicationContext context;

    public static void main(String[] args) {
        log.info("song collector starts collecting!");

        context = new SpringApplicationBuilder()
                .sources(ServerApplication.class)
                .web(NONE)
                .run(args);

        SongCollector songCollector = context.getBean(SongCollector.class);
        songCollector.collect();

        context.close();
    }
}

그래서 ConfigurablApplicationContext를 사용했다.
이름에서도 알 수 있듯, 스프링 컨텍스트를 좀 더 능동적으로 구성하는 데에 필요하다.

sources()에는 @SpringBootApplication이 붙은 클래스 참조를 넣었고,
web()에는 NONE을 입력해 웹 서버가 실행되지 않도록 했다.

 

자세히 보면 SongCollectorRunner에는 main 메서드가 존재한다.
그 전에 @SpringBootApplication이 붙지 않은 클래스가 main 메서드를 가지는 게 어색하기도 하다.

그런데 사실 스프링 애플리케이션의 시작점으로서 ServerApplication이 있던 것이지,
우리는 언제든 main 메서드를 붙일 수 있었다.

문제는, main 메서드를 어떻게 실행시킬 것인가에 있다.

 

 

task runSongCollector(type: JavaExec) {
    mainClass = 'com.projectlyrics.server.global.dev.cron.SongCollectorRunner'
    classpath = sourceSets.main.runtimeClasspath
}

이번 기회에 조금 더 잘 알게된 Gradle은 크게 2가지 일을 하는 것 같다.
프로젝트를 구성하는 여러 클래스를 묶어주고, 스프링 프로젝트에서 의존하는 수많은 라이브러리들을 캐시해준다.

프로젝트를 빌드하는 데에는 2가지 일이 모두 필요하기 때문에
우리는 Gradle을 통해 크롤러의 메인 메서드를 실행할 계획이다.

(참고로, 이 글에서 다루는 주제는 API 크롤링이 아니므로 크롤링에 관한 자세한 로직은 따로 적어두지 않겠다.
댓글로 요청해주시면 드릴게요!~)

 

 

2. CodeBuild

Amazon CodeBuild는 깃허브와 같은 소스 코드 리포지토리를 추적해서 프로젝트를 빌드해준다.

단순하게 말했지만, 코드를 다양한 형태(자바로 생각했을 때는 jar 파일 혹은 도커 이미지 등으로)로 빌드할 수도 있고
CodePipeline과 결합하여 빌드 결과를 배포까지 할 수도 있다.
(젠장 또 AWS야 숭배해야만 해)

그런데!

우리 회사에서는 CodeBuild를 매우 신기한 방법으로 사용하고 있었다.
조회수나 클릭수 등의 통계를 슬랙으로 보낼 때나 뉴스 크롤링과 같은 크론 작업에 활용하고 있었다.

근데 그런 작업에 최적화된 람다가 있지 않은가..?

그러나 람다는 Re:제로에서 시작하는 코드 만들기와 같은 형태니까,
ORM으로 DB를 건드려야 하는 상황에서는 CodeBuild가 더 적합할 수 있다.

 

그래서!

나는 이 방식을 벤치마킹하여 내 곡 크롤링 크론 작업에 활용해보기로 했다.

프로젝트를 만들자.

하단에 소스 코드를 제공하는 곳이 있다.
우리 프로젝트는 깃허브에서 관리하고 있으므로 리포지토리 url을 제공하면 충분하다.
참고로, 하단의 source version을 통해 특정 브랜치를 가리키도록 설정할 수도 있다.

다음은 빌드 환경인데, 기본적인 환경에 맞춘다면
스프링 애플리케이션을 빌드하는 데에는 문제가 없을 것이다.

이 부분이 중요한데, 빌드 스크립트를 yml 형식으로 전달해야 한다.
CodeBuild는 깃허브 리포지토리를 보기 때문에 거기에 buildspec 파일을 저장하자.
(참고로, 하단의 buildspec name을 통해 디렉토리 안의 buildspec 파일을 가리키도록 할 수 있다)

마지막으로 아티팩트와 로그가 등장한다.

아티팩트는 빌드 결과를 이미지처럼 관리하기 위해 제공하는 옵션인 것 같다.
우리는 빌드 결과가 중요한 게 아니라 작업을 하는 게 중요하기 때문에 No artifacts!

로그의 경우엔, CloudWatch logs를 선택하는 것이 좋을 수 있다.
빌드 도중 로그를 실시간으로 찍어주기 때문이다.
(이거 없으면 어디서 뻑났는지 찾을 수 없습니다...)

 

마지막으로, 하단에 CodeBuild 프로젝트를 처음 만들며 참고했던 블로그 링크를 첨부한다.
https://happy-jjang-a.tistory.com/92

 

그리고 하나 염두할 것

우리는 보통 깃허브 리포지토리에 보안상 비밀 값들을 공개하지 않는다.
스프링을 예로 들면 application.yml이 있다.

우리 프로젝트에서는 Github Actions의 secrets를 활용하고 있다.
그러면 결국 어딘가에서 이 파일을 주입해야 한다..!

version: 0.2

phases:
  install:
    runtime-versions:
      java: corretto21
  pre_build:
    commands:
      - aws s3 cp s3://{bucket_name}/application.yml src/main/{bucket_name}/application.yml
  build:
    commands:
      - chmod +x ./gradlew
      - ./gradlew runSongCollector -x test

그럴 때 사용하는 것이 S3다..!

S3에 해당 파일들을 private으로 올려두고, CodeBuild가 이 파일에 접근할 수 있도록 설정한 뒤에
buildspec 파일에서 cp 명령어로 복사하면 그만이다.
(이렇게 AWS의 노예가 ...)

 

 

자, 그러면 시작해볼까?

위에서 밝혔듯, 우리는 크론 작업을 위해 CodeBuild를 사용하는 중이다.
우리는 이 크론 작업의 트리거를 가장 단순한 시간으로 설정했다.

사진과 같이 Build triggers에서 트리거를 생성하면 된다.

cron expresion을 설정하여 얼마나 자주 작업을 진행할지 결정해야 하는데,
이 표현식에 대한 건 우리 GPT 선생님께 각자 문의하도록 하자.

다시 메인 탭으로 돌아와서 보면, 우측의 'start build' 버튼을 볼 수 있을 것이다.
사실 (당연하게도) 수동으로 빌드를 시작할 수 있다.

내가 만든 이 CI 파이프라인을 아직 테스트해야 한다면 위 버튼을 클릭하여
직접 빌드를 시작해보도록 하자..!

 

(와 슬랙 서버 다운되면 정말 행복하겠다 딱 128125719515시간만 다운되었으면 좋겠다)

 

3. 슬랙 알림

구현

에 대한 건 사실 내가 정확히 말할 수 없을 것 같다...
프로젝트 타 팀원께서 구현하셨기 때문...

그러나 내가 아는 한 그리고 조금은 추상적으로 전달하겠다!

 

private void sendToSlack(List<Song> songs) {
        songs.forEach(song ->
            slackService.sendFeedbackToSlack(SlackResponseBuilder.createSong(song), null, channelId));
    }

우선은 의존하고 있는 부분부터 살펴보자.

크롤링한 곡을 매개변수로 받아서,
각 곡에 대해 SlackService의 메서드에 곡을 가공한 무언가채널 id를 전달하고 있다.

 

public static JSONArray createSong(Song song) {
        JSONArray blocks = new JSONArray();

        JSONObject section = new JSONObject();
        section.put("type", "section");

        String songDetails = String.format(
                "*Song Name:* %s\n*Artist:* %s\n*Album:* %s\n*Release Date:* %s\n",
                song.getName(),
                song.getArtist().getName(),
                song.getAlbumName(),
                song.getReleaseDate()
        );

        JSONObject text = new JSONObject();
        text.put("type", "mrkdwn");
        text.put("text", songDetails);

        section.put("text", text);
        blocks.put(section);

        if (song.getImageUrl() != null && !song.getImageUrl().isEmpty()) {
            JSONObject imageBlock = new JSONObject();
            imageBlock.put("type", "image");
            imageBlock.put("image_url", song.getImageUrl());
            imageBlock.put("alt_text", "Album cover for " + song.getName());
            blocks.put(imageBlock);
        }

        return blocks;
    }

곡을 가공하는 부분이다.
슬랙에 json으로 데이터를 전송해야 하기 때문에 JSONObject를 활용하고 있다.

슬랙이 이 json 데이터를 알맞게 포맷팅하여 예쁜 GUI로 보여준다.
이에 대한 프로토콜은 하단의 슬랙 공식문서에 자세히 나와 있다.
https://api.slack.com/reference/block-kit/block-elements

그만 알아보자...

 

	@Value("${slack.token}")
    private String token;

    public void sendFeedbackToSlack(JSONArray blocks, String threadTs, String channelId) {
        try {
            JSONObject responseJson = new JSONObject();
            responseJson.put("channel", channelId);
            responseJson.put("blocks", blocks);

            // 스레드에 답장할 경우 thread_ts 포함
            if (threadTs != null && !threadTs.isEmpty()) {
                responseJson.put("thread_ts", threadTs);
            }

            HttpHeaders headers = new HttpHeaders();
            headers.setContentType(MediaType.APPLICATION_JSON);
            headers.set("Authorization", "Bearer " + token);

            HttpEntity<String> entity = new HttpEntity<>(responseJson.toString(), headers);
            String slackApiUrl = "https://slack.com/api/chat.postMessage";

            restTemplate.postForEntity(slackApiUrl, entity, String.class);
        } catch (Exception e) {
            throw new SlackFeedbackFailureException();
        }
    }

SlackService에서 제공하는 메서드다.

token은 해당 슬랙 그룹?에 대한 식별자로 사용하고,
channelId는 슬랙 알림을 전송할 채널 id로 사용된다.

 

(참고로 channelId는 해당 채널 슬랙 GUI에서 `채널 세부정보 보기` 하단에 나타난다)

 

(그래서?)

 

4. 결론

AWS에는 정말 다양한 서비스가 있는데,
결국 이걸 사용하는 건 인간이기 때문에 이런 창조적인 방식이 등장하기도 한다.

사실 CodeBuild는 말 그대로 소스코드를 빌드하기 위한 도구지만
결국 빌드한 결과에 대해 ./${file_name}만 입력해도 실행이 아니던가?
(빌드와 실행은 한 끗 차이)

여튼, 덕분에 자동화에 대해 한걸음 더 가까워질 수 있었고,
생각보다 어려운 문제가 아니라는 것을 알게 되었다.

제일 어려운 것은 언제나 의지다! 의지!

profile
日日是好日

0개의 댓글