마지막 주차, 월요일은 휴가에 화요일은 최종발표날이라 우리조가 최종 본선에 진출하지 못하여
포스팅은 기존 최종 융합 프로젝트 코드의 리뷰로 대체 및 보강할 점 찾기로 대체한다.
1. IntegratedAnalysisScheduler 리뷰: "분석 엔진의 문지기"
이 클래스는 분석 대기 중인 데이터를 안전하게 픽업하여 분석 프로세스에 태우는 역할을 합니다.
@Slf4j
@Component
@RequiredArgsConstructor
public class IntegratedAnalysisScheduler {
// ... 의존성 주입 생략
// [Line] 중복 실행 방지를 위한 원자적 플래그
private final AtomicBoolean isProcessing = new AtomicBoolean(false);
@Scheduled(fixedDelay = 20000) // [Line] 20초마다 주기적으로 실행
public void executeIntegratedAnalysis() {
// [Line] compareAndSet(false, true): "지금 실행 중인 작업이 없으면(false), 내가 true로 바꾸고 들어간다"
// [Insight] CAS 연산을 통해 멀티 스레드/서버 환경에서 동일 작업이 중복 실행되는 것을 원자적으로 방지함 (Lock 역할)
if (!isProcessing.compareAndSet(false, true)) {
log.warn("[Batch] 이전 통합 분석 작업이 아직 진행 중입니다. 이번 스케줄은 건너뜜");
return;
}
try {
// [Line] PageRequest.of(0, 20): 한 번에 20건씩만 가져와서 처리함
// [Insight] 대량 데이터 조회 시 발생할 수 있는 메모리 부하(OOM)를 방지하고 처리 속도를 제어함
List<ResultEventStatus> pendingResults =
resultEventRepository.findReadyToProcessPairs(PageRequest.of(0, 20));
if (pendingResults.isEmpty()) return;
// [Line] TaskPair 리스트로 변환 (짝 맞추기)
// [Insight] 요약(Result)과 채점(Excellent) 작업은 한 세트임. consultId를 기준으로
// 두 태스크의 존재를 모두 확인한 뒤 묶어줌으로써 데이터 정합성을 확보함
List<TaskPair> taskPairs = pendingResults.stream()
.map(resultTask -> {
return excellentEventRepository.findByConsultId(resultTask.getConsultId())
.map(excellentTask -> new TaskPair(resultTask, excellentTask))
.orElse(null); // 한쪽이 없으면 null 반환
})
.filter(Objects::nonNull) // 짝이 맞는 건들만 필터링
.collect(Collectors.toList());
// [Line] Manager에게 번들 처리 위임: 실제 분석 로직 실행
analysisManager.processIntegratedBundledTasks(taskPairs);
} catch (Exception e) {
log.error("[Critical Error] 예상치 못한 오류: {}", e.getMessage());
} finally {
// [Line] isProcessing.set(false): 작업이 성공하든 실패하든 무조건 플래그 해제
// [Insight] 이 처리가 없으면 예외 발생 시 다음 배치가 영원히 실행되지 않는 데드락 발생 위험이 있음
isProcessing.set(false);
}
}
}
2. IntegratedRetryScheduler 리뷰: "시스템의 자해 복구 엔진"
이 클래스는 '비정상 종료'나 '네트워크 오류'로 인해 버려진 데이터들을 다시 살려내는 아주 중요한 역할을 합니다.
@Slf4j
@Component
@RequiredArgsConstructor
public class IntegratedRetryScheduler {
// ... 의존성 주입 생략
@Scheduled(fixedDelay = 600000) // [Line] 10분마다 복구 엔진 가동
@Transactional // [Line] 복구 과정은 DB 상태를 변경하므로 하나의 트랜잭션으로 묶음
public void retryAndRecoverTasks() {
log.info("[Recovery] 통합 복구 엔진 가동...");
// [Line] threshold = LocalDateTime.now().minusMinutes(30)
// [Insight] "30분"이라는 기준을 설정. PROCESSING 상태인데 30분 동안 변화가 없다면 '좀비 데이터'로 간주함
LocalDateTime threshold = LocalDateTime.now().minusMinutes(30);
// [Line] cleanupStaleProcessingTasks(threshold): 좀비 데이터 일괄 복구
// [Insight] 서버 다운 등으로 작업 중 멈춰버린 건들을 다시 READY로 되돌려 다음 배치가 픽업하게 함
int cleanedResult = resultEventRepository.cleanupStaleProcessingTasks(threshold);
int cleanedScoring = excellentEventRepository.cleanupStaleProcessingTasks(threshold);
// [Line] findByStatusAndRetryCountLessThan(EventStatus.FAILED, 3)
// [Insight] "3-Strike Policy". 실패한 데이터 중 재시도 횟수가 3회 미만인 건들만 추출함
// 무한 재시도로 인한 API 비용 낭비를 방지하는 가드레일임
List<ResultEventStatus> failedSummaries =
resultEventRepository.findByStatusAndRetryCountLessThan(EventStatus.FAILED, 3);
// [Line] retry(): 상태를 READY로 바꾸고 retryCount를 +1 하는 도메인 로직 실행
failedSummaries.forEach(ResultEventStatus::retry);
// [Line] 채점 실패 건들도 동일하게 재시도 처리
List<ExcellentEventStatus> failedScoring =
excellentEventRepository.findByStatusAndRetryCountLessThan(EventStatus.FAILED, 3);
failedScoring.forEach(ExcellentEventStatus::retry);
}
}
1. ConsultationAnalysisManager: 분석 워크플로우의 총괄 감독
이 클래스는 여러 데이터 소스를 취합하고, 비동기 작업을 스케줄링하며, 결과를 최종 저장하는 중앙 제어 장치입니다.
@Service
public class ConsultationAnalysisManager {
// [Line] Propagation.REQUIRES_NEW
// [Insight] 배치 처리 중 특정 번들(Bundle)의 실패가 전체 프로세스를 롤백시키지 않도록
// 독립적인 트랜잭션을 생성함. 데이터 저장의 원자성을 보장하면서도 장애 전파를 차단함.
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void processIntegratedBundledTasks(List<TaskPair> taskPairs) {
// [STEP 1] 상태 선점 (State Pre-emption)
// [Insight] DB의 상태를 'START'로 먼저 업데이트하여 다른 스케줄러가 해당 건을
// 건드리지 못하게 하는 '소프트 락' 역할을 수행함.
taskPairs.forEach(pair -> { pair.summaryTask().start(); pair.scoringTask().start(); });
// [STEP 3] 원문 데이터 벌크 조회 및 전처리
// [Insight] N+1 문제를 방지하기 위해 findAllByConsultIdIn을 사용하여 데이터를 벌크 조회함.
// 또한 10,000자 초과 데이터는 substring으로 절삭하여 LLM 컨텍스트 윈도우 초과 에러를 방지함.
List<String> processedRawTexts = pairs.stream()
.map(p -> {
// ... 생략 ...
return (text != null && text.length() > 10000) ? text.substring(0, 10000) + "..." : text;
}).toList();
// [STEP 4] AI 비동기 실행 (CompletableFuture)
// [Insight] 요약(Extractor)과 채점(Scorer)은 서로 의존성이 없는 작업이므로 병렬로 실행함.
// geminiTaskExecutor라는 별도의 스레드 풀을 사용하여 CPU/메모리 자원을 격리함 (Bulkhead 패턴).
CompletableFuture<List<AiExtractionResponse>> extractionFuture = CompletableFuture.supplyAsync(() ->
geminiExtractor.extractBatch(processedRawTexts, groupType, validCodes), geminiTaskExecutor
);
// [STEP 5] 타임아웃 및 결과 수집
// [Insight] join() 시 orTimeout을 설정하여 AI 응답이 무한정 지연될 경우 시스템 전체가
// 블로킹되는 현상을 방지함. 응답 시간의 상한선(180초/60초)을 명확히 정의함.
List<AiExtractionResponse> extractionResults =
extractionFuture.orTimeout(180, TimeUnit.SECONDS).join();
}
}
2. GeminiExtractor: 효율적인 AI 번들 분석 엔진
이 클래스는 여러 건의 상담을 하나로 묶어 처리하는 '번들링(Bundling)' 전략을 통해 비용과 속도를 최적화합니다.
@Service
public class GeminiExtractor {
// [Line] @Retryable & @Backoff
// [Insight] 외부 API 통신은 언제든 실패할 수 있음. 지수 백오프(2s -> 4s -> 8s)를 적용하여
// 일시적인 네트워크 장애나 API Rate Limit 상황에서 시스템의 회복 탄력성(Resilience)을 확보함.
@Retryable(
retryFor = {Exception.class},
maxAttempts = 3,
backoff = @Backoff(delay = 2000, multiplier = 2.0, maxDelay = 10000)
)
public List<AiExtractionResponse> extractBatch(...) {
// [Insight] 단일 호출이 아닌 '번들 프롬프트'를 구성하여 전송함.
// API 왕복 시간(RTT)을 줄이고 토큰당 분석 단가를 낮추는 고효율 전략임.
String prompt = buildOutboundBatchPrompt(rawIssues, validCodes.get("outbound_category"));
// [Line] generationConfig (temperature 0.1)
// [Insight] temperature를 낮게 설정하여 AI의 창의성보다는 '결정론적 응답(Deterministic Output)'을 유도함.
// 분석 결과의 일관성을 높이고 환각(Hallucination) 현상을 최소화함.
// [Line] cleanJsonString
// [Insight] LLM은 응답에 불필요한 마크다운(```json)이나 텍스트를 섞을 수 있음.
// 이를 정규표현식으로 정제하여 순수 JSON만 추출하는 방어적 파싱 로직을 구현함.
}
}
3. GeminiQualityScorer: 매뉴얼 기반 자동 QA 엔진
이 클래스는 상담 매뉴얼을 AI에게 학습시켜 객관적인 상담 품질 평가를 수행합니다.
@Service
public class GeminiQualityScorer {
public QualityScoringResponse evaluate(String rawIssue, String manualContent) {
// [Line] Prompt Injection (Manual Content)
// [Insight] 상담 유형에 따른 실제 운영 매뉴얼을 프롬프트에 직접 주입함.
// AI가 매뉴얼의 필수 요소를 준수했는지 정밀하게 체크하게 하여 채점의 신뢰도를 확보함.
// [Line] RestClient post()
// [Insight] RestTemplate의 후속인 최신 RestClient를 사용하여 선언적인 API 호출을 구현함.
// setConnectTimeout(5s), setReadTimeout(30s)를 통해 네트워크 가용성을 엄격히 관리함.
// [Line] parseGeminiResponse & usageMetadata
// [Insight] AI 응답에서 단순히 텍스트만 가져오는 것이 아니라, 사용된 토큰 수(Usage)를 로그로 남김.
// 이는 추후 운영 비용 산정 및 대시보드 모니터링을 위한 핵심 데이터로 활용됨.
}
private String cleanJsonString(String raw) {
// [Insight] 정규표현식을 활용해 JSON 객체 시작{ 과 끝} 사이의 데이터만 추출함.
// AI 답변의 불확실성을 백엔드 코드에서 완벽하게 보완하는 '파싱 가드레일' 로직임.
return raw.replaceAll("(?s)^.*?\\{", "{").replaceAll("(?s)\\}.*?$", "}");
}
}
[Backend] 상담 매뉴얼 관리 시스템: 상태 기반 버전 제어와 RBAC 설계
AI 채점의 정합성을 유지하기 위해서는 **'어느 시점에 어떤 기준(매뉴얼)이 활성화되었는가'**를 관리하는 것이 매우 중요합니다. 본 프로젝트에서는 관리자가 매뉴얼을 교체하면 기존 매뉴얼이 자동으로 비활성화되는 **'단일 활성 버전 전략'**을 채택했습니다.
1. ManualService: 비즈니스 로직의 핵심 (상태 제어)
매뉴얼의 생명 주기(Lifecycle)를 관리하며, 데이터 정합성을 유지하는 핵심 로직입니다.
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class ManualService {
/** 1. 생성 (Create): 교체 로직 포함 */
@Transactional
public void createManual(ManualRequest request, Integer empId) {
// [Line] 기존 활성 매뉴얼 탐색 및 비활성화
// [Insight] 특정 카테고리에 '활성화'된 매뉴얼은 단 하나만 존재해야 함.
// 새 매뉴얼 등록 시 기존 매뉴얼을 자동으로 'Soft Delete' 상태로 변경하여 버전 교체를 수행함.
manualRepository.findByCategoryPolicy_CategoryCodeAndIsActiveTrue(request.categoryCode())
.ifPresent(old -> {
old.setIsActive(false);
old.setUpdatedAt(LocalDateTime.now());
});
// [Line] 신규 매뉴얼 빌드 및 저장
Manual manual = Manual.builder()
.categoryPolicy(policy)
.employee(employee)
.isActive(true) // 신규 매뉴얼을 즉시 활성 상태로 설정
.build();
manualRepository.save(manual);
}
/** 3. 조회 (Read): 동적 필터링 및 페이징 */
public Page<ManualResponse> getHistory(String categoryCode, Boolean isActive, Pageable pageable) {
// [Insight] 카테고리 코드와 활성 여부(Boolean)의 조합에 따른 4가지 분기 처리.
// 클라이언트의 다양한 검색 요구사항(전체 조회, 활성 건만 조회 등)을 효율적으로 대응함.
if (categoryCode == null || categoryCode.isBlank()) {
return (isActive == null)
? manualRepository.findAll(pageable)
: manualRepository.findAllByIsActive(isActive, pageable);
}
// ... 특정 카테고리 필터링 로직
}
/** 5. 활성화 (Activate): 수동 복구 로직 */
@Transactional
public void activateManual(Integer manualId) {
// [Insight] 과거 이력 중 하나를 다시 활성화할 때도 '단일 활성 원칙'을 고수함.
// 현재 활성화된 건을 찾아 끄고, 선택한 건을 켬으로써 데이터 충돌을 방지함.
manualRepository.findByCategoryPolicy_CategoryCodeAndIsActiveTrue(...)
.ifPresent(old -> old.setIsActive(false));
targetManual.setIsActive(true);
}
}
2. Controller 레이어: 역할 분리 및 보안 (RBAC)
사용자의 권한에 따라 접근 가능한 API를 물리적으로 분리하여 보안성을 높였습니다.
AdminManualController: 관리자 전용 제어 센터
- 권한 제어: @PreAuthorize("hasRole('ADMIN')")를 통해 관리자만 CUD(생성, 수정, 삭제) 권한을 가짐.
- 상태 매핑: 프론트엔드의 직관적인 한글 상태값(활성화/비활성화)을 백엔드의 Boolean으로 변환하는 가교 역할 수행.
UserManualController: 상담사 전용 조회 채널
- 권한 제어: 일반 상담사 권한으로 접근 가능.
- 기능 제한: 데이터 오염 방지를 위해 오직 '조회(Read-Only)' 기능만 제공.
📸 포트폴리오용 기술적 차별점 (Technical Highlights)
- 단일 활성 버전 전략 (Single Active Versioning):
- 데이터베이스 수준에서 특정 카테고리의 isActive=true인 레코드가 논리적으로 하나임을 보장하는 로직을 구현하여 채점 기준의 혼선을 방지함.
- Soft Delete 기반 이력 관리:
- 매뉴얼을 실제로 삭제하지 않고 isActive 플래그를 사용하여, 과거에 어떤 기준으로 상담이 채점되었는지 역추적(Audit)이 가능한 구조 설계.
- Spring Data JPA를 활용한 고성능 페이징:
- 매뉴얼 이력이 쌓이더라도 시스템 성능이 저하되지 않도록 서버 사이드 페이징(Pageable)을 적용하여 리소스 사용 최적화.
내가 개발한 기능 관련 페이지



