Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
50 commits
Select commit Hold shift + click to select a range
8be0405
init: 프로젝트 초기 설정 추가
jhan0121 Dec 21, 2025
7dd27a5
이메일 기반 멀티 디바이스 인증 및 관리 기능 구현 (#3)
jhan0121 Dec 24, 2025
c1e87f8
jacoco 기반 테스트 커버리지 CI 구축 (#6)
jhan0121 Dec 24, 2025
1b5f44d
디바이스 삭제 기능 추가 (#7)
jhan0121 Dec 24, 2025
5412e66
등록한 디바이스 조회 기능 응답 형식 수정 (#9)
jhan0121 Dec 24, 2025
f6a58db
복습할 URL 저장 기능 추가 (#10)
jhan0121 Dec 24, 2025
0496449
swagger 기반 API 문서 작성 (#12)
jhan0121 Dec 26, 2025
2c9c56a
CI 대상 branch 설정 추가 (#13)
jhan0121 Dec 26, 2025
afce778
복습 대상 URL 이메일 전송 스케줄러 구현 (#19)
jhan0121 Dec 30, 2025
5e3cc60
로그 기능 추가 (#21)
jhan0121 Dec 31, 2025
3b049d0
test: MemberServiceTest#authenticateDevice 테스트 커버리지 보완 (#22)
jhan0121 Jan 1, 2026
f67a53a
flyway 기반 db 마이그레이션 의존성 추가 (#24)
jhan0121 Jan 2, 2026
b861baf
배포 스크립트 추가 (#31)
jhan0121 Jan 3, 2026
68ce9b6
feat: 모니터링을 위한 alloy 설정 추가 (#34)
jhan0121 Jan 5, 2026
4e610cd
Merge branch 'be/prod' into be/dev
jhan0121 Jan 5, 2026
39969af
배포 최적화 적용 (#36)
jhan0121 Jan 5, 2026
12cfe8d
배포 스크립트 오류 수정 (#38)
jhan0121 Jan 5, 2026
94b654b
Merge branch 'be/prod' into be/dev
jhan0121 Jan 5, 2026
69455b3
모니터링 설정 불일치 수정 (#40)
jhan0121 Jan 5, 2026
59312e0
모니터링 연결 오류 수정 (#43)
jhan0121 Jan 6, 2026
a7ccfca
Merge branch 'be/prod' into be/dev
jhan0121 Jan 6, 2026
d16ed1f
디바이스 인증 방식 헤더 마이그레이션 (Phase 1) (#46)
jhan0121 Jan 15, 2026
a09f0ce
디바이스 인증 방식 헤더 마이그레이션 (Phase 3) (#49)
jhan0121 Jan 19, 2026
1a53274
Merge branch 'be/prod' into be/dev
jhan0121 Jan 23, 2026
8b76242
hotfix: prod - dev 불일치 수정 (#51)
jhan0121 Jan 23, 2026
1b4e5d2
사용자 커스텀 복습 주기 관리 및 커스텀 주기 기반 리뷰 저장 기능 구현 (#53)
jhan0121 Feb 2, 2026
3f7971f
LocalDateTime 초 단위 절삭 적용 (#54)
jhan0121 Feb 3, 2026
1509630
이메일 전송 실패 재시도 로직 구현 (#56)
jhan0121 Feb 3, 2026
9208223
Merge branch 'be/prod' into be/dev
jhan0121 Feb 3, 2026
6904a08
멤버 알림 시간 설정 변경 기능 추가 (#59)
jhan0121 Feb 6, 2026
f0fe4ff
복습 주기 하위 호환성 로직 제거 (#61)
jhan0121 Feb 7, 2026
eb2cb35
feat: 멤버 알림 시간 조회 API 추가 (#63)
jhan0121 Feb 7, 2026
ef55269
로그 패턴에 스레드 정보 추가 (#65)
jhan0121 Feb 12, 2026
e772273
복습 주기 조회 쿼리 성능 개선 (#66)
jhan0121 Feb 12, 2026
ec401c5
본인 소유 검증 누락으로 인한 멤버/디바이스 권한 문제 수정 (#69)
jhan0121 Feb 13, 2026
af0e9ec
feat: 리뷰 저장 시 커스텀 복습 주기 소유권 검증 로직 추가 (#71)
jhan0121 Feb 13, 2026
71978ea
코드 리뷰 actions 스크립트 추가 (#72)
jhan0121 Feb 24, 2026
7343557
Merge branch 'be/prod' into be/dev
jhan0121 Mar 2, 2026
659c79a
Virtual Thread 적용을 통한 이메일 발송 처리량 개선 (#75)
jhan0121 Mar 3, 2026
fa65c23
이메일 발송 로직 notification_history outbox 패턴 전환 (#77)
jhan0121 Mar 4, 2026
276e7a6
다음 리뷰 전송 예정 정보 조회 API 구현 (#79)
jhan0121 Mar 5, 2026
2906fdd
Gmail SMTP에서 AWS SES SDK v2로 이메일 발송 인프라 교체 (#81)
jhan0121 Mar 7, 2026
31cd44e
Merge branch 'be/prod' into be/dev
jhan0121 Mar 7, 2026
e0fcf91
이메일 재전송 포기 기준을 maxRetry 횟수 -> deadline 기반으로 전환 (#84)
jhan0121 Mar 7, 2026
a9efc1c
notification_history.review_cycle_id unique constraint 추가 (#86)
jhan0121 Mar 7, 2026
936d8cb
deadline을 PENDING 생성 시점에 결정하도록 NotificationHistory 설계 개선 (#89)
jhan0121 Mar 8, 2026
2b07471
README.md 설명 추가 (#90)
jhan0121 Mar 9, 2026
6605b54
Merge branch 'be/prod' into be/dev
jhan0121 Mar 9, 2026
2c64679
서버 및 DB 시간 처리 정책 UTC로 통일 (#93)
jhan0121 Mar 9, 2026
cf6a131
Merge branch 'be/prod' into be/dev
jhan0121 Mar 9, 2026
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
7 changes: 2 additions & 5 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,7 @@ FROM amazoncorretto:25-alpine3.21

WORKDIR /app

RUN apk add --no-cache curl tzdata && \
cp /usr/share/zoneinfo/Asia/Seoul /etc/localtime && \
echo "Asia/Seoul" > /etc/timezone && \
apk del tzdata
RUN apk add --no-cache curl

RUN addgroup -g 1001 appgroup && adduser -u 1001 -G appgroup -D appuser
RUN mkdir -p /app/log && chown -R appuser:appgroup /app
Expand All @@ -16,4 +13,4 @@ USER appuser

EXPOSE 8080

ENTRYPOINT ["java", "-jar", "app.jar"]
ENTRYPOINT ["java", "-Duser.timezone=UTC", "-jar", "app.jar"]
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,6 @@ public class TimeConfig {

@Bean
public Clock clock() {
return Clock.systemDefaultZone();
return Clock.systemUTC();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ public class ReviewEmailSender {
private final ReviewCycleService reviewCycleService;
private final Clock clock;

@Scheduled(cron = "${schedule.review-mail.cron}", zone = "Asia/Seoul")
@Scheduled(cron = "${schedule.review-mail.cron}", zone = "UTC")
public void sendReviewMail() {
final LocalDateTime targetDateTime = LocalDateTime.now(clock).truncatedTo(ChronoUnit.MINUTES);

Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
package com.recyclestudy.member.controller.response;

import com.recyclestudy.member.service.output.MemberFindOutput;
import java.time.LocalDateTime;
import java.time.Instant;
import java.util.List;

public record MemberFindResponse(String email, List<MemberFindElement> devices) {
Expand All @@ -14,6 +14,6 @@ public static MemberFindResponse from(final MemberFindOutput output) {
return new MemberFindResponse(output.email().getValue(), memberFindElements);
}

private record MemberFindElement(String identifier, LocalDateTime createdAt) {
private record MemberFindElement(String identifier, Instant createdAt) {
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
import com.recyclestudy.member.domain.Device;
import com.recyclestudy.member.domain.DeviceIdentifier;
import com.recyclestudy.member.domain.Email;
import java.time.LocalDateTime;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.List;

public record MemberFindOutput(Email email, List<MemberFindElement> elements) {
Expand All @@ -13,11 +14,12 @@ public static MemberFindOutput of(
final List<Device> devices
) {
final List<MemberFindElement> memberFindElements = devices.stream()
.map(device -> new MemberFindElement(device.getIdentifier(), device.getCreatedAt()))
.map(device -> new MemberFindElement(device.getIdentifier(),
device.getCreatedAt() != null ? device.getCreatedAt().toInstant(ZoneOffset.UTC) : null))
.toList();
return new MemberFindOutput(email, memberFindElements);
}

public record MemberFindElement(DeviceIdentifier identifier, LocalDateTime createdAt) {
public record MemberFindElement(DeviceIdentifier identifier, Instant createdAt) {
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ public ResponseEntity<ReviewSaveResponse> saveReview(
) {
final ReviewSaveInput input = ReviewSaveInput.of(identifier, request.targetUrl(), request.cycle());
final ReviewSaveOutput output = reviewService.saveReview(input);
ReviewSaveResponse response = ReviewSaveResponse.of(output.url(), output.scheduledAts());
ReviewSaveResponse response = ReviewSaveResponse.from(output);
return ResponseEntity.status(HttpStatus.CREATED).body(response);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
package com.recyclestudy.review.controller.response;

import com.recyclestudy.review.service.output.NextReviewOutput;
import java.time.LocalDateTime;
import java.time.Instant;

public record NextReviewResponse(LocalDateTime scheduledAt, int count) {
public record NextReviewResponse(Instant scheduledAt, int count) {

public static NextReviewResponse of(final NextReviewOutput output) {
return new NextReviewResponse(output.scheduledAt(), output.count());
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
package com.recyclestudy.review.controller.response;

import com.recyclestudy.review.domain.ReviewURL;
import java.time.LocalDateTime;
import com.recyclestudy.review.service.output.ReviewSaveOutput;
import java.time.Instant;
import java.util.List;

public record ReviewSaveResponse(String url, List<LocalDateTime> scheduledAts) {
public record ReviewSaveResponse(String url, List<Instant> scheduledAts) {

public static ReviewSaveResponse of(ReviewURL url, List<LocalDateTime> scheduledAts) {
return new ReviewSaveResponse(url.getValue(), scheduledAts);
public static ReviewSaveResponse from(final ReviewSaveOutput output) {
return new ReviewSaveResponse(output.url().getValue(), output.scheduledAts());
}
}
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
package com.recyclestudy.review.service.output;

import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneOffset;

public record NextReviewOutput(LocalDateTime scheduledAt, int count) {
public record NextReviewOutput(Instant scheduledAt, int count) {

public static NextReviewOutput empty() {
return new NextReviewOutput(null, 0);
}

public static NextReviewOutput of(final LocalDateTime scheduledAt, final int count) {
return new NextReviewOutput(scheduledAt, count);
return new NextReviewOutput(scheduledAt.toInstant(ZoneOffset.UTC), count);
}
}
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
package com.recyclestudy.review.service.output;

import com.recyclestudy.review.domain.ReviewURL;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.List;

public record ReviewSaveOutput(ReviewURL url, List<LocalDateTime> scheduledAts) {
public record ReviewSaveOutput(ReviewURL url, List<Instant> scheduledAts) {

public static ReviewSaveOutput of(ReviewURL url, List<LocalDateTime> scheduledAts) {
return new ReviewSaveOutput(url, scheduledAts);
return new ReviewSaveOutput(url,
scheduledAts.stream().map(scheduledAt -> scheduledAt.toInstant(ZoneOffset.UTC)).toList());
}
}
10 changes: 9 additions & 1 deletion src/main/resources/application.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,19 @@ spring:
flyway:
enabled: true

jackson:
serialization:
write-dates-as-timestamps: false
time-zone: UTC

jpa:
hibernate:
ddl-auto: validate

open-in-view: false
properties:
hibernate:
jdbc:
time_zone: UTC


server:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
-- review_cycle.scheduled_at 보정: PENDING 상태 + 미래 주기만 (-9h)
UPDATE review_cycle rc
INNER JOIN notification_history nh ON nh.review_cycle_id = rc.id
SET rc.scheduled_at = DATE_SUB(rc.scheduled_at, INTERVAL 9 HOUR)
WHERE nh.status = 'PENDING'
AND rc.scheduled_at > NOW();

-- notification_history.deadline 보정: PENDING 상태만 (-9h)
UPDATE notification_history nh
SET nh.deadline = DATE_SUB(nh.deadline, INTERVAL 9 HOUR)
WHERE nh.status = 'PENDING';

-- member.notification_time 보정: Seoul 기준 -> UTC (-9h)
-- LocalTime은 날짜가 없으므로 자정을 넘는 케이스 처리 필요
-- 예: 01:00 Seoul -> UTC 전날 16:00 → LocalTime으로 16:00 저장
UPDATE member
SET notification_time = CASE
WHEN notification_time >= '09:00:00'
THEN SUBTIME(notification_time, '09:00:00')
ELSE
ADDTIME(notification_time, '15:00:00')
END
WHERE notification_time IS NOT NULL;
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import com.recyclestudy.member.service.output.MemberNotificationTimeFindOutput;
import com.recyclestudy.member.service.output.MemberSaveOutput;
import com.recyclestudy.restdocs.APIBaseTest;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.time.temporal.ChronoUnit;
Expand Down Expand Up @@ -114,11 +115,11 @@ void findAllMemberDevices() {

final MemberFindOutput.MemberFindElement device1 = new MemberFindOutput.MemberFindElement(
DeviceIdentifier.from(headerIdentifier),
LocalDateTime.now().truncatedTo(ChronoUnit.MINUTES).minusDays(1)
Instant.now().minus(1, ChronoUnit.DAYS).truncatedTo(ChronoUnit.MINUTES)
);
final MemberFindOutput.MemberFindElement device2 = new MemberFindOutput.MemberFindElement(
DeviceIdentifier.from("device-id-2"),
LocalDateTime.now().truncatedTo(ChronoUnit.MINUTES)
Instant.now().truncatedTo(ChronoUnit.MINUTES)
);

final MemberFindOutput output = new MemberFindOutput(
Expand All @@ -145,7 +146,7 @@ void findAllMemberDevices() {
fieldWithPath("devices[].identifier").type(JsonFieldType.STRING)
.description("디바이스 식별자 값"),
fieldWithPath("devices[].createdAt").type(JsonFieldType.STRING)
.description("디바이스 생성일")
.description("디바이스 생성일 (UTC, ISO 8601)")
)
))
.header("X-Device-Id", headerIdentifier)
Expand Down Expand Up @@ -337,7 +338,7 @@ void findAllMemberDevices_NullEmail() {
Email.from("test@test.com"),
List.of(new MemberFindOutput.MemberFindElement(
DeviceIdentifier.from(headerIdentifier),
LocalDateTime.now().truncatedTo(ChronoUnit.MINUTES)
Instant.now().truncatedTo(ChronoUnit.MINUTES)
))
);
given(memberService.findAllMemberDevices(any())).willReturn(output);
Expand All @@ -362,11 +363,11 @@ void findAllMemberDevices_WithHeader() {

final MemberFindOutput.MemberFindElement device1 = new MemberFindOutput.MemberFindElement(
DeviceIdentifier.from(headerIdentifier),
LocalDateTime.now().truncatedTo(ChronoUnit.MINUTES).minusDays(1)
Instant.now().minus(1, ChronoUnit.DAYS).truncatedTo(ChronoUnit.MINUTES)
);
final MemberFindOutput.MemberFindElement device2 = new MemberFindOutput.MemberFindElement(
DeviceIdentifier.from("device-id-2"),
LocalDateTime.now().truncatedTo(ChronoUnit.MINUTES)
Instant.now().truncatedTo(ChronoUnit.MINUTES)
);

final MemberFindOutput output = new MemberFindOutput(
Expand Down Expand Up @@ -396,7 +397,7 @@ void findAllMemberDevices_WithHeader() {
fieldWithPath("devices[].identifier").type(JsonFieldType.STRING)
.description("디바이스 식별자 값"),
fieldWithPath("devices[].createdAt").type(JsonFieldType.STRING)
.description("디바이스 생성일")
.description("디바이스 생성일 (UTC, ISO 8601)")
),
queryParameters(
parameterWithName("email").description("이메일 (다음 버전에서 제거 예정)")
Expand Down Expand Up @@ -434,7 +435,7 @@ void findNotificationTime() {
)
.responseFields(
fieldWithPath("notificationTime").type(JsonFieldType.STRING)
.description("알림 시간 (HH:mm:ss)")
.description("알림 시간 (HH:mm:ss, UTC 기준)")
)
))
.header("X-Device-Id", headerIdentifier)
Expand Down Expand Up @@ -531,7 +532,7 @@ void updateNotificationTime() {
)
.requestFields(
fieldWithPath("notificationTime").type(JsonFieldType.STRING)
.description("알림 시간 (HH:mm:ss)")
.description("알림 시간 (HH:mm:ss, UTC 기준)")
)
))
.contentType(MediaType.APPLICATION_JSON_VALUE)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ void saveReview_withDefaultCycle() {
.responseFields(
fieldWithPath("url").type(JsonFieldType.STRING).description("리뷰할 URL"),
fieldWithPath("scheduledAts").type(JsonFieldType.ARRAY)
.description("복습 예정 일시 목록")
.description("복습 예정 일시 목록 (UTC, ISO 8601)")
)
))
.contentType(MediaType.APPLICATION_JSON_VALUE)
Expand Down Expand Up @@ -146,7 +146,7 @@ void saveReview_withCustomCycle() {
.responseFields(
fieldWithPath("url").type(JsonFieldType.STRING).description("리뷰할 URL"),
fieldWithPath("scheduledAts").type(JsonFieldType.ARRAY)
.description("복습 예정 일시 목록")
.description("복습 예정 일시 목록 (UTC, ISO 8601)")
)
))
.contentType(MediaType.APPLICATION_JSON_VALUE)
Expand Down Expand Up @@ -274,7 +274,7 @@ void findNextReview_success() {
)
.responseFields(
fieldWithPath("scheduledAt").type(JsonFieldType.STRING)
.description("다음 발송 예정 시간 (PENDING 없을 시 null)"),
.description("다음 발송 예정 시간, UTC ISO 8601 형식 (PENDING 없을 시 null)"),
fieldWithPath("count").type(JsonFieldType.NUMBER)
.description("해당 시간에 발송될 URL 개수")
)
Expand All @@ -284,6 +284,7 @@ void findNextReview_success() {
.get("/api/v1/reviews/next")
.then()
.statusCode(HttpStatus.OK.value())
.body("scheduledAt", equalTo("2026-03-06T09:00:00Z"))
.body("count", equalTo(3));
}

Expand All @@ -309,7 +310,7 @@ void findNextReview_empty() {
)
.responseFields(
fieldWithPath("scheduledAt").type(JsonFieldType.NULL)
.description("다음 발송 예정 시간 (PENDING 없을 시 null)"),
.description("다음 발송 예정 시간, UTC ISO 8601 형식 (PENDING 없을 시 null)"),
fieldWithPath("count").type(JsonFieldType.NUMBER)
.description("해당 시간에 발송될 URL 개수")
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@
import com.recyclestudy.review.service.output.NextReviewOutput;
import com.recyclestudy.review.service.output.ReviewSendOutput;
import com.recyclestudy.review.service.output.ReviewSendOutput.ReviewSendElement;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.List;
import java.util.Optional;
import org.junit.jupiter.api.DisplayName;
Expand Down Expand Up @@ -200,7 +202,7 @@ void findNextReview_returnsEarliestGroup() {

// then
assertSoftly(softly -> {
softly.assertThat(result.scheduledAt()).isEqualTo(t1);
softly.assertThat(result.scheduledAt()).isEqualTo(t1.toInstant(ZoneOffset.UTC));
softly.assertThat(result.count()).isEqualTo(1);
});
}
Expand Down Expand Up @@ -231,7 +233,7 @@ void findNextReview_countsSameScheduledAt() {

// then
assertSoftly(softly -> {
softly.assertThat(result.scheduledAt()).isEqualTo(t1);
softly.assertThat(result.scheduledAt()).isEqualTo(t1.toInstant(ZoneOffset.UTC));
softly.assertThat(result.count()).isEqualTo(3);
});
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ void saveReview_withPreferredNotificationTime() {

final Email email = Email.from("test@test.com");
final Member member = Member.withoutId(email);
final LocalTime preferredTime = LocalTime.of(9, 0);
final LocalTime preferredTime = LocalTime.of(0, 0);
member.updateNotificationTime(preferredTime);

final Review review = Review.withoutId(member, ReviewURL.from(urlValue));
Expand Down
Loading