Backend/API

ICal을 이용한 외부 캘린더 연동

dddzr 2025. 7. 26. 15:41

ICal 방식을 사용하여 외부 캘린더를 구독하고자 했는데 

java 라이브러리인 ical4j가 버전에 따라 달라진게 많은 것 같아서 4.x 기준으로 기록을 남긴다!!

 

🔍 1. ICal 이란?

iCal 또는 iCalendar는 전자 일정 정보를 교환하기 위한 표준 포맷.

텍스트 기반 포맷 (.ics 파일)으로 여러 캘린더 기능을 제공하는 서비스에서 제공함.

 

대신 이 방식으로 연동하면 조회만 가능하다!!

그리고 텍스트 파일이라 데이터 많으면 들고오고 파싱하는게 오래 걸릴 수 있음.

 

📌2. ICal Url 가져오기

Ical Url을 제공하는 타 시스템에서 url 정보를 가져온다.

예) 구글 캘린더

Public은 공개캘린더인 경우 이용 가능, secret 주소를 복사 한다.

 

🔍 3. ical4j란?

ical4j는 Java에서 iCalendar(.ics 파일)를 파싱하고 생성할 수 있도록 도와주는 라이브러리

 

🔹 특징

  • Java용 iCalendar 처리 오픈소스 라이브러리 (Apache License 2.0)
  • .ics 파일을 읽고, 수정하고, 새로 만들 수 있음
  • VEVENT, DTSTART, RRULE, TZID, VTODO 등 모든 iCalendar 구성요소 지원
  • Java 8+ 이후부터는 Temporal, ZonedDateTime 같은 java.time API와 호환

🔹 주요 클래스

  • CalendarBuilder : .ics 파일 파싱
  • VEvent : 하나의 일정(Event) 표현
  • Property / DtStart, DtEnd, Summary, RRule : 일정 속성 표현
  • Recur : 반복 규칙 파싱
  • DateTime, Date : 날짜 객체 표현

 

📌4. Ical4j 의존성 추가 (Maven pom.xml)

Ical4j 4.1.1 이용

<dependency>
    <groupId>org.mnode.ical4j</groupId>
    <artifactId>ical4j</artifactId>
    <version>4.1.1</version>
</dependency>



📌 5. 외부 캘린더 구독 서비스 구현

5.1. 사용자 기능

  • 유저가 1회 url입력하고 저장(insertExternalCalendar(ExternalCalendarDTO externalCalendarDTO))
  • 캘린더 리스트 표시(getExternalCalendars())
  • 가져오기 체크 시  데이터 가져오기 (fetchExternalCalendarData(ExternalCalendarDTO externalCalendarDTO))

 

5.2. DB생성

User별로 구독한 Url을 저장 할 DB 테이블 생성. 

public class ExternalCalendarDTO {
    private String calendarId;
    private String userId;
    private String calendarUrl;
    private String calendarNm;
    private LocalDateTime createdAt;
    private LocalDateTime modifiedAt;
}



5.3 외부 캘린더 서비스 코드

import java.io.InputStream;
import java.net.URL;
import java.util.ArrayList;
import java.util.List;
import java.time.LocalDateTime;
import java.time.OffsetDateTime;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.util.Date;
import java.time.Duration;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.time.temporal.Temporal;


import net.fortuna.ical4j.model.property.Summary;
import net.fortuna.ical4j.util.CompatibilityHints;
import net.fortuna.ical4j.model.property.Description;
import net.fortuna.ical4j.model.property.Location;
import net.fortuna.ical4j.model.property.RRule;
import net.fortuna.ical4j.model.property.DtStart;
import net.fortuna.ical4j.model.property.DtEnd;


import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;


import kr.co.example.calender.mapper.ExternalCalendarMapper;
import kr.co.example.calender.vo.ExternalCalendarVO;
import kr.co.example.calender.dto.ScheduleDTO;
import net.fortuna.ical4j.data.CalendarBuilder;
import net.fortuna.ical4j.model.Calendar;
import net.fortuna.ical4j.model.Property;
import net.fortuna.ical4j.model.Recur;
import net.fortuna.ical4j.model.component.VEvent;


@Service
public class ExternalCalendarServiceImpl implements ExternalCalendarService {
    @Autowired
    private ExternalCalendarMapper externalCalendarMapper;