매뉴얼 관련 기능


AI 분석 스케줄러 관련 수동 재처리 페이지

AI 추출 관련 페이지

LLM 기반 상담 분석 엔진: 백엔드 아키텍처 기술 리뷰
단순히 기능을 구현하는 것을 넘어, 대규모 트래픽과 외부 API 장애를 견디는 운영 수준의 안정성을 확보했는가가 핵심이다. 이번 프로젝트에서 구현한 로직들을 백엔드 설계 관점에서 분석하고 피드백을 정리했다.
1. 동시성 제어: AtomicBoolean을 활용한 세이프 가드
IntegratedAnalysisScheduler에서 AtomicBoolean의 compareAndSet을 사용한 것은 매우 적절한 선택이다.
- 설계 의도: 스케줄러 주기가 짧을 때, 이전 작업이 끝나지 않았음에도 다음 작업이 실행되어 발생하는 데이터 경합(Race Condition)을 원자적으로 차단한다.
- 피드백: synchronized 키워드보다 오버헤드가 적은 Non-blocking 연산을 택해 성능을 챙겼다. finally 블록에서 반드시 플래그를 해제하도록 설계하여 예외 발생 시 시스템이 데드락에 빠지는 위험을 원천 차단한 점이 훌륭하다.
2. 장애 회복력: 지수 백오프(Exponential Backoff) 전략
GeminiExtractor에 적용된 리트라이 전략은 외부 API 연동 시 필수적인 **회복 탄력성(Resilience)**을 보여준다.
- 설계 의도: 네트워크 지연이나 Rate Limit 에러 발생 시, 고정된 간격이 아닌 점진적으로 늘어나는 간격($2s \rightarrow 4s \rightarrow 8s$)으로 재시도한다.
- 피드백: 단순 재시도는 API 서버에 가해지는 부하(Thundering Herd)를 가중시킬 수 있는데, 이를 지수적으로 분산시켜 성공 확률을 높였다.
- 재시도 대기 시간 공식: $Wait\ Time = delay \times (multiplier^{attempt-1})$
3. 성능 최적화: CompletableFuture 병렬 트랙과 번들링
I/O Bound 작업인 LLM 분석의 병목 현상을 아키텍처적으로 해결했다.
- 비동기 병렬화: ConsultationAnalysisManager에서 요약과 채점 작업을 독립적인 스레드로 분리해 실행 시간을 절반으로 줄였다. geminiTaskExecutor라는 별도 스레드 풀을 할당해 **자원 격리(Bulkhead)**를 구현한 점도 우수한 설계다.
- 트랜잭션 격리: @Transactional(propagation = Propagation.REQUIRES_NEW)를 사용해 각 번들 단위의 성공/실패를 격리했다. 한 번들의 실패가 전체 배치의 롤백으로 이어지지 않아 AI 토큰 비용 낭비를 막았다.
4. 자해 복구(Self-Healing): "좀비 데이터" 클린업 로직
IntegratedRetryScheduler의 좀비 데이터 구출 로직은 이 프로젝트의 가장 날카로운 지점이다.
- 설계 의도: 서버 셧다운이나 처리 중 예외로 인해 PROCESSING 상태에 영원히 갇힌 데이터를 찾아 복구한다.
- 피드백: 일반적인 try-catch로 잡을 수 없는 인프라 결함까지 고려한 설계다. 마지막 업데이트 시각 기준(30분)으로 데이터를 강제 회복시킴으로써 데이터 파이프라인의 종단 간 안정성을 완성했다.
5. 데이터 거버넌스: 매뉴얼 상태 제어와 수동 리트라이
운영 가시성과 데이터의 신뢰성을 확보하기 위한 관리 기능을 포함했다.
- 단일 활성 버전 전략: 매뉴얼 등록 시 기존 활성 매뉴얼을 자동으로 비활성화하여 채점 기준의 혼선을 막았다.
- 수동 재처리(Admin Retry): 자동 재시도 횟수(3회)를 초과한 실패 건에 대해 관리자가 직접 상태를 초기화할 수 있는 인터페이스를 제공해, 운영자가 시스템을 완전히 통제할 수 있게 설계했다.