thumbnail

<img src="https://static.podo-dev.com/blogs/images/2020/02/24/origin/a8936218-aaa1-46ba-b483-774ea9d97b4e.jpg" alt="KakaoTalk_20200224_200250101.jpg" style="width:400px;">

헬로프라이스는 다나와 최저가 알림 텔레그램 봇입니다!

헬로프라이스 코드.GIT
헬로프라이스 개발기 01
헬로프라이스 개발기 02
헬로프라이스 도움말
헬로프라이스

<br>

서론.

헬로프라이스 개발과정에서 생긴 문제점과,
해결방안을 하나하나 기록하고 싶었습니다..

문제는 이 생각을 왜 개발을 하고나서 했을까(?)

<img src="https://static.podo-dev.com/blogs/images/2020/02/21/origin/f90f230c-d972-47b8-981e-5ff164c44c6d.png" alt="base64.png" style="width:412px;">

그럼에도 해보려했습니다. (01, 02 편!)

그리고, book. 오브젝트 내용중, 다음 내용을 읽었습니다.

처음의 설계 도메인 모델은 코드의 가이드라인을 제공하며,
코드의 변화의 빌맞추어 변한다.
처음 설계를 시작하는 단계에서는
개념들의 의미와 관계가 정확하거나 완벽할 필요는 없다.
단지 우리에게는 출발점을 제공해줄 뿐이다.

생각해보면,
처음에 그려본 설계가,
구현을 하나하나 진행하면서 변화하고 있었습니다.

그럼, 이제와 개발기를 쓰자니..
이미 구체화된 설계로, 떡하니 이렇게 설계했습니다! 라고 한다면,
모순이 아닐까..

그래서 헬로링크 라는 새로운 아이디어가 떠올라,
뉴 프로젝트에는 개발기를 쓰고.

이번에는 후기를 쓰며 도움받은 글과,
각 로직을 공유해보려고 합니다.

감사합니다..!

<br>

프로젝트 구조.

프로젝트 구조는 다음과 같습니다.

<img src="https://static.podo-dev.com/blogs/images/2020/02/24/origin/2dff7879-fd20-4075-862c-f3c4f21d7134.png" alt="base64.png" style="width:720px;">

이중화, 다중화를 구성해보고 싶지만,
인프라쪽에는 지식이 없기에, 논리적 구성으로 마무리하였습니다.

<br>

각 서버의 역할

helloprice-telegram :
최전방에서 사용자와 피드백합니다.
사용자 요청에 상품 검색, 상품 알림 추가, 이메일 알림 등록 등을 수행합니다.

상품 갱신 알림 기능을 포함하고 있습니다.
주기적으로 갱신된 상품을 확인해, 사용자에게 알림을 전송합니다.

<br>

helloprice-crawl-scheduler :
db를 조회하여, 가격 확인이 필요한 상품을 검색합니다.
확인이 필요한 상품이 있을 경우 메세지큐에 상품정보를 퍼블리쉬 합니다.

<br>

helloprice-crawl-agent :
메세지큐에 메세지를 수신합니다.
메세지의 상품정보로 상품페이지를 크롤하여 상품 가격 정보를 확인, 갱신합니다.

실시간 처리가 어려운 대용량 데이터는 아니지만.
sprig-batch를 사용해보고싶어서 spring-batch로 진행했습니다.

<br>

selenium-chrome :
비동기 요청을 포함하는 페이지를 크롤할 경우 사용합니다.
다나와의 상품 검색 페이지가 그렇습니다.
페이지 로딩 후, 비동기 요청이 완료되어야 상품목록을 확인 할 수 있습니다.
이때 단순히 jsoup으로는 크롤이 불가능하고,
selenium을 이용해 크롤할 수 있는데,
원격 selenium 서버로 별도 분리하였습니다.

<br>

mysql :
사용자 정보와, 상품정보를 저장합니다.
1일 주기로 구글드라이브에 백업을 진행합니다.

<br>

Stack.

  • spring-boot 2.2.0
  • spring-batch
  • spring-cloud-stream
  • spring-data-jpa
  • querydsl
  • kafka
  • selenium
  • mysql
  • gradle
  • docker

<br>

열심히 돌아가는 NAS..

물리서버는 개인용 NAS 하나로 구성되있습니다.
NAS는 일을 열심히 합니다...

