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 |