    public List<ExternalCalendarVO> svcSelectExternalCalendars(String userId) {
        return externalCalendarMapper.selectExternalCalendars(userId);
    }


    public ExternalCalendarVO svcSelectExternalCalendar(String extrCaldrId) {
        return externalCalendarMapper.selectExternalCalendar(extrCaldrId);
    }


    public int svcInsertExternalCalendar(ExternalCalendarVO externalCalendarVO) {
        ExternalCalendarVO selectedExternalCalendar = externalCalendarMapper.selectExternalCalendarWithUrl(externalCalendarVO);
        if (selectedExternalCalendar != null) {
            return 0;
        }
        return externalCalendarMapper.insertExternalCalendar(externalCalendarVO);
    }


    public int svcUpdateExternalCalendar(ExternalCalendarVO externalCalendarVO) {
        return externalCalendarMapper.updateExternalCalendar(externalCalendarVO);
    }


    public int svcDeleteExternalCalendar(String extrCaldrId, String userId) {
        return externalCalendarMapper.deleteExternalCalendar(extrCaldrId, userId);
    }


    public List<ScheduleDTO> fetchEventsFromIcal(String extrCaldrId, String extrCaldrNm, String icalUrl, String startDate, String endDate) {
        List<ScheduleDTO> result = new ArrayList<>();
        DateTimeFormatter dateFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd");
        LocalDate startRange = null;
        LocalDate endRange = null;
        if (startDate != null && endDate != null) {
            startRange = LocalDate.parse(startDate, dateFormatter);
            endRange = LocalDate.parse(endDate, dateFormatter);
        }
        CompatibilityHints.setHintEnabled(CompatibilityHints.KEY_RELAXED_PARSING, true);
        try (InputStream in = new URL(icalUrl).openStream()) {
            CalendarBuilder builder = new CalendarBuilder();
            Calendar calendar = builder.build(in);


            for (Object component : calendar.getComponents("VEVENT")) {
                VEvent event = (VEvent) component;


                // 시작/종료 시간
                DtStart<?> dtStart = event.getProperty(Property.DTSTART)
                    .filter(DtStart.class::isInstance)
                    .map(DtStart.class::cast)
                    .orElse(null);
                DtEnd<?> dtEnd = event.getProperty(Property.DTEND)
                    .filter(DtEnd.class::isInstance)
                    .map(DtEnd.class::cast)
                    .orElse(null);


                Temporal startTemporal = dtStart != null ? dtStart.getDate() : null;
                Temporal endTemporal = dtEnd != null ? dtEnd.getDate() : null;


                LocalDate eventBeginDate = null;
                Date start = convertTemporalToDate(startTemporal);
                Date end = convertTemporalToDate(endTemporal);


                System.out.println("startTemporal: " + startTemporal);
                System.out.println("endTemporal: " + endTemporal);
                System.out.println("start: " + start);
                System.out.println("end: " + end);


                // 반복 일정 처리
                RRule rrule = (RRule) event.getProperty(Property.RRULE).orElse(null);
                if (rrule != null && start != null) {
                    Recur recur = rrule.getRecur();
                    result.addAll(handleRecurringEvents(extrCaldrId, extrCaldrNm, event, start, end, recur));
                    continue;
                }


                // 일반 단일 일정 처리
                ScheduleDTO dto = buildScheduleFromEvent(extrCaldrId, extrCaldrNm, event, start, end);


                // 날짜 범위 필터링
                if (eventBeginDate != null && startRange != null && endRange != null) {
                    if (eventBeginDate.isBefore(startRange) || eventBeginDate.isAfter(endRange)) {
                        continue;
                    }
                }
                System.out.println("ICAL 이벤트 데이터: " + component);
                result.add(dto);
            }
        } catch (Exception e) {
            System.out.println("ICAL 가져오기 실패: " + e.getMessage());
            throw new RuntimeException("iCal 캘린더 파싱 실패", e);
        }
        return result;
    }


    private String getValue(Summary s) {
        return s != null ? s.getValue() : null;
    }


    private String getValue(Description d) {
        return d != null ? d.getValue() : null;
    }


    private String getValue(Location l) {
        return l != null ? l.getValue() : null;
    }


    private LocalDateTime toLocalDateTime(Date date) {
        if (date instanceof java.sql.Date) {
            // 종일 일정일 경우, 초 단위 없이 자정으로 고정
            return ((java.sql.Date) date).toLocalDate().atStartOfDay();
        } else {
            // 일반 일정 (시간 포함)
            return date.toInstant().atZone(ZoneId.systemDefault()).toLocalDateTime();
        }
    }