NAS는 일을 열심히 합니다..

<img src="https://static.podo-dev.com/blogs/images/2020/02/21/origin/ae068b21-96fe-459a-b5d9-9c21be2585e9.png" alt="base64.png" style="width:300px;">

<br>

docker를 이용해, 컨테이너 기반으로 서버를 유지하고있습니다.

<img src="https://static.podo-dev.com/blogs/images/2020/02/21/origin/efefe287-fe59-4969-ab59-9db33196c9b8.png" alt="base64.png" style="width:720px;">

<br>

테이블 스키마

초간단.

<img src="https://static.podo-dev.com/blogs/images/2020/02/21/origin/9b4d175e-cd33-4656-98f7-29caf3c50244.png" alt="base64.png" style="width:672px;">

user: 사용자 정보,
user_item_notify : 사용자 상품 알림 정보
item : 상품 정보.

세부 필드는, 이제부터,

<br>

구현을 시작해 봅니다

멀티모듈 세팅하기

시작에 앞서, 멀티 모듈로 세팅하였습니다.

<img src="https://static.podo-dev.com/blogs/images/2020/02/21/origin/eb249fa7-b992-4aa6-852f-8d01e250ca8c.png" alt="base64.png" style="width:262px;">

이동욱님의 Gradle 멀티 프로젝트 관리 글을 따라, 세팅 할 수 있었습니다.
(감사합니다..)

그럼 모듈을 어떻게 나누는게 좋을까? 라고 고민해봤습니다.

공통으로 사용하는 기능에대해, common 모듈을 정의하고,
해당 기능을 포함하였습니다다.
후에 X모듈은 common 모듈의 일부 기능이 기능이 필요하여 의존하는데,
불필요한 코드를 포함하게 됩니다.
common 모듈을 분리해야할까?
반대로 너무세세하면 모듈이 너무 많아집니다...

지식이 많이 부족한 부분이었습니다. (지금도 부족한....)
다음 세미나를 보고, 많은 도움이 됬습니다.(감사합니다..)

우아한테크 우아한 멀티모듈 by 권용근님

<br>

플로우를 이해하며,

어떻게 로써,
쉽게 공유할 수 있을까를 고민했는데,,
플로우를 따라간다면 더 이해가 쉽지 않을까 합니다.

<br>

시작은 봇 생성하기

<img src="https://static.podo-dev.com/blogs/images/2020/02/22/origin/d39c6d45-8244-4b29-b1cc-1a4b02bb90af.png" alt="base64.png" style="width:378px;">

텔레그램 봇은 텔레그램에서 BotFather라고 검색후, // (봇아빠!)
대화를 시작하면, 무료 봇을 만들 수 있습니다.
봇 생성 시 botToken을 발급해줍니다.

https://github.com/rubenlagus/TelegramBots 라이브러리를 사용하면
java로 쉽게 봇서버를 구현할 수 있습니다.

다만, docs를 도저히 못찾겠는데,,
저장소의 wiki에 샘플 코드가 있어, 많은 도움이 됩니다.

<br>

봇 서버 구현하기

TelegramLongPollingBot을 상속받아, 봇구현체를 만듭니다.
앞서 말해듯이, botToken은 봇 생성 시, BotFather가 발급해줍니다.
해당 botToken을 주입합니다.

@Component
public class TelegramBot extends TelegramLongPollingBot {

    //...

    @Override
    public String getBotUsername() {
        return botUsername;
    }

    @Override
    public String getBotToken() {
        return botToken; // 봇생성시, 봇아빠가 발급해준다
    }

    //...

<br>

생성된 봇구현체를, 다음과 같이 등록해줍니다.

@Configuration
public class TelegramBotRegisterConfig {

    public TelegramBotRegisterConfig(List<TelegramBot> telegramBots) throws TelegramApiRequestException {
        final TelegramBotsApi api = new TelegramBotsApi();

        for (TelegramBot telegramBot : telegramBots) {
            api.registerBot(telegramBot);
        }
    }
}
@SpringBootApplication
public class HellopriceTelegramApplication {

     public static void main(String[] args) {
        ApiContextInitializer.init(); //Telegram Api Initial
        SpringApplication.run(HellopriceTelegramApplication.class, args);
    }
}

<br>

메세지 수신하기

이제 사용자가 메세지를 보내면
봇구현체의 onUpdateReceived(Update update) 메소드가 호출됩니다.

@Component
public class TelegramBot extends TelegramLongPollingBot {

