Skip to content

Open Api 최적화 방법

Sim-km edited this page Jun 27, 2024 · 13 revisions

성능 변화 960000ms(16분) -> 30000ms(30초) 약 30배

전제

서울 초등학교 수는 610개,
반 수는 16,611개,
하루 당 전체 시간표의 수는 87,526개 이다
이처럼 매년, 매주 변화하는 데이터를 어떤 방식으로 불러오고 저장하면
더 빠르고 정확하게 가져올 수 있는 지 최적화 한 과정을 적어보고자 한다.

schedular 사용

서울시 교육청과 나이스에서 제공하는 open api를 사용하였다.

-학교 정보
https://data.seoul.go.kr/dataList/OA-20555/S/1/datasetView.do
-학급 정보
https://open.neis.go.kr/portal/data/service/selectServicePage.do?page=1&rows=10&sortColumn=&sortDirection=&infId=OPEN15320190408174919197546&infSeq=1
-시간표
https://open.neis.go.kr/portal/data/service/selectServicePage.do?page=1&rows=10&sortColumn=&sortDirection=&infId=OPEN15020190408160341416743&infSeq=1
-급식 정보
https://open.neis.go.kr/portal/data/service/selectServicePage.do?page=1&rows=10&sortColumn=&sortDirection=&infId=OPEN17320190722180924242823&infSeq=2

초등학교 정보와 반의 정보는 1년의 한번씩 변동의 주기를 가지고 있으므로 1년에 한번,
시간표의 경우 임시 시간표, 공휴일, 학교 재량휴업일 등을 고려하기위해 1주일에 한번,
급식의 경우 1달에 한번 openapi에서 정보를 불러오고 DB에 저장하였다.

DB findBy~In, saveAll

기존 코드

@Service
class TimetableAppender(
    private val subjectRepository: SubjectRepository,
    private val timetableRepository: TimetableRepository
) {

    fun addSubjectAndTimetable(timetables: List<TimetableInfoProvider.TimetableRequest>) {
        timetables.forEach {
            val existSubject = subjectRepository.findByClassroomAndNameAndSemester(
                it.classroom,
                it.name,
                it.semester
            )
            if (existSubject == null && it.name != null) {
                val subject = Subject(
                    it.classroom,
                    it.name,
                    it.semester
                )
                subjectRepository.save(subject)
            }
        }
        timetables.forEach {
            if (it.name != null) {
                val subject = subjectRepository.findByClassroomAndNameAndSemester(
                    it.classroom,
                    it.name,
                    it.semester
                )
                val timetable = Timetable(
                    subject,
                    it.day,
                    it.time
                )
                timetableRepository.save(timetable)
            }
        }
    }
}  

이 당시에는 반을 10개씩 가져오고,
each마다 select 쿼리 2번 insert 쿼리 2번씩 나가므로 DB에서 조회하고 값을 넣는데 들어가는 시간이 많이 소요된다.
이 당시 each가 반 단위로 시간표를 조회하였으므로 대략 16,611개의 반 * 4개의 쿼리 (66,444번)이므로 상당한 시간이 소요됨을 예상할 수 있다. 실제로 이렇게 코드를 짰을 때 튜플 16만개를 저장하는데 16분 정도의 시간이 소요되었다.

batch 단위로 한번에 조회하고 한번에 저장하기

@Service
class TimetableAppender(
    private val classroomRepository: ClassroomRepository,
    private val subjectRepository: SubjectRepository,
    private val timetableRepository: TimetableRepository
) {

    fun addSubjectAndTimetable(timetables: List<TimetableInfoProvider.TimetableRequest>) {
        val existClassroom = classroomRepository.findBySchoolIn(timetables.map { it.school })

        val subjectList = mutableListOf<Subject>()
        val timetableList = mutableListOf<Timetable>()

        val existingSubject = subjectRepository.findByClassroomIn(existClassroom)
        subjectList.addAll(existingSubject)

        timetables.forEach {
            val classroom = existClassroom.find { room ->
                room.school.id == it.school.id && room.grade == Grade.convert(it.grade) && room.className == it.className
            }

            val existSubject = subjectList.find { subject ->
                subject.classroom.id == classroom?.id && subject.name == it.name && subject.semester == it.semester
            }

            if (existSubject == null && it.name != null && classroom !=null) {
                val subject = Subject(
                    classroom = classroom,
                    name = it.name,
                    semester = it.semester
                )
                subjectList.add(subject)

                val timetable = Timetable(
                    subject = subject,
                    day = it.day,
                    time = it.time
                )
                timetableList.add(timetable)
            } else if (it.name != null) {

                val timetable = Timetable(
                    subject = existSubject,
                    day = it.day,
                    time = it.time
                )
                timetableList.add(timetable)
            }
        }
        subjectRepository.saveAll(subjectList)
        timetableRepository.saveAll(timetableList)
    }
}

