본문 바로가기
Projects/청하-청년을 위한 커뮤니티 서비스

[청하] 24. 청년 정책 Open API 연동 및 데이터베이스 저장 (feat. XML 데이터 매핑, 스케줄러 적용)

by Lpromotion 2024. 10. 17.

기존에는 클라이언트 단에서 정책 Open API를 연동하고 조회할 수 있도록 했는데,

정책 데이터를 데이터베이스에 저장하는 방식으로 변경하기로 하여 서버에서 연동 작업을 하게 되었다.

 

작업 내용

  • 정책 Open API 연동
  • 정책 데이터베이스 모델링
  • 정책 Open API 호출하여 데이터베이스에 저장
  • 주 1회 데이터 리셋하고 정책 Open API 호출하여 최신 데이터 저장

 

1. 프로젝트 구조

src/main/java/com/example/withpeace/
│
├── domain/                 # 도메인 모델 (엔티티)
│   └── YouthPolicy.java    # 청년 정책 엔티티
│
├── repository/                       # 데이터 접근 계층
│   └── YouthPolicyRepository.java    # 게시글 레포지토리
│
├── dto/                                     # 데이터 전송 객체
│   └── response/
│       └── YouthPolicyListResponseDto.java  #  응답 DTO
│       └── YouthPolicyResponseDto.java      #  응답 DTO
│
├── controller/               # 컨트롤러 계층
│   └── PolicyController.java # 정책 관련 API 엔드포인트
│
└── service/                  # 비즈니스 로직 계층
    └── PolicyService.java    # 정책 관련 비즈니스 로직

 

2. 스프링부트 프로젝트 설정 - 의존성 추가

build.gradle

dependencies {
    implementation 'com.fasterxml.jackson.dataformat:jackson-dataformat-xml'
}

XML 형식의 데이터를 처리하기 위한 Jackson 라이브러리를 추가한다.

 

3. 도메인 엔티티 구현

YouthPolicy

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@DynamicUpdate
@Table(name = "youth_policies")
public class YouthPolicy {
    @Id
    @Column(name = "rnum", nullable = false, unique = true)
    private Long rnum;

    @Column(name = "id", nullable = false)
    private String id; // 정책 id

    @Column(name = "title")
    private String title; // 정책명

    @Column(name = "introduce")
    private String introduce; // 정책 소개

    @Enumerated(EnumType.STRING)
    @Column(name = "region")
    private EPolicyRegion region; // 지역 코드

    @Enumerated(EnumType.STRING)
    @Column(name = "classification")
    private EPolicyClassification classification; // 정책 분야 코드

    @Column(name = "age_info")
    private String ageInfo; // 연령 정보

    @Column(name = "application_details")
    private String applicationDetails; // 신청 세부 사항

    @Column(name = "residence_and_income")
    private String residenceAndIncome; // 거주지 및 소득 조건

    @Column(name = "education")
    private String education; // 학력 요건

    @Column(name = "specialization")
    private String specialization; // 전공 요건

    @Column(name = "additional_notes")
    private String additionalNotes; // 추가 사항

    @Column(name = "participation_restrictions")
    private String participationRestrictions; // 참여 제한 사항

    @Column(name = "application_process")
    private String applicationProcess; // 신청 절차

    @Column(name = "screening_and_announcement")
    private String screeningAndAnnouncement; // 심사 발표 내용

    @Column(name = "application_site")
    private String applicationSite; // 신청 사이트 주소

    @Column(name = "submission_documents")
    private String submissionDocuments; // 제출 서류 내용

    @Builder
    public YouthPolicy(String rnum, String id, String title, String introduce, String regionCode, String classificationCode,
                       String ageInfo, String applicationDetails, String residenceAndIncome, String education,
                       String specialization, String additionalNotes, String participationRestrictions,
                       String applicationProcess, String screeningAndAnnouncement, String applicationSite,
                       String submissionDocuments) {
        this.rnum = Long.parseLong(rnum);
        this.id = id;
        this.title = title;
        this.introduce = introduce.equals("null") ? "-" : introduce;
        this.region = EPolicyRegion.fromCode(regionCode);
        this.classification = EPolicyClassification.fromCode(classificationCode);
        this.ageInfo = ageInfo.equals("null") ? "-" : ageInfo;
        this.applicationDetails = applicationDetails.equals("null") ? "-" : applicationDetails;
        this.residenceAndIncome = residenceAndIncome.equals("null") ? "-" : residenceAndIncome;
        this.education = education.equals("null") ? "-" : education;
        this.specialization = specialization.equals("null") ? "-" : specialization;
        this.additionalNotes = additionalNotes.equals("null") ? "-" : additionalNotes;
        this.participationRestrictions = participationRestrictions.equals("null") ? "-" : participationRestrictions;
        this.applicationProcess = applicationProcess.equals("null") ? "-" : applicationProcess;
        this.screeningAndAnnouncement = screeningAndAnnouncement.equals("null") ? "-" : screeningAndAnnouncement;
        this.applicationSite = applicationSite.equals("null") ? "-" : applicationSite;
        this.submissionDocuments = submissionDocuments.equals("null") ? "-" : submissionDocuments;
    }
}