    //...
    
    @Override
    public void onUpdateReceived(Update update) {
        //사용자가 메세지 전송 시,  호출된다.
    }
    
    //...

Update객체에는 사용자가 보낸 메세지 정보들이 들어있습니다.
(사용자ID, 메세지ID, 메세지내용, 전송시간 등등)

텔레그램 메세시 송수신은 로 구현되어 있습니다.
사용자가 봇에 송신하면, 큐에 쌓이고
차례대로 deque되어, onUpdateReceived()를 호출합니다.
메소드 처리가 완료되면, 새로운 메세지를 수신합니다.
극한으로, 만약 동시에 사용자들이 100,000개를 전송하면,,
하나하나 차례대로 응답하게되는 불상사가 생긴다.
따라서 필요하다면, 별도의 병렬처리를 필요로 합니다.
봇구현체에서도 threadPool 개수를 지정할 수 있습니다.

<br>

메세지를 수신하면

사용자ID로하여,
DB에 저장된 사용자의 현재 메뉴 상태 를 조회합니다.

사용자 테이블 스키마

<img src="https://static.podo-dev.com/blogs/images/2020/02/22/origin/f2e70c2a-1fd6-4a97-bc9a-5a6611d60ab8.png" alt="base64.png" style="width:560px;">

메뉴 상태

<img src="https://static.podo-dev.com/blogs/images/2020/02/22/origin/1f631ee6-97a6-4787-acac-d6e0f7f12732.png" alt="base64.png" style="width:176px;">

<br>

봇구현체는
메뉴 상태 를 분기하여
적절한 MenuHandler 를 찾아, 메세지를 위임합니다.

public class TelegramBot extends TelegramLongPollingBot {
     //..
        private void handleCommand(TMessageVo tMessageVo, String requestMessage, Menu userMenuStatus) {
            final MenuHandler menuHandler = menuHandlers.get(userMenuStatus); // 메뉴상태에 따라 메뉴핸들러를 꺼낸다.
            menuHandler.handle(tMessageVo, requestMessage); // 메세지좀 이쁘게 다루어주게나...
        }
     //..
    }

<br>

메세지를 위임받은 MenuHandler
사용자가 보낸 메세지에 따라,
로직을 수행 후 사용자에게 응답을 전송합니다.
전송 시, 버튼식 키보드와 같은 텔레그램 인터페이스 정의하여 같이 보낼 수 있습니다.

public class HomeMenuHandler extends AbstractMenuHandler {

    @Override
    public Menu getHandleMenu() {
        return Menu.HOME; // 내가 이 메뉴를 관리할게!
    }

    public void handle(TMessageVo tMessageVo, String requestMessage) {
           / /... 로직 수행
    }
}

getHandleMenu()를 구현하여, 어떤 메뉴를 핸들할지를 정의한다.

<br>

그 밖에 메뉴에대해서,
동일하게 MenuHandler를 구현하여 확장합니다.

TelegramBot -> MenuHandler
 MenuHandler <|.. HomeMenuHandler
 MenuHandler <|.. ItemAddMenuHandler
 MenuHandler <|.. ItemDeleteMenuHandler
 
