실무 대출 비교 플랫폼 운영 경험을 바탕으로 설계한 개인 핀테크 포트폴리오 프로젝트. 다수의 금융사와 연동하여 대출 한도조회 및 신청을 처리하는 백엔드 시스템.
현재 재직 중인 회사에서 50개 이상의 금융사와 연동하는 대출 비교 플랫폼 을 개발·운영하고 있습니다.
실무에서는 아래와 같은 제약이 있었습니다.
- Java 8 + Spring Boot 2.7 기반의 레거시 구조 유지
- Layered Architecture (Controller → Service → Repository)
- 기존 시스템과의 호환성을 고려한 점진적 개선만 가능
실무에서 직접 경험한 제휴 금융사 한도조회 시스템의 핵심 도메인을 새로운 기술 스택과 아키텍처로 재설계하여 구현했습니다.
- 실무와 동일한 비즈니스 로직 (콜백 기반 비동기 한도조회, 상품별 채번, 디자인 패턴 등)
- Java 17 + Spring Boot 3.5로 업그레이드하여 최신 기능 적용
- Layered Architecture → Domain-driven Package Structure로 전환
| 구분 | 실무 | 개인 프로젝트 (Bigin) |
|---|---|---|
| Language | Java 8 | Java 17 |
| Framework | Spring Boot 2.7 | Spring Boot 3.5 |
| Architecture | Layered Architecture | Domain-driven Package Structure |
| DB | Oracle DB | H2 DB (In-memory) |
| API 통신 | RestTemplate | RestClient |
| 구분 | 기술 |
|---|---|
| Language | Java 17 |
| Framework | Spring Boot 3.5 |
| ORM | Spring Data JPA / Hibernate |
| DB | H2 DB |
| 비동기 | Spring @Async / CompletableFuture |
| 장애격리 | Resilience4j Circuit Breaker / Retry |
| 암복호화 | AES-256-CBC, AES-256-ECB, RSA-OAEP |
| API 문서 | SpringDoc OpenAPI (Swagger) |
| Build | Gradle |
| 메시지큐 | Apache Kafka |
com.ghyinc.finance
├── domain
│ ├── loan
│ │ ├── controller # API 진입점
│ │ ├── service # 비즈니스 로직
│ │ │ ├── LoanLimitService.java
│ │ │ ├── LoanLimitSenderService.java # @Async 비동기 전송
│ │ │ └── LoanLimitResultService.java # 콜백 수신 처리
│ │ ├── adaptor # 금융사별 API 변환
│ │ │ ├── common # 표준 Layout 금융사 공통
│ │ │ ├── impl # 비표준 금융사 개별 구현
│ │ │ └── callback # 콜백 수신 Adaptor
│ │ ├── strategy # 대출유형별 전략 패턴
│ │ ├── entity # JPA Entity
│ │ ├── repository # Spring Data JPA
│ │ ├── dto # 요청/응답 DTO
│ │ └── enums # 상태/타입 Enum
│ ├── notification
│ │ ├── service # 알림 비즈니스 로직
│ │ │ ├── NotificationService.java
│ │ │ └── NotificationSenderService.java
│ │ ├── event
│ │ │ ├── NotificationEventConsumer.java # Kafka Consumer (발송 처리)
│ │ │ └── LoanLimitCompletedEventConsumer.java # loan-limit-completed 토픽 수신
│ │ ├── entity
│ │ ├── repository
│ │ └── enums
│ └── external # 외부 기관 API
│ ├── nice # Nice DNR (자동차등록원부) 연동
│ └── coocon # KB 부동산 시세 연동
├── global
│ ├── client # 통신 방식별 ApiClient (REST, 전용선)
│ ├── common # 공통 유틸 (채번, BaseEntity 등)
│ ├── config # Spring 설정
│ ├── crypto # 암복호화 (AES, RSA)
│ ├── event # Kafka Event Publisher
│ ├── exception # 전역 예외 처리
│ ├── outbox # Outbox 패턴 (트랜잭션 보장)
│ │ ├── entity
│ │ │ ├── OutboxEvent.java
│ │ │ └── OutboxStatus.java
│ │ ├── event
│ │ │ └── OutboxCreatedEvent.java
│ │ ├── repository
│ │ │ └── OutboxEventRepository.java
│ │ ├── service
│ │ │ └── OutboxEventService.java # @TransactionalEventListener
│ │ └── scheduler
│ │ └── OutboxEventBatchPublisher.java # @Scheduled 재시도
FE → POST /api/loan/limit/inquiry
│
▼
LoanLimitService
├── Strategy 선택 (대출유형별)
├── 외부데이터 조회 (Nice DNR 등)
├── LoanLimitInquiry INSERT (PENDING)
└── 202 Accepted 즉시 응답
│
▼ @Async (병렬)
LoanLimitSenderService
├── LoanLimitResult INSERT (금융사당 1건)
├── LoanLimitProductResult INSERT (상품당 1건, PENDING 선저장)
├── 금융사별 API 병렬 전송 (CompletableFuture)
└── 완료 시 알림 이벤트 발행
├── OutboxEvent INSERT (같은 트랜잭션 - 원자적 보장)
└── ApplicationEventPublisher.publishEvent(OutboxCreatedEvent)
│
▼ @TransactionalEventListener(AFTER_COMMIT)
OutboxEventService.publishAfterCommit()
├── Kafka 발행 성공 → OutboxEvent PUBLISHED UPDATE
└── Kafka 발행 실패 → OutboxEvent PENDING 유지 (배치 재시도)
│
▼ Kafka (loan-limit-completed)
LoanLimitCompletedEventConsumer (notification 도메인)
└── NotificationService → notification.send 토픽 발행
│
▼ Kafka (notification.send)
NotificationEventConsumer
└── 실제 Push/SMS 발송
Callback
금융사 → POST /api/loan/limit/callback
LoanLimitResultService
├── loReqtNo + productCode로 선저장 데이터 조회 및 UPDATE
└── 비관적 락으로 count 동시성 제어
loan-limit-completed loan → notification 도메인 간 이벤트 전달
한도조회 완료 시 발행 (inquiryNo가 partition key)
OutboxEventService가 발행 (Outbox Pattern)
notification.send notification 도메인 내부 비동기 발송 처리
Notification INSERT 후 실제 발송 분리
대출유형(신용/담보/사업자/오토담보)별로 지원 금융사, 유효성 검증, 요청 변환 로직을 캡슐화합니다.
// 대출유형별 전략 자동 선택
LoanLimitStrategy strategy = strategyFactory.getStrategy(request.getLoanType());
strategy.validate(request);
ExternalDataContext context = strategy.fetchExternalData(request);
LoanLimitAdaptorRequest adaptorRequest = strategy.toAdaptorRequest(request, context);금융사별 자체 API Layout을 내부 표준 DTO로 변환합니다.
표준 Layout 금융사 → CommonLoanLimitAdaptor (yml 설정만으로 금융사 추가)
자체 Layout 금융사 → KakaobankLoanLimitAdaptor / TossBankLoanLimitAdaptor
REST → RestApiClient
전용선 → LeaseLineApiClient (고정길이 전문, EUC-KR 인코딩)
LoanLimitInquiry (한도조회 요청 1건)
├── inquiryNo 업무 식별번호 (채번)
├── status PENDING → IN_PROGRESS → SUCCESS/PARTIAL_SUCCESS/FAILED
├── totalProductCount 전체 상품 수 (콜백 완료 판단)
└── callbackReceivedCount / approvedProductCount
LoanLimitResult (금융사당 1건)
├── partnerCode
└── status PENDING → SEND_SUCCESS / SEND_FAILED
LoanLimitProductResult (상품당 1건)
├── loReqtNo 상품별 유니크 채번 (콜백 연결 Key)
├── productCode
├── status PENDING → SUCCESS / TIMEOUT
├── resultCode SUCCESS / LIMIT_DENIED / CREDIT_SCORE_LOW ...
└── limitAmount / minRate / maxRate
LoanApplication (대출신청 1건)
└── loReqtNo → LoanLimitProductResult 연결
LoanLimitInquiry를 Aggregate Root로 하여 LoanLimitResult, LoanLimitProductResult의 생성과 상태 변경을 Aggregate Root를 통해서만 수행합니다.
[한도조회 Aggregate]
LoanLimitInquiry (Aggregate Root)
├── List<LoanLimitResult> (금융사당 1건)
└── List<LoanLimitProductResult> (상품당 1건)
// 외부에서 직접 생성 금지 → Aggregate Root를 통해서만 추가
inquiry.addResult(result); // LoanLimitResult 추가
inquiry.addProductResult(productResult); // LoanLimitProductResult 추가
// 도메인 로직도 Aggregate Root에서 실행
inquiry.updateInquiryStatus(InquiryStatus.IN_PROGRESS);
inquiry.initProductCount(totalCount);
inquiry.incrementSuccessCount(); // count 증가 + 상태 자동 결정금융사별 독립적인 Circuit Breaker 인스턴스로 특정 금융사 장애 시 격리합니다.
resilience4j:
circuitbreaker:
configs:
default:
sliding-window-type: COUNT_BASED
sliding-window-size: 10 # 최근 10건 기준
minimum-number-of-calls: 5 # 최소 5건 이후 통계
failure-rate-threshold: 50 # 실패율 50% 이상 시 OPEN
slow-call-duration-threshold: 5s # 5초 이상 응답은 느린 호출로 기록
slow-call-rate-threshold: 50 # 느린 호출 50% 이상 시 OPEN
wait-duration-in-open-state: 60s
permitted-number-of-calls-in-half-open-state: 3
automatic-transition-from-open-to-half-open-enabled: true
# 기록할 예외
record-exceptions:
- com.ghyinc.finance.global.exception.ExternalApiFailException
- org.springframework.web.client.ResourceAccessException
- org.springframework.web.client.HttpServerErrorException
- java.net.ConnectException
- java.net.SocketTimeoutException
# 무시할 예외 (Circuit Breaker에 영향 안 줌)
ignore-exceptions:
- org.springframework.web.client.HttpClientErrorException.BadRequest
- org.springframework.web.client.HttpClientErrorException.Unauthorized
instances:
KAKAO_BANK:
base-config: default
KB_BANK:
base-config: default
slow-call-duration-threshold: 10s # 전용선 응답 지연 고려CLOSED → 정상 (모든 요청 통과)
OPEN → 장애 (즉시 CallNotPermittedException, 해당 금융사만 격리)
HALF_OPEN → 복구 시도 (제한적 요청으로 복구 여부 확인)
Circuit Breaker OPEN 시 CallNotPermittedException을 Adaptor에서 직접 캐치하여 즉시 실패 응답을 반환합니다. 특정 금융사 장애가 전체 한도조회를 중단시키지 않고 나머지 금융사는 정상 진행합니다.
// LoanLimitAdaptor - CB OPEN 시 Fallback 처리
@Override
public LoanLimitAdaptorResponse inquireLimit(PartnerCode partnerCode,
LoanLimitAdaptorRequest request) {
try {
// Circuit Breaker + Retry 적용된 API 호출
CommonLimitResponse result = apiClient.post(...);
return LoanLimitAdaptorResponse.success(partnerCode, resTimeMs);
} catch (CallNotPermittedException e) {
// Fallback: CB OPEN 시 즉시 실패 응답 반환
// → 실제 API 호출 없이 해당 금융사 격리
// → 나머지 금융사는 정상 진행 (Partial Success)
log.warn("[{}] Circuit Breaker OPEN → Fallback 실행", partnerCode);
return LoanLimitAdaptorResponse.fail(partnerCode, "CB_OPEN", resTimeMs);
} catch (Exception e) {
log.error("[{}] 한도조회 오류", partnerCode, e);
return LoanLimitAdaptorResponse.fail(partnerCode, e.getMessage(), resTimeMs);
}
}금융사 3개 중 1개 CB OPEN 시
KAKAO_BANK → Fallback (즉시 실패) → SEND_FAILED
TOSS_BANK → 정상 전송 → SEND_SUCCESS
KB_BANK → 정상 전송 → SEND_SUCCESS
Inquiry 최종 상태
성공 2 / 전체 3 → PARTIAL_SUCCESS
→ FE에 조회 가능한 금융사 결과만 반환
→ 장애 금융사 결과 누락 명시
connectTimeout (3초) → 서버 연결 실패 → ResourceAccessException → CB 실패 기록
readTimeout (7초) → 응답 미수신 → SocketTimeoutException → CB 실패 기록
orTimeout (8초) → CompletableFuture 강제 종료 (최후 안전망)
connectTimeout < readTimeout = slow-call-duration-threshold < orTimeout
3초 < 7초 = 7초 < 8초
resilience4j:
retry:
configs:
default:
max-attempts: 3 # 최초 1회 + 재시도 2회
wait-duration: 1s # 재시도 간격
retry-exceptions:
- java.io.IOException
- java.util.concurrent.TimeoutException
- org.springframework.web.client.HttpServerErrorException
- org.springframework.web.client.ResourceAccessException
ignore-exceptions:
- io.github.resilience4j.circuitbreaker.CallNotPermittedExceptionRetry → Circuit Breaker 순으로 실행
→ maxAttempts(3) 모두 실패 후 CB 실패로 기록
→ CB OPEN 시 Retry 없이 즉시 Fallback 실행 (ignoreExceptions)
금융사별 암호화 알고리즘과 키를 DB 관리합니다. CryptoFactory가 PartnerCode를 기준으로 적합한 CryptoService 구현체를 선택합니다.
| 금융사 | 알고리즘 |
|---|---|
| 카카오뱅크 | AES-256-CBC |
| KB국민은행 | AES-256-ECB |
| 토스뱅크 | RSA-OAEP (2048bit) |
// 금융사별 암호화 자동 선택
CryptoService cryptoService = cryptoFactory.getCryptoService(partnerCode);
String encryptedRrn = cryptoService.encrypt(request.getRrn(), partnerCode);// Strategy 패턴으로 대출유형별 외부 데이터 조회 분기
ExternalDataContext context = strategy.requiresExternalData()
? strategy.fetchExternalData(request)
: ExternalDataContext.empty();로컬 테스트 시 @Profile("local") MockNiceDnrService로 가데이터를 사용합니다.
한도조회 완료 후 고객에게 알림을 발송하는 notification 도메인을 Kafka로 loan 도메인과 분리했습니다.
[loan 도메인]
LoanLimitResultService
→ KafkaTemplate.send("loan-limit-completed", inquiryNo, event)
↓ Kafka (loan-limit-completed 토픽)
[notification 도메인]
LoanLimitCompletedEventConsumer
→ NotificationService.sendNotification()
→ Notification INSERT
→ KafkaTemplate.send("notification.send", id, event)
↓ Kafka (notification.send 토픽)
NotificationEventConsumer
→ NotificationSenderService.call() ← 실제 Push/SMS 발송
→ 발송 결과 UPDATE
Kafka Consumer는 별도 스레드에서 실행되므로 HTTP 요청의 MDC(requestId)가 자동 전파되지 않습니다. payload에 requestId를 포함시켜 Consumer 스레드에서 복원합니다.
@KafkaListener(topics = "loan-limit-completed", groupId = "notification-group")
public void consume(LoanLimitCompletedEvent event) {
String requestId = Optional.ofNullable(event.getRequestId())
.orElse(UUID.randomUUID().toString());
try {
MDC.put(REQUEST_ID_KEY, requestId); // Consumer 스레드 MDC 설정
notificationService.sendNotification(...);
} finally {
MDC.clear(); // Consumer 스레드 재사용 시 오염 방지
}
}Kafka 발행과 DB 트랜잭션의 원자성을 보장하기 위해 Outbox 패턴을 적용했습니다.
Outbox 패턴 미적용 시 문제
→ DB UPDATE 성공 + Kafka 발행 실패
→ DB에는 SUCCESS로 기록
→ 알림 발송 누락
→ DB UPDATE 성공 + Kafka 발행 성공 + 트랜잭션 롤백
→ DB 롤백
→ Kafka 메시지는 이미 발행됨
→ 알림 중복 발송
비즈니스 트랜잭션
├── DB UPDATE ─┐
└── OutboxEvent INSERT (PENDING) ├─ 같은 트랜잭션 (원자적)
─┘
↓ 트랜잭션 커밋 후
@TransactionalEventListener(AFTER_COMMIT)
OutboxEventService.publishAfterCommit()
├── Kafka 즉시 발행 시도
├── 성공 → OutboxEvent PUBLISHED UPDATE
└── 실패 → OutboxEvent PENDING 유지
↓ 1분마다 (보조 안전망)
@Scheduled OutboxEventBatchPublisher
└── 5분 이상 PENDING 건 재시도 → PUBLISHED or FAILED
String topic = switch (outboxEvent.getAggregateType()) {
case "LoanLimitInquiry" -> "loan-limit-completed";
case "Notification" -> "notification.send";
default -> throw new InvalidRequestException(...);
};loan 도메인 LoanLimitCallbackService → 한도조회 완료 이벤트
notification NotificationService → 알림 발송 이벤트
여러 금융사 콜백이 동시에 수신될 때 LoanLimitInquiry count 업데이트의 Lost Update를 방지합니다.
// LoanLimitProductResultRepository - 비관적 락으로 inquiry 조회
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT p.inquiry FROM LoanLimitProductResult p WHERE p.loReqtNo = :loReqtNo")
Optional<LoanLimitInquiry> findInquiryByLoReqtNoAndProduceCodeWithLock(@Param("loReqtNo") String loReqtNo, @Param("productCode") String productCode);금융사 수에 따른 동시성 전략
현재 → 비관적 락
예정 → Message Queue 직렬화 처리
| Method | URL | 설명 |
|---|---|---|
| POST | /api/loan/limit/inquiry | 한도조회 요청 |
| GET | /api/loan/limit/inquiry/{inquiryNo} | 한도조회 결과 폴링 |
| POST | /api/loan/limit/callback | 한도결과 콜백 수신 (금융사 → 플랫폼) |
| POST | /api/loan/apply | 대출신청 |
| GET | /api/loan/apply/{applicationNo} | 대출신청 결과 조회 |
# 1. 프로젝트 클론
git clone https://github.com/your-repo/bigin.git
# 2. 로컬 프로파일로 실행 (H2 DB, Mock Nice DNR)
./gradlew bootRun --args='--spring.profiles.active=local'
# 3. Swagger UI 접속
http://localhost:8080/swagger-ui.htmlspring:
datasource:
url: jdbc:h2:mem:financedb
h2:
console:
enabled: true
partner-api:
partners:
KAKAO_BANK:
base-url: https://api.kakaobank.com
path: /v1/loan/limit
connection-type: REST# 전체 테스트 실행
./gradlew test
# 주요 테스트 대상
- LoanLimitServiceTest # 한도조회 요청 비즈니스 로직
- LoanLimitSenderServiceTest # 비동기 전송 및 상태 처리
- LoanLimitCallbackServiceTest # 콜백 수신 및 중복 방어
- LoanLimitStrategyTest # 대출유형별 전략 검증
- AesCryptoServiceTest # AES 암복호화
- RsaCryptoServiceTest # RSA 암복호화
- InquiryNoGeneratorTest # 채번 중복 없음 검증
- OutboxEventServiceTest # Outbox 즉시 발행 / 실패 시 PENDING 유지| 결정 | 이유 |
|---|---|
| 상품별 loReqtNo 선저장 | 콜백 loReqtNo 유효성 검증, 타임아웃 처리, 대출신청 연결 |
| LoanLimitResult 분리 | 상품 수가 많아도 금융사당 1건만 INSERT/UPDATE |
| 통신방식별 ApiClient 분리 | REST/전용선 금융사 혼재 대응, OCP 준수 |
| 금융사별 Circuit Breaker | 특정 금융사 장애 시 다른 금융사 영향 없이 격리 |
| Adaptor에서 CB Fallback 처리 | @CircuitBreaker 어노테이션 방식은 금융사별 독립 인스턴스 지정 불가, 수동 catch로 명시적 Fallback 처리 |
| Partial Failure 패턴 | 특정 금융사 CB OPEN 시 Fallback 응답 반환, 나머지 금융사 정상 진행 |
| 타임아웃 계층 분리 | readTimeout(CB 실패 기록) + orTimeout(스레드 강제 해제) 역할 분리 |
| 암호화 키 DB 관리 | DB에서 알고리즘/키 관리, 배포 없이 키 교체 가능, CryptoFactory의 supports()로 구현체 자동 선택 |
| ExternalDataContext | 외부 조회 결과 파라미터 고정 (Nice DNR, KB시세 등 확장 시 파라미터 불변) |
| Kafka 알림 연동 | 다중 인스턴스 환경에서 이벤트 소실 방지, loan-notification 도메인 물리적 분리 |