정책 데이터를 데이터베이스에 저장하고 관리하기 위한 정책 엔티티 클래스이다.

 

this.introduce = introduce.equals("null") ? "-" : introduce;

API를 호출하여 데이터를 살펴봤을 때, <![CDATA[null]]> 와 같이 “null” 문자열이 들어있는 경우가 있어 “null”일 경우 “-”이 저장되도록 작성했다.

 

4. 레포지토리 구현

YouthPolicyRepository

public interface YouthPolicyRepository extends JpaRepository<YouthPolicy, Long> {}

 

5. DTO 구현 (XML 데이터 매핑)

정책 Open API를 호출했을 때 예시 출력 결과는 다음과 같다.

<?xml version="1.0" encoding="UTF-8"?>
<youthPolicyList>
   <pageIndex>1</pageIndex>
   <totalCnt>1945</totalCnt>
   <youthPolicy>
      <rnum>1</rnum>
      <bizId><![CDATA[R2024062424328]]></bizId>
      <polyBizSecd>003002001024</polyBizSecd>
      <polyBizTy>지자체</polyBizTy>
      <polyBizSjnm><![CDATA[파이썬&웹기반 빅데이터 분석 전문가 과정 교육생 모집]]></polyBizSjnm>
      <polyItcnCn><![CDATA[청년과 고용취약계층에게 지역산업과 연계된 생산적 일자리 발굴로 직무역량을 높이고 민간취업 연계 강화]]></polyItcnCn>
      <sporCn><![CDATA[■ 교육개요

  - 교육기간: 2024. 7. 4. ~ 8. 29.

  - 교육내용: 파이썬 기초문법, 데이터분석, 데이터분석 활용 프로젝트 수행 등

  - 교육장소: 송파여성인력개발센터(송파구 중대로9길 34, 2층)]]></sporCn>
      <sporScvl><![CDATA[-]]></sporScvl>
      <bizPrdCn><![CDATA[null]]></bizPrdCn>
      <prdRpttSecd><![CDATA[002004]]></prdRpttSecd>
      <rqutPrdCn><![CDATA[2024-05-20~2024-06-30
2024. 5. 20. ~ 6. 30.]]></rqutPrdCn>
      <ageInfo><![CDATA[만 20세 ~ 39세]]></ageInfo>
      <majrRqisCn><![CDATA[제한없음
※ 관련 전공자/경력자/교육이수자 우대]]></majrRqisCn>
      <empmSttsCn><![CDATA[제한없음]]></empmSttsCn>
      <splzRlmRqisCn><![CDATA[제한없음]]></splzRlmRqisCn>
      <accrRqisCn><![CDATA[제한없음]]></accrRqisCn>
      <prcpCn><![CDATA[20 ~ 39세 이하 송파구 거주 구직자]]></prcpCn>
      <aditRscn><![CDATA[null]]></aditRscn>
      <prcpLmttTrgtCn><![CDATA[null]]></prcpLmttTrgtCn>
      <rqutProcCn><![CDATA[송파여성인력개발센터 홈페이지(www. songpa.seoulwomanup.or.kr) > 교육프로그램 > 직업훈련 참조
※ 직종설명회[2024. 7. 1.(월) 14시] 당일 면접진행 후 최종선발]]></rqutProcCn>
      <pstnPaprCn><![CDATA[null]]></pstnPaprCn>
      <jdgnPresCn><![CDATA[null]]></jdgnPresCn>
      <rqutUrla><![CDATA[www. songpa.seoulwomanup.or.kr]]></rqutUrla>
      <rfcSiteUrla1><![CDATA[http://songpaict.com]]></rfcSiteUrla1>
      <rfcSiteUrla2><![CDATA[https://www.songpa.go.kr/www/index.do]]></rfcSiteUrla2>
      <mngtMson><![CDATA[송파구청 경제진흥과]]></mngtMson>
      <mngtMrofCherCn><![CDATA[null]]></mngtMrofCherCn>
      <cherCtpcCn><![CDATA[02-2147-4915]]></cherCtpcCn>
      <cnsgNmor><![CDATA[-]]></cnsgNmor>
      <tintCherCn><![CDATA[null]]></tintCherCn>
      <tintCherCtpcCn><![CDATA[null]]></tintCherCtpcCn>
      <etct><![CDATA[송파여성인력개발센터 070-4322-2883]]></etct>
      <polyRlmCd><![CDATA[023030]]></polyRlmCd>
   </youthPolicy>
   ...
</youthPolicyList>

<youthPolicyList> 태그 안에 <pageIndex>와 <totalCnt>가 있어 전체 페이지 수와 총 정책 수를 알 수 있다. 각 정책 정보는 <youthPolicy> 태그 안에 포함되어 있고, <rnum>을 통해 정책의 순서를 구분할 수 있다.

 

 

5.1. YouthPolicyListResponseDto

@Builder
@JsonIgnoreProperties(ignoreUnknown = true)
public record YouthPolicyListResponseDto(
        @JacksonXmlProperty(localName = "pageIndex")
        int pageIndex,

        @JacksonXmlProperty(localName = "totalCnt")
        int totalCount,

        @JacksonXmlElementWrapper(useWrapping = false)
        @JacksonXmlProperty(localName = "youthPolicy")
        List<YouthPolicyResponseDto> youthPolicyListResponseDto
) {

정책 리스트 API 호출 결과를 매핑하는 DTO 클래스이다. 페이지 인덱스와 총 데이터 개수, 정책 리스트를 담는다.

출력 결과에서 원하는 데이터만 가지고 오도록 DTO를 생성했다.

 

  • @JsonIgnoreProperties(ignoreUnknown = true)
    • JSON 파싱 할 때 DTO에 정의되지 않은 필드가 있어도 무시하고 파싱을 진행한다. 필요한 필드만 사용할 수 있다.
  • @JacksonXmlElementWrapper
    • 컬렉션 타입의 필드를 XML 요소로 매핑할 때, 그 컬렉션을 감싸는 래퍼(Wrapper) 요소를 지정할 수 있는 어노테이션이다. 컬렉션을 특정한 부모 태그로 감싸는 구조를 만들 수 있다. (컬렉션 매핑)
    • useWrapping 속성은 true 일 경우 컬렉션을 부모 요소로 감싸고, false 이면 개별 요소로 직렬화한다. false 이면 컬렉션의 각 요소는 직접적으로 XML의 개별 요소로 매핑된다.
  • @JacksonXmlProperty
    • 필드를 특정 XML 요소와 매핑하는데 사용한다. XML 요소의 이름을 지정해서 필드와 XML 요소 간의 매핑을 정의한다. (필드 매핑)
    • localName 속성은 필드가 매핑될 XML 요소의 이름을 지정한다.

 

코드로 다시 살펴보면

@JacksonXmlElementWrapper(useWrapping = false)
@JacksonXmlProperty(localName = "youthPolicy")
List<YouthPolicyResponseDto> youthPolicyListResponseDto
  • @JacksonXmlElementWrapper(useWrapping = false): XML 응답에서 요소가 컬렉션을 감싸지 않도록 한다. 각 YouthPolicyResponseDto 객체는 태그로 직접 매핑된다.
  • @JacksonXmlProperty(localName = "youthPolicy"): YouthPolicyResponseDto 리스트의 각 요소를 XML의 요소와 매핑한다.

 

정리하다 보니 용어가 헷갈려서 용어 정리

  • 요소 (Element)
    • XML이나 HTML 문서에서 데이터를 구성하는 태그
    • 데이터를 구조화하고, 태그를 통해 데이터의 계층적 관계를 정의
  • 객체 (Object)
    • 클래스의 인스턴스로, 메모리상에 존재하는 실제 데이터와 메서드를 가진 실체
    • 클래스에 정의된 속성과 메서드를 사용하여 실제 동작을 수행
  • 필드 (Field)
    • 클래스나 객체가 가지는 데이터 속성을 저장하는 변수
    • 클래스의 인스턴스 변수로, 객체의 상태를 저장

 

5.2. YouthPolicyResponseDto

@JsonIgnoreProperties(ignoreUnknown = true)
public record YouthPolicyResponseDto(
        @JacksonXmlProperty(localName = "rnum")
        String rnum,

        @JacksonXmlProperty(localName = "bizId")
        String id,

        @JacksonXmlProperty(localName = "polyBizSjnm")
        String title,

        @JacksonXmlProperty(localName = "polyItcnCn")
        String introduce,

        @JacksonXmlProperty(localName = "polyRlmCd")
        String classificationCode,

        @JacksonXmlProperty(localName = "polyBizSecd")
        String regionCode,

        @JacksonXmlProperty(localName = "ageInfo")
        String ageInfo,

        @JacksonXmlProperty(localName = "sporCn")
        String applicationDetails,

        @JacksonXmlProperty(localName = "prcpCn")
        String residenceAndIncome,

        @JacksonXmlProperty(localName = "accrRqisCn")
        String education,

        @JacksonXmlProperty(localName = "splzRlmRqisCn")
        String specialization,

        @JacksonXmlProperty(localName = "aditRscn")
        String additionalNotes,

        @JacksonXmlProperty(localName = "prcpLmttTrgtCn")
        String participationRestrictions,

        @JacksonXmlProperty(localName = "rqutProcCn")
        String applicationProcess,

        @JacksonXmlProperty(localName = "jdgnPresCn")
        String screeningAndAnnouncement,

        @JacksonXmlProperty(localName = "rqutUrla")
        String applicationSite,

        @JacksonXmlProperty(localName = "pstnPaprCn")
        String submissionDocuments
) {
}

정책 상세 데이터를 담는 DTO 이다.

각 필드는 Jackson 라이브러리의 @JacksonXmlProperty 어노테이션을 사용하여 XML 응답의 요소와 매핑된다.

 

5.3. 두 DTO 매핑 과정

YouthPolicyListResponseDto 와 YouthPolicyResponseDto 는 외부 API의 XML 응답을 Java 객체로 매핑하여 처리하기 위한 DTO 이다.

  1. YouthPolicyListResponseDto는 페이지 인덱스, 정책 총 개수, 그리고 정책 목록을 매핑한다.
  2. 각 요소는 YouthPolicyResponseDto 객체로 매핑되어 youthPolicyListResponseDto 리스트에 추가된다.

 

6. Enum 구현

6.1. EPolicyRegion

package com.example.withpeace.type;

import java.util.HashMap;
import java.util.Map;

public enum EPolicyRegion {
    중앙부처("003001"),
    서울("003002001"),
    부산("003002002"),
    대구("003002003"),
    인천("003002004"),
    광주("003002005"),
    대전("003002006"),
    울산("003002007"),
    경기("003002008"),
    강원("003002009"),
    충북("003002010"),
    충남("003002011"),
    전북("003002012"),
    전남("003002013"),
    경북("003002014"),
    경남("003002015"),
    제주("003002016"),
    세종("003002017"),
    기타("");

    private final String code;

    EPolicyRegion(String code) {
        this.code = code;
    }

    public String getCode() {
        return code;
    }

    // 코드 값을 통해 EPolicyRegion을 찾기 위해 HashMap 사용
    private static final Map<String, EPolicyRegion> codeToRegionMap = new HashMap<>();

    static {
        for (EPolicyRegion region : EPolicyRegion.values()) {
            codeToRegionMap.put(region.getCode(), region);
        }
    }

    public static EPolicyRegion fromCode(String code) {
        if (code.substring(0, 6).equals(중앙부처.getCode())){
            return 중앙부처;
        } else if (code.length() >= 9) {
            String substringCode = code.substring(0, 9);
            return codeToRegionMap.getOrDefault(substringCode, 기타);
        } else {
            return 기타;
        }
    }
}

지역 정보를 관리하는 Enum 클래스이다.

각 지역은 고유한 코드를 가지며, 이 코드를 통해 지역을 식별할 수 있다.

 

fromCode 메서드

주어진 코드 값에 해당하는 지역을 반환한다.

코드 값을 통해 지역을 반환해야 하기 때문에 처음에는 for문을 사용해서 해당하는 지역을 반환하도록 구현했는데, 데이터가 많으면 자원이 많이 소모된다.

그래서 지역과 코드 간의 매핑을 HashMap 을 사용해서 O(1)의 시간 복잡도로 빠르게 지역을 검색할 수 있도록 구현했다.

  • 정적 HashMap인 codeToRegionMap을 사용하여 코드를 EPolicyRegion으로 매핑한다.
  • “중앙부처”의 경우 별도로 처리하고, 그 외의 경우 코드의 앞 9자리를 사용해 지역을 결정한다.
    (”중앙부처”는 6자리 코드를 가지기 때문)
  • 매칭되는 지역이 없을 경우 “기타”를 반환한다.

 

코드 값으로 해당하는 지역을 반환하도록 구현한 이유

기존 클라이언트에서 진행하던 방식이 Open API 를 호출하는 방식으로 이루어졌다. 이때 코드 값을 통해 API 호출을 할 수 있었다.

서버에서 API를 연동하는 것으로 변경하게 되어, 최대한 클라이언트의 변경을 최소화하고자 클라이언트 호출 방식에서 크게 벗어나지 않는 방향으로 구현하였다.

 

6.2. EPolicyClassification

package com.example.withpeace.type;

import java.util.HashMap;
import java.util.Map;

public enum EPolicyClassification {
    JOB("023010"),
    RESIDENT("023020"),
    EDUCATION("023030"),
    WELFARE_AND_CULTURE("023040"),
    PARTICIPATION_AND_RIGHT("023050"),
    ETC("");

    private final String code;

    EPolicyClassification(String code) {
        this.code = code;
    }

    public String getCode() {
        return code;
    }

    // 코드 값을 통해 EPolicyClassification을 찾기 위해 HashMap 사용
    private static final Map<String, EPolicyClassification> codeToClassificationMap = new HashMap<>();

    static {
        for (EPolicyClassification classification : EPolicyClassification.values()) {
            codeToClassificationMap.put(classification.getCode(), classification);
        }
    }

    public static EPolicyClassification fromCode(String code) {
        if (code.length() >= 6) {
            return codeToClassificationMap.getOrDefault(code, ETC);
        } else {
            return ETC;
        }
    }
}

정책 분류를 관리하는 Enum 클래스이다.

 

fromCode 메서드

주어진 코드 값에 해당하는 지역을 반환한다.

EPolicyRegion의 fromCode()와 마찬가지로 HashMap 을 사용해서 정책 분류와 코드 간의 매핑을 구현하고, O(1)의 시간 복잡도로 정책 분류를 검색하도록 했다.

  • 정적 HashMap인 codeToClassificatinoMap을 사용하여 코드를 EPolicyClassification으로 매핑한다.
  • 코드가 6자리 미만이거나 매칭되는 분류가 없을 경우 “ETC”를 반환한다.

 

7. 서비스 구현

Open API 데이터 - 청년 정책(신) API 사용

API 스펙

데이터베이스에 모든 데이터를 저장하기 위해서는 API 요청을 반복적으로 호출해서 페이지별로 데이터를 수집할 수 있다.

  1. 총 페이지 수 확인: 처음 API 호출을 통해 totalCnt를 확인하고, 이를 통해 전체 페이지 수를 계산한다.
  2. 반복적으로 API 호출: 페이지 인덱스를 변경하면서 반복적으로 API를 호출하여 모든 데이터를 수집한다.
  3. 데이터 저장: 수집한 데이터를 데이터베이스에 저장한다.

 

API 호출, XML 파싱, 데이터 저장하는 서비스를 생성한다.

@Service
@RequiredArgsConstructor
@Slf4j
public class YouthPolicyService {

    @Value("${youth-policy.api-key}")
    private String apiKey;

    private int saveCount = 0;

    private final YouthPolicyRepository youthPolicyRepository;

    @Transactional
    private void fetchAndSaveYouthPolicy(){
        try {
            RestTemplate restTemplate = new RestTemplate();
            XmlMapper xmlMapper = new XmlMapper();

            // 첫 번째 페이지 요청
            String apiUrl = "https://www.youthcenter.go.kr/opi/youthPlcyList.do" +
                    "?openApiVlak=" + apiKey + "&pageIndex=1&display=1";

            // XML 파싱 & 전체 페이지 수 계산
            String firstPageResponse = restTemplate.getForObject(apiUrl, String.class);
            YouthPolicyListResponseDto firstPageData = xmlMapper.readValue(firstPageResponse, YouthPolicyListResponseDto.class);
            int totalCount = firstPageData.totalCount();
            int pageCount = (totalCount/100) + ((totalCount%100 == 0) ? 0 : 1);

            List<YouthPolicy> entities = new ArrayList<>();

            // 모든 페이지에 대해 데이터 수집
            for(int pageIndex=1; pageIndex<=pageCount; pageIndex++){
                String pageUrl = "https://www.youthcenter.go.kr/opi/youthPlcyList.do" +
                        "?openApiVlak=" + apiKey + "&pageIndex=" + pageIndex + "&display=100";
                String pageResponse = restTemplate.getForObject(pageUrl, String.class);
                YouthPolicyListResponseDto pageData = xmlMapper.readValue(pageResponse, YouthPolicyListResponseDto.class);

                List<YouthPolicyResponseDto> policies = pageData.youthPolicyListResponseDto();
                entities.addAll(loadPolicies(policies));
            }

            // 수집된 모든 데이터를 한 번에 저장
            youthPolicyRepository.saveAll(entities);
            saveCount = entities.size();

        } catch (Exception e) {
            throw new CommonException(ErrorCode.YOUTH_POLICY_FETCH_AND_SAVE_ERROR);
        }

    }

    @Transactional
    private List<YouthPolicy> loadPolicies(List<YouthPolicyResponseDto> policies) {
        List<YouthPolicy> entities = new ArrayList<>();
        for(YouthPolicyResponseDto policyDto : policies) {
            YouthPolicy entity = YouthPolicy.builder()
                    .rnum(policyDto.rnum())
                    .id(policyDto.id())
                    .title(policyDto.title())
                    .introduce(policyDto.introduce())
                    .regionCode(policyDto.regionCode())
                    .classificationCode(policyDto.classificationCode())
                    .ageInfo(policyDto.ageInfo())
                    .applicationDetails(policyDto.applicationDetails())
                    .residenceAndIncome(policyDto.residenceAndIncome())
                    .education(policyDto.education())
                    .specialization(policyDto.specialization())
                    .additionalNotes(policyDto.additionalNotes())
                    .participationRestrictions(policyDto.participationRestrictions())
                    .applicationProcess(policyDto.applicationProcess())
                    .screeningAndAnnouncement(policyDto.screeningAndAnnouncement())
                    .applicationSite(policyDto.applicationSite())
                    .submissionDocuments(policyDto.submissionDocuments())
                    .build();
            entities.add(entity);
        }
        return entities;
    }

}

 

7.1. fetchPoliciesFromApi 메서드

Open API 데이터 (청년 정책(신) API)를 통해 정책 데이터를 수집하고 저장한다. API 호출, XML 데이터 파싱, 데이터 저장 순으로 진행된다.

 

RestTemplate 및 XmlMapper 초기화

외부 API 에서 YouthPolicy 데이터를 가져오기 위해 RestTemplateXmlMapper 를 사용한다.

RestTemplate restTemplate = new RestTemplate();
XmlMapper xmlMapper = new XmlMapper();
  • RestTemplate 을 사용해서 HTTP 요청을 보내고 응답을 받는다.
  • XmlMapper를 사용해서 XML 형태의 데이터를 Java 객체로 반환한다.

 

첫 번째 페이지에 대한 요청을 보내고 전체 페이지 수 계산

String firstPageResponse = restTemplate.getForObject(apiUrl, String.class);
YouthPolicyListResponseo firstPageData = xmlMapper.readValue(firstPageResponse, YouthPolicyListResponseDto.class);
int totalCount = firstPageData.totalCount();
int pageCount = (totalCount/100) + ((totalCount%100 == 0) ? 0 : 1);

첫 번째 페이지의 데이터를 가져와서 YouthPolicyListResponseDto 객체로 매핑하고, 전체 페이지 수를 계산한다.

전체 페이지 수 계산 시 (totalCount/100) + ((totalCount%100 == 0) ? 0 : 1)

  • totalCount를 100으로 나눠 기본 페이지 수를 구한다.
  • 나머지가 있을 경우 (totalCount%100 != 0) 마지막 페이지에 일부 데이터가 있기 때문에 1을 추가한다.
  • 나머지가 없을 경우 (totalCount%100 == 0) 모든 페이지가 꽉 차있기 때문에 추가하지 않는다.

 

모든 페이지에 대한 데이터 수집하고 저장

for(int pageIndex=1; pageIndex<=pageCount; pageIndex++){
    String pageUrl = "https://www.youthcenter.go.kr/opi/youthPlcyList.do" +
            "?openApiVlak=" + apiKey + "&pageIndex=" + pageIndex + "&display=100";
    String pageResponse = restTemplate.getForObject(pageUrl, String.class);
    YouthPolicyListResponseDto pageData = xmlMapper.readValue(pageResponse, YouthPolicyListResponseDto.class);

    List<YouthPolicyResponseDto> policies = pageData.youthPolicyListResponseDto();
    entities.addAll(loadPolicies(policies));
}
youthPolicyRepository.saveAll(entities);
saveCount = entities.size();
  • 각 페이지마다 API를 호출해서 데이터를 받아온다.
  • 각 페이지의 데이터를 YouthPolicyResponseDto 리스트로 변환하고, loadPolicies 메서드를 통해 엔티티로 변환한 후 리스트에 추가한다.
  • API 호출을 줄이기 위해서 한 번에 최대 100개의 데이터를 반환하도록 했다. display=100을 설정하여 한 페이지에 최대 100개의 정책을 가져오도록 했다. (공식 API 명세서를 보면 display는 기본값 10, 최대 100까지 가능하다.)


  • 데이터 하나를 저장할 때마다 데이터베이스를 호출하면 비효율적이기 때문에 List<YouthPolicy> entities 를 사용해서 한 루프에 최대 100개의 엔티티를 저장하고, youthPolicyRepository.saveAll(entities) 을 통해 한 번에 전체 엔티티를 처리하도록 했다.
  • 마지막으로 모든 엔티티를 한 번에 데이터베이스에 저장하고, 저장된 엔티티 개수를 saveCount에 저장한다. (로그로 전체 저장된 엔티티 개수를 보여주기 위함.)

 

7.2. loadPolicies 메서드

List<YouthPolicyResponseDto>를 입력으로 받아, 각 요소를 YouthPolicy 엔티티로 변환해 리스트로 반환한다.

API로부터 받은 DTO 객체들을 데이터베이스에 저장 가능한 엔티티 객체로 변환하는 작업을 수행한다.

 

7.3. 전체 동작 과정

  1. API를 호출하여 첫 번째 페이지 데이터를 가져오고, 전체 데이터 수를 확인하여 페이지 수를 계산한다.
  2. 반복문을 사용하여 각 페이지의 데이터를 API를 통해 가져온다.
  3. XML 데이터를 파싱해 각 정책의 세부 정보를 추출한다.
  4. 추출된 데이터를 데이터베이스에 저장한다.

 

8. 데이터베이스 스키마 변경

2024-06-25T01:29:50.928+09:00  INFO 40260 --- [nio-8080-exec-2] org.hibernate.orm.jdbc.batch             : HHH100503: On release of batch it still contained JDBC statements
2024-06-25T01:29:50.929+09:00 ERROR 40260 --- [nio-8080-exec-2] org.hibernate.orm.jdbc.batch             : HHH100501: Exception executing batch [java.sql.BatchUpdateException: Data truncation: Data too long for column 'application_details' at row 7], SQL: insert into
 youth_policies (additional_notes,age_info,application_details,application_process,application_site,classification,education,introduce,participation_restrictions,region,residence_and_income,screening_and_announcement,specialization,submission_documents,title,id) values (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
2024-06-25T01:29:50.931+09:00  WARN 40260 --- [nio-8080-exec-2] o.h.engine.jdbc.spi.SqlExceptionHelper   : SQL Error: 1406, SQLState: 22001
2024-06-25T01:29:50.932+09:00 ERROR 40260 --- [nio-8080-exec-2] o.h.engine.jdbc.spi.SqlExceptionHelper   : Data truncation: Data too long for column 'application_details' at row 7
2024-06-25T01:29:50.938+09:00 ERROR 40260 --- [nio-8080-exec-2] c.e.w.service.YouthPolicyService         : youth policy error: could not execute batch [Data truncation: Data too long for column 'application_details' at row 7] [insert into youth_policies (additional_n
otes,age_info,application_details,application_process,application_site,classification,education,introduce,participation_restrictions,region,residence_and_income,screening_and_announcement,specialization,submission_documents,title,id) values (?,?,?,?,?,?,?,?,?,?,?,?,?
,?,?,?)]; SQL [insert into youth_policies (additional_notes,age_info,application_details,application_process,application_site,classification,education,introduce,participation_restrictions,region,residence_and_income,screening_and_announcement,specialization,submission_documents,title,id) values (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)]
2024-06-25T01:29:50.943+09:00 ERROR 40260 --- [nio-8080-exec-2] c.e.w.exception.GlobalExceptionHandler   : Handler in CommonException Error Message = 정책 정보를 가져오는 중에 오류가 발생했습니다. 잠시 후 다시 시도해주세요.

application_details 컬럼에 삽입하려는 데이터가 컬럼의 길이 제한을 초과한다는 오류가 발생했다.

코드로는 해결할 수 없는 것 같아서 데이터베이스 스키마에서 application_details 컬럼의 길이를 늘이는 방법을 선택했다.

 

기존에는 varchar(255) 이어서 text 타입으로 변경했다.

ALTER TABLE youth_policies MODIFY application_details TEXT;

 

application_details 외에도 다른 컬럼에서 길이 제한 초과 오류가 발생해서 모두 TEXT로 변경해주었다.

title은 비교적 짧기 때문에 varchar(500)으로 변경했다.

ALTER TABLE youth_policies MODIFY title varchar(500);
ALTER TABLE youth_policies MODIFY introduce TEXT;
ALTER TABLE youth_policies MODIFY application_details TEXT;
ALTER TABLE youth_policies MODIFY residence_and_income TEXT;
ALTER TABLE youth_policies MODIFY application_process TEXT;
ALTER TABLE youth_policies MODIFY screening_and_announcement TEXT;
ALTER TABLE youth_policies MODIFY participation_restrictions TEXT;
ALTER TABLE youth_policies MODIFY additional_notes TEXT;
ALTER TABLE youth_policies MODIFY submission_documents TEXT;
ALTER TABLE youth_policies MODIFY application_site TEXT;

 

9. 스케줄러 적용

팀원들과 이 정책 데이터를 어떻게 업데이트할 것인지 상의한 결과, 데이터를 주 1회 업데이트하기로 결정했다. 업데이트 방식은 기존에 저장되어 있는 데이터를 모두 삭제하고 다시 API를 호출하여 저장하는 방법을 선택했다.

이 방법은 비효율적이긴 하지만, 원본 데이터가 어떻게 관리되는지 명확히 알 수 없어 이 방법을 선택했다.

예를 들어 신청 기간이 만료된 정책의 경우 원본 데이터에서 사라지게 되면, API 호출 때마다 모든 정책이 원본에서 유효한지 검사하는 과정이 필요하다.

스케줄러를 통해 정책 데이터가 리프레시 될 때 많은 시간이 들지는 않기 때문에 우선 이 방법을 사용하고, 만약 이후에 진행되는 기능에서 문제가 된다면 그때 다시 팀원들과 고민해볼 계획이다.

 

YouthPolicyService

@Scheduled(cron = "0 0 0 * * MON") // 매주 월요일 00:00에 실행되도록 설정
@Transactional
public void scheduledFetchAndSaveYouthPolicy() {
    try {
        // 데이터 삭제
        deleteAllYouthPolicies();
        saveCount = 0;

        // 데이터 가져오기 및 저장
        fetchAndSaveYouthPolicy();

        log.info("Youth Policy data update job completed. Total {} policies saved.", saveCount);
    } catch (CommonException e) {
        if (e.getErrorCode() == ErrorCode.YOUTH_POLICY_FETCH_AND_SAVE_ERROR
            || e.getErrorCode() == ErrorCode.YOUTH_POLICY_DELETE_ERROR)
            throw e;
    } catch (Exception e) {
        throw new CommonException(ErrorCode.YOUTH_POLICY_SCHEDULED_ERROR);
    }
}

@Transactional
private void deleteAllYouthPolicies() {
    try {
        youthPolicyRepository.deleteAll();
        log.info("All existing youth policies deleted.");
    } catch (Exception e) {
        throw new CommonException(ErrorCode.YOUTH_POLICY_DELETE_ERROR);
    }
}

 

9.1. scheduledFetchAndSaveYouthPolicy 메서드

@Scheduled 어노테이션은 Spring에서 제공하는 기능으로 메서드를 주기적으로 실행할 수 있도록 해준다.

(cron = "0 0 0 * * MON")로 cron 표현식을 사용해 매주 월요일 자정에 실행되도록 설정했다.

이후 더 자주 리프레시가 필요하다고 판단되면 더 짧은 주기로 변경할 것이다.

 

동작 과정

  1. 기존의 모든 Youth Policy 데이터를 삭제하는 deleteAllYouthPolicies 메서드를 호출한다.
  2. fetchAndSaveYouthPolicy 메서드를 호출해 새로운 데이터를 가져와 데이터베이스에 저장한다.
  3. 최종적으로 저장된 정책의 개수를 로그로 출력한다.

 

9.2. deleteAllYouthPolicies 메서드

YouthPolicy 데이터를 모두 삭제하는 메서드이다.

스케줄러가 실행될 때마다 기존의 모든 정책 데이터를 삭제하여 새로운 데이터로 갱신할 수 있도록 한다.

반응형

댓글