 interface MenuHandler{
    getHandleMenu()
    handle()
 }
    @Bean
    public Map<Menu, MenuHandler> menuHandlers() {
        return menuHandlers.stream()
                .collect(toMap(MenuHandler::getHandleMenu, x -> x));
    }

확장된 메뉴들

<img src="https://static.podo-dev.com/blogs/images/2020/02/21/origin/23813bb1-af8b-4ad3-ac6f-3b445ab350cd.png" alt="base64.png" style="width:264px;">

<br>

자 그럼, 사용자는 메뉴를 통해서 상품 알림을 등록했습니다.

앞서 메뉴를 확장하고,
각 메뉴에 어떻게 메세지를 처리할것인지를 정의했습니다.
(사실상, 가장 오래걸리는 구현부분 입니다..)
사용자와 서버간에 프로토콜을 정의한 것 입니다.

<img src="https://static.podo-dev.com/blogs/images/2020/02/22/origin/707172d6-286f-481a-bcb7-802be526f67b.png" alt="base64.png" style="width:492px;">

이제, 사용자는 상품 알림을 추가할 것입니다.

사용자가 알림을 추가한한다면,
상품 테이블에 상품 정보가 저장 됩니다.
물론 사용자_상품_알림 테이블에도, 사용자 알림정보가 저장 됩니다.

상품 테이블
<img src="https://static.podo-dev.com/blogs/images/2020/02/21/origin/e33414db-807e-4e28-910e-883a5ecad720.png" alt="base64.png" style="width:720px;">

사용자_상품_알림 테이블
<img src="https://static.podo-dev.com/blogs/images/2020/02/21/origin/03f9195e-2c3a-4060-9817-6d1993bde6c2.png" alt="base64.png" style="width:413px;">

다음 내용을 위해, 상품 테이블 스키마를 살짝 봅니다

<img src="https://static.podo-dev.com/blogs/images/2020/02/21/origin/f6177820-3dc1-4d95-afcb-39c1f277c543.png" alt="base64.png" style="width:546px;">

<br>

사용자의 상품 가격정보를 눈팅합니다.

사용자가 상품을 등록했으니,
누군가는 이제 상품의 가격변동을 계속 지켜봐야 합니다.
crawl-scheduler 서버는 누군가 입니다.

<img src="https://static.podo-dev.com/blogs/images/2020/02/21/origin/608f291c-5c96-4ac8-a375-d2efaadd5608.png" alt="base64.png" style="width:400px;">

crawl-scheduler10초 주기로
DB에 특정 상품을 조회하는 쿼리를 요청합니다.

  @Scheduled(cron = "*/10 * * * * *")
    public void schedule() {
        LocalDateTime now = LocalDateTime.now();
        for (Worker worker : workers) {
            worker.run(now);
        }
    }

사실, 1초로 한다면 더 좋겠지만,
그래도 다나와에 1초간격으로 페이지를 요청하면..
문제가 되지 않을까 합니다..
혹시 모르니, 마음의 기준선(?) 10초간격으로 두었습니다..

<br>

쿼리는 다음 조건으로 요청됩니다.
일정 시간 동안 가격 확인이 되지 않은상품 중,
최종 메세지 퍼블리시 시간 필드를 기준으로 최신순으로 정렬합니다.
그리고 최상위의 하나의 상품을 조회합니다

요약하면, 가장 마지막에 크롤된 상품을 조회하는 쿼리입니다.

<br>

조회된 상품의 정보 포함한 메세지를 정의하여,
메세지큐에 퍼블리쉬 합니다.

public class LastPublishedItemPublishWorker implements Worker {
    @Override
    public void run(LocalDateTime dateTime) {
        final LastPublishedItem lastPublishedItem = lastPublishedItemService.getLastPublishedItem(dateTime.minusMinutes(crawlExpireMinute));

        if (Objects.isNull(lastPublishedItem)) {
            return;
        }

        publish(lastPublishedItem, dateTime); // 메세지 퍼블리시!!!!
    }

상품 마지막 크롤시간 필드로 정렬하지 않은 이유는,
상품 마지막 크롤시간 필드는 crawl-agent 서버에서 상품 크롤 완료 후 갱신합니다.
10초안에 크롤이 완료되지 않는다면, 갱신이 되지 않는 것입니다.
따라서, 스케줄러에서 쿼리 요청시 똑같은 상품이 조회되어 중복 크롤이 발생하게 됩니다.
이 이슈에, last_publish_at(최종 메세지 퍼블리시 시간) 필드를 별도로 정의하였습니다.

<br>

publish() 메소드를 보면,
spring-cloud-stream과 관련된 부분이 있습니다.
processor.onNext(message) 호출 시,
메세지큐에 메세지가 퍼블리시 됩니다.