    private List<ScheduleDTO> handleRecurringEvents(String extrCaldrId, String extrCaldrNm, VEvent event, Date start, Date end, Recur recur) {
        List<ScheduleDTO> recurringList = new ArrayList<>();


        try {
            // ZonedDateTime 기반으로 변경
            ZonedDateTime seed = ZonedDateTime.ofInstant(start.toInstant(), ZoneId.systemDefault());
            ZonedDateTime periodStart = seed;


            Temporal until = recur.getUntil();
            ZonedDateTime periodEnd;
            if (until instanceof LocalDateTime) {
                periodEnd = ((LocalDateTime) until).atZone(ZoneId.systemDefault());
            } else if (until instanceof OffsetDateTime) {
                periodEnd = ((OffsetDateTime) until).atZoneSameInstant(ZoneId.systemDefault());
            } else if (until instanceof ZonedDateTime) {
                periodEnd = (ZonedDateTime) until;
            } else if (until instanceof LocalDate) {
                periodEnd = ((LocalDate) until).atStartOfDay(ZoneId.systemDefault());
            } else {
                // 기본값: 1개월 후
                periodEnd = ZonedDateTime.now().plusMonths(1);
            }
       
            List<Temporal> recurrenceDates = recur.getDates(seed, periodStart, periodEnd);
       
            for (Temporal temporal : recurrenceDates) {
                if (!(temporal instanceof ZonedDateTime)) continue;
       
                LocalDateTime startLdt = ((ZonedDateTime) temporal).toLocalDateTime();
                ScheduleDTO dto = new ScheduleDTO();
       
                dto.setSchdId(event.getUid().orElse(null).getValue());
                dto.setSchdCatgId(extrCaldrId);
                dto.setExtrCaldrNm(extrCaldrNm);
                dto.setSchdTitle(getValue(event.getSummary()));
                dto.setSchdMemo(getValue(event.getDescription()));
                dto.setBeginDate(startLdt.toLocalDate().toString());
                dto.setBeginTime(startLdt.toLocalTime().toString().substring(0, 5));
                dto.setReptFinDate(periodEnd.toLocalDate().toString());


                if (end != null) {
                    // 반복된 start 시간 기준으로 duration 계산
                    LocalDateTime endLdt = startLdt.plus(Duration.between(toLocalDateTime(start), toLocalDateTime(end)));
                    dto.setFinTime(endLdt.toLocalTime().toString().substring(0, 5));
                }
       
                // 반복 타입 코드
                String freq = recur.getFrequency().name(); // Frequency enum → String
                if ("DAILY".equals(freq)) dto.setReptTypeCd("001");
                else if ("WEEKLY".equals(freq)) dto.setReptTypeCd("002");
                else if ("MONTHLY".equals(freq)) dto.setReptTypeCd("003");
       
                // 종일 여부 판단
                DtStart<?> dtStart = event.getProperty(Property.DTSTART)
                .filter(DtStart.class::isInstance)
                .map(DtStart.class::cast)
                .orElse(null);


                boolean isAllDay = false;
                if (dtStart != null) {
                    Object dateObj = dtStart.getDate();
                    isAllDay = dateObj != null && dateObj.getClass().equals(java.time.LocalDate.class);
                }
                dto.setAlldayYn(isAllDay ? "Y" : "N");


                recurringList.add(dto);
            }
        } catch (Exception e) {
            System.out.println("handleRecurringEvents 실패: " + e.getMessage());
            throw new RuntimeException("반복 일정 처리 실패", e);
        }
        return recurringList;
    }


    private Date convertTemporalToDate(Temporal temporal) {
        if (temporal instanceof LocalDateTime) {
            return java.sql.Timestamp.valueOf((LocalDateTime) temporal);
        } else if (temporal instanceof LocalDate) {
            return java.sql.Date.valueOf((LocalDate) temporal);
        } else if (temporal instanceof ZonedDateTime) {
            return java.sql.Timestamp.valueOf(((ZonedDateTime) temporal).toLocalDateTime());
        } else if (temporal instanceof OffsetDateTime) {
            return java.sql.Timestamp.valueOf(((OffsetDateTime) temporal).toLocalDateTime());
        }
        return null;
    }


