Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
947e4f0
FEAT : 공공데이터 공급자 추상화 인터페이스 추가 (#61)
Aug 4, 2026
0904720
FEAT : 전국 어린이집 동기화 서비스 추가 (#61)
Aug 4, 2026
6c5ee75
FEAT : 보조금24 정부지원서비스 동기화 추가 (#61)
Aug 4, 2026
bee53d4
FEAT : 공공데이터 주기 동기화 스케줄러 및 관리자 API 추가 (#61)
Aug 4, 2026
fa91a8e
REFACTOR : 미사용 공공데이터 상수 정리 및 시도 코드 조회 추가 (#61)
Aug 4, 2026
b0d5a0b
CHORE : 공공데이터 공급자별 설정 및 동기화 크론 추가 (#61)
Aug 4, 2026
eb44536
TEST : 공공데이터 공급자 및 시도 코드 테스트 추가 (#61)
Aug 4, 2026
924cd72
Merge branch 'feat/RosieOh' into feat/public-data-integration
Aug 4, 2026
a4c1671
FEAT : XML 응답 파서 추가 및 응답 포맷 파라미터 중복 방지 (#61)
Aug 4, 2026
3bd84c6
FEAT : 병원 외부 식별자 컬럼 및 마이그레이션 V3 추가 (#61)
Aug 4, 2026
11670ac
FEAT : 소아청소년과 병원 동기화 서비스 추가 (#61)
Aug 4, 2026
76417ee
FEAT : 병원 동기화 스케줄러 및 관리자 API 연결 (#61)
Aug 4, 2026
4fdaa63
TEST : XML 응답 파서 테스트 추가 (#61)
Aug 4, 2026
1eb0cd7
STYLE : 여러 줄 주석을 한 줄로 정리 (#61)
Aug 4, 2026
1a78b04
PERF : 시설 위치 검색 바운딩 박스 적용 및 잘못된 테이블 참조 수정 (#63)
Aug 4, 2026
258aa55
FEAT : 시설 전문 검색 도입 및 LIKE 폴백 (#63)
Aug 4, 2026
764b8fa
FEAT : 챗봇 근거 검색 다중 키워드 및 조사 제거 (#63)
Aug 4, 2026
615ce35
FEAT : 자녀 월령·거주지 기반 정책 개인화 추천 (#63)
Aug 4, 2026
00cef24
FEAT : 요청별 쿼리 수 측정으로 N+1 관측 기반 마련 (#63)
Aug 4, 2026
cb7f234
FEAT : 공공데이터 지원대상 텍스트에서 대상 연령 개월 환산 (#63)
Aug 4, 2026
97819de
REFACTOR : 공공데이터 페이징 수집 공통화 및 매핑 불일치 조기 중단 (#63)
Aug 4, 2026
32736a3
FEAT : 데이터셋 경로 외부화 및 절대 URL 리소스 지원 (#63)
Aug 4, 2026
55ddf3f
FEAT : 전국 유치원 표준데이터 동기화 추가 (#63)
Aug 4, 2026
2cd6154
DOCS : 이슈 라벨·타입·마일스톤 운영 가이드 추가 (#34)
Aug 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 109 additions & 0 deletions docs/ISSUE_MANAGEMENT.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
# 이슈 운영 가이드

이슈가 늘어나도 분류 기준이 흔들리지 않도록 라벨·타입·마일스톤·관계의 사용 규칙을 고정한다.

## 제목

`.github/ISSUE_TEMPLATE` 규칙을 따른다.

```
[ 영역 ] 내용
```

- 대괄호 안에 **공백**을 넣는다. `[ Auth ]` (O), `[Auth]` (X)
- 영역은 도메인 또는 관심사. `Core` `Auth` `Infra` `Booking` `Notification` `Child` `Policy` `Search` `PublicData` `CI`
- 커밋 메시지 규칙(`TYPE : 한글 설명 (#이슈번호)`)과는 별개다. 섞지 않는다.

## Type (GitHub Issue Type)

한 이슈에 하나만 붙는다. 라벨 `type:*` 과 중복돼 보이지만, Type 은 **GitHub 전역 집계·필터**에 쓰이고 `type:*` 라벨은 저장소 안에서 더 세분화된 성격을 나타낸다.

| Type | 기준 |
|---|---|
| `Bug` | 지금 동작이 잘못됐다. 기동 실패, 잘못된 결과, 보안 결함, 설정이 효력 없음 |
| `Feature` | 없던 것을 만든다 |
| `Task` | 동작은 그대로인데 주변을 정비한다. 테스트, CI, 문서, 리팩토링, 트래킹 |

성능 결함은 **의도한 결과가 나오지 않는 것**이면 `Bug`, 단순히 더 빠르게 만드는 것이면 `Task` 다.

## 라벨

### priority

실행 순서를 정하는 유일한 기준이다. 담당자가 다음에 뭘 할지 고를 때 이것만 본다.

| 라벨 | 기준 | 대응 |
|---|---|---|
| `priority:P0` | 서비스가 지금 망가져 있다. 기동 불가, 인가 우회, 데이터 유출 | 즉시, 다른 작업 중단 |
| `priority:P1` | 사용자가 체감하는 오류이거나 배포를 막는다 | 현재 스프린트 안 |
| `priority:P2` | 개선하면 좋지만 미루어도 손해가 누적되지 않는다 | 여유 있을 때 |

판단이 갈리면 **"내일 아침 배포를 막을 일인가"** 를 묻는다. 막으면 P0/P1.

### domain

건드리는 코드의 위치. 한 이슈가 여러 도메인에 걸치면 **가장 무게가 실린 쪽 하나**만 붙인다.

| 라벨 | 범위 |
|---|---|
| `domain:core` | 공통 유틸, 보안 필터, 전역 예외, 설정 클래스 |
| `domain:api` | 컨트롤러 계약, 요청/응답 스키마, 페이징 |
| `domain:infra` | 빌드, CI/CD, 배포, Docker, 마이그레이션 실행 |
| `domain:data` | 시드 데이터, 외부 데이터 연동, 마이그레이션 내용 |
| `domain:health` | 건강 기록, 예방접종, 성장, 병원 |
| `domain:search` | 검색, 추천, 랭킹 |

### type

Type 필드를 세분화한다. 복수 부착 가능.

`type:bug` `type:feature` `type:perf` `type:security` `type:test` `type:tech-debt`

`type:security` 는 항상 `priority:P0` 또는 `P1` 이다. 보안 결함에 P2 를 붙이지 않는다.

## 마일스톤

**언제 나가야 하는가**로 나눈다. 크기나 난이도가 아니다.

| 마일스톤 | 기준 |
|---|---|
| `Hotfix` | 다음 배포를 기다릴 수 없다. P0 는 여기로 |
| `API Stabilization` | 클라이언트가 붙기 전에 계약이 확정돼야 한다 |
| `Refactor Sprint` | 동작을 바꾸지 않고 구조를 정리한다 |
| `Production Readiness` | 운영에 올리기 전에 갖춰야 한다. 관측, 배포, 테스트, 데이터 |

## Relationships (하위 이슈)

여러 이슈에 걸친 작업은 **상위 트래킹 이슈**를 만들고 하위 이슈로 연결한다. 본문에 번호를 나열만 하지 말고 실제 sub-issue 로 등록한다 — 진행률이 자동 집계된다.

- 상위 이슈는 `Task` 타입
- 상위 이슈 본문의 마지막 To Do 는 항상 `PR 머지 후 본 이슈 종료`
- 하위 이슈는 **독립적으로 닫을 수 있는 단위**여야 한다. 나눌 수 없으면 하나로 둔다

예: #40 (검토 제안 트래킹) ← #41~#52 / #64 (백엔드 전수 점검) ← #53~#59, #61, #63

## 신규 이슈 체크리스트

- [ ] 제목이 `[ 영역 ] 내용` 형식인가
- [ ] Type 을 지정했는가
- [ ] `priority:*` 를 붙였는가 (없으면 실행 순서에서 누락된다)
- [ ] `domain:*` 을 하나 붙였는가
- [ ] 마일스톤을 배정했는가
- [ ] 프로젝트 보드에 올렸는가
- [ ] 상위 작업의 일부라면 sub-issue 로 연결했는가
- [ ] To Do 가 **검증 가능한 단위**로 쪼개져 있는가

## 종료 기준

- 계획한 To Do 가 전부 체크됐고 코드가 기본 브랜치에 올라갔을 때 닫는다
- PR 로 해소되는 이슈는 PR 본문에 `Closes #번호` 를 넣어 **머지 시 자동으로 닫히게** 한다. 수동으로 미리 닫지 않는다
- **부분 완료는 닫지 않는다.** 완료된 To Do 를 체크하고 남은 범위를 코멘트로 명시한 뒤 열어 둔다
- 다른 이슈가 같은 범위를 완전히 흡수했으면 `duplicate` 사유로 닫고 흡수한 이슈를 링크한다

## 정기 점검

월 1회 다음을 확인한다.

- Type/`priority:*`/마일스톤이 비어 있는 열린 이슈
- 90일 이상 갱신 없는 이슈 — 여전히 유효한지, 아니면 `not planned` 로 닫을지
- 상위 트래킹 이슈의 하위 진행률과 본문 To Do 가 어긋나지 않는지
93 changes: 93 additions & 0 deletions src/main/java/com/carecode/core/client/XmlResponseParser.java
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
package com.carecode.core.client;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
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.InputSource;

import javax.xml.XMLConstants;
import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import java.io.StringReader;

/** XML 응답을 JsonNode 로 변환한다. */
@Component
public class XmlResponseParser {

private final ObjectMapper objectMapper = new ObjectMapper();

/** XML 문자열을 JsonNode 트리로 변환한다. */
public JsonNode parse(String xml) {
if (xml == null || xml.isBlank()) {
return null;
}
try {
Document document = newSecureBuilder().parse(new InputSource(new StringReader(xml)));
document.getDocumentElement().normalize();
return toNode(document.getDocumentElement());
} catch (Exception e) {
return null;
}
}

/** 외부 엔티티 참조(XXE)를 차단한 DocumentBuilder. */
private DocumentBuilder newSecureBuilder() throws Exception {
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
factory.setFeature("http://xml.org/sax/features/external-general-entities", false);
factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
factory.setXIncludeAware(false);
factory.setExpandEntityReferences(false);
return factory.newDocumentBuilder();
}

private JsonNode toNode(Element element) {
NodeList children = element.getChildNodes();

boolean hasElementChild = false;
for (int i = 0; i < children.getLength(); i++) {
if (children.item(i).getNodeType() == Node.ELEMENT_NODE) {
hasElementChild = true;
break;
}
}

// 자식 엘리먼트가 없으면 텍스트 값 노드
if (!hasElementChild) {
String text = element.getTextContent();
return objectMapper.getNodeFactory().textNode(text != null ? text.trim() : "");
}

ObjectNode node = objectMapper.createObjectNode();
for (int i = 0; i < children.getLength(); i++) {
Node child = children.item(i);
if (child.getNodeType() != Node.ELEMENT_NODE) {
continue;
}
Element childElement = (Element) child;
String name = childElement.getNodeName();
JsonNode childNode = toNode(childElement);

JsonNode existing = node.get(name);
if (existing == null) {
node.set(name, childNode);
} else if (existing.isArray()) {
// 같은 이름이 반복되면 배열로 모은다 (<item>...</item><item>...</item>)
((ArrayNode) existing).add(childNode);
} else {
ArrayNode array = objectMapper.createArrayNode();
array.add(existing);
array.add(childNode);
node.set(name, array);
}
}
return node;
}
}
Original file line numberDiff line numberDiff line change
@@ -1,82 +1,55 @@
package com.carecode.core.client.constants;

/**
* 공공데이터 API 관련 상수
* API 엔드포인트, 파라미터명, 응답 코드 등을 상수로 관리
*/
public class PublicDataApiConstants {
import java.util.Map;

// API 응답 코드
public static final String SUCCESS_CODE = "00";
public static final String ERROR_CODE = "99";
/** 공공데이터 API 공통 상수. */
public final class PublicDataApiConstants {

// API 파라미터명
public static final String PARAM_SERVICE_KEY = "serviceKey";
public static final String PARAM_PAGE_NO = "pageNo";
public static final String PARAM_NUM_OF_ROWS = "numOfRows";
public static final String PARAM_TYPE = "type";
public static final String PARAM_SIDO_CD = "sidoCd";
public static final String PARAM_POLICY_TYPE = "policyType";
public static final String PARAM_EDUCATION_TYPE = "educationType";
/** 공공데이터포털 공통 응답 코드. */
public static final String RESULT_CODE_SUCCESS = "00";

// API 응답 타입
public static final String RESPONSE_TYPE_JSON = "json";
public static final String RESPONSE_TYPE_XML = "xml";

// 육아 관련 API 엔드포인트
public static final String ENDPOINT_CHILDCARE_FACILITIES = "/getChildcareFacilities";
public static final String ENDPOINT_CHILDCARE_POLICIES = "/getChildcarePolicies";
public static final String ENDPOINT_PEDIATRIC_HOSPITALS = "/getPediatricHospitals";
public static final String ENDPOINT_CHILDCARE_SUBSIDIES = "/getChildcareSubsidies";
public static final String ENDPOINT_CHILDCARE_EDUCATION = "/getChildcareEducation";

// 지역 코드
public static final String REGION_SEOUL = "11";
public static final String REGION_BUSAN = "21";
public static final String REGION_DAEGU = "22";
public static final String REGION_INCHEON = "23";
public static final String REGION_GWANGJU = "24";
public static final String REGION_DAEJEON = "25";
public static final String REGION_ULSAN = "26";
public static final String REGION_SEJONG = "29";
public static final String REGION_GYEONGGI = "31";
public static final String REGION_GANGWON = "32";
public static final String REGION_CHUNGBUK = "33";
public static final String REGION_CHUNGNAM = "34";
public static final String REGION_JEONBUK = "35";
public static final String REGION_JEONNAM = "36";
public static final String REGION_GYEONGBUK = "37";
public static final String REGION_GYEONGNAM = "38";
public static final String REGION_JEJU = "39";

// 정책 유형
public static final String POLICY_TYPE_SUBSIDY = "subsidy";
public static final String POLICY_TYPE_FACILITY = "facility";
public static final String POLICY_TYPE_EDUCATION = "education";
public static final String POLICY_TYPE_MEDICAL = "medical";

// 교육 유형
public static final String EDUCATION_TYPE_PARENTING = "parenting";
public static final String EDUCATION_TYPE_CHILDCARE = "childcare";
public static final String EDUCATION_TYPE_SAFETY = "safety";
public static final String EDUCATION_TYPE_HEALTH = "health";

// 기본 페이지 설정
public static final int DEFAULT_PAGE_NO = 1;
public static final int DEFAULT_NUM_OF_ROWS = 10;
/** 한 번에 요청할 수 있는 최대 건수 (서울 열린데이터광장 기준). */
public static final int MAX_NUM_OF_ROWS = 1000;

// 타임아웃 설정 (밀리초)
public static final int CONNECT_TIMEOUT = 10000;
public static final int READ_TIMEOUT = 30000;

// 에러 메시지
public static final String ERROR_MSG_API_CALL_FAILED = "API 호출에 실패했습니다.";
public static final String ERROR_MSG_INVALID_RESPONSE = "잘못된 응답입니다.";
public static final String ERROR_MSG_TIMEOUT = "요청 시간이 초과되었습니다.";
public static final String ERROR_MSG_NETWORK_ERROR = "네트워크 오류가 발생했습니다.";
/** 시도명 → 법정동 시도 코드. */
public static final Map<String, String> SIDO_CODES = Map.ofEntries(
Map.entry("서울특별시", "11"),
Map.entry("부산광역시", "21"),
Map.entry("대구광역시", "22"),
Map.entry("인천광역시", "23"),
Map.entry("광주광역시", "24"),
Map.entry("대전광역시", "25"),
Map.entry("울산광역시", "26"),
Map.entry("세종특별자치시", "29"),
Map.entry("경기도", "31"),
Map.entry("강원특별자치도", "32"),
Map.entry("충청북도", "33"),
Map.entry("충청남도", "34"),
Map.entry("전북특별자치도", "35"),
Map.entry("전라남도", "36"),
Map.entry("경상북도", "37"),
Map.entry("경상남도", "38"),
Map.entry("제주특별자치도", "39"));

private PublicDataApiConstants() {
// 유틸리티 클래스이므로 인스턴스화 방지
}
}

/** 시도명으로 시도 코드를 찾는다. */
public static String findSidoCode(String sidoName) {
if (sidoName == null || sidoName.isBlank()) {
return null;
}
String normalized = sidoName.trim();

String exact = SIDO_CODES.get(normalized);
if (exact != null) {
return exact;
}
return SIDO_CODES.entrySet().stream()
.filter(e -> e.getKey().startsWith(normalized) || normalized.startsWith(e.getKey()))
.map(Map.Entry::getValue)
.findFirst()
.orElse(null);
}
}
Loading