  private void publish(LastPublishedItem lastPublishedItem, LocalDateTime lastPublishAt) {
        try {
            log.info("메세지 전송 : {}", lastPublishedItem);
            processor.onNext(toMessage(lastPublishedItem)); // 메세지 퍼블리시!
            lastPublishedItemService.updateLastPublishAt(lastPublishedItem.getItemCode(), lastPublishAt);
        } catch (Exception e) {
            log.error("Fail Publish {}", lastPublishedItem, e);
        }
    }
   @Bean
    public Supplier<Flux<String>> lastPublishedItem() { // 메소드명을 보자.
        return this::processor;
    }

<br>

appplication.yml 파일의 설정을보면,
정말 간단한게 설정이 가능함을 알 수 있습니다.

spring:
  cloud:
    function:
      definition: lastPublishedItem // @Bean 메소드명
    stream:
      bindings:
        lastPublishedItem-out-0: // 메소드명 - out은 송신
          destination : com.podo.helloprice.crawl.crawl-items // topic이름
          binder : kafka  // kafka 또는 rabbit
      kafka: // 카프카 서버 세팅
        binder:
          brokers: 192.168.219.103:9092
          auto-add-partitions: true
          min-partition-count: 1
          auto-create-topics: true

spring에서 스트림을 추상화한 것인데,
binder값을 rabbit으로 바꾸는식으로 메세지큐를 쉽게 전환 할 수 있습니다. (오!)

서울 피보탈 세미나에서, 해당 내용을 라이브코딩으로 쉽게 설명해줍니다.

<br>

crawl-agent는 메세지를 수신했습니다. 자, 그럼 일을해야지?

crawl-scheduler에서 상품정보를 담음 메세지를 송신했다면,
crawl-agent에서는 메세지를 수신합니다.

<img src="https://static.podo-dev.com/blogs/images/2020/02/22/origin/4fac53c0-8bea-4a4f-879d-c5c431698abf.png" alt="base64.png" style="width:250px;">

마찬가지로 spring-cloud-stream을 이용해서 메세지 수신을 설정하였고,
다음과 같이 메세지 수신후, job을 실행합니다.

    @Bean
    public Consumer<String> lastCrawledItem(CrawlJobRunner crawlJobRunner) {
        return (lastPublishedItem) ->{
            log.info("메세지 수신 : " + lastPublishedItem);
            crawlJobRunner.run(lastPublishedItem);
        };
    }

<br>

spring-batchjobParameters 세팅을 유심히보면,
Date, Long, Double, String만 지원하고 있는 슬픈 사실을 알 수 있습니다..

public class CrawlJobRunner {
    public void run(String lastCrawledItem) {
            log.info("상품 정보 갱신 작업을 실행합니다");

            try {

                //강려크하게 객체를  넣고 싶은 욕망이 생긴다..
                final JobParameters jobParameters = new JobParametersBuilder()
                        .addDate("createAt", new Date()) // 여기를 보자. 
                        .addString("lastCrawledItem", lastCrawledItem) // 여기를 보자.
                        .toJobParameters();

                jobLauncher.run(crawlJob, jobParameters);

            } catch (Exception e) {
                log.error("Start Crawler Job Error");
            }
        }
    }

그런데, 이런 문제를 기가막히게 해결 할 수 있는 방법을
다음 세미나에서 알려줍니다.

우아한테크 우아한 스프링 배치 By 이동욱님

<img src="https://static.podo-dev.com/blogs/images/2020/02/24/origin/2073ae4b-7253-447f-84c8-dd5cc79a6b4b.png" alt="base64.png" style="width:229px;">

@JobScope를 이용하는 것인데
@JobScope는 매번 잡이 실행될때 Bean이 새로 정의되고,
이때 Autowired를 이용하여 다음과 같이,
데이터를 변환해주는 것입니다.

@JobScope
@Component
public class CrawlJobParameter {

    public static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();

    private LocalDateTime createAt;
    private LastPublishedItem lastPublishedItem;

    @Value("#{jobParameters[createAt]}")
    public void setCreateAt(Date createAt) {
        this.createAt = LocalDateTime.ofInstant(createAt.toInstant(), ZoneId.systemDefault());
    }

    @Value("#{jobParameters[lastPublishedItem]}")
    public void setLastPublishedItem(String lastPublishedItem) {
        try {
            this.lastPublishedItem = OBJECT_MAPPER.readValue(lastPublishedItem, LastPublishedItem.class);
        } catch (JsonProcessingException e) {
            log.error("Object Mapper Fail, {}", lastPublishedItem, e);
        }
    }

}

추가적으로, 해당 논리를 이용하면,
step간에 공유 저장소를 만들 수 도 있습니다.
JobScope 범위에, 저장소 역할을 하는 Bean을 만드는 것입니다.

<br>

그래서 이제 다시 본론으로 돌아와,
batch가 어떤일을 하는지봅니다.

<br>

process 부분을 보면, 상품 정보를 크롤 합니다.

public class CrawlJobProcessor implements ItemProcessor<LastPublishedItem, CrawledItem> {
    private final DanawaCrawler danawaCrawler;