최적화를 하기 위해서 우선 배치의 단위부터 바꿔보았다. 기존의 10개의 반에서 100개의 학교로 한번에 불러오는 범위를 매우 크게 바꿨다.

그 학교의 반을 한번에 불러오고 과목을 조회하여 select하는 쿼리를 2번으로 최소화하고, 불러온 값에서 각각의 반과 시간표를 찾는 방식으로 바꾸면, 한 번의 쿼리로 그 안의 개체를 검색할 수 있으므로 데이터베이스에 대한 접근이 줄어 시간을 효과적으로 감소 할 수 있었다.

이후에 값을 넣는 방식에서도 매번 값을 DB에 넣는 것이 아닌 List에 담아두고, 배치 단위가 끝나면 한번에 save하여 하나의 트랜잭션 단위에서 처리되고 한번에 호출로 대량의 데이터가 처리되므로 효과적으로 시간을 단축시킬 수 있도록 하였다.

실제로 이 방식으로 기존의 튜플 16만개를 저장하는데 16분에서 1분으로 큰 폭으로 시간을 단축할 수 있게 되었다.

webClient 동기식 -> 비동기식

기존 코드

@Component
class TimetableOpenApiClient(
    private val schoolRepository: SchoolRepository,
    private val objectMapper: ObjectMapper
) : TimetableInfoProvider {
    override fun retrieveTimetableInfo(batchSize: Int, pageNumber: Int): TimetableInfoProvider.TimetableDataContainer {
        val pageable = PageRequest.of(pageNumber, batchSize)
        val schools = schoolRepository.findAll(pageable)
        val hasNext = schools.hasNext()
        val timetableInfoList = schools.map { school ->
            val apiUrl = "https://open.neis.go.kr/hub/elsTimetable"
            val timetableInfoResult = WebClient.create(apiUrl).get()
                .uri { uriBuilder: UriBuilder ->
                    uriBuilder
                        .queryParam("KEY", "32e897d4054342b19fd68dfb1b9ba621")
                        .queryParam("ATPT_OFCDC_SC_CODE", school.eduOfficeCode)
                        .queryParam("SD_SCHUL_CODE", school.schoolCode)
                        .queryParam("AY", Year.now())
                        .queryParam("TI_FROM_YMD", 20240507)
                        .queryParam("TI_TO_YMD", 20240507)
                        .queryParam("pSize",1000)
                        .queryParam("Type", "json")
                        .build()
                }
                .retrieve()
                .bodyToMono(String::class.java)
                .map {
                    objectMapper.readValue(it, TimetableOpenApiResponse::class.java)
                }
                .block()
            timetableInfoResult?.elsTimetable?.flatMap { timetableInfo ->
                timetableInfo.row?.map {
                    it.toTimetableInfo(school)
                } ?: emptyList()
            } ?: emptyList()
        }.flatten()
        return TimetableInfoProvider.TimetableDataContainer(
            timetableInfo = timetableInfoList,
            hasNext = hasNext
        )
    }
}

기존에는 학교 마다 대략 180개(6개 학년 * 6개 반 * 5교시)의 list를 불러오고, 100개의 학교를 가져왔다.
즉 18,000개의 list를 불러오는 방식을 block단위로 가져오고 이를 리스트로 넘겨주는데 동기식으로 구성하여서 이를 가져오는데 걸리는 n초동안 꼼짝 없이 기달려야하는 방식으로 구성되어있었다.

async 코드

