개발 ON
  • [Spring] RSS(XML) Parsing
    2024년 09월 21일 14시 32분 43초에 업로드 된 글입니다.
    작성자: 이주여이

    개인 홈페이지 배포하고 공부한 내용들을 올리려는데 블로그에 올린 내용이랑 겹치다보니 번거롭다는 생각이 들었다.
     
    티스토리에서 RSS 지원하는 걸 알고 있어서 이 김에 RSS 파싱에 대해 다뤄봤다.
     
    생각을 여러 개 좀 했었는데 뭐냐면..
     
    DB를 다뤄야 하나.. 최신 글만 필요한걸까.. 내가 나중에 갖고오고 싶은 글을 가져오려나 생각 했었는데 아직은 그렇게까지는 할 생각이 없어서 DB에 값 안넣고 RSS 연동해서 최신 글만 가져오는 걸로 결정했다.
     
    일단 RSS을 파싱할 파일 혹은 URL이 있어야 한다.
     
    나는 내 티스토리 블로그의 RSS 주소를 application.yaml에 저장해놨다.
     

    rss:
      url: https://mytilblog.tistory.com/rss

     

     
    이건 내 티스토리 블로그의 RSS 구성인데 참고하면 좋을 것 같아서 캡처했다.
     
    item이라는 Node를 왜 가져와서 NodeList에 담고 그 NodeLIst에서 왜 title, link, PubDate라는 태그 값을 가져오는지 소스코드랑 비교하면서 보자.
     
    이제 RSS를 파싱할 클래스를 만들어준다.
     
    참고로 나는 responseMap이라는 List<Map<String, Object>>에 응답 코드와 결과 값을 담을 것이다. (DTO 만들기 귀찮아서 이렇게 하는 거 맞다)
     

    package com.project.homepage.cmmn.util;
    
    import java.io.IOException;
    import java.util.ArrayList;
    import java.util.HashMap;
    import java.util.List;
    import java.util.Map;
    
    import javax.xml.parsers.DocumentBuilder;
    import javax.xml.parsers.DocumentBuilderFactory;
    import javax.xml.parsers.ParserConfigurationException;
    
    import org.slf4j.Logger;
    import org.slf4j.LoggerFactory;
    import org.springframework.beans.factory.annotation.Value;
    import org.springframework.stereotype.Component;
    import org.w3c.dom.Document;
    import org.w3c.dom.Element;
    import org.w3c.dom.Node;
    import org.w3c.dom.NodeList;
    import org.xml.sax.SAXException;
    
    import com.project.homepage.cmmn.Const;
    import com.project.homepage.cmmn.ResponseCode;
    
    import jakarta.annotation.PostConstruct;
    
    @Component
    public class RSSParseUtil {
        // 응답 코드와 결과 값을 담을 Map
        public static Map<String, Object> responseMap             = new HashMap<String, Object>();
        // 결과 값을 담을 List
        private List<Map<String, Object>> rss                     = new ArrayList<Map<String, Object>>();
        // Slf4j(STS에서는 @Slf4j가 잘 안먹어서 직접 적었다)
        private final Logger log                                  = LoggerFactory.getLogger(getClass());
        // application.yaml에 있는 프로퍼티를 담을 변수 생성
        private final String url;
    
        // resources/application.yaml에 있는 프로퍼티 중 rss.url에 해당하는 프로퍼티의 value를 담는다.
        public RSSParseUtil(@Value("${rss.url}") String url) {
            // this는 객체 주소 값이 담겨져 있다.
            // 그렇다면 this.url은 위에서 선언한 private final String url이 해당 주소 값이며 여기에 값이 할당된다.
            this.url = url;
        }
    
        // 의존성 주입 이후 딱 한 번 실행된다.
        @PostConstruct
        public List<Map<String, Object>> rssParse() throws ParserConfigurationException, SAXException, IOException {
            try {
               // 메소드 호출하는 클래스의 이름을 로그로 찍는다.
                log.info("{}", getClass());
    
                // XML 문서를 파싱할 수 있는 빌더를 제공하는 객체 생성
                DocumentBuilderFactory factory             = DocumentBuilderFactory.newInstance();
                // XML 문서를 파싱할 수 있는 빌더 객체 생성
                DocumentBuilder builder                    = factory.newDocumentBuilder();
                // URL을 읽어 XML 문서를 Dom 객체로 파싱한다.
                // 아래 소스코드를 보면 알겠지만 HTML Document ...
                Document document                          = builder.parse(url);
                // XML 문서에서 <item> 태그를 가진 모든 노드를 찾아 NodeList 참조변수에 저장한다.
                NodeList list                              = document.getElementsByTagName("item");
    
                // 나는 게시글 10개까지만 출력할 것 이기 때문에 10으로 지정했다.
                for (int i = 0; i < 10; i++) {
                    // <item> 태그를 Node의 참조변수에 저장한다.
                    Node node = list.item(i);
    
                    // Node가 태그 요소일 경우 실행된다.
                    if (node.getNodeType() == Node.ELEMENT_NODE) {
                        // Node를 Element 즉 요소로 형변환한다.
                        // Element로 바꾸면 자식 요소를 쉽게 다룰 수 있다. 아래 처럼 ...
                        Element element               = (Element) node;
                        String title                  = element.getElementsByTagName("title").item(0).getTextContent();
                        String link                   = element.getElementsByTagName("link").item(0).getTextContent();
                        String date                   = element.getElementsByTagName("pubDate").item(0).getTextContent();
                        Map<String, Object> map       = new HashMap<String, Object>();
                        map.put("title", title);
                        map.put("link", link);
                        map.put("date", date);
                        rss.add(map);
                    }
                }
    
                responseMap.put(Const.RESULT   , ResponseCode.SUCCESS.code);
                responseMap.put(Const.RSS      , rss);
    
                log.info("responseMap = {}", responseMap);
    
            } catch (ParserConfigurationException | SAXException | IOException e) {
                responseMap.put(Const.RESULT, ResponseCode.RSS_PARSE_ERROR.code);
    
                log.info("responseMap = {}", responseMap);
            }
            return rss;
        }
    }

     
    이렇게하고 나서 내가 출력할 페이지는 Home이기 때문에 HomeController로 간다.
     

    package com.project.homepage.home;
    
    import java.io.IOException;
    import java.util.List;
    import java.util.Map;
    
    import javax.xml.parsers.ParserConfigurationException;
    
    import org.slf4j.Logger;
    import org.slf4j.LoggerFactory;
    import org.springframework.stereotype.Controller;
    import org.springframework.ui.Model;
    import org.springframework.web.bind.annotation.GetMapping;
    import org.springframework.web.bind.annotation.RequestParam;
    import org.xml.sax.SAXException;
    
    import com.project.homepage.cmmn.Const;
    import com.project.homepage.cmmn.util.RSSParseUtil;
    
    import jakarta.annotation.PostConstruct;
    
    @Controller
    public class HomeController {
        private final Logger log = LoggerFactory.getLogger(getClass());
        private final HomeService service;
        private final RSSParseUtil rssReaderUtil;
    
        public HomeController(HomeService service, RSSParseUtil rssReaderUtil) {
            this.service            = service;
            this.rssReaderUtil      = rssReaderUtil;
        }
    
        @GetMapping("/")
        public String home(@RequestParam Map<String, Object> requestMap, Model model) throws ParserConfigurationException, SAXException, IOException {
            List<Map<String, Object>> latestPostGet = service.latestPostGet(requestMap);                        // 게시판 별 최신 글 5개씩 ...
    
            // RSS 관련
            Map<String, Object> responseMap = rssReaderUtil.responseMap;                                        // rssParseUtil 클래스 rssParse 메소드의 결과 값
            int responseCode                        = (int) responseMap.get(Const.RESULT);                      // responseMap에서 꺼낸 응답 코드(정상 / 에러)
            List<Map<String, Object>> rss           = (List<Map<String, Object>>)responseMap.get(Const.RSS);    // responseMap에서 꺼낸 Tistory Blog RSS 결과물
    
            for(Map<String, Object> post : latestPostGet) {
                post.putIfAbsent("NAME", "");
            }
    
            log.info("responseMap = {}"     , responseMap);
            log.info("responseCode = {}"    , responseCode);
            log.info("rss = {}"             , rss);
    
            if(responseCode == 1) {
                model.addAttribute(Const.RSS, rss);
            }
    
            model.addAttribute(Const.RESULT    , responseCode);
            model.addAttribute(Const.DATA      , latestPostGet);
            return "home";
        }
    }

     

     
    로그도 잘 찍혀나오고 @PostConstruct 어노테이션을 사용했다보니 의존성 주입 이후 한 번만 실행되는 것도 확인할 수 있다.
     
    근데 저 부분은 스프링 스케줄러를 통해 6시간에 한번씩 실행되게 다시 수정할꺼라.. 넘어가자.
     
    그리고 이제 RSS 파싱 후 받아 온 값을 클라이언트에 뿌려보자.
     

    <div id="thumbnail-home-study">
        <p class="thumbnail-home-title">STUDY(RSS)</p>
        <div class="rss-contents-wrap">
            <th:block th:if="${RESULT == 1}" th:each="rss : ${RSS}">
                    <div class="rss-item">
                        <p class="rss-contents">
                            <a th:href="${rss.link}" target="_blank"><span th:text="${rss.title}"></span></a>
                            <span th:text="${#dates.format(rss.date, 'yyyy-MM-dd')}"></span>
                        </p>
                    </div>
            </th:block>
    
            <th:block th:if="${RESULT == -4}">
                    <div class="rss-item">
                        <p class="rss-contents-error">
                            <span>RSS를 불러올 수 없습니다.</span>
                        </p>
                    </div>
            </th:block>
        </div>
    </div>

     
    응답 코드는 RESULT에 담겨져 있고 이 코드가 1일 경우엔 정상적으로 데이터를 출력하고 예외가 발생해서 RSSParseUtil에서 예외를 던질 경우엔 응답 코드에 -4가 들어있다. 이 때는 RSS를 불러올 수 없다는 문구를 출력한다.
     
    예외를 터트릴꺼면 나같은 경우에는 RSS 주소 철자 중 하나를 다르게 입력했다. mytilblog 라면 me ~ 이런 식으로 ... 그 때 예외 메세지가 출력되면 제대로 예외 처리가 된 것이다.
     

     

     
    게시글의 저 사진들은 썸네일 첨부 안했을 경우 NO IMAGE 맥락으로 내가 띄워준거라 넘기고 보면 된다.
     

    💥 RSS 게시글이 쌓이는 이슈

     
    폰으로 들어갔다가 RSS 게시글들이 2번씩 출력되길래 소스코드를 다시 확인하니 rss 파싱하는 메소드가 실행될 때 안에있던 데이터를 지우지 않아서 게시글이 쌓여있었다.
     
    responseMap은 배포하고 1시간마다 tistory에 올라간 글 있는지 확인한다고 스케줄러 돌리고 있어서 그냥 static으로 바로 호출할 수 있게 만들어놨는데.. rss는 1시간마다 다시 지워지고 데이터를 받아와야하는데 클래스 레벨에 변수를 선언할 필요가 있었나? 라는 생각이 들었다.
     
    그래서 아래와 같이 생성 시점을 수정하고 1시간마다 responseMap 내에 있는 데이터를 지우고 다시 데이터를 저장하는 방식으로 수정했다.
     
    수정된 소스코드는 아래와 같다.
     

    @Component
    public class RSSParseUtil {
        public Map<String, Object> responseMap          = new HashMap<String, Object>();
        private final Logger log                        = LoggerFactory.getLogger(getClass());
    //    private final int fixedRate                   = 60000;    // 1분(테스트용)
    //    private final int fixedRate                   = 21600000;    // 6시간(배포용)
        private final int fixedRate                     = 3600000;    // 1시간(배포용)
        private final String url;
    
        public RSSParseUtil(@Value("${rss.url}") String url) {
            this.url = url;
        }
    
        @Scheduled(fixedRate = fixedRate)
    //    @PostConstruct
        public void rssParse() throws ParserConfigurationException, SAXException, IOException {
            try {
                responseMap.clear();
    
                List<Map<String, Object>> rss    = new ArrayList<Map<String, Object>>();
                DocumentBuilderFactory factory   = DocumentBuilderFactory.newInstance();
                DocumentBuilder builder          = factory.newDocumentBuilder();
                Document document                = builder.parse(url);
                NodeList list                    = document.getElementsByTagName("item");
    
                for (int i = 0; i < 10; i++) {
                    Node node = list.item(i);
    
                    if (node.getNodeType() == Node.ELEMENT_NODE) {
                        Element element         = (Element) node;
                        String title            = element.getElementsByTagName("title").item(0).getTextContent();
                        String link             = element.getElementsByTagName("link").item(0).getTextContent();
                        String date             = element.getElementsByTagName("pubDate").item(0).getTextContent();
                        Map<String, Object> map = new HashMap<String, Object>();
                        map.put("title"    , title);
                        map.put("link"    , link);
                        map.put("date"    , date);
                        rss.add(map);
                    }
                }
    
                responseMap.put(Const.RESULT , ResponseCode.SUCCESS.code);
                responseMap.put(Const.RSS    , rss);
    
            } catch (ParserConfigurationException | SAXException | IOException e) {
                responseMap.put(Const.RESULT , ResponseCode.RSS_PARSE_ERROR.code);
            }
        }
    }

    참고 레퍼런스
    https://zzangyeon.tistory.com/137

    'Study > Spring' 카테고리의 다른 글

    [Spring] CustomExceptionController  (0) 2024.11.29
    [Spring] 검색 결과 미리보기  (0) 2024.11.17
    [Spring] 공개/비공개글  (2) 2024.11.03
    댓글