블로그

EDN+ 서버 재개발기 — Spring MVC로 다시 짓고, 당근마켓에 광고를 서빙하기까지

Gmarket의 외부 광고 네트워크 EDN+ 서버를 Node.js 레거시에서 Spring MVC(서블릿 컨테이너) 기반으로 재개발하고, 당근마켓·다나와 광고 지면에 상품+광고 데이터를 통합 서빙한 과정을 공유합니다.

Spring MVCRedis로컬캐시광고시스템Gmarket당근마켓

참고: 이 글의 코드는 실제 업무에서 경험한 내용을 바탕으로 개념적으로 재작성한 것입니다. 실제 회사 코드와는 무관합니다.

EDN+란 무엇인가

EDN+(Ebay Display Network AD)는 Gmarket의 외부 디스플레이 광고 네트워크입니다. Google GDN, Criteo처럼 Gmarket CPC 광고를 당근마켓·다나와 같은 외부 매체에 노출시키는 상품입니다.

EDN+ API는 외부 매체가 광고 요청을 보내오면, 광고 데이터와 상품 데이터를 조합해 응답을 반환합니다. 당근마켓 앱에 보이는 배너 광고가 이 API를 통해 서빙됩니다.


왜 재개발했나

기존 EDN+ 서버는 Node.js로 구성되어 있었습니다. AdTech팀이 Spring 기반으로 기술 스택을 통일하는 흐름에서 EDN+도 같이 이관하게 되었습니다.

재개발 방향은 심플했습니다. 기존 Node.js 서버가 하던 동작을 Spring MVC(서블릿 컨테이너)로 그대로 래핑하되, 외부 매체 서빙 API의 성능을 개선하는 것이었습니다.


시스템 구조

par [광고 조회] [상품/미니샵 조회] 광고 영역 로드 광고 요청 (user_id, placement) CPC 광고 데이터 조회 광고 정보 미니샵 정보 조회 캐시 히트 (또는 내부 API 호출) 상품 API 조회 (max 60ms timeout) 상품 정보 광고 + 상품 + 미니샵 데이터 병합 광고 응답 배너 광고 노출 당근마켓 앱 사용자 당근마켓 서버 EDN+ API (Spring MVC) Redis (광고 데이터) 내부 상품 API 로컬 캐시

광고 서빙 로직: CPC 고정, Redis에서 조회

EDN+가 서빙하는 광고는 CPC 광고로 고정되어 있었습니다. 복잡한 타겟팅 선택 엔진이 필요하지 않았고, 이미 선정된 광고 데이터를 Redis에서 조회해 반환하는 구조였습니다.

@RestController
@RequestMapping("/api/v1/edn")
public class EdnAdController {

    @GetMapping("/ads")
    public AdResponse getAd(
        @RequestParam String placementCode,
        @RequestParam(required = false) String userId
    ) {
        // Redis에서 CPC 광고 데이터 조회
        AdData ad = adRedisRepository.findByPlacement(placementCode);
        if (ad == null) {
            return AdResponse.empty();
        }

        // 상품 + 미니샵 데이터 조합
        ProductData product = productService.getProductData(ad.getProductId());

        return AdResponse.of(ad, product);
    }
}

당근마켓 연동에서 만난 문제: 상품 API 지연

당근마켓은 광고 요청에 대해 최대 200ms 내 응답을 요구했습니다. 광고 데이터는 Redis에서 빠르게 나오는데, 문제는 내부 상품 API였습니다.

당근마켓은 광고 데이터 외에 미니샵(판매자 정보) 데이터도 함께 요구했습니다. 내부 상품 API가 이 데이터를 포함해 응답하는데, p99 기준으로 100ms 이상 걸리는 경우가 빈번했습니다. 200ms 안에 응답하려면 EDN+ 자체 처리 시간을 포함해 상품 API는 최대 60ms 안에 끝나야 했습니다.

해결 1: 상품 API 타임아웃 제한

상품 API 호출에 60ms 타임아웃을 설정했습니다.

@Service
public class ProductService {

    private static final Duration PRODUCT_API_TIMEOUT = Duration.ofMillis(60);

    public ProductData getProductData(String productId) {
        try {
            return productApiClient.get()
                .uri("/products/{id}", productId)
                .retrieve()
                .bodyToMono(ProductData.class)
                .timeout(PRODUCT_API_TIMEOUT)
                .block();
        } catch (TimeoutException e) {
            log.warn("Product API timeout for productId: {}", productId);
            return ProductData.empty(); // 타임아웃 시 빈 데이터 반환
        }
    }
}

해결 2: 로컬 캐시로 정적 데이터 성능 개선

미니샵 정보나 기본 메타 정보는 자주 변경되지 않습니다. 이런 데이터를 매 요청마다 내부 API로 조회하는 것은 낭비입니다.

Caffeine 기반 로컬 인메모리 캐시를 적용했습니다.

@Configuration
@EnableCaching
public class CacheConfig {

    @Bean
    public CacheManager cacheManager() {
        CaffeineCacheManager manager = new CaffeineCacheManager();
        manager.setCaffeine(Caffeine.newBuilder()
            .expireAfterWrite(5, TimeUnit.MINUTES)  // 5분 TTL
            .maximumSize(1000)
        );
        return manager;
    }
}

@Service
public class MiniShopService {

    @Cacheable(value = "miniShop", key = "#shopId")
    public MiniShopData getMiniShopInfo(String shopId) {
        return miniShopApiClient.get(shopId); // 캐시 미스 시에만 API 호출
    }
}

결과

  • Node.js 레거시 → Spring MVC 신규 서버 전환 완료
  • 당근마켓·다나와 광고 지면 통합 서빙 API 안정적 운영
  • 로컬 캐시 적용으로 내부 API 의존 감소, 응답 시간 안정화

마치며

이 프로젝트에서 배운 핵심은 외부 파트너 SLA를 역산해 내부 의존성의 타임아웃을 설계하는 것이었습니다. 당근마켓이 요구하는 200ms에서 시작해서, 내 서버 처리 시간을 빼면 상품 API에 허용 가능한 시간이 자연스럽게 정해집니다. 그 시간 안에 내부 시스템이 응답하지 못한다면, 로컬 캐시나 타임아웃 처리로 보완하는 것이 현실적인 접근입니다.