내가 관심있어 하는 정보, 나의 자산과 직접적인 관련이 있는 금융 정보들만 놓치지 않고 따끈따끈하게 소화하고 싶지 않으신가요?
FINsight와 함께라면 간단한 키워드 설정만으로 나에게 꼭 필요한, 최신의 투자 관련 뉴스들을 하나의 플랫폼에서 모두 받아보세요!
API 명세서: Finsight REST API Swagger
해당 프로젝트는 핀사이트(Finsight) 서비스의 백엔드 시스템을 구축한 개인/팀 프로젝트로, 안정적인 API 제공, 유연한 확장성, 운영 편의성을 목표로 설계되었습니다.
📹 시연 영상
📄 발표 자료
| 기능 1 | 기능 2 |
|---|---|
![]() |
![]() |
| 기사 클롤링 | 기사 AI 요약 |
| 기능 3 | 기능 4 | 기능 5 |
|---|---|---|
![]() |
![]() |
![]() |
| 마이컬렉션 추가 | 마이컬렉션 추가 성공 | 나만의 기사 모음 요약 |
- 비로그인(마이컬렉션 사용 불가) / 로그인 기능 분리
- 스케줄러 기반 자동 웹 크롤링
- 다수 / 단일 기사 AI 요약 기능 제공
왜 JWT가 아닌 Session 기반 인증을 선택했는가?
로그인 인증 방식을 선택하는 과정에서 JWT와 Session 방식 중 어떤 것을 사용할지 고민하게 되었습니다.
우리 프로젝트는 단일 인스턴스 환경에서 운영되기 때문에 Session 방식을 선택했습니다.
- Session: 서버에서 세션을 삭제하면 즉시 인증 무효화
- JWT: 토큰이 만료될 때까지 무효화 불가능 (블랙리스트 관리 필요)
- Session: 서버가 모든 세션 정보를 관리하므로 실시간 제어 가능
- JWT: 클라이언트가 토큰을 보관하므로 서버에서 직접 제어 불가
- Session: 세션 ID만 클라이언트에 저장 (민감 정보는 서버에 보관)
- JWT: 토큰에 사용자 정보가 포함되어 있어 탈취 시 위험
- 별도의 Redis나 세션 스토리지 없이 메모리 내 세션 관리 가능
- 네트워크 오버헤드 없이 빠른 세션 조회
| 구분 | Session | JWT |
|---|---|---|
| 저장 위치 | 서버 메모리/DB | 클라이언트 |
| 무효화 | 즉시 가능 | 어려움 (만료 시간까지 유효) |
| 확장성 | 단일 서버에 유리 | 분산 환경에 유리 |
| 서버 부하 | 세션 저장 공간 필요 | Stateless (서버 부하 적음) |
| 보안 | 서버에서 관리 | 토큰 탈취 시 위험 |
| 구현 복잡도 | 간단 | Refresh Token 등 추가 로직 필요 |
- ✅ 단일 서버 환경
- ✅ 즉시 로그아웃/세션 무효화가 중요한 경우
- ✅ 민감한 정보를 다루는 서비스 (금융, 결제 등)
- ✅ 서버에서 사용자 세션을 실시간으로 관리해야 하는 경우
- ✅ 다중 서버 환경 (MSA, 로드밸런싱)
- ✅ Stateless 아키텍처를 유지하고 싶은 경우
- ✅ 모바일 앱처럼 장기간 인증을 유지해야 하는 경우
- ✅ 외부 API 인증이 필요한 경우
우리 프로젝트는 단일 인스턴스 환경이기 때문에, 즉시 세션 무효화가 가능하고 보안성이 높은 Session 방식이 더 적합하다고 판단했습니다.
만약 향후 서비스가 확장되어 다중 서버 환경으로 전환된다면, Redis와 같은 **중앙 세션 스토리지(REdis)**를 도입하거나 JWT 방식으로 전환하는 것을 고려할 수 있습니다.
왜 NCP SSL 인증서가 아닌 Nginx + Let's Encrypt를 선택했는가?
HTTPS 적용을 위해 SSL 인증서가 필요했고, NCP에서 제공하는 SSL 인증서와 Nginx를 통한 Let's Encrypt 인증서 중 선택해야 했습니다.
NCP Certificate Manager에서 무료로 SSL 인증서를 발급받을 수 있지만, Load Balancer나 CDN+와 같은 NCP 서비스와 연동해야만 사용할 수 있습니다. 즉, SSL 인증서 자체는 무료지만 로드밸런서가 필수입니다.
서버에 직접 Nginx를 설치하고 Let's Encrypt로 SSL 인증서를 발급받는 방식을 선택했습니다.
- 우리 프로젝트는 단일 서버 환경
- 로드밸런서는 여러 서버에 트래픽을 분산하기 위한 것인데, 서버가 1대뿐이라면 오버스펙
- 불필요한 네트워크 홉(hop)만 추가되어 응답 속도 저하 가능
NCP Certificate Manager 방식:
- SSL 인증서: 무료 ✅
- BUT, 로드밸런서 필수:
→ 로드밸런서: 시간당 26원 = 월 18,720원
→ 공인 IP: 시간당 5.6원 = 월 4,032원
→ 총 월 22,752원 (VAT 별도)
→ 연간 비용: 273,024원 (VAT 별도)
→ VAT 포함 시: 약 300,326원
Nginx + Let's Encrypt:
- Nginx 설치: 무료
- Let's Encrypt 인증서: 무료
- 로드밸런서 불필요
→ 총 비용: 0원
💰 연간 약 30만원의 비용 절감 효과
# Certbot을 통한 자동 갱신 설정
certbot renew --dry-run- Let's Encrypt는 90일마다 자동 갱신 가능
- Certbot으로 갱신 자동화 설정이 간단함
- NCP Certificate Manager도 자동 갱신을 지원하지만 로드밸런서 비용은 계속 발생
- Nginx 설정을 통해 리버스 프록시, 캐싱, Gzip 압축 등 추가 기능 구현 가능
- SSL/TLS 버전, 암호화 알고리즘 등 세부 설정 직접 관리 가능
# Nginx에서 직접 SSL 설정 가능
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;NCP Certificate Manager 방식:
클라이언트 → 로드밸런서 (SSL 종료) → 서버
(불필요한 네트워크 레이어)
Nginx 방식:
클라이언트 → 서버 (Nginx에서 SSL 처리)
(단순하고 직관적)
- NCP Certificate Manager는 Load Balancer나 CDN+와 연동해야만 사용 가능
- 서버에 직접 설치하는 방식은 불가능
- Nginx + Let's Encrypt는 어떤 환경에서도 독립적으로 사용 가능
- NCP Certificate Manager를 사용하면 NCP 플랫폼에 강하게 종속됨
- Nginx + Let's Encrypt는 어떤 서버 환경에서도 동일하게 적용 가능
- 향후 AWS, GCP 등 다른 클라우드로 마이그레이션 시 유리
| 구분 | NCP Certificate Manager + 로드밸런서 | Nginx + Let's Encrypt |
|---|---|---|
| SSL 인증서 | 무료 | 무료 |
| 로드밸런서 | 필수 (월 22,752원) | 불필요 |
| 연간 총 비용 | 약 30만원 (VAT 포함) | 0원 |
| 아키텍처 | 클라이언트 → LB → 서버 | 클라이언트 → 서버 |
| 단일 서버 적합성 | ❌ 오버스펙 | ✅ 최적 |
| 서버 직접 설치 | ❌ 불가능 (LB 필수) | ✅ 가능 |
| 갱신 | 자동 갱신 | 자동 갱신 |
| 설정 자유도 | 제한적 | 높음 (직접 제어 가능) |
| 확장성 | NCP 환경에 종속 | 모든 환경에서 사용 가능 |
| 추가 기능 | SSL + 부하 분산 | 리버스 프록시, 캐싱, 압축 등 |
| 학습 효과 | 낮음 (관리형 서비스) | 높음 (직접 구축 경험) |
- ✅ 다중 서버 환경에서 트래픽 분산이 필요한 경우
- ✅ Auto Scaling을 통해 서버를 동적으로 추가/제거하는 경우
- ✅ CDN+와 연동하여 정적 컨텐츠 배포가 필요한 경우
- ✅ NCP 생태계 내에서만 운영하며 관리형 서비스를 선호하는 경우
- ✅ 서버 직접 관리 리소스가 부족한 경우
- ✅ 단일 서버 환경 (우리 프로젝트처럼)
- ✅ 비용 절감이 중요한 경우 (스타트업, 개인 프로젝트)
- ✅ 서버 설정에 대한 완전한 제어권이 필요한 경우
- ✅ 클라우드 벤더 종속성을 피하고 싶은 경우
- ✅ 로드밸런서 없이 서버에 직접 SSL을 적용하고 싶은 경우
- ✅ 학습 및 실무 경험을 쌓고 싶은 경우
우리 프로젝트는 단일 서버 환경이기 때문에 로드밸런서를 도입하는 것은 불필요한 비용과 복잡도만 증가시킵니다.
NCP Certificate Manager는 SSL 인증서 자체는 무료로 제공하지만, Load Balancer나 CDN+와의 연동이 필수라는 제약이 있습니다. 단일 서버 환경에서는 이러한 서비스가 불필요하므로, 로드밸런서 비용(연간 약 30만원)이 추가로 발생하게 됩니다.
Nginx + Let's Encrypt 방식을 선택함으로써:
- ✅ 연간 약 30만원의 비용 절감 (로드밸런서 + 공인 IP 비용)
- ✅ 불필요한 네트워크 레이어 제거로 응답 속도 개선
- ✅ 아키텍처 단순화로 유지보수 용이
- ✅ 서버에 직접 SSL 설치 가능 (로드밸런서 불필요)
- ✅ 향후 확장 시에도 유연하게 대응 가능
특히 Let's Encrypt의 자동 갱신 기능으로 인증서 관리 부담도 최소화할 수 있었고, 어떤 클라우드 환경으로 이전하더라도 동일한 방식을 적용할 수 있어 플랫폼 독립성을 유지할 수 있다는 점도 큰 장점이었습니다.
만약 향후 트래픽이 증가하여 다중 서버 환경으로 확장된다면, 그때 NCP의 로드밸런서와 Certificate Manager를 함께 도입하는 것을 고려할 수 있습니다.
왜 외부 라이브러리 대신 인메모리 캐시를 선택했는가?
프로젝트는 단일 서버에서 동작하며, 캐싱 기능은 한 곳에서만 사용됩니다.
따라서 기능을 구현할 때 추가 라이브러리를 도입하기보다는, 기존 기능과 자바 표준 도구를 최대한 활용하는 것이 합리적이었습니다.
| 기준 | 외부 라이브러리(Redis 등) | 인메모리 캐시(현재 방식) |
|---|---|---|
| 속도 | 네트워크/IPC 오버헤드 존재 | 메모리 직접 접근 → 가장 빠름 |
| 설치/운영 | 별도 설치 및 관리 필요 | 불필요 |
| 복잡도 | 운영/설정 필요 | 매우 단순 |
| 장애 대응 | 분리된 서비스 관리 필요 | 서버 내부에서 처리 가능 |
| 적합한 구조 | 다중 서버/수평 확장 | 단일 서버 + 단순 캐싱 |
- 캐싱이 한 군데에서만 사용되고, 구현도 간단한 경우 외부 라이브러리 도입은 불필요
- 최대한 의존성을 줄이고 **자바 인메모리(Map 기반)**만으로 충분히 구현 가능
- 네트워크 오버헤드 없이 빠른 접근과 간단한 관리가 가능
- 추후 여러 곳에서 공통으로 쓰여야 할 필요가 생기면, 그때 라이브러리 도입을 고려 가능
왜 TTL 기반이 아닌 LRU 기반 캐싱 방식을 선택했는가?
AI 요약 데이터는 한 번 생성되면 시간이 지나도 의미가 크게 변하지 않습니다.
따라서 단순히 시간이 지나면 삭제되는 TTL 방식보다는, 실제 사용 패턴에 따라 유지되는 LRU 방식이 더 적합했습니다.
| 비교 기준 | TTL 기반 | LRU 기반 |
|---|---|---|
| 삭제 기준 | 설정된 시간 경과 | "가장 오래 사용되지 않은" 데이터 |
| 데이터 가치 반영 | 반영 어려움 | 사용량 기반으로 반영 가능 |
| 적합한 경우 | 데이터가 일정 시간마다 갱신될 때 | 인기 항목을 오래 유지해야 할 때 |
| 문제점 | 인기 데이터도 TTL 지나면 삭제 | 최대 캐싱 개수 초과 시 LRU 기준으로 제거 |
기사 요약과 같이 시간이 지나도 가치가 유지되는 데이터는
TTL로 단순 삭제하는 것보다,
LRU 방식으로 인기 항목 위주로 관리하는 것이 메모리 효율과 성능 측면에서 더 합리적입니다.
리액티브 프로그래밍에서의 이중 구독 문제 해결
스케줄러 기반 크롤링 작업에서 callContent() 메서드 내부에서 별도로 .subscribe()를 호출하는 중첩 구독(Nested Subscribe) 패턴으로 인해 다음과 같은 문제가 발생했습니다:
- Fire-and-Forget: 외부 스트림이 내부 작업 완료를 알 수 없음
- 스케줄링 스레드 조기 종료: 실제 작업은 진행 중인데 스케줄러는 완료로 간주
- 작업 중첩:
fixedRate로 인해 이전 작업 미완료 상태에서 새 작업 시작 - 동시성 제어 실패: 의도한 병렬/순차 처리가 제대로 작동하지 않음
| 항목 | Before (문제점) | After (해결책) |
|---|---|---|
| 구독 | 중첩된 .subscribe() |
단일 체인 + 마지막 .blockLast() |
| 스케줄링 | fixedRate + 즉시 리턴 |
fixedDelay + .blockLast()로 완료 대기 |
| 작업 제어 | "Fire-and-Forget" | 모든 작업의 완료/실패를 메인 스레드가 인지 |
| 중복 실행 | 크롤링 시간 > 15분이면 중복 실행 | 절대 중복 없음 (완료 후 15분 대기) |
| 동시성 | 제어 불가 (N개 카테고리 동시 실행) | flatMap(..., 5)로 최대 5개 병렬 실행 |
| 순차성 | 5개 병렬 concatMap (의도와 다름) |
각 병렬 작업 내 concatMap (의도대로) |
@Scheduled(fixedRate = 900000)
public void scheduledCrawlYNS() {
log.info("스케줄링 시작...");
Flux.fromArray(YNSCategory.values())
.flatMap(category ->
crawlerClient.call(category.getPath())
.flatMap(...)
.doOnNext(this::callContent) // 1. callContent 호출
.doOnError(...)
.onErrorResume(...)
.then()
, 5)
.subscribe(); // 2. 외부 스트림 구독
log.info("스케줄링 종료..."); // 3. 이 로그가 '즉시' 찍힘
}
private void callContent(List<ArticleSummaryDto> articleList) {
Flux.fromIterable(articleList)
.concatMap(...) // 4. 내부 스트림 정의
.subscribe(); // 5. 내부에서 별도 구독 (문제의 핵심)
}문제점:
callContent내부의.subscribe()는 리액티브 스트림의 대표적인 안티패턴- "Fire-and-Forget":
doOnNext(this::callContent)는 메서드를 호출할 뿐, 내부 작업이 언제 끝나는지 알 수 없음 - 체인 단절: 외부 스트림과 내부 스트림의 연결이 끊어짐
subscribe()는 비동기 작업의 "시작"만 트리거하므로,log.info("스케줄링 종료")가 1초도 안 되어 찍힘fixedRate = 900000(15분)은 이전 작업 시작 후 15분이면 다음 작업 시작 → 작업 중첩 발생
@Scheduled(fixedDelay = 900000) // fixedRate → fixedDelay 변경
public void scheduledCrawlYNS() {
log.info("스케줄링 시작...");
Flux.fromArray(YNSCategory.values())
.flatMap(category ->
fetchCategoryArticleList(category)
.flatMap(this::fetchArticleContents) // Mono<Void> 반환
.doOnError(...)
.onErrorResume(e -> Mono.empty())
, MAX_CONCURRENCY) // 최대 5개 병렬 실행
.doOnComplete(...)
.doOnError(...)
.blockLast(); // 모든 작업 완료까지 대기 (핵심)
log.info("스케줄링 종료."); // 이제 정말 작업이 다 끝난 후 찍힘
}
// Mono<Void> 반환 (내부 subscribe 제거)
private Mono<Void> fetchArticleContents(List<ArticleSummaryDto> articleList) {
return Flux.fromIterable(articleList)
.concatMap(...) // 순차 처리
.then(); // Mono<Void>를 반환하여 '완료' 신호 전달
}fetchArticleContents가.subscribe()대신.then()으로Mono<Void>반환- 모든 Mono/Flux를 하나의 거대한 스트림 체인으로 연결
.blockLast(): 스케줄러 스레드를 붙잡아두고 모든 작업 완료까지 대기- 결과:
log.info("스케줄링 종료")가 진짜 완료 후에 찍힘
fixedDelay = 900000: 이전 작업 완료 후 15분 뒤에 다음 작업 시작.blockLast()덕분에 크롤링이 30분 걸리면 30분 완료 + 15분 대기- 작업 중첩 위험 원천 차단
flatMap(..., MAX_CONCURRENCY): 최대 5개 카테고리 크롤링이 병렬 실행concatMap(내부): 각 카테고리 내 기사는 순차 처리 (polite crawling)- 결과: 최대 5개의 기사 본문이 동시 처리되며, 각 기사 처리 후 5~15초 지연
- ✅ 작업 중첩 방지:
fixedDelay+.blockLast()로 안전한 주기 보장 - ✅ 정확한 완료 시점 파악: 스케줄러가 실제 완료를 인지
- ✅ 의도한 동시성 제어: 최대 5개 병렬 + 각 카테고리 내 순차 처리
- ✅ 에러 추적 가능: 단일 체인으로 모든 에러 캡처
캐싱을 통한 성능 개선
AI 기사 요약은 생성 시 연산 비용이 높고, 사용자가 같은 기사를 반복해서 클릭하면 매번 새로 요약을 생성해야 했습니다.
그 결과 불필요한 계산 비용과 응답 지연이 발생했습니다.
이를 해결하기 위해 AI 기사 요약 결과를 인메모리 캐시에 저장했습니다.
- 사용자가 기사를 처음 클릭하면 요약을 생성하고 캐시에 저장
- 이후 동일 기사를 요청할 때는 캐시에서 바로 반환 → 연산 비용 제거
- 최대 캐시 크기를 설정하고 LRU 기준으로 오래 사용되지 않은 항목 제거 → 메모리 효율 유지
- 응답 속도 개선: 캐시에서 즉시 반환 → 사용자 체감 속도 향상
- 연산 비용 절감: 동일 기사 반복 요약 제거 → 서버 부담 감소
- 메모리 관리 효율화: 최대 개수 초과 시 LRU 제거 → 불필요한 메모리 점유 최소화
@Component
public class AiArticleCache {
private static final int MAX_CACHE_SIZE = 1000;
private final Map<Long, AIArticle> cache = new LinkedHashMap<>(16, 0.75f, true) {
@Override
protected boolean removeEldestEntry(Map.Entry<Long, AIArticle> eldest) {
return size() > MAX_CACHE_SIZE;
}
};
public synchronized void put(Long id, AIArticle article) {
cache.put(id, article);
}
public synchronized Optional<AIArticle> findArticleById(Long id) {
return Optional.ofNullable(cache.get(id));
}
}synchronized 사용 단일 서버 환경에서 동시에 여러 스레드가 캐시에 접근할 수 있으므로 안전하게 동기화 간단한 구조에서 별도 ConcurrentMap 라이브러리 없이 안전한 접근 가능
크롤링 로직 리팩토링: for + Thread.sleep에서 Flux.concatMap으로 전환
기존 크롤링 로직은 for 루프 내에서 리액티브 스트림을 subscribe()하고, Thread.sleep()으로 딜레이를 주는 혼합된 구조
for(ArticleSummaryDto articleSummary : articleList) {
// 1. 루프 내에서 subscribe() 호출
crawlerClient.callContent(...)
.publishOn(Schedulers.boundedElastic())
.doOnNext(...) // DB 저장 로직
.subscribe(); // <--- 문제 1
// 2. 메인 스레드 강제 블로킹
try {
Thread.sleep(randomNumber); // <--- 문제 2
} catch (InterruptedException e) { ... }
}- 문제 1 (루프 내
subscribe):for루프는subscribe()가 반환하는 비동기 작업(Mono)의 완료를 기다리지 않는다. 즉시 다음 루프로 넘어간다. - 문제 2 (
Thread.sleep):Thread.sleep()은 비동기 작업의 완료를 기다리는 것이 아니라, 메인 스레드를 강제로 블로킹한다. - 결과:
for루프가 돌면서 모든 크롤링 작업을boundedElastic스레드 풀에 거의 동시에 병렬로 던짐. (순차 실행 실패)- 메인 스레드는 크롤링 작업과는 아무 상관 없이, 혼자
Thread.sleep을 반복하며 스레드 자원을 낭비. - "하나씩 크롤링하고 딜레이"하려던 원래 의도와 정반대로 동작.
모든 로직을 선언적인 리액티브 스트림으로 통합하여, for 루프와 Thread.sleep을 완전히 제거.
- 개선 코드
Flux.fromIterable(articleList) // 1. for 루프 대체
.concatMap(articleSummary -> // 2. 순차 실행 보장
crawlerClient.callContent(...)
.flatMap(...)
.publishOn(Schedulers.boundedElastic())
.doOnNext(...) // DB 저장 로직
.onErrorResume(error -> Mono.empty()) // 4. 안정성
.then(Mono.delay(...)) // 3. 비차단 딜레이
)
.subscribe(); // 5. 단일 구독1. Flux.fromIterable(articleList): for 루프를 대체하여 리스트를 리액티브 스트림으로 변환.
2. concatMap: flatMap과 달리, 이전 Mono(내부 스트림)가 완료될 때까지 다음 Mono를 구독(시작)하지 않는다. 이것이 하나씩 순서대로 실행하는 것을 100% 보장.
3. Mono.delay(): Thread.sleep()을 대체하는 논블로킹 딜레이. 스레드를 점유하지 않고 리액터 스케줄러를 통해 효율적으로 대기한다.
4. onErrorResume(error -> Mono.empty()): 특정 기사 크롤링에 실패하더라도, 에러를 로깅하고(doOnError) 스트림이 중단되지 않도록 하여 전체 작업의 안정성을 향상.
5. 단일 subscribe(): 모든 체인의 마지막에 subscribe()를 한 번만 호출하여 전체 스트림을 활성화.
-
완벽한 순차 실행 보장:
concatMap을 통해 [ (1번 기사 크롤링 + DB 저장) + 딜레이 ] 작업이 완전히 완료되어야만 2번 기사 작업이 시작되도록 하여, 원래의 비즈니스 요구사항을 정확히 구현. -
압도적인 자원 효율성:
스레드를 붙잡고 낭비하는Thread.sleep()을 제거하고, 리소스를 점유하지 않는Mono.delay()로 변경하여 매우 적은 리소스로도 안정적인 대기가 가능해졌다. -
안정성 및 예측 가능성:
onErrorResume을 통해 일부 항목의 실패가 전체 배치 작업을 중단시키는 문제를 해결. 또한, 모든 로직이 하나의 스트림으로 통합되어 동작을 예측하고 디버깅하기 쉬워짐.
| 유성안 | 김정인 | 이주연 | 손상희 | 김도균 | 이준영 | 장군호 |
|---|---|---|---|---|---|---|
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
![]() |
| PM | PD(디자이너) | FE | FE | BE | BE | BE |
- 유성안
- 팀장, 기획, 와이어프레임 설계, 발표, 피피티 제작
- 김정인
- 와이어프레임 디자인, 컴포넌트 디자인
- 이주연
- UI 개발 및 API 연동
- 손상희
- UI 개발 및 API 연동
- 김도균
- API 개발, 서버 배포
- 이준영
- API 개발, 서버 배포
- 장군호
- AI 도메인 개발










