Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions src/main/java/com/mjsec/lms/LmsApplication.java
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package com.mjsec.lms;

import com.mjsec.lms.common.config.TimeZones;
import java.util.TimeZone;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
Expand All @@ -9,7 +11,13 @@
public class LmsApplication {

public static void main(String[] args) {
applyDefaultTimeZone();
SpringApplication.run(LmsApplication.class, args);
}

// 컨테이너 기본값(UTC)을 따르면 LocalDateTime.now(), 생성 시각이 9시간 어긋나서 서울로 고정함
static void applyDefaultTimeZone() {
TimeZone.setDefault(TimeZone.getTimeZone(TimeZones.SEOUL_ZONE));
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
import java.util.Map;
import java.util.stream.Collectors;
import com.mjsec.lms.notification.external.EmailService;
import com.mjsec.lms.common.config.TimeZones;

@Service
@Slf4j
Expand All @@ -36,7 +37,8 @@ public void checkAndSendAbsenceAlert() {
log.info("Starting 10-day absence alert check...");

// 정확히 10일 전 날짜 계산
LocalDate targetDate = LocalDate.now().minusDays(10);
// 출석 날짜가 서울 날짜로 저장되므로 JVM 기본 시간대와 무관하게 서울 기준으로 계산함
LocalDate targetDate = LocalDate.now(TimeZones.SEOUL_ZONE).minusDays(10);

log.info("Checking absences for date: {}", targetDate);

Expand Down
71 changes: 71 additions & 0 deletions src/main/java/com/mjsec/lms/common/config/PlanDateCorrection.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package com.mjsec.lms.common.config;

import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.SmartInitializingSingleton;
import org.springframework.context.annotation.Profile;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.support.TransactionTemplate;

// JVM 기본 시간대가 UTC이던 시절 plan의 start_date, end_date는 입력값보다 9시간 늦게 저장됨
// 현재 운영 DB를 직접 건들 수 없는 상황이라 긴급 패치용으로 기동 시 한 번만 9시간 당김
// 웹 서버가 요청을 받기 전에 돌아야 새 버전이 쓴 행까지 당기지 않음
@Slf4j
@Component
@Profile("prod")
public class PlanDateCorrection implements SmartInitializingSingleton {

static final String MARKER_TABLE = "data_correction";
static final String MARKER_NAME = "2026-09-30-plan-date-kst";
static final String BACKUP_TABLE = "plan_backup_20260930";

private final JdbcTemplate jdbcTemplate;
private final TransactionTemplate transactionTemplate;

public PlanDateCorrection(JdbcTemplate jdbcTemplate, PlatformTransactionManager transactionManager) {
this.jdbcTemplate = jdbcTemplate;
this.transactionTemplate = new TransactionTemplate(transactionManager);
}

@Override
public void afterSingletonsInstantiated() {
jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS " + MARKER_TABLE
+ " (name VARCHAR(100) PRIMARY KEY, applied_at DATETIME(6) NOT NULL)");

if (isApplied()) {
log.info("Plan date correction already applied, skipping: {}", MARKER_NAME);
return;
}

try {
// 표식이 없으면 보정이 커밋된 적이 없으므로 이전 시도의 백업은 버리고 지금 상태로 다시 뜸
// DDL은 MySQL에서 암묵적으로 커밋되므로 트랜잭션 밖에서 함
jdbcTemplate.execute("DROP TABLE IF EXISTS " + BACKUP_TABLE);
jdbcTemplate.execute("CREATE TABLE " + BACKUP_TABLE + " AS SELECT * FROM plan");

Integer shifted = transactionTemplate.execute(status -> {
// 인스턴스가 혹시 두 개 겹쳐 떠도 표식을 먼저 넣은 쪽만 UPDATE까지 감
jdbcTemplate.update("INSERT INTO " + MARKER_TABLE + " (name, applied_at) VALUES (?, NOW(6))",
MARKER_NAME);
return jdbcTemplate.update("UPDATE plan SET start_date = start_date - INTERVAL 9 HOUR, "
+ "end_date = end_date - INTERVAL 9 HOUR");
});
log.info("Plan date correction applied: {} rows shifted by -9 hours, backup table {}",
shifted, BACKUP_TABLE);
} catch (DuplicateKeyException e) {
log.info("Plan date correction already applied by another instance");
} catch (RuntimeException e) {
// 틀린 마감 시각으로 서비스하지 않도록 기동을 멈춤, 표식과 UPDATE는 함께 롤백됨
log.error("Plan date correction failed, application startup aborted", e);
throw e;
}
}

private boolean isApplied() {
Integer count = jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM " + MARKER_TABLE + " WHERE name = ?", Integer.class, MARKER_NAME);
return count != null && count > 0;
}
}
17 changes: 17 additions & 0 deletions src/main/java/com/mjsec/lms/common/config/TimeZones.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package com.mjsec.lms.common.config;

import java.time.ZoneId;

// 서비스 기준 시간대, 시각을 다루는 코드는 JVM 기본값 대신 이 값을 씀
// application.yml의 hibernate.jdbc.time_zone과 같아야 LocalDateTime이 저장될 때 밀리지 않음
public final class TimeZones {

// @Scheduled(zone = ...)처럼 컴파일 타임 상수가 필요한 곳에서 씀
public static final String SEOUL = "Asia/Seoul";

public static final ZoneId SEOUL_ZONE = ZoneId.of(SEOUL);

private TimeZones() {
throw new IllegalStateException("Utility class");
}
}
11 changes: 10 additions & 1 deletion src/main/java/com/mjsec/lms/common/util/ValidationUtils.java
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,16 @@ public class ValidationUtils {
private static final String[] ALLOWED_DOMAINS = {
"velog.io",
"tistory.com",
"blog.naver.com"
"blog.naver.com",
"github.io",
"github.com",
"notion.site",
"notion.so",
"oopy.io",
"medium.com",
"brunch.co.kr",
"blogspot.com",
"gitbook.io"
};

private static final String[] XSS_PATTERNS = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import com.mjsec.lms.attendance.service.AttendanceAlertService;
import com.mjsec.lms.common.config.TimeZones;

@Service
@Slf4j
Expand All @@ -20,7 +21,7 @@ public class ScheduledTaskService {
}

//매주 월요일 0시에 체크
@Scheduled(cron = "0 0 0 * * MON")
@Scheduled(cron = "0 0 0 * * MON", zone = TimeZones.SEOUL)
public void checkAssignmentNotSubmitted() {
log.info("Scheduled task started: checkAssignmentNotSubmitted");
try {
Expand All @@ -32,7 +33,7 @@ public void checkAssignmentNotSubmitted() {
}

// 매일 오전 9시에 10일 경과 결석자 체크
@Scheduled(cron = "0 0 9 * * *")
@Scheduled(cron = "0 0 9 * * *", zone = TimeZones.SEOUL)
public void checkAbsenceAlert() {
log.info("Scheduled task started: checkAbsenceAlert");
try {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
import java.util.*;
import java.util.stream.Collectors;
import com.mjsec.lms.notification.external.EmailService;
import com.mjsec.lms.common.config.TimeZones;

@Service
@Slf4j
Expand All @@ -41,7 +42,8 @@ public WeeklyAlertService(PlanRepository planRepository,
public void checkAndSendWeeklyAssignmentReport() {
log.info("Starting weekly assignment report generation...");

LocalDateTime now = LocalDateTime.now();
// 마감 시각이 서울 시각으로 저장되므로 JVM 기본 시간대와 무관하게 서울 기준으로 조회함
LocalDateTime now = LocalDateTime.now(TimeZones.SEOUL_ZONE);
LocalDateTime oneWeekAgo = now.minusWeeks(1);

// hasAssignment=true이고 최근 1주일간 마감된 과제만 조회
Expand Down
59 changes: 59 additions & 0 deletions src/test/java/com/mjsec/lms/LmsApplicationTimeZoneTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
package com.mjsec.lms;

import java.util.TimeZone;
import java.util.concurrent.atomic.AtomicReference;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.MockedStatic;
import org.springframework.boot.SpringApplication;

import static org.assertj.core.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mockStatic;

@DisplayName("서버 기본 시간대 설정 테스트")
class LmsApplicationTimeZoneTest {

private TimeZone originalTimeZone;

@BeforeEach
void setUp() {
originalTimeZone = TimeZone.getDefault();
// 운영 컨테이너처럼 JVM 기본 시간대가 UTC인 상황을 만든다
TimeZone.setDefault(TimeZone.getTimeZone("UTC"));
}

@AfterEach
void tearDown() {
TimeZone.setDefault(originalTimeZone);
}

@Test
@DisplayName("JVM 기본 시간대가 UTC여도 Asia/Seoul로 맞춘다")
void setsDefaultTimeZoneToSeoul() {
LmsApplication.applyDefaultTimeZone();

assertThat(TimeZone.getDefault().getID()).isEqualTo("Asia/Seoul");
}

@Test
@DisplayName("main()은 스프링을 띄우기 전에 시간대를 서울로 맞춘다")
void mainAppliesSeoulBeforeSpringStarts() {
AtomicReference<String> zoneAtRun = new AtomicReference<>();

try (MockedStatic<SpringApplication> spring = mockStatic(SpringApplication.class)) {
spring.when(() -> SpringApplication.run(eq(LmsApplication.class), any(String[].class)))
.thenAnswer(invocation -> {
zoneAtRun.set(TimeZone.getDefault().getID());
return null;
});

LmsApplication.main(new String[0]);
}

assertThat(zoneAtRun.get()).isEqualTo("Asia/Seoul");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package com.mjsec.lms.attendance.service;

import com.mjsec.lms.attendance.repository.AttendanceRepository;
import com.mjsec.lms.notification.external.EmailService;
import java.time.LocalDate;
import java.time.ZoneId;
import java.util.List;
import java.util.TimeZone;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;

import static org.mockito.Mockito.*;

@ExtendWith(MockitoExtension.class)
@DisplayName("결석 알림 조회 기준 날짜 테스트")
class AttendanceAlertServiceClockTest {

private static final ZoneId SEOUL = ZoneId.of("Asia/Seoul");

@Mock
private AttendanceRepository attendanceRepository;
@Mock
private EmailService emailService;

private TimeZone originalTimeZone;

@BeforeEach
void setUp() {
originalTimeZone = TimeZone.getDefault();
// main()의 기본 시간대 고정이 빠진 상황을 만든다
TimeZone.setDefault(TimeZone.getTimeZone("UTC"));
}

@AfterEach
void tearDown() {
TimeZone.setDefault(originalTimeZone);
}

@Test
@DisplayName("JVM 기본 시간대가 UTC여도 서울 날짜 기준 10일 전을 조회한다")
void absenceAlertUsesSeoulDate() {
when(attendanceRepository.findByAttendanceDate(any())).thenReturn(List.of());
AttendanceAlertService service = new AttendanceAlertService(attendanceRepository, emailService);

service.checkAndSendAbsenceAlert();

// 서울과 UTC 날짜가 다른 시간대(00~09시 KST)에만 수정 전 코드와 구별된다
verify(attendanceRepository).findByAttendanceDate(LocalDate.now(SEOUL).minusDays(10));
}
}
Loading
Loading