    @Override
    public CrawledItem process(LastPublishedItem lastPublishedItem) {
        final String existedItemName = lastPublishedItem.getItemName();
        final String existedItemCode = lastPublishedItem.getItemCode();

        final CrawledItem crawledItem = danawaCrawler.crawlItem(existedItemCode);

        return crawledItem;
    }
}

<br>

writer는 변경된 상품 정보를 갱신합니다.

public class CrawlJobWriter implements ItemWriter<CrawledItem> {

    private Integer maxDeadCount;

    @Override
    public void write(List<? extends CrawledItem> crawledItems) {
        for (CrawledItem crawledItem : crawledItems) {
            updateItem(crawledItem);
        }
    }

    private void updateItem(CrawledItem crawledItem) {
        // ...
        
        final Item item = itemRepository.findByItemCode(existedItemCode);

        if (Objects.isNull(crawledItem)) {
            log.info("{}({}) 상품의 정보 갱신 에러 발생", existedItemName, existedItemCode);

            item.increaseDeadCount(); // 데드카운트 추가

            // 데드카운트 초과!
            if (item.hasDeadCountMoreThan(maxDeadCount)) {
                log.info("{}({}) 상품의 에러카운트 초과, DEAD 상태 변경", existedItemName, existedItemCode);
                item.died(now);
            }
        }

        item.updateByCrawledItem(crawledItem, now);
    }
}

<br>

상품 정보 갱신 시, 상품상태 는 3가지 케이스로 나뉘게 됩니다.

- 1번. 상품 상태 : DEAD
dead_count는 상품크롤에 실패 할 시 증가하는데,
일시적인 장애일 수 있으니 n번의 기회를 부여합니다.
그래도 실패하여 dead_max_count 초과 시, 상품은 DEAD상태로 변경 됩니다.

- 2번. 상품상태 : UPDATE
상품의 가격정보가 바뀌거나, 재고상태가 바뀌거나, 단종되거나 등
어떤 상품의 변화에, 상품은 UPDATE 상태로 변경됩니다.

- 3번. 상품 상태 : BE
아무런 변화가 없다면 BE상태를 유지합니다.

<br>

자 이제, 상품의 가격정보가 변경됬습니다, 사용자에게 알림을 주어야 합니다.

잠깐의 교통정리를 하면, 다음 플로우까지 진행되었습니다.

  1. 봇을 만듭니다.
  2. 사용자 메세지를 수신 합니다.
  3. 사용자의 메세지를 수신하여, 적절하게 로직을 수행하여 응답합니다.
  4. 사용자가 상품을 추가했다면, 다나와를 지켜보며 가격을 확인 후 갱신해줍니다.

<br>

이제, 상품 상태가 변경됬을때, 사용자에게 알림을 주어야합니다.
따라서, 상품 상태 정보 변경을, 누군가 또 지켜봐야합니다.
여기서의 누군가는 telegram 서버입니다.

<img src="https://static.podo-dev.com/blogs/images/2020/02/22/origin/6eda6f2d-742e-4185-871b-b91ba591b3b3.png" alt="base64.png" style="width:400px;">

별도의 알림서버로 분리하는게 맞지만, 아직 진행하지 않고 있습니다.
생각해보면, 굳이 주기적으로 지켜보고있는게 아니라,
이벤트성으로 사용자에게 알림을 전송하는 것이 더 좋은 방향이라 생각됩니다.
추후 반영해볼 예정입니다.

<br>

다시 본론으로 돌아오면
telegram서버에서는 30초 주기로 상품상태를 지켜보고 있습니다.

 @Scheduled(cron = "*/30 * * * * *")
    public void schedule() {
        for (Worker worker : workers) {
            worker.doIt();
        }
    }

해당 스케줄러는,
UPDATE또는 DEAD 상태의 상품을 조회하는 쿼리를 요청합니다.

상품테이블의 스키마를 보면,
상태정보 필드가 있는데, 해당 필드를 이용합니다.

상품 테이블

<img src="https://static.podo-dev.com/blogs/images/2020/02/22/origin/207e8cb0-ce60-44a8-8fdc-6fc668c4100a.png" alt="base64.png" style="width:364px;">

<br>

상품 조회 후에는,
상태정보별로 분기하여, 사용자에게 알림 메세지를 전송합니다.

public class ItemStatusNotifyWorker implements Worker {

