1. 다중 사용자 환경에서의 데이터 경합(Race Condition) 해결
- 문제 상황: 여러 명의 팀원이 채팅방에서 동시에 AI 요약(
@mates)을 호출하거나 상충하는 데이터를 입력할 때, AI 서버로 중복 요청이 가거나 DB의 요약 데이터가 덮어씌워지는(Lost Update) 데이터 정합성 파괴 현상 발생.
- 해결 과정:
- 입구 통제 (Redis 분산 락): AI 호출 시
projectId를 Key로 Redis 분산 락(Distributed Lock)을 걸어, 누군가 요약을 진행 중일 때는 다른 팀원의 중복 호출을 차단함.
- 출구 방어 (JPA 낙관적 락): AI 요약본을 DB에 최종 업데이트할 때
@Version을 활용한 낙관적 락(Optimistic Lock)을 적용. 읽은 시점과 쓰는 시점의 버전이 다르면 예외를 발생시키고 안전하게 롤백하여 데이터 오염 방지.
- 결과: 100%의 데이터 정합성을 보장하는 안전한 실시간 협업 환경 구축 완료.
@Entity
public class ProjectSummary {
@Id @GeneratedValue
private Long id;
private String content;
@Version
private Long version;
}
@Service
@RequiredArgsConstructor
public class AISummaryService {
private final RedissonClient redissonClient;
private final SummaryRepository summaryRepository;
public void requestAiSummary(Long projectId) {
RLock lock = redissonClient.getLock("AI_LOCK:" + projectId);
try {
boolean isLocked = lock.tryLock(0, 10, TimeUnit.SECONDS);
if (!isLocked) {
throw new CustomException("이미 AI가 요약을 진행 중입니다.");
}
updateSummaryWithAI(projectId);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
if (lock.isLocked() && lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
}
2. 다중 서버(Scale-out) 환경에서의 WebSocket 세션 동기화 문제
- 문제 상황: 단일 서버에서는 STOMP 기반 채팅이 잘 동작했으나, 트래픽 분산을 위해 백엔드 서버를 여러 대(Scale-out)로 늘렸을 때 서로 다른 서버에 접속한 유저 간에 메시지가 전달되지 않는 브로드캐스팅 단절 문제 발생.
- 해결 과정: * Redis Pub/Sub 도입: 기존에 세션 관리용으로 쓰던 Redis의 Pub/Sub(발행/구독) 기능을 메세지 브로커로 확장 활용.
- A서버의 유저가 메시지를 보내면 Redis로 Publish하고, Redis가 연결된 모든 서버(B, C 등)로 메시지를 브로드캐스팅하여, 어떤 서버에 붙어있든 실시간 통신이 끊기지 않도록 분산 웹소켓 아키텍처 재설계.
- 결과: 대규모 접속 시에도 안정적인 채팅이 가능한 확장성(Scalability) 확보.
@MessageMapping("/chat/message")
public void sendMessage(ChatMessage message) {
chatService.saveMessage(message);
redisTemplate.convertAndSend("CHAT_ROOM:" + message.getProjectId(), message);
}
@Service
public class RedisSubscriber implements MessageListener {
private final SimpMessageSendingOperations messagingTemplate;
@Override
public void onMessage(Message message, byte[] pattern) {
ChatMessage chatMessage = objectMapper.readValue(message.getBody(), ChatMessage.class);
messagingTemplate.convertAndSend("/sub/chat/room/" + chatMessage.getProjectId(), chatMessage);
}
}
3. 외부 API(Notion) Rate Limit(호출 제한) 및 트랜잭션 병목 대응
- 문제 상황: 프로젝트 완료 후 Notion으로 템플릿을 내보낼 때, 다수의 팀이 동시에 요청할 경우 Notion API의 엄격한 호출 횟수 제한(429 Too Many Requests)에 걸려 실패. 게다가 동기식으로 API를 기다리다가 우리 DB 커넥션 풀까지 고갈될 위험 감지.
- 해결 과정:
- 트랜잭션 분리: 우리 DB에 데이터를 안전하게 커밋(Commit)하여 완전히 저장한 이후에만 외부 API(Notion)를 호출하도록 설계하여, 외부 장애가 내부 시스템으로 번지지 않도록 격리.
- 비동기 큐 및 백오프: Redis 작업 큐(Task Queue)를 도입해 쏟아지는 노션 생성 요청을 담아두고, 워커(Worker)가 속도를 조절하며 순차 처리. API 에러 발생 시 대기 시간을 점진적으로 늘리는 '지수 백오프(Exponential Backoff)' 적용 및 실패 시 사용자 재시도(Retry) 로직 구현.
- 결과: 외부 API 장애로부터 내결함성(Fault Tolerance)을 갖춘 견고한 시스템 완성.
@Service
public class ProjectService {
@Transactional
public void finishProject(Long projectId) {
projectRepository.updateStatus(projectId, "COMPLETED");
eventPublisher.publishEvent(new ProjectCompletedEvent(projectId));
}
}
@Component
public class NotionApiEventListener {
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
@Async
@Retryable(value = {RateLimitException.class}, maxAttempts = 3, backoff = @Backoff(delay = 1000, multiplier = 2))
public void handleProjectCompletedEvent(ProjectCompletedEvent event) {
notionApiClient.exportToNotion(event.getProjectId());
}
@Recover
public void recoverNotionExport(RateLimitException e, ProjectCompletedEvent event) {
log.error("Notion API 최대 재시도 횟수 초과. 프로젝트 ID: {}", event.getProjectId());
projectRepository.updateExportStatus(event.getProjectId(), "FAILED");
}
}
4. AI 환각(Hallucination) 및 비정형 JSON 응답에 대한 서버 생존성 확보
- 문제 상황: 프롬프트로 JSON 형식을 강제했음에도, AI가 가끔 규격에 맞지 않는 엉뚱한 응답을 보내거나 정의되지 않은 Key를 던질 때 백엔드 서버에서 파싱 에러(Mapping Exception)가 발생하며 프로세스가 중단됨.
- 해결 과정:
- 방어적 프로그래밍과 Fallback: Spring Boot 단에서 철저한 객체 검증(DTO Validation) 및 Try-Catch 예외 처리 적용. 에러 포착 시 서버를 멈추지 않고(Graceful Degradation), 사용자에게 알림을 보낸 뒤 '가장 최근에 검증된 성공 데이터(Last Known Good State)'를 DB에 유지(Fallback)하여 데이터 유실 방지.
- 결과: AI의 실수에도 시스템이 무너지지 않고 사용자 데이터를 100% 보호하는 안전장치 마련.
@Service
public class AiResponseHandler {
private final ObjectMapper objectMapper = new ObjectMapper()
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
@Transactional
public void processAiResponse(Long projectId, String aiRawResponse) {
ProjectSummary currentSummary = summaryRepository.findByProjectId(projectId);
try {
AiSummaryDto dto = objectMapper.readValue(aiRawResponse, AiSummaryDto.class);
currentSummary.updateContent(dto.getContent());
} catch (JsonProcessingException e) {
log.error("AI 응답 JSON 파싱 실패. Raw Data: {}", aiRawResponse, e);
}
}
}