처음 요구사항을 들었을 때는 이렇게 생각했다.
“날씨 기능? 공공 API 하나 붙이고 화면에 보여주면 끝 아닌가?”
놉!
기능 자체는 간단하다
홈 화면에 진입하면 상단에 날씨 관련 정보가 노출된다.
예를 들면 이런 식이다.
- 오늘은 기온이 높아요. 수분 섭취를 충분히 해주세요.
- 공기질이 좋아요. 가벼운 산책을 해보세요.
겉으로 보면 굉장히 단순한 기능이다. 하지만 실제 구현 과정에서는 생각보다 많은 요소들이 얽혀 있었다.
문제 1. 요구사항 복잡도가 높다
날씨 문구는 단순히 한 API 결과로 결정되는 것이 아니라
여러 공공 API 데이터를 조합해서 계산해야 했다.
사용된 데이터는 다음과 같다.
사용 API
- 단기예보
- 기상 특보
- 자외선 지수
- 대기오염 정보
문구 결정 로직
4개의 영역 데이터를 기반으로 우선순위 문구가 결정된다.
예를 들면
- 한파 특보 → 무조건 한파 문구 노출
- 미세먼지 나쁨 → 마스크 안내 문구
- 일교차 큼 → 옷차림 안내 문구
즉 여러 조건을 비교해서 최종 문구를 선택해야 했다.
그리고 또 하나의 문제가 있었다. 조회해야 하는 지역이 50개가 넘는다.
작업 과정
- 필요한 데이터 정리
먼저 각 API에서 어떤 데이터가 필요한지 정리했다.
- 단기예보: 최고기온, 기온, 최저기온, 강수확률, 습도, 강수형태, 풍속, 하늘상태
- 기상 특보: 특보종류, 특보명령
- 자외선 지수: 자외선지수
- 대기오염정보: pm25Grade, pm10Grade
- API 테스트
Postman을 사용해서 API 테스트를 진행했다. 이 초입부터 시간이 꽤 소요됐는데, 그 이유는 문서가 최신 상태가 아니었다. 예를 들어 자외선 지수 APi의 경우 공식 문서엔느 이전 버전 API가 그대로 남아 있엇고, 실제 사용 가능한 API는 Swagger 기반 문서에서만 확인할 수 있었다. 결국 문서, Swagger, 실제 응답을 모두 비교하면서 API를 확인해야 했다.
- 지역 데이터 정리
API 호출에 필요한 지역 정보를 정리했다. 여기서 문제가 있었는데, API마다 요구하는 지역 파라미터가 전부 달랐다. 지역 하나를 조회하려면 아래 값들이 필요했고, 모든 지역 정보를 엑셀로 정리하는 작업이 필요헀다.
API 필요한 값
| 대기오염정보 | sidoName |
| 단기예보 | nx, ny |
| 자외선지수 | areaNo |
| 기상특보조회 | areaCode |
여기서 1차 삽질: 행정구역 코드 엑셀이 API 문서에 있었는데, 지역에 맞는 데이터를 추출해야했다. AI한테 맡겨봤더니 없는 값을 임의로 생성해버리는 이슈가 있었고 이걸 테스트 과정에서 발견해 다시 하나씩 검증하고 엑셀 데이터 정리 작업을 다시 해야헀다.
기상특보 조회에 필요한 areaCode는 엑셀에 시 단위로 탭이 나눠져 있었고, 특보 구역 이름도 “부산 동부/ 부산 중부/ 부산 서부” 이런 식이였어서 내가 만약 필요한 지역이 “부산 해운대구 우동” 이면 이 지역이 동부/중부/서부인지 직접 찾아야 했다. 이 때 부터 한숨이 나왔다.
(아 빨리 기능 구현해야하는데,,, 데이터 정리에서 이렇게 시간이 오래 걸리면 어떡하지……….)
- 공공 API raw 데이터 테이블 설계
API 연동 전에 Raw 데이터를 저장할 테이블을 설계했다. 날씨쪽은 특히 하루 단위 데이터만 제공하고 있기 때문에 이슈를 트래킹하기 위해서는 raw 데이터 저장이 필요했다. Postman 응답을 기준으로 어떤 파라미터를 저장할지, 어떤 타입으로 저장할지 정의한 뒤 테이블을 생성했다.
- API 연동 코드 작성(단기예보 API 연동)
로직 흐름은 최대한 단순하게 가져갔다.
단기예보 API 호출
→ JSON 응답 파싱
→ IF 테이블(if_village_forecast) 저장
이를 위해 구조를 4개의 레이어로 분리했다.
1️⃣ Feign Client
→ 공공 API 호출
2️⃣ 응답 VO
→ response.body.items.item[] 매핑
3️⃣ MyBatis Mapper + IF VO
→ if_village_forecast 테이블 INSERT
4️⃣ Batch Tasklet
→ 지역 목록 기준으로 API 호출 후 DB 저장
아래 코드는 모두 village_forecast 기준이다.
1. Feign Client – getVilageFcst
Weather 공용 Feign Client 안에 단기예보 메서드를 추가했다.
// WeatherApiClient.java
@FeignClient(
name = "weather-api",
url = "${hime.service.weather.base-url:<http://apis.data.go.kr>}",
configuration = HhiClientConfig.class
)
public interface WeatherApiClient {
/** 단기예보(동네예보) getVilageFcst - raw JSON */
@GetMapping(
value = "/1360000/VilageFcstInfoService_2.0/getVilageFcst",
produces = MediaType.APPLICATION_JSON_VALUE
)
String getVilageFcst(
@RequestParam("serviceKey") String serviceKey,
@RequestParam("dataType") String dataType,
@RequestParam("numOfRows") int numOfRows,
@RequestParam("pageNo") int pageNo,
@RequestParam("base_date") String baseDate,
@RequestParam("base_time") String baseTime,
@RequestParam("nx") int nx,
@RequestParam("ny") int ny
);
}
특징
- base_date
- base_time
- nx
- ny
API 파라미터와 1:1 매핑
응답은 DTO로 바로 받지 않고 String(JSON 원문) 으로 받은 뒤
파싱은 Tasklet에서 처리하도록 했다.
2. 응답 VO – VillageForecastItem
API 응답의
response.body.items.item[] 구조를 그대로 매핑하는 VO이다.
// VillageForecastItem.java
@JsonIgnoreProperties(ignoreUnknown = true)
@Getter @Setter @ToString
public class VillageForecastItem {
@JsonProperty("baseDate")
private String baseDate;
@JsonProperty("baseTime")
private String baseTime;
@JsonProperty("category")
private String category;
@JsonProperty("fcstDate")
private String fcstDate;
@JsonProperty("fcstTime")
private String fcstTime;
@JsonProperty("fcstValue")
private String fcstValue;
@JsonProperty("nx")
private Integer nx;
@JsonProperty("ny")
private Integer ny;
}
공공 API 응답 예시
{
"baseDate": "20260313",
"baseTime": "0800",
"category": "PCP",
"fcstDate": "20260313",
"fcstTime": "1100",
"fcstValue": "강수없음",
"nx": 55,
"ny": 127
}
이 JSON 객체 하나가 VillageForecastItem 한 개로 매핑된다.
3. IF VO + Mapper – if_village_forecast
3.1 IF VO
IF 테이블 저장용 VO이다.
// WeatherVillageForecastRequest.java
@Getter @Setter @ToString
public class WeatherVillageForecastRequest {
private Long ifSeq;
private String ifDtm;
private String baseDate;
private String baseTime;
private String category;
/** 예보 일자/시각 */
private String fcstDate;
private String fcstTime;
private Integer nx;
private Integer ny;
/** 예보 값 (강수없음, 온도 등) */
private String fcstValue;
private String dealYn;
private String dealSuccessYn;
private String useYn;
}
3.2 MyBatis Mapper XML
<!-- WeatherVillageForecastMapper.xml -->
<mapper namespace="...WeatherVillageForecastMapper">
<insert id="insertIfVillageForecast"
parameterType="...WeatherVillageForecastRequest">
INSERT INTO if_village_forecast
(
if_dtm,
base_date,
base_time,
category,
fcst_date,
fcst_time,
nx,
ny,
fcst_value,
deal_yn,
deal_success_yn,
use_yn,
reg_dtm
)
VALUES
(
#{ifDtm},
#{baseDate},
#{baseTime},
#{category},
#{fcstDate},
#{fcstTime},
#{nx},
#{ny},
#{fcstValue},
'N',
'N',
'Y',
now()
)
</insert>
</mapper>
DB 테이블은 if_village_forecast이며
fcst_date, fcst_time, fcst_value 컬럼을 추가해 API 구조에 맞춰두었다.
4. Tasklet – API 호출 + JSON 파싱 + DB 저장
단기예보 배치의 핵심 로직은 Tasklet 하나에 들어있다.
@Slf4j
@Component
@RequiredArgsConstructor
public class WeatherVillageForecastTasklet implements Tasklet {
private final WeatherApiClient weatherApiClient;
private final WeatherVillageForecastMapper weatherVillageForecastMapper;
private final ServiceProperties serviceProperties;
/** 단기예보 base_time */
private static final String BASE_TIME = "0500";
private static final int NUM_OF_ROWS = 1000;
private static final int PAGE_NO = 1;
/** 동시에 요청할 API 개수 */
private static final int CONCURRENT_REQUESTS = 10;
@Override
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) throws Exception {
String ifDtm = DateUtil.getCurrentDate(DateUtil.DATE_PATERRN_YYYYMMDDHHMMSS);
String baseDate = ifDtm.substring(0, 8);
String serviceKey = serviceProperties.getWeather().getKey();
// 1) 지역 목록 조회
List<CompanyEmployeeRegion> regions =
(List<CompanyEmployeeRegion>) chunkContext.getStepContext()
.getJobExecutionContext()
.get(WeatherRegionLoadTasklet.JOB_CONTEXT_KEY_REGION_LIST);
if (regions == null || regions.isEmpty()) {
log.debug("Weather VillageForecast no region");
return RepeatStatus.FINISHED;
}
// 2) 병렬 API 호출
ExecutorService executor = Executors.newFixedThreadPool(CONCURRENT_REQUESTS);
AtomicInteger totalInserted = new AtomicInteger(0);
try {
List<Future<?>> futures = regions.stream()
.filter(r -> r.getNx() != null && r.getNy() != null)
.map(region -> executor.submit(() -> {
try {
int n = callApiAndInsert(
region.getNx(),
region.getNy(),
serviceKey,
baseDate,
ifDtm
);
totalInserted.addAndGet(n);
} catch (Exception e) {
log.warn(
"VillageForecast nx={}, ny={} failed: {}",
region.getNx(),
region.getNy(),
e.getMessage()
);
}
}))
.collect(Collectors.toList());
for (Future<?> f : futures) {
f.get(60, TimeUnit.SECONDS);
}
} finally {
executor.shutdown();
if (!executor.awaitTermination(30, TimeUnit.SECONDS)) {
executor.shutdownNow();
}
}
log.debug(
"WeatherVillageForecastTasklet inserted: {} (regions: {})",
totalInserted.get(),
regions.size()
);
return RepeatStatus.FINISHED;
}
/** 단일 지역 API 호출 */
private int callApiAndInsert(
int nx,
int ny,
String serviceKey,
String baseDate,
String ifDtm
) throws Exception {
String responseJson = weatherApiClient.getVilageFcst(
serviceKey,
"json",
NUM_OF_ROWS,
PAGE_NO,
baseDate,
BASE_TIME,
nx,
ny
);
JsonNode root = ObjectMapperUtil.instance().readTree(responseJson);
JsonNode itemArray =
root.path("response")
.path("body")
.path("items")
.path("item");
List<VillageForecastItem> items =
ObjectMapperUtil.instance()
.convertValue(
itemArray,
new TypeReference<List<VillageForecastItem>>() {}
);
int count = 0;
for (VillageForecastItem item : items) {
WeatherVillageForecastRequest row =
new WeatherVillageForecastRequest();
row.setIfDtm(ifDtm);
row.setBaseDate(item.getBaseDate());
row.setBaseTime(item.getBaseTime());
row.setCategory(item.getCategory());
row.setFcstDate(item.getFcstDate());
row.setFcstTime(item.getFcstTime());
row.setNx(item.getNx());
row.setNy(item.getNy());
row.setFcstValue(item.getFcstValue());
count += weatherVillageForecastMapper.insertIfVillageForecast(row);
}
return count;
}
}
구조 요약
단기예보 연동 구조는 다음과 같이 구성했다.
Feign Client
↓
JSON 응답(String)
↓
ObjectMapper 파싱
↓
VillageForecastItem 리스트
↓
IF VO 변환
↓
if_village_forecast 테이블 INSERT
즉,
- API 호출 → Feign
- JSON 파싱 → ObjectMapper
- DB 저장 → MyBatis
오 거의 다 왔군! 응 아니야.
단일 지역인데 응답이 871개?
하나의 지역에 응닶값이 871개 날아왔고 5개 지역만 테스트를 했는데 DB에 3628개 행이 쌓여있었다.
(하 왜 이렇게 데이터를 많이 내려주시는건데요?)
2탄에서 계속..
'Dev Log' 카테고리의 다른 글
| 크롬 익스텐션 만들기 1 (0) | 2026.03.29 |
|---|---|
| 개발자가 알아야 하는 A/B 테스트와 관련 지표 (0) | 2026.03.22 |
| 엑셀 업로드부터 DB 저장까지의 전체 프로세스 (0) | 2026.03.08 |
| 무한스크롤을 적용하며 다시 보게 된 페이징 구조 (0) | 2026.03.01 |
| 파일 업로드: “썸네일/메인 이미지” 업로드에서 두 필드가 동기화되는 버그 트러블슈팅 (1) | 2026.02.22 |