    @Override
    public void doIt() {
        log.info("상품 업데이트 알림 WORKER, 상품 상태체크를 시작합니다");

        final LocalDateTime now = LocalDateTime.now();

        handleItemUpdateStatusUpdated(now);  // UPDATED 상태
        handleItemStatusDead(); // DEAD 상태
    }
    
    ///..
    
    //상품 상태별로 알림 전송
    private void handleByItemSaleStatus(LocalDateTime notifyAt, ItemDto.detail item) {
    switch (item.getItemSaleStatus()) {
        case DISCONTINUE:
            handleDiscontinueItem(item); // 상품이 단종되써요!
            break;
        case UNKNOWN: // 상품의 가격정보를 확인 할 수 없어요!
            handleUnknownItem(item); 
            break;
        case EMPTY_AMOUNT: // 상품이 일시품절 됬어요!
            handleEmptyAmount(item, notifyAt);
            break;
        case NOT_SUPPORT: // 더이상 가격 비교를 지원하지 않아요!
            handleNotSupport(item);
            break;
        case SALE: // 상품가격이 변동되었습니다!
            handleSale(item, notifyAt);
            break;
    }
}

<br>

사용자에게 메세지 전송시,
사용자가 봇을 차단할경우.. 또는 일시적인 장애가 발생하면
exception이 발생하게 됩니다.

마찬가지로, n번의 기회를 부여하여
exception 발생 시, 사용자의 error_count를 증가시켰습니다.

<img src="https://static.podo-dev.com/blogs/images/2020/02/22/origin/1155d9b1-ecfe-4731-80e0-50181c182ed2.png" alt="base64.png" style="width:192px;">

max_error_count값을 초과하면, 사용자는 disbled 상태가 됩니다.
사용자의 새로운 메세지 전송이 올 때 까지, 알림에서 제외하도록 하였습니다.

   return new SentCallback<Message>() {
            @Override
            public void onResult(BotApiMethod<Message> method, Message response) {
                //..
            }

            @Override
            public void onError(BotApiMethod<Message> method, TelegramApiRequestException e) {
               //..
            userService.increaseUserErrorCountByTelegramId(telegramId); // 에러 카운트 증가
                log.error("{} >> Send Error, 메시지를 전송 할 수 없습니다 '{}'", telegramId, e.getMessage());
            }

            @Override
            public void onException(BotApiMethod<Message> method, Exception e) {           
            //..
            userService.increaseUserErrorCountByTelegramId(telegramId); // 에러 카운트 증가
                log.error("{} >> Send Exception, 메시지를 전송 할 수 없습니다 '{}'", telegramId, e.getMessage());
            }
        };

<br>

또한 상품의 DEAD상태가 n개 이상일때
관리자에게 알림을 전송하는 로직을 구현하였습니다.

DEAD상태는 크롤이 불가한 상태입니다.
웹페이지의 document가 변경된 상황일 수도 있습니다.
즉 대응해야 하는 상황이 올수 있기 때문에,
관리자에게 알림을 전송하도록하였습니다.

 private void increaseDeadCount() {
        this.deadCount++;

        if (deadCount >= maxDeadCount) {
            log.info("{} 이상 상품페이지를 확인 할 수 없습니다", deadCount);
            globalNotifier.notifyAdmin(NotifyTitle.notifyTooManyDead(unknownCount), NotifyContents.notifyTooManyDead(unknownCount));
            deadCount = 0;
        }

    }

<br>

마무리.

자 이제,

가격이 바뀌거나,
재고가 바뀌거나,
단종되거나,
가격비교를 지원하지 않거나 등!

상품의 상태가 바뀐다면

사용자는 다음과 같은 알림을 받게됩니다.

<img src="https://static.podo-dev.com/blogs/images/2020/02/22/origin/9e464f63-92b9-4b66-96de-1fd1c65b861f.png" alt="base64.png" style="width:350px;">

(!)

<img src="https://static.podo-dev.com/blogs/images/2020/02/24/origin/c57846ef-4afb-4264-a925-d60222e6c5ed.png" alt="base64.png" style="width:250px;">

<br>

ㄱㅇㄷ ^_^
<img src="https://static.podo-dev.com/blogs/images/2020/02/24/origin/bd4986e8-8c5c-4e7f-b126-aefda4d1ae9e.png" alt="base64.png" style="width:240px;">

<br>

후기 및 한계점

한계점

매도 먼저 맞아야!

