상세 컨텐츠

본문 제목

[StockAlarm] 프로젝트 코드 구조 변경하기

카테고리 없음

by 개복신 개발자 2025. 7. 3. 16:12

본문

반응형

이번에 개인 프로젝트로 주식 알리미 서비스를 만들고 있다. 유저가 자신의 관심 주식 목록을 설정하면 그 주식에 대한 뉴스들을 통해 이슈들을 정리해서 매일 아침마다 이메일로 해당 정보들을 요약해서 보내주는 서비스이다.

아직 완성되지 않은 프로토타입


해당 서비스를 만드는데 문제점을 발견했다. 코드 스타일과 구조가 너무 조잡한 것이었다. SOLID 원칙은 모두 무시된채로 작성되었다. 그래서 이를 리팩토링 하는 것을 목표로 잡았다.

 

문제점 파악

A. CrawlerService가 너무 많은 책임을 진다(SRP 위반)

//CrawlerService의 메소드
public void fetchAndSaveTop100Symbols() throws IOException {
    List<String> all = new ArrayList<>();
    // r=1,21,41,61,81 페이징 순회
    for (int offset : new int[]{1,21,41,61,81}) {
        all.addAll(fetchSymbolsFromPage(offset));
    }

    // 중복 제거 후 100개로 자르고 엔티티 변환
    List<Stock> docs = all.stream()
            .distinct()
            .limit(100)
            .map(sym -> Stock.builder().symbol(sym).build())
            .collect(Collectors.toList());

    // 콜렉션 교체
    stockRepository.deleteAll();
    stockRepository.saveAll(docs);
}

public List<String> fetchSymbolsFromPage(int r) throws IOException {
    String url = String.format(FINVIZ_BASE_URL, r);
    Document doc = Jsoup.connect(url)
                .userAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64)")
                .referrer("https://finviz.com/")
                .timeout(10_000)
                .get();

    return doc.select("table.screener_table a.tab-link")
                .stream()
                .map(Element::text)
                .collect(Collectors.toList());
    }

📍Problem

fetchAndSaveTop100Symbols() 메서드 하나가 다음 4가지 역할을 동시에 하고 있음:

1. 외부 웹사이트에 요청 (크롤링)

2. HTML 파싱

3. 중복 제거 및 정제

4. DB 저장

 

🛠 Solve

각각의 역할을 별도 메서드 또는 별도 컴포넌트로 나누자.

예를 들어:

SymbolCrawler: HTML 요청 및 파싱

SymbolProcessor: 중복 제거 및 정제

StockPersistenceService: DB 저장

 

개선된 코드

1. SymbolCrawler

import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import org.jsoup.nodes.Element;
import org.springframework.stereotype.Component;

import java.io.IOException;
import java.util.ArrayList;
import java.util.List;

@Component
public class SymbolCrawler {
    // 책임: Symbol들을 추출하는 역할!

    private static final String FINVIZ_BASE_URL =
            "https://finviz.com/screener.ashx?v=152&f=cap_largeover&o=-marketcap&r=%d";

    public List<String> crawlTopSymbols() throws IOException {
        List<String> all = new ArrayList<>();
        for (int offset : new int[]{1, 21, 41, 61, 81}) {
            all.addAll(fetchSymbolsFromPage(offset));
        }
        return all;
    }

    private List<String> fetchSymbolsFromPage(int r) throws IOException {
        String url = String.format(FINVIZ_BASE_URL, r);
        Document doc = Jsoup.connect(url)
                .userAgent("Mozilla/5.0")
                .referrer("https://finviz.com/")
                .timeout(10_000)
                .get();

        return doc.select("table.screener_table a.tab-link")
                .stream()
                .map(Element::text)
                .toList();
    }

}

 

오직 주식 정보의 Symbol들만 추출하는 역할만 수행한다. 저장하거나 데이터들을 가공하는 역할은 일절 하지 않고 분리했다.

 

