1. 개요

Airbnb·Agoda·Vrbo·WeHome 은 각자 예약 일정을 iCal 로 내보낸다. 이걸 주기적으로 받아 지점별 캘린더에 합치는 것이 이 프로젝트 백엔드의 절반이다. 단순해 보이는데 네 가지가 걸렸다. 주기를 어떻게 바꾸게 할 것인가, 레플리카가 늘면 어떻게 한 번만 돌게 할 것인가, iCal URL 이 리다이렉트하면 어디까지 따라갈 것인가, 한 플랫폼이 죽으면 나머지는 어떻게 할 것인가.


2. 핵심 내용

2-1. 주기는 관리자가 정한다

기획서는 고정 cron 이었다. 운영해 보니 성수기엔 자주, 비수기엔 덜 받고 싶어졌다. 코드 수정 없이 바꾸려면 주기가 DB 설정이어야 한다.

Spring 의 SchedulingConfigurer 로 addTriggerTask 를 쓰면 매 트리거마다 다음 실행 시각을 계산할 수 있다. 그때 DB 에서 icalSyncIntervalHours(1~24) 를 읽는다. 관리자가 값을 바꾸면 다음 실행부터 반영된다.

한 가지 더 넣은 것은 자정 anchor 다. “지금부터 N 시간 뒤” 가 아니라 “자정 기준 N 시간 슬롯 중 다음 것” 으로 맞춘다. 재배포로 앱이 다시 뜨면 “지금부터” 방식은 실행 시각이 밀리지만, anchor 방식은 정시로 돌아간다. 부팅 직후엔 1회 즉시 실행해서 재배포 뒤 캘린더가 비어 보이는 창을 없앴다.

2-2. 동적 잡에는 @SchedulerLock 이 안 걸린다

레플리카가 둘이면 둘 다 iCal 을 받아 둘 다 DB 를 갈아엎는다. ShedLock 으로 한 번만 돌게 한다. 정적 @Scheduled 메서드에는 @SchedulerLock 어노테이션을 붙이면 AOP 가 처리한다.

문제는 addTriggerTask 로 등록한 동적 잡은 AOP 프록시를 거치지 않는다. 어노테이션을 붙여도 아무 일이 안 일어난다. 조용히. 여기서는 DefaultLockingTaskExecutor.executeWithLock() 으로 프로그래밍 방식 락을 건다. lockAtMostFor 10분(잡이 죽어도 10분 뒤 풀림), lockAtLeastFor 30초(빠르게 끝나도 30초는 다른 레플리카가 못 잡음).

lockAtLeastFor 는 다른 프로젝트에서 플래키 테스트의 원인이 된 적이 있다(DoEatFit 7편). 테스트에서 프록시 빈을 직접 호출하면 락 창에서 본문이 스킵된다. 여기서는 프로그래밍 방식이라 테스트가 본문 메서드를 직접 부를 수 있어 그 함정은 없다.

2-3. 삭제 후 재삽입, 그리고 self 프록시

동기화는 소스별로 기존 이벤트를 전부 지우고 새로 넣는 전량 교체다. upsert 보다 단순하고, iCal 에서 사라진 예약(취소)이 자연히 반영된다. 단 이 두 단계가 한 트랜잭션이어야 한다. 삭제만 되고 삽입이 실패하면 캘린더가 빈다.

Kotlin/Spring 에서 같은 클래스의 @Transactional 메서드를 this. 로 부르면 프록시를 안 타서 트랜잭션이 안 열린다. 흔한 함정이다. 여기서는 @Lazy 로 자기 자신을 주입받아 self.updateCalendarEvents() 로 부른다. 그리고 deleteByBranchIdAndPlatform 뒤에 flush() 를 명시한다. 안 하면 Hibernate 가 삭제와 삽입 순서를 바꿔 유니크 충돌이 날 수 있다. 코드 옆 주석에 “this. 로 바꾸면 원자성이 조용히 사라진다” 고 적어 뒀다.