  • 텔레그램 인터페이스 한계점

    • 현재 가격이 1퍼센트 이하 떨어지면 알림이 전송됩니다.
    • 사용자 마다, 상품별로 가격 알림 기준을 설정하게 하고싶은데,
    • 텔레그램 버튼식 인터페이스는 한계가 있습니다.
    • 메뉴가 2depth만 들어가도, 음 여기가 어디지. 그래 홈으로 돌아가자의 복잡성을 느끼게 됩니다.
  • 텔레그램 서버가 느립니다.

    • 텔레그램은 공짜지만, 서버가 느립니다..그리고 주기적으로 펑!
    • 사용자가 메세지를 보내면, 간혈적으로 응답이 느립니다.
    • 아니이거 왜느려?!라고 구현의 문제점을 찾았는데,
    • 사용자가 메세지를 송신하고.
    • 텔레그램 서버로부터의 수신과정이 오래 걸립니다. (에공..)
  • 다나와가 생각보다 느립니다(?)

    • 정확히 어떤 로직인지는 모르겠지만,
    • 다나와도 계약을 맺던가, 외부 쇼핑몰을 크롤하여 데이터를 갱신할것입니다.
    • 이 사이클이 생각보다 빠르지 않습니다.
    • 따라서 최근 코로나 사태의 마스크 재고 알림과 같이,
    • 핫한 상품에 대해서, 빠르게 대응 할 수가 없습니다.
  • 홍보가 안됩니다(ㅠ)

    • 텔레그램 봇은 숨겨져있습니다.
    • 정보 없이는 해당 봇의 존재를 확인 할 수 없습니다.
    • 최초 커뮤니티에 배포 게시글을 작성하고,
    • 홍보는 진행하고 있지 않습니다.
    • 지속적으로 새로운 사용자가 유입되고 있지만!
    • 그럼에도 증가폭이, 크지 않아 씁슬함을 감출 수 없습니다 (흑)

<br>

회고

굉장히 빨리 만들었지만, 굉장히 불안합니다.

헬로프라이스는 최초 2.0 배포까지 굉장히 빨리 만들었습니다.
앞서 가상화폐가격알리미라는 텔레그램 챗봇 경험도 있으며,
사내에서도 크롤과 관련된 업무를 두어 학습하고 있었습니다.

바로 테스트코드 때문이었습니다.
신나서 만드니 테스트코드는 어디로 갔을까..
완성하고 나서 배포하고 보니,
버그가 있진 않을까라는 불안감이 생각이 듭니다.

테스트코드의 중요함을 다시 한번 깨닫는 순간이었습니다.

2019년의 가장큰 반성은 테스트코드였습니다.
2019년의 회고록의 내용처럼,
현재 테스트코드를 슬금 슬금 짜고 있습니다(!)

<br>

개발하고 배포하고 나서 굉장히 뿌듯함을 느꼈습니다 (와!)

  1. 덕분에, 사용자가 상품을 싸게 샀다. (와!)
  2. 덕분에, 지인이 상품을 싸게 샀다. (와!)
  3. 덕분에, 내가 상품을 싸게 샀다. (와!)

<br>

다나와한테도 이득이지 않을까? (합리화...)
다나와의 일부 수익구조는
다나와 링크를 통해 상품을 구입하면,
쇼핑몰업체로부터 일부 판매수수료를 받는것으로 알고있습니다
따라서, 헬로프라이스다나와악어악어새 관계가 아닐까 (?)

<br>

드르륵...드르륵..
10초주기로 크롤하니,
방에 있는 NAS가 새벽에도 10초마다 드르륵 거립니다..(..)
지금은 적응해서 잘 자고 있습니다..zzZ

<br>

감사합니다.

읽어주셔서 감사합니다 :)

헬로프라이스는 지속적으로 업데이트 합니다!

CommentCount 0
이전 댓글 보기
등록
이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.
TOP