2.SymbolProcessor

import org.springframework.stereotype.Component;

import java.util.List;
import java.util.stream.Collectors;

@Component
public class SymbolProcessor {
    // 책임: 중복 제거, 100개 자르기 등 가져온 데이터들을 올바르게 가공 역할
    public List<String> filterAndLimit(List<String> rawSymbols) {
        return rawSymbols.stream()
                .distinct()
                .limit(100)
                .collect(Collectors.toList());
    }
}

Api로 가져온 데이터들을 가공만 하는 역할

 

3. StockPersistenceService

import com.example.stock_helper.domain.stock.model.Stock;
import com.example.stock_helper.domain.stock.repository.StockRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;

import java.util.List;

@Component
@RequiredArgsConstructor
public class StockPersistenceService {
    //책임: 가져온 주식 Symbol들을 저장(만!!) 하는 역할

    private final StockRepository stockRepository;

    public void saveSymbols(List<String> symbols) {
        List<Stock> stocks = symbols.stream()
                .map(sym -> Stock.builder().symbol(sym).build())
                .toList();

        stockRepository.deleteAll();
        stockRepository.saveAll(stocks);
    }
}

동일하게 저장만 하는 역할!

 

4.TopStockIngestionService

그리고 이를 전체적으로 조율하는 관리자 역할이 필요하다. 다른 클래스들의 역할이 배우였다면 이번에 필요한 역할을 감독인 것!

import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

import java.io.IOException;
import java.util.List;

@Service
@RequiredArgsConstructor
public class TopStockIngestionService {
    //책임: 흐름 조절 지휘자 역할

    private final SymbolCrawler symbolCrawler;
    private final SymbolProcessor symbolProcessor;
    private final StockPersistenceService stockPersistenceService;

    public void ingest() throws IOException {
        List<String> rawSymbols = symbolCrawler.crawlTopSymbols();
        List<String> topSymbols = symbolProcessor.filterAndLimit(rawSymbols);
        stockPersistenceService.saveSymbols(topSymbols);
    }
}

 

이렇게 역할을 분리함으로써 코드를 더 간결하고 역할을 분명하게 했다. 이를 통해 리팩토링에 더욱 유리하도록 바꿀 수 있었다.

 

 

 결론: 좋은 설계를 위한 핵심 원칙들

 

이번 리팩토링을 통해 다음과 같은 중요한 개념을 실전에서 적용해보았다:

 


 

1. 

SRP (단일 책임 원칙)

하나의 클래스(또는 메서드)는 오직 하나의 책임만 가져야 한다.

 

 

SymbolCrawler: **데이터 수집 (크롤링)**만 담당

SymbolProcessor: 데이터 정제만 담당

StockPersistenceService: 저장 로직만 담당

TopStockIngestionService: 비즈니스 흐름 조율만 담당

➡ 책임을 분리하니, 테스트/수정/확장 모두 쉬워짐

 


 

2. 

구조는 의도를 표현해야 한다

 

클래스를 보자마자 “이 클래스는 뭘 하지?” 가 명확해야 한다.

SymbolCrawler, SymbolProcessor 같은 네이밍은 책임이 뚜렷하게 드러나는 이름이다.

 


 

3. 

비즈니스 흐름은 서비스 계층이 조율한다

 

TopStockIngestionService는 말 그대로 **“상위 주식 정보를 통합 처리하는 서비스”**로서 다른 컴포넌트를 orchestration 한다.

서비스 계층은 복잡한 의존 관계 없이, 전체 비즈니스 플로우만 조립해야 한다.

 


 

4. 

테스트 용이성 향상

 

크롤러, 가공기, 저장기 각각 단위 테스트가 독립적으로 가능하다.

예를 들어 SymbolProcessor는 입력값만 주면 순수하게 가공 결과만 반환하므로 단위 테스트하기 최적이다.

반응형

댓글 영역