    private ScheduleDTO buildScheduleFromEvent(String extrCaldrId, String extrCaldrNm, VEvent event, Date start, Date end) {
        ScheduleDTO dto = new ScheduleDTO();
        dto.setSchdId(event.getUid().orElse(null).getValue());
        dto.setSchdTitle(getValue(event.getSummary()));
        dto.setSchdMemo(getValue(event.getDescription()));
        dto.setSchdCatgId(extrCaldrId);
        dto.setExtrCaldrNm(extrCaldrNm);


        if (start != null) {
            LocalDateTime ldt = toLocalDateTime(start);
            dto.setBeginDate(ldt.toLocalDate().toString());
            dto.setBeginTime(ldt.toLocalTime().toString().substring(0, 5));
        }


        if (end != null) {
            LocalDateTime ldt = toLocalDateTime(end);
            dto.setFinTime(ldt.toLocalTime().toString().substring(0, 5));
        }


        // 종일 여부 판단
        DtStart<?> dtStart = event.getProperty(Property.DTSTART)
                .filter(DtStart.class::isInstance)
                .map(DtStart.class::cast)
                .orElse(null);


        boolean isAllDay = false;
        if (dtStart != null) {
            Object dateObj = dtStart.getDate();
            isAllDay = dateObj != null && dateObj.getClass().equals(java.time.LocalDate.class);
        }
        dto.setAlldayYn(isAllDay ? "Y" : "N");


        return dto;
    }
}



📌 6. Ical event 형태 참고

[BEGIN:VEVENT
DTSTART;TZID=Asia/Seoul:20250717T113000
DTEND;TZID=Asia/Seoul:20250717T123000
RRULE:FREQ=DAILY;UNTIL=20250721T145959Z
UID:{}@google.com
SEQUENCE:0
STATUS:CONFIRMED
SUMMARY:일정제목
CREATED:20250724T054618Z
LAST-MODIFIED:20250724T054618Z
DTSTAMP:20250724T054618Z
TRANSP:OPAQUE
END:VEVENT
]

 

📌 7. Ical4j주의사항 (4.x)

4점대 버전을 쓸 때 이전 버전과 다른게 있어서 추가함. 위의 컨트롤러는 다 수정된 코드!!

 

7.1. 날짜 파싱 에러 해결 Unsupported unit: Seconds

4.1.1 기준

Event의 dtStart 형태가 아래와 같음. 종일 일정 파싱 시 에러 났음.

종일 일정: DTSTART;VALUE=DATE:20250716

일반 일정: DTSTART:20250716T080000Z

 

ical4j 4.1.1에서는 getDate()가 Temporal 타입 중에서도 LocalDate, ZonedDateTime, LocalDateTime 중 하나로 반환됨 (OffsetDateTime도 있었다.)

 

수정 전 코드는 LocalDateTime만 처리하고 있어서,

→ Temporal.plus(1, ChronoUnit.SECONDS) 같은 내부 연산에서

지원되지 않는 단위(초, Seconds) 를 쓴다고 해석되어 Unsupported unit: Seconds 예외 발생함.

 

🛠 해결 방법: Temporal 타입별 if 처리

private Date convertTemporalToDate(Temporal temporal) {
    if (temporal instanceof LocalDateTime) {
        return java.sql.Timestamp.valueOf((LocalDateTime) temporal);
    } else if (temporal instanceof LocalDate) {
        return java.sql.Date.valueOf((LocalDate) temporal);
    } else if (temporal instanceof ZonedDateTime) {
        return java.sql.Timestamp.valueOf(((ZonedDateTime) temporal).toLocalDateTime());
    } else if (temporal instanceof OffsetDateTime) {
        return java.sql.Timestamp.valueOf(((OffsetDateTime) temporal).toLocalDateTime());
    }
    return null;
}

 

7.2. 최신 버전은 getDate()가 optional로 DtStart를 반환한다.

orElse(null)을 붙여줘야 정상동작한다.

DtStart<?> dtStart = event.getProperty(Property.DTSTART)
                    .filter(DtStart.class::isInstance)
                    .map(DtStart.class::cast)
                    .orElse(null);

 

'Backend > API' 카테고리의 다른 글

[Google API] 구글 캘린더 연동  (0) 2025.07.26
[Google API] 구글 앱 인증 받기  (0) 2025.07.26