Skip to content
Merged
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
6 changes: 5 additions & 1 deletion .github/workflows/cicd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -68,12 +68,14 @@ jobs:
DB_USERNAME: ${{ secrets.DB_USERNAME }}
DB_PASSWORD: ${{ secrets.DB_PASSWORD }}
FCM_JSON: ${{ secrets.FCM_JSON }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
with:
host: ${{ secrets.EC2_HOST }}
username: ${{ secrets.EC2_USERNAME }}
key: ${{ secrets.EC2_PRIVATE_KEY }}
script_stop: true
envs: DOCKER_USERNAME,IMAGE_NAME,DB_URL,DB_USERNAME,DB_PASSWORD,FCM_JSON
envs: DOCKER_USERNAME,IMAGE_NAME,DB_URL,DB_USERNAME,DB_PASSWORD,FCM_JSON, SENTRY_DSN, SENTRY_AUTH_TOKEN
script: |
set -e

Expand All @@ -85,6 +87,8 @@ jobs:
DB_USERNAME=${DB_USERNAME}
DB_PASSWORD=${DB_PASSWORD}
FCM_JSON=${FCM_JSON}
SENTRY_DSN=${SENTRY_DSN}
SENTRY_AUTH_TOKEN=${SENTRY_AUTH_TOKEN}
EOF

sudo docker-compose down
Expand Down
25 changes: 24 additions & 1 deletion build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ plugins {
id 'org.springframework.boot' version '3.5.6'
id 'io.spring.dependency-management' version '1.1.7'
id 'org.asciidoctor.jvm.convert' version '4.0.5'
id "io.sentry.jvm.gradle" version "6.0.0"
}

group = 'com.todaysound'
Expand Down Expand Up @@ -30,11 +31,24 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-aop'
implementation 'org.springframework.boot:spring-boot-starter-actuator'

// Micrometer

// 1. Sentry
implementation platform('io.sentry:sentry-bom:8.31.0')
implementation 'io.sentry:sentry-spring-boot-starter-jakarta'
implementation 'io.sentry:sentry-logback'

// P6Spy Spring Boot Starter
implementation 'com.github.gavlyukovskiy:p6spy-spring-boot-starter:1.9.1'

// Micrometer Metrics
implementation 'io.micrometer:micrometer-registry-prometheus' // Prometheus

// Micrometer Tracing - traceId/spanId 자동 생성
implementation 'io.micrometer:micrometer-tracing-bridge-brave'

// 구조화된 JSON 로깅
implementation 'net.logstash.logback:logstash-logback-encoder:8.0'

Expand Down Expand Up @@ -123,4 +137,13 @@ bootJar { // 생성된 HTML 파일을 static/docs 폴더에 복사
}
}

sentry {
// 소스 코드를 센트리에 업로드하여 에러 발생 시 코드 문맥을 보여줍니다.
includeSourceContext = true

org = "todaysound"
projectName = "java-spring-boot"

authToken = System.getenv("SENTRY_AUTH_TOKEN")
}

Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,14 @@
import com.todaysound.todaysound_server.domain.summary.entity.Summary;
import com.todaysound.todaysound_server.domain.summary.repository.SummaryRepository;
import com.todaysound.todaysound_server.domain.user.entity.User;
import static com.todaysound.todaysound_server.global.utils.LogMarkers.BUSINESS;
import static net.logstash.logback.argument.StructuredArguments.kv;

