PROMATE 백엔드 트러블슈팅

Seoyeon·2026년 5월 4일

백엔드

목록 보기
27/27

1. 다중 사용자 환경에서의 데이터 경합(Race Condition) 해결

  • 문제 상황: 여러 명의 팀원이 채팅방에서 동시에 AI 요약(@mates)을 호출하거나 상충하는 데이터를 입력할 때, AI 서버로 중복 요청이 가거나 DB의 요약 데이터가 덮어씌워지는(Lost Update) 데이터 정합성 파괴 현상 발생.
  • 해결 과정:
    1. 입구 통제 (Redis 분산 락): AI 호출 시 projectId를 Key로 Redis 분산 락(Distributed Lock)을 걸어, 누군가 요약을 진행 중일 때는 다른 팀원의 중복 호출을 차단함.
    2. 출구 방어 (JPA 낙관적 락): AI 요약본을 DB에 최종 업데이트할 때 @Version을 활용한 낙관적 락(Optimistic Lock)을 적용. 읽은 시점과 쓰는 시점의 버전이 다르면 예외를 발생시키고 안전하게 롤백하여 데이터 오염 방지.
  • 결과: 100%의 데이터 정합성을 보장하는 안전한 실시간 협업 환경 구축 완료.
// 1. 엔티티에 낙관적 락 적용 (출구 방어)
@Entity
public class ProjectSummary {
    @Id @GeneratedValue
    private Long id;
    private String content;
    
    @Version // JPA Optimistic Lock
    private Long version; 
}

// 2. 분산 락을 활용한 AI 호출 로직 (입구 방어)
@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 {
            // 락 획득 시도 (대기 시간 0초, 락 유지 10초)
            boolean isLocked = lock.tryLock(0, 10, TimeUnit.SECONDS);
            if (!isLocked) {
                throw new CustomException("이미 AI가 요약을 진행 중입니다.");
            }
            
            // AI 호출 및 DB 업데이트 로직 (낙관적 락 충돌 시 ObjectOptimisticLockingFailureException 발생)
            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) 확보.
// 1. 메시지 발행 (Publish)
@MessageMapping("/chat/message")
public void sendMessage(ChatMessage message) {
    // DB에 메시지 선 저장 (단일 진실 공급원)
    chatService.saveMessage(message);
    // Redis의 채팅 채널로 메시지 발행
    redisTemplate.convertAndSend("CHAT_ROOM:" + message.getProjectId(), message);
}

// 2. 메시지 구독 (Subscribe) - RedisMessageListenerContainer 설정
@Service
public class RedisSubscriber implements MessageListener {
    private final SimpMessageSendingOperations messagingTemplate;

    @Override
    public void onMessage(Message message, byte[] pattern) {
        // Redis에서 메시지를 수신하면, 자신(서버)에게 연결된 웹소켓 클라이언트들에게 브로드캐스팅
        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 커넥션 풀까지 고갈될 위험 감지.
  • 해결 과정:
    1. 트랜잭션 분리: 우리 DB에 데이터를 안전하게 커밋(Commit)하여 완전히 저장한 이후에만 외부 API(Notion)를 호출하도록 설계하여, 외부 장애가 내부 시스템으로 번지지 않도록 격리.
    2. 비동기 큐 및 백오프: Redis 작업 큐(Task Queue)를 도입해 쏟아지는 노션 생성 요청을 담아두고, 워커(Worker)가 속도를 조절하며 순차 처리. API 에러 발생 시 대기 시간을 점진적으로 늘리는 '지수 백오프(Exponential Backoff)' 적용 및 실패 시 사용자 재시도(Retry) 로직 구현.
  • 결과: 외부 API 장애로부터 내결함성(Fault Tolerance)을 갖춘 견고한 시스템 완성.
// 1. DB 트랜잭션과 외부 API 분리 (@TransactionalEventListener 활용)
@Service
public class ProjectService {
    @Transactional
    public void finishProject(Long projectId) {
        projectRepository.updateStatus(projectId, "COMPLETED");
        // DB 커밋이 완료된 후 이벤트 발행
        eventPublisher.publishEvent(new ProjectCompletedEvent(projectId));
    }
}

// 2. 이벤트 리스너에서 비동기로 Notion API 호출 및 재시도 로직 적용
@Component
public class NotionApiEventListener {

    // 트랜잭션이 성공적으로 커밋된 후에만 실행됨
    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
    @Async // 비동기 워커로 넘김 (API 병목 방지)
    // 429 에러 발생 시 1초, 2초, 4초 대기하며 최대 3번 재시도
    @Retryable(value = {RateLimitException.class}, maxAttempts = 3, backoff = @Backoff(delay = 1000, multiplier = 2))
    public void handleProjectCompletedEvent(ProjectCompletedEvent event) {
        notionApiClient.exportToNotion(event.getProjectId());
    }
    
    // 최종 실패 시 처리 (Fallback)
    @Recover
    public void recoverNotionExport(RateLimitException e, ProjectCompletedEvent event) {
        log.error("Notion API 최대 재시도 횟수 초과. 프로젝트 ID: {}", event.getProjectId());
        // DB에 상태를 'FAILED'로 기록하고 사용자에게 '재시도 버튼' 노출 유도
        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 {
    
    // JSON에 정의되지 않은 필드가 들어와도 무시하도록 ObjectMapper 설정
    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 {
            // AI 응답 파싱 시도
            AiSummaryDto dto = objectMapper.readValue(aiRawResponse, AiSummaryDto.class);
            
            // 검증 성공 시에만 데이터 업데이트
            currentSummary.updateContent(dto.getContent());
            
        } catch (JsonProcessingException e) {
            // 파싱 실패 시 서버를 죽이지 않고 로그만 남김 (Graceful Degradation)
            log.error("AI 응답 JSON 파싱 실패. Raw Data: {}", aiRawResponse, e);
            
            // TODO: 슬랙 등 모니터링 채널로 알림 발송
            
            // 기존 데이터(currentSummary)는 롤백하지 않고 그대로 유지됨 (안전한 Fallback)
        }
    }
}

0개의 댓글