이전에 GoogleTrendsRssService의 경우, 외부 기능을 가져오는 기능임에도 Service를 통해 코어에서 직접 다루는 방식을 채택했었다.
이 경우 이후에 실시간 인기 키워드를 가져오는 외부 기능이 변경될 경우, 코어의 코드 전체가 수정이 될 가능성이 생기고, 외부 도구를 변경하고 싶을 때도 변경이 어려워 확장성과 유연성 측면에서 굉장히 비효율적인 설계라는 생각이 들었다.
기존 코드
@Slf4j
@Service
@RequiredArgsConstructor
public class GoogleTrendsRssService {
private final KeywordService keywordService;
private final KeywordMetricHourlyService keywordMetricHourlyService;
@Value("${google.trends.rss.url}")
private String googleTrendsRssUrl;
@Value("${google.trends.rss.namespace}")
private String namespaceUrl;
@Value("${mock.enabled}")
private boolean mockEnabled;
@Value("classpath:mock/${mock.keyword-file}")
private Resource mockKeywordsResource;
private List<String> mockKeywords = List.of();
private final Random random = new Random();
@Transactional
public List<Keyword> getTrendingKeywordsFromRss() {
if (mockEnabled) {
return generateMockKeywords();
}
List<Keyword> trendingKeywords = new ArrayList<>();
try {
URL feedUrl = new URL(googleTrendsRssUrl);
SyndFeedInput input = new SyndFeedInput();
SyndFeed feed = input.build(new XmlReader(feedUrl));
for (SyndEntry entry : feed.getEntries()) {
saveKeywordMetric(entry.getTitle(), parseApproxTraffic(entry), trendingKeywords);
}
} catch (Exception e) {
log.error("Failed to fetch Google Trends RSS", e);
}
return trendingKeywords;
}
private int parseApproxTraffic(SyndEntry entry) {
String approxTraffic = entry.getForeignMarkup().stream()
.filter(el -> "approx_traffic".equals(el.getName())
&& namespaceUrl.equals(el.getNamespaceURI()))
.map(org.jdom2.Element::getText)
.findFirst()
.orElse("0");
if (approxTraffic.endsWith("+")) {
return Integer.parseInt(approxTraffic.substring(0, approxTraffic.length() - 1));
}
try {
return Integer.parseInt(approxTraffic);
} catch (NumberFormatException e) {
// 변환 실패 시 기본값 또는 에러 처리
return 0; // 또는 다른 적절한 기본값
}
}
private void saveKeywordMetric(String text, int volume, List<Keyword> trendingKeywords) {
Keyword keyword = keywordService.createKeyword(
Keyword.builder().text(text).build()
);
trendingKeywords.add(keyword);
LocalDateTime bucketAt = LocalDateTime.now()
.withMinute(0).withSecond(0).withNano(0);
KeywordMetricHourlyId id = KeywordMetricHourlyId.builder()
.keywordId(keyword.getId())
.bucketAt(bucketAt)
.platform(Platform.GOOGLE_TREND)
.build();
KeywordMetricHourly metric = KeywordMetricHourly.builder()
.id(id)
.keyword(keyword)
.volume(volume)
.score(volume)
.build();
keywordMetricHourlyService.createKeywordMetricHourly(metric);
@@ -130,7 +93,7 @@ private List<Keyword> generateMockKeywords() {
? "테스트키워드" + i
: mockKeywords.get(random.nextInt(mockKeywords.size()));
int volume = random.nextInt(1001); // 0~1000 범위
saveKeywordMetric(text, volume, trendingKeywords);
}
return trendingKeywords;
}
@PostConstruct
private void loadMockKeywords() {
if (!mockEnabled) {
return;
}
if (!mockKeywordsResource.exists()) {
log.error("[mock] 키워드 리소스를 찾을 수 없습니다: {}", mockKeywordsResource);
return;
}
try (InputStream is = mockKeywordsResource.getInputStream();
BufferedReader reader = new BufferedReader(new InputStreamReader(is, StandardCharsets.UTF_8))) {
mockKeywords = reader.lines()
.map(String::trim)
.filter(line -> !line.isEmpty())
.collect(Collectors.toList());
log.info("[mock] Loaded {} keywords from {}", mockKeywords.size(), mockKeywordsResource.getFilename());
} catch (IOException e) {
log.error("[mock] Failed to load mock keywords", e);
}
}
}
이에 우리 팀은 부분적인 헥사고날 도입을 사용하여 확장성과 유연성 측면에서 코드의 개선을 모색하였다.
그래서 헥사고날이 뭔데?
다른 말로는 Port & Adapter 아키텍처. 간단하게 말해서 코어 로직을 외부와 단절시키고, 포트를 통해서만 외부와 소통하도록 만드는 아키텍처이다.
비유하면… 방에 가둬놓고 군만두로만 소통하는 올드보이 같은 느낌…
코어 로직은 포트를 통해서만 외부의 데이터를 받아온다. 외부 기능은 어댑터를 통해 포트와 연결하여 코어와 소통하게 된다.
실제 구현 해보기
헥사고날 아키텍처의 가징 중요한 부분은 외부의 데이터를 가져오는 로직을 코어와 분리해야 하는 것이다.
우선 GoogleTrendsRssService 를 분리해보자. 해당 코드에서 외부 데이터를 가져오고 파싱하는 메소드는
@Transactional
public List<Keyword> getTrendingKeywordsFromRss() {
if (mockEnabled) {
return generateMockKeywords();
}
List<Keyword> trendingKeywords = new ArrayList<>();
try {
URL feedUrl = new URL(googleTrendsRssUrl);
SyndFeedInput input = new SyndFeedInput();
SyndFeed feed = input.build(new XmlReader(feedUrl));
for (SyndEntry entry : feed.getEntries()) {
saveKeywordMetric(entry.getTitle(), parseApproxTraffic(entry), trendingKeywords);
}
} catch (Exception e) {
log.error("Failed to fetch Google Trends RSS", e);
}
return trendingKeywords;
}
private int parseApproxTraffic(SyndEntry entry) {
String approxTraffic = entry.getForeignMarkup().stream()
.filter(el -> "approx_traffic".equals(el.getName())
&& namespaceUrl.equals(el.getNamespaceURI()))
.map(org.jdom2.Element::getText)
.findFirst()
.orElse("0");
if (approxTraffic.endsWith("+")) {
return Integer.parseInt(approxTraffic.substring(0, approxTraffic.length() - 1));
}
try {
return Integer.parseInt(approxTraffic);
} catch (NumberFormatException e) {
// 변환 실패 시 기본값 또는 에러 처리
return 0; // 또는 다른 적절한 기본값
}
}
이 부분이다. 이 메소드들은 사용하는 도구가 GoogleTrends가 아닌 다른 Trends 도구가 되었을 때, 변경 가능성이 가장 크다.
이 중에서도 getTrendingKeywordsFromRss는 직접적으로 외부 URL을 통해 키워드들을 가져오는
try {
URL feedUrl = new URL(googleTrendsRssUrl);
SyndFeedInput input = new SyndFeedInput();
SyndFeed feed = input.build(new XmlReader(feedUrl));
for (SyndEntry entry : feed.getEntries()) {
saveKeywordMetric(entry.getTitle(), parseApproxTraffic(entry), trendingKeywords);
}
} catch (Exception e) {
log.error("Failed to fetch Google Trends RSS", e);
}
이 로직을 포함하고 있다. 우리는 이 부분을 따로 분리해 어댑터로 구현할 것이다.
Port
우선 어댑터와 연결할 포트를 작성해보자.
public interface TrendsPort {
/**
* Google Trends RSS를 호출하여 실시간 키워드 목록을 가져옵니다.
*
* @return 수집된 키워드 텍스트 리스트
*/
List<KeywordInfo> fetchTrendingKeywords();
}
포트의 경우 인터페이스로 구현한다.
코어에서는 포트에서 작성된 fetchTrendingKeywords 를 통해 외부 데이터를 접근 할 수 있게 된다.
Adapter
다음은 어댑터다.
@Slf4j
@Component
@RequiredArgsConstructor
public class GoogleTrendsAdapter implements TrendsPort {
private final KeywordService keywordService;
private final KeywordMetricHourlyService keywordMetricHourlyService;
@Value("${google.trends.rss.url}")
private String googleTrendsRssUrl;
@Value("${google.trends.rss.namespace}")
private String namespaceUrl;
@Override
public List<KeywordInfo> fetchTrendingKeywords() {
List<KeywordInfo> trendingKeywordsInfo = new ArrayList<>();
try {
URL feedUrl = new URL(googleTrendsRssUrl);
SyndFeedInput input = new SyndFeedInput();
SyndFeed feed = input.build(new XmlReader(feedUrl));
for (SyndEntry entry : feed.getEntries()) {
String text = entry.getTitle();
int volume = parseApproxTraffic(entry);
trendingKeywordsInfo.add(KeywordInfo.builder().text(text).volume(volume).build());
}
} catch (Exception e) {
throw new ExternalApiException("Failed to fetch Google Trends RSS", e);
}
return trendingKeywordsInfo;
}
private int parseApproxTraffic(SyndEntry entry) {
String approxTraffic = entry.getForeignMarkup().stream()
.filter(el -> "approx_traffic".equals(el.getName())
&& namespaceUrl.equals(el.getNamespaceURI()))
.map(org.jdom2.Element::getText)
.findFirst()
.orElse("0");
if (approxTraffic.endsWith("+")) {
return Integer.parseInt(approxTraffic.substring(0, approxTraffic.length() - 1));
}
try {
return Integer.parseInt(approxTraffic);
} catch (NumberFormatException e) {
// 변환 실패 시 기본값 또는 에러 처리
return 0; // 또는 다른 적절한 기본값
}
}
}
짠… 이라고 했지만 솔직히 고민이 좀 많은 부분이었다.
헥사고날을 일부라고는 해도 적용 자체가 처음인 작업이었기 때문에, GoogleTrendsRssService 에서 어디까지 로직을 가져와야 하는지를 몰랐으니…
위에서 말한 로직 분리에 관한 내용도 어느 정도 고민 후에 얻게 된 방식이다.
코어 로직에서 직접적으로 필요한 것은 키워드의 정보이다. 따라서 데이터를 외부에서 가져오고 파싱하는 로직만을 따로 분리해 Adapter로써 작성하게 되었다.
처음에는 저장 메소드, 그러니까 키워드들을 테이블에 저장하는 saveKeywordMetric 메소드 또한 어댑터에 구현하여, fetchTrendingKeywords 메소드에서 키워드를 가져오고, 파싱하여, 저장하는 방식으로 로직 자체를 하나로 묶어서 실행하는 방식으로 구현하였으나, 바로 엎어버렸다.
당장 위에 적힌거만 봐도 한 클래스에 너무 많은 책임이 들어가 있으니… 헥사고날로 구현한 이유가 없는 셈이기 때문이다.
따라서 saveKeywordMetric 메소드는 Service에 구현하고, Adapter에서는 순수하게 외부 데이터를 가공하여 Port를 통해 Service에게 전달하는 역할로만 작성하여, 결합도를 낮추고 ㄷ를 높이는 방향을 꾀하였다.
Service
마지막으로 Service다.
@Slf4j
@Service
@RequiredArgsConstructor
public class TrendsService {
private final TrendsPort trendsAdapter; // TrendsPort 인터페이스 주입
private final KeywordService keywordService;
private final KeywordMetricHourlyService keywordMetricHourlyService;
@Value("${trend.platform}")
private Platform platform;
@Value("${mock.enabled}")
private boolean mockEnabled;
@Value("classpath:mock/${mock.keyword-file}")
private Resource mockKeywordsResource;
private List<String> mockKeywords = List.of();
private final Random random = new Random();
@Transactional
public List<Keyword> getTrendingKeywordsFromRss() {
if (mockEnabled) {
return generateMockKeywords();
}
List<Keyword> trendingKeywords = new ArrayList<>();
List<KeywordInfo> trendingKeywordsInfo = trendsAdapter.fetchTrendingKeywords();
for(KeywordInfo keywordInfo : trendingKeywordsInfo) {
saveKeywordMetric(keywordInfo, trendingKeywords);
}
return trendingKeywords;
}
public void saveKeywordMetric(KeywordInfo keywordInfo, List<Keyword> trendingKeywords) {
Keyword keyword = keywordService.createKeyword(
Keyword.builder().text(keywordInfo.getText()).build()
);
trendingKeywords.add(keyword);
LocalDateTime bucketAt = LocalDateTime.now()
.withMinute(0).withSecond(0).withNano(0);
KeywordMetricHourlyId id = KeywordMetricHourlyId.builder()
.keywordId(keyword.getId())
.bucketAt(bucketAt)
.platform(platform)
.build();
KeywordMetricHourly metric = KeywordMetricHourly.builder()
.id(id)
.keyword(keyword)
.volume(keywordInfo.getVolume())
.score(keywordInfo.getVolume())
.build();
keywordMetricHourlyService.createKeywordMetricHourly(metric);
}
private List<Keyword> generateMockKeywords() {
List<Keyword> trendingKeywords = new ArrayList<>();
int count = 10;
for (int i = 0; i < count; i++) {
String text = mockKeywords.isEmpty()
? "테스트키워드" + i
: mockKeywords.get(random.nextInt(mockKeywords.size()));
int volume = random.nextInt(1001); // 0~1000 범위
saveKeywordMetric(KeywordInfo.builder().text(text).volume(volume).build(), trendingKeywords);
}
return trendingKeywords;
}
@PostConstruct
private void loadMockKeywords() {
if (!mockEnabled) {
return;
}
if (!mockKeywordsResource.exists()) {
log.error("[mock] 키워드 리소스를 찾을 수 없습니다: {}", mockKeywordsResource);
return;
}
try (InputStream is = mockKeywordsResource.getInputStream();
BufferedReader reader = new BufferedReader(new InputStreamReader(is, StandardCharsets.UTF_8))) {
mockKeywords = reader.lines()
.map(String::trim)
.filter(line -> !line.isEmpty())
.collect(Collectors.toList());
log.info("[mock] Loaded {} keywords from {}", mockKeywords.size(), mockKeywordsResource.getFilename());
} catch (IOException e) {
log.error("[mock] Failed to load mock keywords", e);
}
}
}
길어보이긴 하는데, 사실 모킹 관련 메소드가 절반이고, getTrendingKeywordsFromRss 와 saveKeywordMetric 가 핵심 로직이다.
모킹을 Service에 그대로 둔 이유는, 모킹 자체가 외부와 연결 없이 가상의 데이터를 만들어내는 작업이기 때문이다. 이 경우에는 외부의 변경에 따라 로직이 바뀔 일이 없다.
코드를 보면
List<KeywordInfo> trendingKeywordsInfo = trendsAdapter.fetchTrendingKeywords();
여기가 아까부터 줄창 만들던 어댑터와 연결된 모습이다. 인터페이스를 구현한 메소드를 사용하여, 이후에 외부 로직이 변경되도 해당 코드가 변경될 일이 없게 된다.
후기
실제로 헥사고날 아키텍처를 만들면서 느낀 점은
‘아 확실히 제대로 구현했을 때 외부 API를 관리하기는 굉장히 수월하겠구나’ 였다.
애초에 어렵기로 유명한 아키텍처다 보니 이번 프로젝트에서도 완벽 구현도 아닌 야매로 흉내를 내는 수준에 불과했겠지만.. 이렇게라도 구현이 되니 확실히 이전 코드보다 응집도가 오르고 결합도가 낮아지는 헥사고날의 목적을 어느 정도 수행한 것이 보인다.
'kkokkio - 프로젝트 > 트러블슈팅' 카테고리의 다른 글
Prometheus & Loki 기반 모니터링 시스템 구축기 (1) | 2025.06.16 |
---|---|
Test Code Redis Connection Error (0) | 2025.06.16 |
JwtAutheticationFilter에서 custom exception을 잡지 못하는 이유 (0) | 2025.06.16 |
Spring Batch 도입기 (0) | 2025.06.16 |
로그인시 쿠키가 붙지 않는 오류 해결 (1) | 2025.06.16 |