Skip to content
Open
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
Binary file added dump.rdb
Binary file not shown.
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package com.tavemakers.surf.application.letter.event;

import com.tavemakers.surf.domain.letter.event.LetterEmailRequestedEvent;
import com.tavemakers.surf.global.util.EmailSender;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.mail.MailException;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener;

/**
* 쪽지 이메일 발송 리스너 — 커밋 후 비동기로 SMTP 발송을 수행한다.
* <p>기존에는 요청 스레드에서 동기 발송해 응답이 SMTP 왕복(~3초)만큼 지연됐다.
* 발송 실패는 응답에 영향을 주지 않으며 서버 로그로만 남긴다(쪽지 저장은 이미 커밋됨).
* LogEventEmitter는 요청 스레드 ThreadLocal 기반이라 비동기 스레드에서는 slf4j로 기록한다.
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class LetterEmailListener {

private final EmailSender emailSender;

/** 쪽지 이메일 발송 (AFTER_COMMIT + 비동기) */
@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handle(LetterEmailRequestedEvent event) {
String body = """
[Surf에서 %s님이 보낸 쪽지입니다.]

%s

회신 희망 이메일: %s
SNS: %s
"""
.formatted(
event.senderName(),
event.content(),
event.replyEmail(),
event.sns() != null ? event.sns() : "-"
);

try {
emailSender.sendMail(event.receiverEmail(), event.title(), body);
log.info("[LetterEmail] sent letterId={} senderId={} receiverId={}",
event.letterId(), event.senderId(), event.receiverId());
} catch (MailException e) {
log.error("[LetterEmail] send failed letterId={} senderId={} receiverId={} - {}",
event.letterId(), event.senderId(), event.receiverId(), e.getMessage());
}
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.tavemakers.surf.application.letter.usecase;

import com.tavemakers.surf.domain.letter.entity.Letter;
import com.tavemakers.surf.domain.letter.event.LetterEmailRequestedEvent;
import com.tavemakers.surf.domain.letter.event.LetterSentEvent;
import com.tavemakers.surf.domain.letter.repository.LetterRepository;
import lombok.RequiredArgsConstructor;
Expand All @@ -10,10 +11,9 @@

/**
* 쪽지 저장 트랜잭션 경계를 소유하는 application 서비스.
* 저장·이벤트 발행을 하나의 트랜잭션으로 커밋한 뒤, 호출자(LetterUsecase)가
* 트랜잭션 밖에서 메일을 보낸다(저장후-메일 패턴). EmailSender를 쥔 LetterUsecase와
* 분리된 별도 빈이어야 한다 — self-invocation 프록시 우회 및 R5(트랜잭션 클래스의
* 외부 클라이언트 의존 금지) 회피를 위함.
* 저장과 이벤트 발행을 하나의 트랜잭션으로 커밋하면, AFTER_COMMIT 리스너가
* 알림(FCM)과 이메일 발송을 비동기로 수행한다. LetterUsecase와 분리된
* 별도 빈이어야 한다 — self-invocation 프록시 우회를 위함.
*/
@Service
@RequiredArgsConstructor
Expand All @@ -22,7 +22,7 @@ public class LetterCreateService {
private final LetterRepository letterRepository;
private final ApplicationEventPublisher eventPublisher;

/** 쪽지 저장 및 발송 이벤트 발행 (AFTER_COMMIT 알림 리스너가 커밋 후 발화) */
/** 쪽지 저장 및 알림·이메일 이벤트 발행 (AFTER_COMMIT 리스너가 커밋 후 발화) */
@Transactional
public Letter save(Letter letter) {
Letter saved = letterRepository.save(letter);
Expand All @@ -31,6 +31,17 @@ public Letter save(Letter letter) {
saved.getSender().getName(),
saved.getSender().getId()
));
eventPublisher.publishEvent(new LetterEmailRequestedEvent(
saved.getLetterId(),
saved.getSender().getId(),
saved.getReceiver().getId(),
saved.getSender().getName(),
saved.getReceiver().getEmail(),
saved.getTitle(),
saved.getContent(),
saved.getReplyEmail(),
saved.getSns()
));
return saved;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,15 @@
import com.tavemakers.surf.presentation.letter.dto.request.LetterCreateReqDTO;
import com.tavemakers.surf.presentation.letter.dto.response.LetterResDTO;
import com.tavemakers.surf.domain.letter.entity.Letter;
import com.tavemakers.surf.domain.letter.exception.LetterMailSendFailException;
import com.tavemakers.surf.application.letter.query.LetterGetService;
import com.tavemakers.surf.domain.member.entity.Member;
import com.tavemakers.surf.application.member.query.MemberGetService;
import com.tavemakers.surf.global.logging.LogEventEmitter;
import com.tavemakers.surf.global.util.EmailSender;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Slice;
import org.springframework.mail.MailException;
import org.springframework.stereotype.Service;

import java.util.HashMap;
import java.util.Map;

@Service
Expand All @@ -24,11 +20,10 @@ public class LetterUsecase {

private final MemberGetService memberGetService;
private final LetterCreateService letterCreateService;
private final EmailSender emailSender;
private final LetterGetService letterGetService;
private final LogEventEmitter logEventEmitter;

/** 쪽지 생성(저장·커밋)트랜잭션 밖에서 이메일 발송 */
/** 쪽지 생성 — 저장 커밋이메일·알림은 AFTER_COMMIT 리스너가 비동기 발송 */
public LetterResDTO createLetter(Long senderId, LetterCreateReqDTO req) {
logEventEmitter.emit("letter_send_api_called", Map.of(
"sender_id", senderId,
Expand Down Expand Up @@ -71,49 +66,16 @@ public LetterResDTO createLetter(Long senderId, LetterCreateReqDTO req) {
receiver
);

// 4) 저장 + 알림 이벤트 발행 (트랜잭션 커밋 → AFTER_COMMIT 리스너 발화)
// 4) 저장 + 알림·이메일 이벤트 발행 (트랜잭션 커밋 → AFTER_COMMIT 리스너가 비동기 발송)
Letter saved = letterCreateService.save(letter);

// 5) 이메일 본문 생성
String emailBody = """
[Surf에서 %s님이 보낸 쪽지입니다.]

%s

회신 희망 이메일: %s
SNS: %s
"""
.formatted(
sender.getName(),
req.content(),
req.replyEmail(),
req.sns() != null ? req.sns() : "-"
);

// 6) 이메일 전송 (트랜잭션 밖, 실패 시 예외 — 저장된 쪽지는 유지)
boolean emailSent = false;
try {
emailSender.sendMail(receiver.getEmail(), req.title(), emailBody);
emailSent = true;
} catch (MailException e) {
Map<String, Object> failedProps = new HashMap<>();
failedProps.put("sender_id", senderId);
failedProps.put("receiver_id", req.receiverId());
failedProps.put("status_code", 500);
failedProps.put("error_code", "MAIL_SEND_FAIL");
failedProps.put("error_message", e.getMessage() != null ? e.getMessage() : "smtp error");
logEventEmitter.emitError("letter_send_api_failed", failedProps, "쪽지 전송 실패 - 이메일 발송 오류");
throw new LetterMailSendFailException();
}

logEventEmitter.emit("letter_send_api_succeeded", Map.of(
"sender_id", senderId,
"receiver_id", req.receiverId(),
"letter_id", saved.getLetterId(),
"email_sent", emailSent
"letter_id", saved.getLetterId()
));

// 7) 저장된 엔티티 기반으로 Response 생성
// 5) 저장된 엔티티 기반으로 Response 생성
return LetterResDTO.from(saved);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.tavemakers.surf.domain.letter.event;

/** 쪽지 저장 커밋 후 수신자에게 이메일을 발송하기 위한 이벤트 (리스너: application/letter/event) */
public record LetterEmailRequestedEvent(
Long letterId,
Long senderId,
Long receiverId,
String senderName,
String receiverEmail,
String title,
String content,
String replyEmail,
String sns
) {
}

This file was deleted.

Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
package com.tavemakers.surf.domain.post.service.search;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.time.Duration;
import java.util.List;

@Slf4j
@Service
@RequiredArgsConstructor
public class RecentSearchService {
Expand All @@ -16,41 +18,48 @@ public class RecentSearchService {
private static final int MAX_SIZE = 10;
private static final Duration TTL = Duration.ofDays(30); // 필요시 0으로 두면 무기한

/** 최근 검색어 저장 */
@Transactional
// 중복 제거 + 앞 삽입 + 트림 + TTL을 한 번의 왕복으로 처리 (기존 4회 왕복 → 1회)
// KEYS[1]=recent 키, ARGV[1]=검색어, ARGV[2]=보관 개수-1, ARGV[3]=TTL(초)
private static final DefaultRedisScript<Long> SAVE_QUERY_SCRIPT = new DefaultRedisScript<>(
"redis.call('LREM', KEYS[1], 0, ARGV[1]); " +
"redis.call('LPUSH', KEYS[1], ARGV[1]); " +
"redis.call('LTRIM', KEYS[1], 0, ARGV[2]); " +
"redis.call('EXPIRE', KEYS[1], ARGV[3]); " +
"return 1",
Long.class
);

/** 최근 검색어 저장 — 중복 제거·삽입·트림·TTL을 Redis 왕복 1회로 처리 (실패해도 검색은 계속) */
public void saveQuery(Long memberId, String raw) {
if (raw == null) return;
String q = normalize(raw);
if (q.isEmpty()) return;

String key = key(memberId);

// 1) 중복 제거
redis.opsForList().remove(key, 0, q);
// 2) 맨 앞에 삽입
redis.opsForList().leftPush(key, q);
// 3) 10개로 트림
redis.opsForList().trim(key, 0, MAX_SIZE - 1);
// 4) TTL 갱신
redis.expire(key, TTL);
try {
redis.execute(
SAVE_QUERY_SCRIPT,
List.of(key(memberId)),
q, String.valueOf(MAX_SIZE - 1), String.valueOf(TTL.toSeconds())
);
} catch (Exception e) {
// 부가 기능이므로 Redis 장애가 검색 응답을 실패시키지 않도록 격리한다
log.warn("최근 검색어 저장 실패 (검색은 계속 진행): {}", e.getMessage());
}
}

/** 최근 검색어 10개 조회 */
@Transactional(readOnly = true)
public List<String> getRecent10(Long memberId) {
String key = key(memberId);
List<String> items = redis.opsForList().range(key, 0, MAX_SIZE - 1);
return items == null ? List.of() : items;
}

/** 최근 검색어 전체 삭제 */
@Transactional
public void clearAll(Long memberId) {
redis.delete(key(memberId));
}

/** 특정 검색어 삭제 */
@Transactional
public void deleteOne(Long memberId, String rawKeyword) {
if (rawKeyword == null) return;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,11 @@
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.time.Duration;
import java.util.List;

@Slf4j
@Service
Expand All @@ -20,26 +21,42 @@ public class ViewCountService {
// 스케줄러(1시간 주기)가 회수하지 못한 델타 키가 무기한 남지 않도록 하는 안전망 TTL
private static final Duration VIEW_COUNT_TTL = Duration.ofDays(2);

// 첫 조회 판정 + 델타 증가 + TTL + 델타 조회를 한 번의 왕복으로 처리 (기존 2~5회 왕복 → 1회).
// Redis에는 DB 미반영 증가분(델타)만 저장한다. INCR는 키가 없으면 0에서 시작한다.
// KEYS[1]=델타 키, KEYS[2]=viewer 키, ARGV[1]=델타 TTL(초), ARGV[2]=viewer TTL(초)
private static final DefaultRedisScript<Long> INCREASE_AND_GET_SCRIPT = new DefaultRedisScript<>(
"if redis.call('EXISTS', KEYS[2]) == 0 then " +
"redis.call('INCR', KEYS[1]); " +
"redis.call('EXPIRE', KEYS[1], ARGV[1]); " +
"redis.call('SET', KEYS[2], '1', 'EX', ARGV[2]) " +
"end; " +
"local delta = redis.call('GET', KEYS[1]); " +
"if delta then return tonumber(delta) else return 0 end",
Long.class
);

private final StringRedisTemplate redisTemplate;

/** 게시글 조회수 증가 및 반환 */
@Transactional
/**
* 게시글 조회수 증가 및 반환 — Redis 왕복 1회.
* 장애 시 DB 직접 증가로 폴백하며, 이 경우 더티체킹 반영을 위해
* 호출자(usecase/query)의 트랜잭션 안에서 호출되어야 한다.
*/
public int increaseViewCount(Post post, Long viewerId) {
String viewCountKey = generateViewCountKey(post.getId());
String viewersKey = generateViewersKey(post.getId(), viewerId);

try {
Boolean alreadyViewed = redisTemplate.hasKey(viewersKey);
if(Boolean.FALSE.equals(alreadyViewed)) {
// Redis에는 DB 미반영 증가분(델타)만 저장한다.
// INCR는 키가 없으면 0에서 시작하므로 별도 초기화가 필요 없다 (비원자 초기화 경합 제거).
redisTemplate.opsForValue().increment(viewCountKey, 1);
redisTemplate.expire(viewCountKey, VIEW_COUNT_TTL);
redisTemplate.opsForValue().set(viewersKey, "1", VIEWERS_TTL);
Long delta = redisTemplate.execute(
INCREASE_AND_GET_SCRIPT,
List.of(viewCountKey, viewersKey),
String.valueOf(VIEW_COUNT_TTL.toSeconds()),
String.valueOf(VIEWERS_TTL.toSeconds())
);
if (delta == null) {
throw new IllegalStateException("view count script returned null");
}

String delta = redisTemplate.opsForValue().get(viewCountKey);
return post.getViewCount() + (delta != null ? Integer.parseInt(delta) : 0);
return post.getViewCount() + delta.intValue();
} catch (Exception e) {
log.error("Redis 커넥션 에러로 Database에서 조회합니다. Error: {}", e.getMessage());
post.increaseViewCount();
Expand Down
Loading
Loading