비슷한 함정 하나 더. iCal 소스 목록을 일괄 교체할 때 deleteAll(oldSources) 를 쓰면, 관리자가 저장 버튼을 두 번 누른 경우 둘째 요청이 이미 지워진 엔티티를 지우려다 StaleStateException 500 을 냈다. 벌크 DELETE WHERE branch_id IN 으로 바꿨다. 없는 걸 지우는 건 에러가 아니다.

2-4. 리다이렉트는 매 홉 재검증

iCal URL 은 관리자가 입력한다. 서버가 그 URL 을 GET 한다. 이건 SSRF 의 교과서적 입구다. 관리자가 실수로, 또는 관리자 계정이 탈취돼 http://169.254.169.254/... 나 클러스터 내부 주소를 넣으면 서버가 대신 요청한다.

URL 을 검증하는 것은 기본이다. 스킴, localhost, 그리고 getAllByName 으로 모든 해석 주소 에 대해 loopback·link-local·site-local·CGN·IPv6 ULA 를 차단한다. DNS 가 여러 주소를 돌려주면 하나라도 사설이면 거부.

그런데 검증을 통과한 URL 이 302 로 사설 주소로 리다이렉트 하면 HTTP 클라이언트가 자동으로 따라간다. 검증은 첫 URL 만 봤다. 그래서 RestTemplate 을 Redirect.NEVER 로 만들고, 3xx 를 앱이 직접 따라가며 매 홉마다 다시 검증 한다. 최대 5홉, 상대 Location 은 절대화.

Spring 7 에서 SimpleClientHttpRequestFactory 의 followRedirects 토글이 사라져 JdkClientHttpRequestFactory 로 교체해야 했다. 부수적으로 배운 것.

2-5. 파싱은 정규식으로, 실패는 격리

iCal 라이브러리를 쓰지 않았다. 네 플랫폼의 피드는 BEGIN:VEVENT ... END:VEVENT 블록에 DTSTART·DTEND·SUMMARY·DESCRIPTION 정도만 있다. 줄 폴딩(75자 넘으면 다음 줄 앞에 공백)만 풀면 정규식으로 충분했다. 라이브러리 의존성 하나와 그 버전 관리를 아끼는 대신, 플랫폼이 포맷을 바꾸면 우리가 고쳐야 한다. 규모상 맞는 선택이었다.

게스트 이름은 Vrbo 의 “Reserved - 이름” 패턴만 살리고 나머지 generic 키워드는 “예약됨” 으로 뭉갠다. 확인 코드는 세 가지 패턴을 순서대로 시도한다. 플랫폼 대시보드 딥링크를 조립해 관리자가 캘린더에서 바로 그 예약으로 갈 수 있게 했다.

실패 격리가 중요했다. 소스 하나가 실패해도 다음 소스로 진행 하고, 다운로드가 실패하면 기존 이벤트를 지우지 않는다. Agoda 가 잠깐 죽었다고 Airbnb 예약까지 캘린더에서 사라지면 운영자가 이중 예약을 낸다. 결과는 텔레그램으로 소스별 성공·실패를 보고한다.

2-6. 시간대는 거칠게

iCal 의 DTSTART 는 대부분 종일 이벤트라 날짜만 있다. 시각이 있으면 Z 를 제거만 하고 변환하지 않는다. 파싱에 실패하면 “지금(KST)” 으로 폴백한다. 알려진 거칢이다. 종일 이벤트만 다루는 한 실무에서 드러나지 않았고, 정밀하게 만들 비용 대비 이득이 없어 주석으로 남기고 넘어갔다. 프론트의 시간대 문제는 달랐다. 4편에서.


3. 마무리

요약

  • 주기는 DB 설정으로, 실행 시각은 자정 anchor 로. 재배포해도 정시.
  • 동적 잡은 AOP 를 안 탄다. LockingTaskExecutor 로 직접 잠근다.
  • 같은 클래스 트랜잭션 메서드는 self 프록시로. this. 는 조용히 트랜잭션을 버린다.
  • URL 검증은 첫 URL 만 보지 않는다. 리다이렉트 매 홉 재검증.
  • 소스 하나의 실패가 다른 소스의 데이터를 지우지 않게.

다음은 Turnstile 을 직접 구현하며 배운 것이다.