해당 포스팅은 프로젝트 팀블로그에 제가 작성한 글을 옮긴 기록입니다. 언제 팀블로그가 사라질 지 모르기에..
모두의 택시 팀블로그
안녕하세요, [모두의택시] 팀 서버 개발자 박성훈입니다.
이번 포스팅에서는 우리 서비스에 예약 알림 메시지 전송을 구현하기 위해 겪었던 시행착오에 대해 공유드리고자 합니다.
1. 요구사항 정의
[모두의택시] 에서는 채팅 기능이 있습니다.
[Spring Boot] WebSocket & Stomp & Redis pub/sub & FCM으로 개발하는 채팅기능
안녕하세요, [모두의택시] 팀 서버 개발자 박성훈입니다.이번 포스팅에서는 우리 서비스에 채팅 기능 개발,도입을 위해 겪었던 시행착오에 대해 공유드리고자 합니다.1. 요구사항 정의택시 동승
modutaxi-tech.tistory.com
우리는 여기서 추가적인 기능을 하나 더 구현해야 했습니다. 바로 [예약 전송] 기능입니다.
우리 서비스에서는 모집방이 생성된 후 채팅방에 아래 요구사항에 맞춰 공지 메시지를 전송해야 했습니다.
1️⃣ 출발 시간 5분 전에 [택시 부르러 가기 알림] 전송
2️⃣ 단, 방 생성 시간 기준 출발 시간이 0~5분 뒤로 설정되어 있다면 [택시 부르러 가기 알림]은 곧바로 전송
3️⃣ 출발 시간이 되면 [매칭완료]를 위한 알림 전송
즉, 동적으로 메시지를 예약 전송보낼 수 있는 방법에 대해 고민했습니다.
2. 계획 수립
Spring Scheduler
예약 전송은 그 이름에 걸맞게 Spring Scheduler를 고려할 수 있습니다. 기본적으로 Spring Scheduler는 @Scheduled 어노테이션을 통해 스케쥴 옵션을 설정할 수 있고 그 종류는 아래와 같습니다.
- fixedDelay: 마지막 호출의 종료와 다음 호출의 시작 사이에 일정한 기간이 지난 후에 실행
- fixedRate: (호출 사이에 일정한 간격을 두고 실행
- initialDelay: 고정된 지연(fixedDelay) 작업의 첫 번째 실행 전 지연할 시간 단위 수
이처럼 Scheudler는 기본적으로 Spring 에서 제공해주는 방식대로 동작하며 Cron식을 활용하여 동작합니다. 하지만 해당 메서드들의 문제는 하나의 함수에 적용되어 반복적인 작업에는 용이하지만, 우리의 요구사항처럼 [특정한 시간] 에 한 번 실행되는 것과는 성격이 달랐습니다.
이에 따라 기술 탐색 중 [병렬 프로그래밍] 을 적용하면 어떨까 생각했습니다.
TaskScheduler
자바는 멀티 스레드를 지원하는 언어입니다. 따라서 내부적으로 다수의 스레드를 생성해 동작을 처리할 수 있습니다. 병렬 프로그래밍을 구현할 수 있는 여러가지 방법이 있지만, TaskScheduler.schedule() 메서드를 사용하여 비동기 작업을 스케줄링하고 실행함으로써 병렬 프로그래밍을 구현할 수 있습니다.
@Scheduled, TaskScheduler 각각의 장단점
장점 | 단점 | |
@Scheduled | + 구현이 간편하다. (어노테이션 한 줄이면 구현 가능) |
- 리소스 낭비: 요청이 없는 상태에서도 동작하기에 서버 리소스 관리에 부적절하다. - 작업 실행 주기가 고정되어 있어 실시간으로 작업을 처리하는 데 부적절하다. |
TaskScheduler | + 효율적으로 리소스를 관리할 수 있다. + 많은 작업을 동시에 처리할 수 있어 확장성이 뛰어나다. |
- 러닝커브..!! - 구현의 복잡성: 동기화 문제, 데드락, Race condition 등의 복잡한 문제의 가능성 존재한다. - 관리의 복잡성: 스레드 풀, 큐, 동기화 메커니즘 등 자원 관리에 대한 추가적인 부담. |
결론
[모두의택시]에서는 결론적으로 TaskScheduler를 활용해 예약 메시지를 구현하기로 했습니다.
첫 번째 이유는 @Scheduled의 명백한 단점입니다. 만약 @Scheduled로써 해당 기능을 구현한다면 Cron 식을 활용해 1분마다 실행하여 전체 Room 테이블을 돌며 현재 시간과 출발 시간을 비교하여 메시지를 전송하는 작업을 할 것입니다. 테이블 전체를 순회하는 것도 문제가 있지만 방이 없는 상태에서도 이 작업은 주기적으로 실행됩니다. 이에 따라 불필요한 메모리 낭비가 가장 큰 단점이라고 할 수 있죠.이는 채팅을 구현할 때 Polling 방식이 아닌 WebSocket을 선택했던 이유와 같은 성격을 띕니다.
두 번째 이유는 우리 서비스에서 예약 메시지가 갖는 특성입니다. 사실 여러 스레드가 동기화를 사용하지 않은 채로 공유 데이터를 건드리면 결과를 예측할 수 없습니다. 하지만 우리의 예약 메시지 전송은 공유 자원에 접근하여 이를 업데이트 하는 일이 없습니다.
결과적으로 이런 이유들을 근거로 동적으로 예약 메시지를 Task로써 스케줄링 하기로 결정하였습니다.
3. 구현
SchedulerConfig 설정
@Configuration
public class SchedulerConfig {
private static final int POOL_SIZE = 3;
@Bean
public ThreadPoolTaskScheduler taskScheduler(){
ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
scheduler.setPoolSize(POOL_SIZE);
scheduler.setThreadNamePrefix("My Scheduler - ");
scheduler.initialize();
return scheduler;
}
}
사용할 ThreadPool의 사이즈를 설정해주고, 쓰레드에 Prefix를 적용하여 Task들이 적절하게 분배되는 지 명시적으로 확인합니다.
@Slf4j
@RequiredArgsConstructor
@Service
public class ScheduledMessageService {
@Qualifier("taskScheduler")
private final TaskScheduler taskScheduler;
private final ChatService chatService;
private final RoomRepository roomRepository;
private final ScheduledMessageRepository scheduledMessageRepository;
private static final String MATCHING_COMPLETE = "매칭완료 하시겠습니까?";
private static final String CALL_TAXI = "택시 부르러 가볼까요?";
private static final long BEFORE_FIVE_MINUTES = 300;
private static final int NO_DELAY = 0;
@EventListener(ApplicationReadyEvent.class)
public void initScheduledMessage() {
List<ScheduledMessage> scheduledMessageList =
scheduledMessageRepository.findAllByStatus(ScheduledMessageStatus.PENDING);
scheduledMessageList.forEach(iter -> {
if (roomRepository.existsById(iter.getRoomId())) {
// 메시지 새로 저장
ScheduledMessage scheduledMessage = scheduledMessageRepository.save(ScheduledMessageMapper
.toEntity(iter.getRoomId(), iter.getContent(), iter.getExecuteTime()));
long delaySeconds = calculateDelaySeconds(LocalDateTime.now(), iter.getExecuteTime());
if (Objects.equals(scheduledMessage.getContent(), CALL_TAXI)) {
delaySeconds = delaySeconds > BEFORE_FIVE_MINUTES + 20 ? delaySeconds - BEFORE_FIVE_MINUTES : NO_DELAY;
}
Runnable task = chatBotNotice(iter.getRoomId(), iter.getContent(), scheduledMessage.getId());
taskScheduler.schedule(task, Instant.now().plusSeconds(delaySeconds));
}
scheduledMessageRepository.delete(iter);
});
log.info("서버 시작 후 실행되지 않은 메시지가 예약되었습니다.");
}
@Transactional
public void updateScheduledMessageStatus(Long scheduledMessageId) {
ScheduledMessage scheduledMessage = scheduledMessageRepository.findById(scheduledMessageId).orElseThrow();
scheduledMessage.scheduledMessageStatusUpdate();
scheduledMessageRepository.save(scheduledMessage);
}
private Runnable chatBotNotice(Long roomId, String content, Long scheduledMessageId) {
return () -> {
Room room = roomRepository.findById(roomId).orElseThrow(() -> new BaseException(RoomErrorCode.EMPTY_ROOM));
updateScheduledMessageStatus(scheduledMessageId);
ChatMessageRequestDto message = new ChatMessageRequestDto(
Long.valueOf(roomId), MessageType.CHAT_BOT, content,
"모두의택시", room.getRoomManager().getId().toString(), LocalDateTime.now(), "");
chatService.sendChatMessage(message);
log.info("{}: {}", content, Thread.currentThread().getName());
};
}
@Transactional
public void addTask(Long roomId, LocalDateTime departureTime) {
long delaySeconds = calculateDelaySeconds(LocalDateTime.now(), departureTime);
ScheduledMessage matchingCompleteMessage =
ScheduledMessageMapper.toEntity(roomId, MATCHING_COMPLETE, departureTime);
scheduledMessageRepository.save(matchingCompleteMessage);
Runnable matchingModal = chatBotNotice(roomId, MATCHING_COMPLETE, matchingCompleteMessage.getId());
taskScheduler.schedule(matchingModal, Instant.now().plusSeconds(delaySeconds));
ScheduledMessage callTaxiMessage = ScheduledMessageMapper.toEntity(roomId, CALL_TAXI, departureTime);
scheduledMessageRepository.save(callTaxiMessage);
Runnable callTaxiTask = chatBotNotice(roomId, CALL_TAXI, callTaxiMessage.getId());
delaySeconds = delaySeconds > BEFORE_FIVE_MINUTES + 20 ? delaySeconds - BEFORE_FIVE_MINUTES : NO_DELAY;
taskScheduler.schedule(callTaxiTask, Instant.now().plusSeconds(delaySeconds));
log.info("{}번 방에 예약메시지가 설정되었습니다.", roomId);
}
private long calculateDelaySeconds(LocalDateTime now, LocalDateTime targetDateTime) {
Duration duration = Duration.between(now, targetDateTime);
return Math.max(NO_DELAY, duration.getSeconds());
}
}
void initScheduledMessage()
애플리케이션이 시작될 때 PENDING 상태인 메시지를 초기화하고 예약합니다.
void updateScheduledMessageStatus(Long scheduledMessageId)
예약된 메시지가 처리되었음을 나타내기 위해 상태값을 업데이트합니다.
Runnable chatBotNotice(Long roomId, String content, Long scheduledMessageId)
지정된 방에 채팅 메시지를 보내는 작업을 생성합니다. 해당 작업은 스케줄러에 등록됩니다.
void addTask(Long roomId, LocalDateTime departureTime)
특정 방이 생성되면 출발 시간과 비교하여 특정 시간에 전송될 메시지를 예약합니다.
long calculateDelaySeconds(LocalDateTime now, LocalDateTime targetDateTime):
Task가 시작 될 시간을 정합니다. 만약 출발 시간이 현재 시간보다 이 전이라면(이미 지났다면) 곧바로 Task를 수행할 수 있도록 세팅합니다.
4. 트러블 슈팅
Thread Pool 설정
생성된 Thread 안에서 동시에 실행되는 Runnable Task가 여럿 있으면 Task가 사라질 수도 있다고 판단하였습니다. 자바의 Thread 생성에는 약간의 시간과 메모리가 필요합니다. 무제한으로 쓰레드를 생성하게 된다면 결과적으로 성능저하와 메모리 고갈의 문제가 생길수 있습니다. 이 문제를 해결하기 위해서 쓰레드풀(Thread Pool)이라는 쓰레드 관리 방식이 사용됩니다. 쓰레드 풀이란 쓰레드를, 허용된 갯수 안에서만 사용할수 있도록 제약할 수 있는 방법입니다.
@Configuration
public class SchedulerConfig {
private static final int POOL_SIZE = 3;
@Bean
public ThreadPoolTaskScheduler taskScheduler(){
ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
scheduler.setPoolSize(POOL_SIZE);
scheduler.setThreadNamePrefix("My Scheduler - ");
scheduler.initialize();
return scheduler;
}
}
해당 설정을 통해 쓰레드 풀의 개수를 3으로 늘렸습니다.(기본 설정은 1) 이는 이후 현재 가용한 쓰레드 풀의 크기를 Thread객체를 통해 계산하여 크기를 설정하는 방법으로 전환하는 것을 고려중입니다. ThreadNamePrefix 설정을 통해 현재 스케줄러가 어떤 쓰레드에서 동작하고 있는 지 로깅을 수월토록 하였습니다.
WAS 재시작 시 Task 소실 이슈
만약 Task를 예약해두었다고 가정해보겠습니다. 해당 Task는 30분 뒤에 실행될 예정입니다. 이 상황에서 서버를 재시작하면 어떻게 될까요? 예약된 Task는 쓰레드에서 대기 상태로 있었기 때문에 서버가 종료되며 해당 정보가 소실됩니다. 이를 해결하기 위해 PENDING, COMPLETE 타입을 상태값으로 가진 ScheduledMessage 엔티티를 생성하였습니다.
(Task 로 생성하려다가 스케줄링 되는 작업이 여럿 있을 수도 있을 것 같아 ScheduledMessage으로 명명했습니다.)
ScheduledMessage 엔티티
@Getter
@AllArgsConstructor
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
@Builder
public class ScheduledMessage extends BaseTime {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@NotNull
private Long roomId;
@NotNull
private String content;
@NotNull
private LocalDateTime executeTime;
@NotNull
private ScheduledMessageStatus status;
public void scheduledMessageStatusUpdate() {
this.status = ScheduledMessageStatus.COMPLETE;
}
}
ScheduledMessage는 Task를 부여하기 위한 식별자, roomId, executeTime, status를 타입으로 갖습니다. 이는 Runnable 인터페이스를 통해 요구사항에 맞는 Task를 생성할 수 있도록 하는 Entity입니다. 또한 생성된 Task 정보가 실행되어 COMPLETE로 바뀌기 전 PENDING상태로 생성될 수 있게 하는 클래스입니다.
이벤트 리스너 추가
@EventListener(ApplicationReadyEvent.class)
public void initScheduledMessage() {
List<ScheduledMessage> scheduledMessageList =
scheduledMessageRepository.findAllByStatus(ScheduledMessageStatus.PENDING);
scheduledMessageList.forEach(iter -> {
if (roomRepository.existsById(iter.getRoomId())) {
// 메시지 새로 저장
ScheduledMessage scheduledMessage = scheduledMessageRepository.save(ScheduledMessageMapper
.toEntity(iter.getRoomId(), iter.getContent(), iter.getExecuteTime()));
long delaySeconds = calculateDelaySeconds(LocalDateTime.now(), iter.getExecuteTime());
if (Objects.equals(scheduledMessage.getContent(), CALL_TAXI)) {
delaySeconds = delaySeconds > BEFORE_FIVE_MINUTES + 20 ? delaySeconds - BEFORE_FIVE_MINUTES : NO_DELAY;
}
Runnable task = chatBotNotice(iter.getRoomId(), iter.getContent(), scheduledMessage.getId());
taskScheduler.schedule(task, Instant.now().plusSeconds(delaySeconds));
}
scheduledMessageRepository.delete(iter);
});
log.info("서버 시작 후 실행되지 않은 메시지가 예약되었습니다.");
}
동작 과정은 다음과 같습니다.
1. 모든 ScheduledMessage는 생성과 동시에 DB에 PENDING 상태로 저장됩니다.
2. 이들은 또한 실행과 동시에 COMPLETE상태로 상태값이 변경됩니다.
3. 따라서 실행되지 않은 Task는 PENDING상태로 남아있게 됩니다.
4. 서버가 재시작할 때 DB에 저장된 PENDING상태(실행되지 않고 예약되어있다 종료당한)인 ScheduledMessage를 전부 불러와 DB에 새로운 상태로 저장하고 scheduler에 스케줄링합니다.
이로써 서버가 종료될 때 예약된 메시지들이 유실되는 문제를 해결하였습니다.
Task의 Transaction 범위
기존 코드에서는 작업을 스케줄링하고 해당 Task가 실행되는 시점에 메시지의 상태를 업데이트했습니다.
ScheduledMessageService 수정 전
@Slf4j
@RequiredArgsConstructor
@Service
public class ScheduledMessageService {
private final TaskScheduler taskScheduler;
private final ChatService chatService;
private final RoomRepository roomRepository;
private final ScheduledMessageRepository scheduledMessageRepository;
private static final String MATCHING_COMPLETE = "매칭완료 하시겠습니까?";
private static final String CALL_TAXI = "택시 부르러 가볼까요?";
private static final long BEFORE_FIVE_MINUTES = 300;
private static final int NO_DELAY = 0;
@EventListener(ApplicationReadyEvent.class)
public void initScheduledMessage() {
List<ScheduledMessage> scheduledMessageList =
scheduledMessageRepository.findAllByStatus(ScheduledMessageStatus.PENDING);
scheduledMessageList.forEach(iter -> {
if(roomRepository.existsById(iter.getRoomId())) {
//메시지 새로 저장
ScheduledMessage scheduledMessage = scheduledMessageRepository.save(ScheduledMessageMapper
.toEntity(iter.getRoomId(), iter.getContent(), iter.getExecuteTime()));
long delaySeconds = calculateDelaySeconds(LocalDateTime.now(), iter.getExecuteTime());
if (Objects.equals(scheduledMessage.getContent(), CALL_TAXI)) {
delaySeconds = delaySeconds > BEFORE_FIVE_MINUTES + 20 ? delaySeconds - BEFORE_FIVE_MINUTES : NO_DELAY;
}
Runnable task = chatBotNotice(iter.getRoomId(), iter.getContent(), scheduledMessage.getId());
taskScheduler.schedule(task, Instant.now().plusSeconds(delaySeconds));
}
scheduledMessageRepository.delete(iter);
}
);
log.info("서버 시작 후 실행되지 않은 메시지가 예약되었습니다.");
}
private Runnable chatBotNotice(Long roomId, String content, Long scheduledMessageId) {
return () -> {
Room room = roomRepository.findById(roomId).orElseThrow(() -> new BaseException(RoomErrorCode.EMPTY_ROOM));
ScheduledMessage scheduledMessage = scheduledMessageRepository.findById(scheduledMessageId).orElseThrow();
/*
여기서 상태 업데이트
*/
scheduledMessage.scheduledMessageStatusUpdate();
ChatMessageRequestDto message = new ChatMessageRequestDto(
Long.valueOf(roomId), MessageType.CHAT_BOT, content,
"모두의택시", room.getRoomManager().getId().toString(), LocalDateTime.now(), "");
chatService.sendChatMessage(message);
log.info("{}: {}", content, Thread.currentThread().getName());
};
}
@Transactional
public void addTask(Long roomId, LocalDateTime departureTime) {
//생략
}
private long calculateDelaySeconds(LocalDateTime now, LocalDateTime targetDateTime) {
//생략
}
}
하지만 Update 로직이 실행만 되고 DB에 반영이 되지 않았습니다. 구체적으로 Thread에 담겨진 Task의 Transaction 범위에 모호함으로 인해 실행과 동시에 변경되어야 하는 scheduledMessage의 상태값이 실행만 되고, DB에 반영될 때는 PENDING상태로써 변경되지 않은 값으로 저장되었기 때문입니다. Runnable을 통해 실행되는 코드에서 발생하는 문제는 별도의 스레드에서 실행되기 때문에 스프링의 영속성 컨텍스트 관리와 충돌할 수 있습니다. 이를 해결하기 위해 Spring boot의 트랜잭션 범위와 Commit시점을 이해해야 했습니다.
영속성 컨텍스트(Persistence Context):
- 영속성 컨텍스트는 엔티티의 상태를 관리하는 1차 캐시입니다.
- 스프링에서는 @Transactional 애노테이션을 사용하여 트랜잭션을 관리합니다. 이는 기본적으로 하나의 스레드에서 실행되는 트랜잭션 범위 내에서 영속성 컨텍스트가 유효하다는 것을 의미합니다.
엔티티 생명주기
- 엔티티는 영속성 컨텍스트 내에서 관리될 때 영속(Managed) 상태입니다.
- 영속성 컨텍스트 밖에서 접근하거나 트랜잭션이 끝난 후에는 준영속(Detached) 상태가 됩니다.
- 변경사항은 영속 상태의 엔티티에만 자동으로 반영됩니다. 준영속 상태의 엔티티는 다시 병합(merge)해야 변경사항이 반영됩니다.
구체적인 문제의 원인 - 별도의 스레드에서 엔티티 접근
- chatBotNotice 메서드는 Runnable을 반환합니다. 이 Runnable은 별도의 스레드에서 실행됩니다.
- Runnable 내부에서 scheduledMessageRepository.findById(scheduledMessageId)로 가져온 ScheduledMessage 엔티티는 이 시점에서 새로운 영속성 컨텍스트에 속해 있다고 판단되어집니다. 따라서 scheduledMessageStatusUpdate() 메서드 호출이 데이터베이스에 반영되지 않습니다.
- 이에 따라 다음과 같이 로직을 수정하여 상태를 업데이트 하는 로직을 개별적인 Transaction 범위로 재설정하였습니다.
ScheduledMessageService 수정 후
@Slf4j
@RequiredArgsConstructor
@Service
public class ScheduledMessageService {
private final TaskScheduler taskScheduler;
private final ChatService chatService;
private final RoomRepository roomRepository;
private final ScheduledMessageRepository scheduledMessageRepository;
private static final String MATCHING_COMPLETE = "매칭완료 하시겠습니까?";
private static final String CALL_TAXI = "택시 부르러 가볼까요?";
private static final long BEFORE_FIVE_MINUTES = 300;
private static final int NO_DELAY = 0;
@EventListener(ApplicationReadyEvent.class)
public void initScheduledMessage() {
List<ScheduledMessage> scheduledMessageList =
scheduledMessageRepository.findAllByStatus(ScheduledMessageStatus.PENDING);
scheduledMessageList.forEach(iter -> {
if (roomRepository.existsById(iter.getRoomId())) {
// 메시지 새로 저장
ScheduledMessage scheduledMessage = scheduledMessageRepository.save(ScheduledMessageMapper
.toEntity(iter.getRoomId(), iter.getContent(), iter.getExecuteTime()));
long delaySeconds = calculateDelaySeconds(LocalDateTime.now(), iter.getExecuteTime());
if (Objects.equals(scheduledMessage.getContent(), CALL_TAXI)) {
delaySeconds = delaySeconds > BEFORE_FIVE_MINUTES + 20 ? delaySeconds - BEFORE_FIVE_MINUTES : NO_DELAY;
}
Runnable task = chatBotNotice(iter.getRoomId(), iter.getContent(), scheduledMessage.getId());
taskScheduler.schedule(task, Instant.now().plusSeconds(delaySeconds));
}
scheduledMessageRepository.delete(iter);
});
log.info("서버 시작 후 실행되지 않은 메시지가 예약되었습니다.");
}
/*
트랜잭션 범위 설정!!
*/
@Transactional
public void updateScheduledMessageStatus(Long scheduledMessageId) {
ScheduledMessage scheduledMessage = scheduledMessageRepository.findById(scheduledMessageId).orElseThrow();
scheduledMessage.scheduledMessageStatusUpdate();
scheduledMessageRepository.save(scheduledMessage);
}
private Runnable chatBotNotice(Long roomId, String content, Long scheduledMessageId) {
return () -> {
Room room = roomRepository.findById(roomId).orElseThrow(() -> new BaseException(RoomErrorCode.EMPTY_ROOM));
updateScheduledMessageStatus(scheduledMessageId);
ChatMessageRequestDto message = new ChatMessageRequestDto(
Long.valueOf(roomId), MessageType.CHAT_BOT, content,
"모두의택시", room.getRoomManager().getId().toString(), LocalDateTime.now(), "");
chatService.sendChatMessage(message);
log.info("{}: {}", content, Thread.currentThread().getName());
};
}
@Transactional
public void addTask(Long roomId, LocalDateTime departureTime) {
long delaySeconds = calculateDelaySeconds(LocalDateTime.now(), departureTime);
ScheduledMessage matchingCompleteMessage =
ScheduledMessageMapper.toEntity(roomId, MATCHING_COMPLETE, departureTime);
scheduledMessageRepository.save(matchingCompleteMessage);
Runnable matchingModal = chatBotNotice(roomId, MATCHING_COMPLETE, matchingCompleteMessage.getId());
taskScheduler.schedule(matchingModal, Instant.now().plusSeconds(delaySeconds));
ScheduledMessage callTaxiMessage = ScheduledMessageMapper.toEntity(roomId, CALL_TAXI, departureTime);
scheduledMessageRepository.save(callTaxiMessage);
Runnable callTaxiTask = chatBotNotice(roomId, CALL_TAXI, callTaxiMessage.getId());
delaySeconds = delaySeconds > BEFORE_FIVE_MINUTES + 20 ? delaySeconds - BEFORE_FIVE_MINUTES : NO_DELAY;
taskScheduler.schedule(callTaxiTask, Instant.now().plusSeconds(delaySeconds));
}
private long calculateDelaySeconds(LocalDateTime now, LocalDateTime targetDateTime) {
Duration duration = Duration.between(now, targetDateTime);
return Math.max(NO_DELAY, duration.getSeconds());
}
}
결과적으로 이에 따라 Runnable 내부에서 트랜잭션이 적용된 별도의 메서드를 호출하여 트랜잭션을 명시적으로 관리하여 트랜잭션 범위 내에서 엔티티를 수정하여 해결할 수 있었습니다.
'개발' 카테고리의 다른 글
[AWS] 실시간의 실시간 - CloudFront, Amazon Kinesis Data Streams (2) | 2024.10.04 |
---|---|
[Spring Boot] 채팅 서비스 고도화 ( With. Stomp 예외처리 ) (0) | 2024.06.20 |
[Spring Boot] WebSocket & Stomp & Redis pub/sub & FCM으로 개발하는 채팅기능 (0) | 2024.06.20 |