import com.todaysound.todaysound_server.global.application.FCMService;
import com.todaysound.todaysound_server.global.exception.BaseException;
import com.todaysound.todaysound_server.global.exception.CommonErrorCode;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
Expand All @@ -21,6 +25,7 @@
* POST /internal/alerts { "user_id": 10, "subscription_id": 1, "site_post_id": "12345", "title": "게시글 제목", "url":
* "https://...", "content_raw": "...원문...", "content_summary": "...요약...", "keyword_matched": true }
*/
@Slf4j
@RestController
@RequestMapping("/internal")
@RequiredArgsConstructor
Expand All @@ -32,6 +37,11 @@ public class InternalAlertController implements InternalAlertApi {

@PostMapping("/alerts")
public void createAlert(@RequestBody InternalAlertRequest request) {
log.info(BUSINESS, "크롤러 알림 수신 {} {} {}",
kv("userId", request.userId()),
kv("subscriptionId", request.subscriptionId()),
kv("sitePostId", request.sitePostId()));

Subscription subscription = subscriptionRepository.findById(request.subscriptionId())
.orElseThrow(() -> BaseException.type(CommonErrorCode.ENTITY_NOT_FOUND));

Expand Down Expand Up @@ -64,6 +74,11 @@ public void createAlert(@RequestBody InternalAlertRequest request) {
);

summaryRepository.save(summary);

log.info(BUSINESS, "크롤러 알림 처리 완료 {} {} {}",
kv("subscriptionId", request.subscriptionId()),
kv("sitePostId", request.sitePostId()),
kv("alarmSent", subscription.isAlarmEnabled()));
}

public record InternalAlertRequest(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,17 @@
import com.todaysound.todaysound_server.domain.subscription.repository.SubscriptionRepository;
import com.todaysound.todaysound_server.domain.user.entity.User;
import com.todaysound.todaysound_server.domain.user.validator.HeaderAuthValidator;
import static com.todaysound.todaysound_server.global.utils.LogMarkers.BUSINESS;
import static net.logstash.logback.argument.StructuredArguments.kv;

import com.todaysound.todaysound_server.global.exception.BaseException;
import java.util.List;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Slf4j
@Service
@Transactional
@RequiredArgsConstructor
Expand All @@ -41,6 +46,10 @@ public void deleteSubscription(final Long subscriptionId, final String userUuid,
}

subscriptionRepository.deleteById(subscriptionId);

log.info(BUSINESS, "구독 삭제 완료 {} {}",
kv("subscriptionId", subscriptionId),
kv("userId", user.getId()));
}

public SubscriptionCreationResponse createSubscription(final String headerUserUuid,
Expand All @@ -58,6 +67,12 @@ public SubscriptionCreationResponse createSubscription(final String headerUserUu
requestDto.isAlarmEnabled()
);
Subscription savedSubscription = subscriptionRepository.save(subscription);

log.info(BUSINESS, "구독 생성 완료 {} {} {}",
kv("subscriptionId", savedSubscription.getId()),
kv("userId", user.getId()),
kv("urlId", requestDto.urlId()));

return SubscriptionCreationResponse.from(savedSubscription);
}

Expand Down Expand Up @@ -87,5 +102,8 @@ public void updateSubscription(Long subscriptionId, String userUuid, String devi
subscription.updateAlias(request.alias());
subscription.updateIsAlarmEnabled(request.isAlarmEnabled());

log.info(BUSINESS, "구독 수정 완료 {} {}",
kv("subscriptionId", subscriptionId),
kv("userId", user.getId()));
}
}
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
package com.todaysound.todaysound_server.domain.summary.infra.scheduler;

import static com.todaysound.todaysound_server.global.utils.LogMarkers.SCHEDULER;
import static net.logstash.logback.argument.StructuredArguments.kv;