@Component
class TimetableOpenApiClient(
    private val schoolRepository: SchoolRepository,
    private val objectMapper: ObjectMapper
) : TimetableInfoProvider {
    override fun retrieveTimetableInfo(batchSize: Int, pageNumber: Int): TimetableInfoProvider.TimetableDataContainer {
        val pageable = PageRequest.of(pageNumber, batchSize)
        val schools = schoolRepository.findAll(pageable)
        val hasNext = schools.hasNext()
        val timetableFluxes = schools.content.map { school ->
            getTimetableResponse(school)
        }
        val timetableInfoList = mutableListOf<TimetableInfoProvider.TimetableResponse>()
        Flux.merge(timetableFluxes)
            .buffer(1000)
            .flatMap {
                Flux.fromIterable(it)
                    .doOnNext(timetableInfoList::add)
                    .then()
            }.blockLast()

        return TimetableInfoProvider.TimetableDataContainer(
            timetableInfo = timetableInfoList,
            hasNext = hasNext
        )
    }

    private fun getTimetableResponse(school: School): Flux<TimetableInfoProvider.TimetableResponse> {
        val apiUrl = "https://open.neis.go.kr/hub/elsTimetable"
        return WebClient.create(apiUrl).get()
            .uri { uriBuilder: UriBuilder ->
                uriBuilder
                    .queryParam("KEY", "32e897d4054342b19fd68dfb1b9ba621")
                    .queryParam("ATPT_OFCDC_SC_CODE", school.eduOfficeCode)
                    .queryParam("SD_SCHUL_CODE", school.schoolCode)
                    .queryParam("AY", Year.now())
                    .queryParam("TI_FROM_YMD", 20240507)
                    .queryParam("TI_TO_YMD", 20240507)
                    .queryParam("pSize", 1000)
                    .queryParam("Type", "json")
                    .build()
            }
            .retrieve()
            .bodyToMono(String::class.java)
            .map {
                val timetableOpenApiResponse = objectMapper.readValue(it, TimetableOpenApiResponse::class.java)
                timetableOpenApiResponse?.elsTimetable?.flatMap { timetableInfo ->
                    timetableInfo.row?.map {
                        it.toTimetableInfo(school)
                    } ?: emptyList()
                } ?: emptyList()
            }
            .flatMapMany { Flux.fromIterable(it) }
    }
}

기존 webclient를 사용할 때는 api의 응답을 block()을 통해 기다린 후 다음 api를 호출하는 방식으로 진행했다. 하지만 block()은 이름에서부터 알 수 있듯이 api 요청이 오기까지 대기한다. 이는 많은 api 요청이 발생하는 상황에서 매우 비효율적인 방법이다.

그래서 이를 Reactor의 mono와 flux를 통해서 해결했다.(간단하게 말하면 mono와 flux는 비동기전용 optional과 collection으로 생각하면 된다.)

Reactor를 이용하면 각 api 요청을 non-blocking, 비동기적으로 처리할 수 있게 된다. 따라서 일정 수의 api 호출을 동시에 진행하여 성능상의 이점을 누릴 수 있는 것이다.

우선 각 api의 응답 결과는 list 형태로 반환되기 때문에 Mono<List<TimetableInfoProvider.TimetableResponse>>로 변환이 된다. 그 다음에 flatMapMany()를 통해서 Flux<TimetableInfoProvider.TimetableResponse>의 형태로 변환했다.

이를 batch size 만큼 반복하여 List<Flux<TimetableInfoProvider.TimetableResponse>>로 가져오며, List<Flux<TimetableInfoProvider.TimetableResponse>>를 하나의 Flux<TimetableInfoProvider.TimetableResponse>로 만들기 위해 merge를 사용했다. merge의 경우 여러 Flux를 하나의 Flux, 즉 하나의 흐름을 만들어준다.

추가로 위 코드에서 buffer가 명시되어 있다. 이는 기본 설정으로 webclient가 처리할 수 있는 최대 connection이 1000개 이기 떄문에 1000개 단위로 flux를 처리하기 위함이다.

결과적으로 앞선 최적화 과정을 통해서 100개의 API 요청을 550ms 내외로 처리할 수 있었다.

TimetableOpenApiClient       : retrieveTimetableInfo end, elapsed time: 1594
TimetableOpenApiClient       : retrieveTimetableInfo end, elapsed time: 560
TimetableOpenApiClient       : retrieveTimetableInfo end, elapsed time: 600
TimetableOpenApiClient       : retrieveTimetableInfo end, elapsed time: 618
TimetableOpenApiClient       : retrieveTimetableInfo end, elapsed time: 519
TimetableOpenApiClient       : retrieveTimetableInfo end, elapsed time: 606
TimetableOpenApiClient       : retrieveTimetableInfo end, elapsed time: 119