[인용 및 참고 출처]
1. 구글 검색: MySQL 8.4 Reference Manua, "MySQL timestamp with time zone", 13.2.2 The DATE, DATETIME, and TIMESTAMP Types, (2025.02.18)
'취하여(취업을 위하여)' 프로젝트는 현재 국내 채용 공고만 조회할 수 있지만, 언젠가 다른 나라의 채용 공고도 조회할 수 있도록 서비스를 개선할 생각을 염두에 두고 ZonedDateTime을 사용 중이었다. 어제에 이어 오늘은 한 번 조회한 채용 공고를 다시 조회하지 않도록 스프링 스케줄러(Spring Scheduler)를 수정했는데, 이상한 점을 한 가지 발견했다.
로그를 찍을 때는 서버 시간이 한국 기준(KST)으로 출력되는데, 데이터베이스에는 협정 세계시(UTC)로 저장됐다.
// (1) ZonedDateTime
private ZonedDateTime createdAt = ZonedDateTime.now();
// (2) OffsetDateTime
private OffsetDateTime creationDate = OffsetDateTime.now();
ZonedDateTime 대신 OffsetDateTime을 사용해도 결과는 그대로였다. 임의로 채용 공고를 몇 개 생성해 '사이트에 올라온 날짜'를 넣었는데, 시차 때문에 로직을 정확히 검증하기가 어려웠다.
MySQL에서 직접 시간도 변경했으나 디버깅(debugging)할 때나 Postman으로 API를 호출할 때나 결과는 마찬가지였다. 처음에는 일종의 오류인 줄 알았는데, 몇 가지 정보를 찾아본 결과 오류는 아니었다.
(1) TimeZoneStorage 어노테이션(annotation) ▼
- 특정 속성의 시간대(Time Zone) 정보를 어떻게 저장할지 지정
- 만약 해당 어노테이션을 사용하지 않을 시
→ 시간대 저장 방식은 Dialect 및 Hibernate 설정 값에 따라 결정됨
- Hibernate 6.0부터 사용 가능
- 기본값은 AUTO
@Incubating
@Retention(RetentionPolicy.RUNTIME)
@Target({ FIELD, METHOD })
public @interface TimeZoneStorage {
TimeZoneStorageType value() default TimeZoneStorageType.AUTO;
}
(2) TimeZoneStorageType ▼
- 기본 전략은 쓰기 및 읽기 시 instant로 보존
- 데이터베이스가 'timestamp with time zone' 타입을 지원하면,
→ instant(순간), zone(시간대) 또는 오프셋(offset) 모두 보존됨
- 지원하지 않으면
→ 값은 UTC로 저장됨
→ 데이터베이스에서 읽을 때도 UTC로 반환됨
- 기본 전략이 적합하지 않으면 아래 대안 선택 가능
(1) AUTO 또는 COLUMN
: 모든 플랫폼에서 순간(instant)과 시간대(offset 또는 zone)가 보존됨
(원문) which each guarantee that both instant and zone or offset are preserved
(2)NORMALIZE_UTC
: 순간(instant)만 보존됨
: 읽을 때 항상 UTC로 반환됨
@Incubating
public enum TimeZoneStorageType {
(3) MySQL 문서 읽기
'MySQL converts TIMESTAMP values from the current time zone to UTC for storage, and back from UTC to the current time zone for retrieval. (This does not occur for other types such as DATETIME.)'
'MySQL은 TIMESTAMP 값을 현재 시간대에서 UTC로 변환하여 저장하고, 저장된 값을 다시 UTC에서 현재 시간대로 변환하여 조회합니다. (DATETIME 같은 다른 타입은 UTC로 변환되지 않습니다.)'
현재 MySQL을 사용 중이므로, 로그를 찍을 때 보기 편하게 UTC로 바꾸기로 했다. 여러 문서를 참고하여 데이터베이스에 문제가 없다는 점을 확인한 뒤에는 아래와 같은 순서대로 로직을 확인했다.
(1) 채용 공고 테이블 비우기 ▼
-- 외래 키 검사 비활성화
SET FOREIGN_KEY_CHECKS = 0;
-- job_opening 테이블 비우기
TRUNCATE TABLE job_opening;
-- 외래 키 검사 다시 활성화
SET FOREIGN_KEY_CHECKS = 1;
(2) 채용 공고 객체(JobOpening Entity)에 '사이트에 올라온 날짜'를 속성으로 추가하기 ▼
private ZonedDateTime createdAt = ZonedDateTime.now();
(3) 조회 시간을 각 레이어(Layer)와 스프링 스케줄러(Spring Scheduler)에 반영하기 ▼
package com.project.cheerha.domain.notice.repository;
import com.project.cheerha.domain.notice.dto.JobOpeningKeywordDto;
import com.project.cheerha.domain.notice.dto.UserKeywordDto;
import java.time.ZonedDateTime;
import java.util.List;
public interface NoticeCreationRepositoryQuery {
// 사용자 관련 정보 조회
// 사용자 ID, 사용자가 고른 키워드 ID, 이메일 반환
List<UserKeywordDto> findAllUserKeywords();
// 주어진 조회 시간 이후에 등록된 채용 공고 관련 정보 조회
// 채용 공고 ID, 채용 공고 속 키워드 ID, URL 반환
List<JobOpeningKeywordDto> findAllJobOpeningKeywords(ZonedDateTime referenceTime);
}
package com.project.cheerha.domain.notice.repository;
import com.project.cheerha.domain.jobOpening.entity.QJobOpening;
import com.project.cheerha.domain.keyword.entity.QJobOpeningKeyword;
import com.project.cheerha.domain.keyword.entity.QUserKeyword;
import com.project.cheerha.domain.notice.dto.JobOpeningKeywordDto;
import com.project.cheerha.domain.notice.dto.UserKeywordDto;
import com.project.cheerha.domain.user.entity.QUser;
import com.querydsl.core.types.Projections;
import com.querydsl.jpa.impl.JPAQueryFactory;
import java.time.ZonedDateTime;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Repository;
@Repository
@RequiredArgsConstructor
public class NoticeCreationRepositoryQueryImpl implements NoticeCreationRepositoryQuery {
private final JPAQueryFactory queryFactory;
@Override
public List<UserKeywordDto> findAllUserKeywords() {
QUserKeyword userKeyword = QUserKeyword.userKeyword;
QUser user = QUser.user;
return queryFactory
.select(Projections.constructor(
UserKeywordDto.class,
userKeyword.user.id,
userKeyword.keyword.id,
user.email
)
)
.from(userKeyword)
.join(userKeyword.user, user)
.fetch();
}
public List<JobOpeningKeywordDto> findAllJobOpeningKeywords(ZonedDateTime referenceTime) {
QJobOpeningKeyword jobOpeningKeyword = QJobOpeningKeyword.jobOpeningKeyword;
QJobOpening jobOpening = QJobOpening.jobOpening;
return queryFactory
.select(Projections.constructor(
JobOpeningKeywordDto.class,
jobOpeningKeyword.jobOpening.id,
jobOpeningKeyword.keyword.id,
jobOpening.jobOpeningUrl
)
)
.from(jobOpeningKeyword)
.join(jobOpeningKeyword.jobOpening, jobOpening)
.where(jobOpening.createdAt.after(referenceTime))
.fetch();
}
}
private final JPAQueryFactory queryFactory;
@Override
public List<UserKeywordDto> findAllUserKeywords() {
QUserKeyword userKeyword = QUserKeyword.userKeyword;
QUser user = QUser.user;
return queryFactory
.select(Projections.constructor(
UserKeywordDto.class, // 해당 클래스로 결과 매핑
userKeyword.user.id, // 사용자 ID
userKeyword.keyword.id, // 키워드 ID
user.email // 사용자 이메일
)
)
.from(userKeyword) // userKeyword 테이블 조회
.join(userKeyword.user, user) // userKeyword와 user 조인
.fetch(); // 결과를 가져옴
}
public List<JobOpeningKeywordDto> findAllJobOpeningKeywords(ZonedDateTime referenceTime) {
QJobOpeningKeyword jobOpeningKeyword = QJobOpeningKeyword.jobOpeningKeyword;
QJobOpening jobOpening = QJobOpening.jobOpening;
return queryFactory
.select(Projections.constructor(
JobOpeningKeywordDto.class,
jobOpeningKeyword.jobOpening.id, // 채용 공고 ID
jobOpeningKeyword.keyword.id, // 키워드 ID
jobOpening.jobOpeningUrl // 채용 공고 URL
)
)
.from(jobOpeningKeyword)
// jobOpeningKeyword 테이블 조회
.join(jobOpeningKeyword.jobOpening, jobOpening)
// jobOpeningKeyword와 jobOpening 조인
.where(jobOpening.createdAt.after(referenceTime))
// 채용 공고 등록 시간이 referenceTime 이후인 조건
.fetch();
}
}
package com.project.cheerha.domain.notice.service;
import com.project.cheerha.domain.notice.dto.JobOpeningKeywordDto;
import com.project.cheerha.domain.notice.dto.UserKeywordDto;
import com.project.cheerha.domain.notice.repository.NoticeCreationRepositoryQuery;
import java.time.ZonedDateTime;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class NoticeCreationService {
private final NoticeCreationRepositoryQuery repositoryQuery;
public List<UserKeywordDto> findAllUserKeywords() {
return repositoryQuery.findAllUserKeywords();
}
public List<JobOpeningKeywordDto> findAllJobOpeningKeywords(ZonedDateTime referenceTime) {
return repositoryQuery.findAllJobOpeningKeywords(referenceTime);
}
}
package com.project.cheerha.domain.notice;
import com.project.cheerha.domain.jobOpening.entity.JobOpening;
import com.project.cheerha.domain.jobOpening.repository.JobOpeningRepository;
import com.project.cheerha.domain.notice.dto.JobOpeningKeywordDto;
import com.project.cheerha.domain.notice.dto.UserKeywordDto;
import com.project.cheerha.domain.notice.service.NoticeCreationService;
import jakarta.annotation.PostConstruct;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
@Slf4j
@Component
@RequiredArgsConstructor
public class NoticeCreationScheduler {
private final NoticeCreationService noticeCreationService;
private final JobOpeningRepository jobOpeningRepository;
// 애플리케이션이 시작될 때 실행되는 초기화 메서드
// 테스트용 채용 공고 객체를 하나 생성하여 데이터베이스에 저장
@PostConstruct
public void init() {
JobOpening jobOpening = new JobOpening(
null, "제목", "회사명", "서울", 7000, "정규직", "대졸",
"url", 0, 2, "백엔드", ZonedDateTime.now(),
ZonedDateTime.now(), ZonedDateTime.now(), 100, List.of()
);
jobOpeningRepository.save(jobOpening);
log.info("채용 공고 생성일 {}: ", jobOpening.getCreatedAt());
}
@Scheduled(cron = "*/30 * * * * *")
@Transactional
public void sendJobOpeningMatchingNotices() {
// 조회 시간을 스케줄러 동작 전으로 설정 (UTC 기준)
ZonedDateTime referenceTime = ZonedDateTime.now().minusSeconds(30L)
.withZoneSameInstant(ZoneId.of("UTC"));
List<JobOpeningKeywordDto> jobOpeningKeywordDtoList = noticeCreationService.findAllJobOpeningKeywords(
referenceTime);
List<UserKeywordDto> userKeywordDtoList = noticeCreationService.findAllUserKeywords();
Map<String, Set<String>> emailUrlMap = new HashMap<>();
for (UserKeywordDto userDto : userKeywordDtoList) {
for (JobOpeningKeywordDto jobOpeningKeywordDto : jobOpeningKeywordDtoList) {
boolean isUserKeywordMatchingJobKeyword = userDto.keywordId()
.equals(jobOpeningKeywordDto.keywordId());
if (isUserKeywordMatchingJobKeyword) {
emailUrlMap
.computeIfAbsent(
userDto.email(),
emailAsKey -> new HashSet<>()
).add(jobOpeningKeywordDto.url());
}
}
}
log.info("조회 시간: {}", referenceTime);
emailUrlMap.forEach((email, urlSet) ->
log.info("📢 메일 수신자: {}, URL 목록: {}", email, urlSet)
);
}
}
로그 기록을 확인한 결과, 스케줄러가 실행된 이후에는 동일한 채용 공고가 다시 조회되지 않았다. 오전 내내 골머리를 앓은 시간 출력 문제가 드디어 해결되었다. 이제 얼기설기 구현한 기능을 한 단계 한 단계 개선할 차례였다.
Day 5에서 계속…….