import com.todaysound.todaysound_server.domain.summary.repository.SummaryRepository;
import java.time.LocalDateTime;
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 SummaryCleanupScheduler {
Expand All @@ -16,9 +21,15 @@ public class SummaryCleanupScheduler {
@Transactional
@Scheduled(cron = "0 0 3 * * *") // 매일 새벽 3시에 실행
public void deleteOldSummaries() {
log.info(SCHEDULER, "Summary 정리 스케줄러 시작");
long startTime = System.currentTimeMillis();

LocalDateTime threshold = LocalDateTime.now().minusDays(7);
summaryRepository.deleteByCreatedAtBefore(threshold);

long elapsed = System.currentTimeMillis() - startTime;
log.info(SCHEDULER, "Summary 정리 스케줄러 완료 {} {}",
kv("threshold", threshold),
kv("elapsedMs", elapsed));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@
import com.todaysound.todaysound_server.domain.user.factory.UserFactory;
import com.todaysound.todaysound_server.domain.user.repository.UserRepository;
import com.todaysound.todaysound_server.domain.user.validator.HeaderAuthValidator;
import static com.todaysound.todaysound_server.global.utils.LogMarkers.AUTH;
import static net.logstash.logback.argument.StructuredArguments.kv;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
Expand All @@ -26,14 +29,13 @@ public class UserService {

public UserIdResponse anonymous(UserSecretRequest userSecretRequest) {

log.info("Anonymous user command received");
log.info(AUTH, "익명 사용자 인증 요청 수신");
boolean secretExists = userQueryService.existsBySecretFingerprint(userSecretRequest.deviceSecret());

User user;

if (!secretExists) {
log.info("User secret does not exist, creating new user");
log.info("fcmToken: {}", userSecretRequest.fcmToken());
log.info(AUTH, "신규 사용자 생성 시작");

User newUser = userFactory.createAnonymousUser(userSecretRequest);

Expand All @@ -43,9 +45,11 @@ public UserIdResponse anonymous(UserSecretRequest userSecretRequest) {

user = userRepository.save(newUser);

log.info(AUTH, "신규 사용자 생성 완료 {}", kv("userId", user.getId()));

user.clearPlainSecret();
} else {
log.info("User secret exists, returning existing user");
log.info(AUTH, "기존 사용자 인증 완료");
user = userQueryService.findBySecretFingerprint(userSecretRequest.deviceSecret());
}

Expand All @@ -58,5 +62,7 @@ public void withdraw(String userUuid, String deviceSecret) {
User user = headerAuthValidator.validateAndGetUser(userUuid, deviceSecret);

userRepository.delete(user);

log.info(AUTH, "회원 탈퇴 완료 {}", kv("userId", user.getId()));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@
import com.google.firebase.messaging.MulticastMessage;
import com.google.firebase.messaging.Notification;
import com.google.firebase.messaging.SendResponse;
import static com.todaysound.todaysound_server.global.utils.LogMarkers.EXTERNAL_API;
import static net.logstash.logback.argument.StructuredArguments.kv;

import com.todaysound.todaysound_server.domain.user.entity.FCM_Token;
import com.todaysound.todaysound_server.domain.user.entity.User;
import com.todaysound.todaysound_server.domain.user.repository.FCMRepository;
Expand Down Expand Up @@ -42,7 +45,7 @@ public void sendNotificationToUser(User user, String title, String body) {
List<FCM_Token> devices = fcmRepository.findByUser(user);

if (devices.isEmpty()) {
log.warn("알림을 보낼 기기(토큰)가 없습니다. (User ID: {})", user.getId());
log.warn(EXTERNAL_API, "알림을 보낼 기기 토큰 없음 {}", kv("userId", user.getId()));
return;
}

Expand All @@ -64,15 +67,19 @@ public void sendNotificationToUser(User user, String title, String body) {
try {
response = FirebaseMessaging.getInstance().sendEachForMulticast(message);

log.info("총 {}건의 알림 발송 요청 성공. (성공: {}건, 실패: {}건)", response.getSuccessCount() + response.getFailureCount(),
response.getSuccessCount(), response.getFailureCount());
log.info(EXTERNAL_API, "FCM 알림 발송 완료 {} {} {}",
kv("total", response.getSuccessCount() + response.getFailureCount()),
kv("success", response.getSuccessCount()),
kv("failure", response.getFailureCount()));

if (response.getFailureCount() > 0) {
handleFailedTokens(response, tokens);
}

} catch (FirebaseMessagingException e) {
log.error("FCM Multicast 발송 실패", e);
log.error(EXTERNAL_API, "FCM Multicast 발송 실패 {} {}",
kv("userId", user.getId()),
kv("errorMessage", e.getMessage()), e);
}
}

Expand All @@ -96,31 +103,35 @@ private void handleFailedTokens(BatchResponse response, List<String> originalTok
MessagingErrorCode errorCode = exception.getMessagingErrorCode(); // Enum 값
String errorMessage = exception.getMessage(); // 실제 에러 내용

log.error("--------------------------------------------------");
log.error("[FCM 발송 실패 상세 로그]");
log.error("대상 토큰: {}", failedToken);
log.error("에러 코드: {}", errorCode);
log.error("에러 메시지: {}", errorMessage);
String maskedToken = maskToken(failedToken);
int httpStatus = exception.getHttpResponse() != null
? exception.getHttpResponse().getStatusCode() : 0;

if (exception.getHttpResponse() != null) {
log.error("HTTP 상태 코드: {}", exception.getHttpResponse().getStatusCode());
log.error("HTTP 응답 본문: {}", exception.getHttpResponse().getContent());
}
log.error(EXTERNAL_API, "FCM 발송 실패 {} {} {} {}",
kv("token", maskedToken),
kv("errorCode", errorCode),
kv("errorMessage", errorMessage),
kv("httpStatus", httpStatus));

if (errorCode == MessagingErrorCode.UNREGISTERED) {
log.warn("FCM 토큰 {}이(가) 만료(UNREGISTERED)되었습니다. DB 삭제 목록에 추가합니다.", failedToken);
log.warn(EXTERNAL_API, "만료된 FCM 토큰 삭제 대상 추가 {}", kv("token", maskedToken));
tokensToDelete.add(failedToken);
} else {
log.warn("FCM 토큰 {} 발송 실패. (에러 코드: {})", failedToken, errorCode);
}
}
}

// 삭제할 토큰이 있다면 DB에서 일괄 삭제
if (!tokensToDelete.isEmpty()) {
fcmRepository.deleteAllByFcmTokenIn(tokensToDelete);
log.info("만료된 FCM 토큰 {}건을 DB에서 삭제했습니다.", tokensToDelete.size());
log.info(EXTERNAL_API, "만료된 FCM 토큰 DB 삭제 완료 {}", kv("deletedCount", tokensToDelete.size()));
}
}

private static String maskToken(String token) {
if (token == null || token.length() <= 8) {
return "****";
}
return token.substring(0, 8) + "****";
}

@Transactional
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package com.todaysound.todaysound_server.global.config;

import static net.logstash.logback.argument.StructuredArguments.kv;

import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.springframework.stereotype.Component;

@Slf4j
@Aspect
@Component
public class ApiLoggingAspect {

@Pointcut("execution(* com.todaysound.todaysound_server..controller..*(..)) || "
+ "execution(* com.todaysound.todaysound_server..presentation..*(..))")
private void controllerMethods() {
}

@Around("controllerMethods()")
public Object logApiCall(ProceedingJoinPoint joinPoint) throws Throwable {
String className = joinPoint.getTarget().getClass().getSimpleName();
String methodName = joinPoint.getSignature().getName();

log.info("API 요청 시작 {} {}",
kv("class", className),
kv("method", methodName));

long startTime = System.currentTimeMillis();
try {
Object result = joinPoint.proceed();
long elapsed = System.currentTimeMillis() - startTime;

log.info("API 요청 완료 {} {} {}",
kv("class", className),
kv("method", methodName),
kv("elapsedMs", elapsed));

return result;
} catch (Throwable ex) {
long elapsed = System.currentTimeMillis() - startTime;

log.warn("API 요청 실패 {} {} {} {}",
kv("class", className),
kv("method", methodName),
kv("elapsedMs", elapsed),
kv("exception", ex.getClass().getSimpleName()));

throw ex;
}
}
}
Loading