RestTemplate과 Transaction 사용 시 주의사항 및 캐싱 문제 해결 방법

궁금하면 500원·2024년 5월 25일

미생의 스프링

목록 보기
1/48

최근 RestTemplate과 관련하여 여러 가지 문제와 해결 방법을 배워서
포스팅으로 정리해보았습니다.

특히, 트랜잭션과 캐싱을 사용할 때 주의할 점과 해결 방법에 대해 다루고자 합니다.

목차

- 예시 설명

- RestTemplate에 Timeout을 적용하지 않은 경우의 시나리오와 문제점

- RestTemplate에 Timeout 설정하기

- RestTemplate을 캐싱과 함께 사용할 때의 고민과 해결방법

- 결론 및 느낀점

1. 예시 설명

먼저, RestTemplate을 사용하는 간단한 예시를 설명하겠습니다.

이 예시에서는 단일 서버 환경에서 Java와 Spring Framework를 이용하여
크롤링을 수행하고 캐싱을 활용하는 구조를 가지고 있습니다.

데이터베이스로는 MySQL을 사용하고, 캐싱은 ConcurrentHashMap을 이용했습니다.

클라이언트가 음식 정보를 크롤링할 때, 캐싱에 데이터가 없다면 RestTemplate을 이용하여
크롤링을 수행하고, 크롤링이 완료된 후 데이터베이스와 캐시에 저장합니다.

2. RestTemplate에 Timeout을 적용하지 않은 경우 시나리오와 문제점

RestTemplate을 사용할 때 타임아웃을 설정하지 않으면, 크롤링 요청이 지연될 경우 트랜잭션이 길어질 수 있습니다. 이로 인해 다음과 같은 문제들이 발생할 수 있습니다:

데드락 발생: 트랜잭션이 길어지면 데이터베이스의 잠금이 길어져 데드락이 발생할 수 있습니다.

DB 커넥션 부족: 많은 트랜잭션이 동시에 길어질 경우, DB 커넥션이 부족해질 수 있습니다.

성능 저하: 피크 타임에 많은 사용자가 크롤링을 요청하면 성능 저하가 발생할 수 있습니다.

이 문제를 해결하기 위해 RestTemplate에 타임아웃을 설정하는 것이 중요합니다.

package com.ex.food.infrastructure;

import com.ex.food.application.FoodRequester;
import com.ex.food.application.dto.FoodsResponse;
import com.ex.food.domain.Food;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.util.UriComponentsBuilder;

import java.net.URI;
import java.util.List;

@Slf4j
@RequiredArgsConstructor
@Component
public class FoodRequesterImpl implements FoodRequester {

    private static final String URL = "https://example.com";

    private final RestTemplate restTemplate;

    @Override
    public List<Food> requestFoods(final Long memberId) {
        try {
            return request(memberId)
                    .foods();
        } catch (final Exception e) {
            log.error("크롤링 실패");
            throw new RuntimeException(e.getMessage());
        }
    }

    private FoodsResponse request(final Long memberId) {
        URI uri = getRequestUrl();
        HttpHeaders headers = getHttpHeaders();

        ResponseEntity<FoodsResponse> response = restTemplate.exchange(
                uri, HttpMethod.POST,
                new HttpEntity<>(memberId, headers),
                FoodsResponse.class
        );

        return response.getBody();
    }

    private URI getRequestUrl() {
        return UriComponentsBuilder.fromUriString(URL)
                .build()
                .toUri();
    }

    private HttpHeaders getHttpHeaders() {
        HttpHeaders headers = new HttpHeaders();
        headers.setContentType(MediaType.APPLICATION_JSON);
        return headers;
    }
}
package com.ex.food.application;

import com.ex.food.domain.Food;
import com.ex.food.domain.FoodRepository;
import com.ex.food.domain.Foods;
import com.ex.food.domain.cache.FoodCache;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;

@RequiredArgsConstructor
@Service
public class FoodService {

    private final FoodRequester foodRequester;
    private final FoodRepository foodRepository;

    @Transactional
    public void scrapFoods(final Long memberId) {
        if (!FoodCache.containsByMemberId(memberId)) {
            List<Food> foundFoods = foodRequester.requestFoods(memberId);
            Foods foods = new Foods(foodRepository.saveAll(foundFoods));
            FoodCache.cacheFoods(memberId, foods);
        }
    }
}

데이터베이스에 저장하기 위해 @Transactional 어노테이션이 붙어있음을 주의해주세요.
예시대로 코드는 캐싱 데이터가 없다면 크롤링을 시도하고, 데이터베이스에 저장하게 됩니다.

여기서 우리가 RestTemplate에 Timeout 설정을 따로 해주지 않게 된다면 어떤 일이 일어날까요?

먼저 크롤링을 하는 RestTemplate에 응답 시간이 지연된다면, Transactional 어노테이션이 붙은 메서드는 크롤링이 지연된 시간만큼 MySQL 커넥션을 물고있게 됩니다.

Transactional 어노테이션은 위에 이미지처럼 timeout 기본 값은 -1이고, 설정하지 않았다면 무한정 트랜잭션이 길어질 수 있음을 의미합니다.

결국 이 Transactional이 붙은 메서드 범위에서 RestTemplate의 응답 시간이 길어진다면 트랜잭션의 길이도 길어질 수 있다는 뜻입니다.

트랜잭션이 길어진다면 상황에 따라 여러 문제가 생길 수 있는데 데드락이 생긴다거나, DB 커넥션 부족으로 이어질 수 있습니다.

피크 타임에 무수히 많은 사용자가 크롤링 API를 사용하는데, 크롤링하는 서버에 문제가 생겨 응답이 엄청나게 길어진다면 요청마다 트랜잭션이 끝나지 않아 MySQL 커넥션을 계속 가지고 있게 되고 이는 결국 성능에 문제가 생길 수 있다는 것을 의미합니다.

이런 문제를 해결하기 위해서 RestTemplate에 우리는 timeout 설정을 해줘야합니다.

(혹은 @Transactional 어노테이션의 timeout을 설정해줘도 되는데 개인적으로는 메서드 범위에 붙은 트랜잭션 설정 보다는 사용부인 RestTemplate의 설정을 건드는 것이 크롤링에 대한 타임아웃 설정이라 생각해서 이는 생략하도록 하겠습니다.)

3. RestTemplate에 Timeout 설정하기

RestTemplate의 타임아웃을 설정하는 방법은 간단합니다.

아래와 같이 RestTemplateBuilder를 이용하여 설정할 수 있습니다.

@Configuration
public class RestTemplateConfig {

    private static final int CONNECT_TIMEOUT = 10; // seconds
    private static final int READ_TIMEOUT = 10; // seconds

    @Bean
    public RestTemplate restTemplate(RestTemplateBuilder builder) {
        return builder
                .setConnectTimeout(Duration.ofSeconds(CONNECT_TIMEOUT))
                .setReadTimeout(Duration.ofSeconds(READ_TIMEOUT))
                .build();
    }
}

이렇게 설정하면 크롤링 서버의 응답 시간이 지연되더라도 지정한 시간 이후에 타임아웃이 발생하고, 트랜잭션이 롤백됩니다.

4. RestTemplate을 캐싱과 함께 사용할 때 했던 고민과 해결방법

캐싱과 관련하여 여러 고민을 했습니다

@RequiredArgsConstructor
@Service
public class FoodService {

    private final FoodRequester foodRequester;
    private final FoodRepository foodRepository;

    @Transactional
    public void scrapFoods(final Long memberId) {
        if (!FoodCache.containsByMemberId(memberId)) {
            List<Food> foundFoods = foodRequester.requestFoods(memberId);
            Foods foods = new Foods(foodRepository.saveAll(foundFoods));
            FoodCache.cacheFoods(memberId, foods);
        }
    }
}

1.비동기 처리와 캐싱 사이즈 문제

* 비동기로 처리할 때 캐시를 활용하여 중복 요청을 방지할 수 있습니다.
* 해결 방법: ConcurrentHashMap을 사용하여 캐싱을 구현하고, 캐시의 사이즈를 제한하거나
주기적으로 초기화하여 메모리 과사용 문제를 방지합니다.

2. 캐싱 실패 시 문제

* 크롤링 실패 시 캐싱된 데이터가 비워지지 않아 사용자가 데이터를 업데이트할 수 없습니다.
* 해결 방법: 캐싱의 TTL(Time-To-Live)을 설정하거나, 중복 요청을 방지하기 위한 별도의
캐싱 구조를 마련합니다.

3. 스케일아웃 후 캐싱 다운 문제

* 글로벌 캐싱이 다운되면 DB에 대한 급격한 조회 요청이 발생할 수 있습니다.
* 해결 방법: 복제 및 페일오버 전략을 적용하여 캐시가 다운되더라도 DB가 급격한 부하를
견딜 수 있도록 합니다.

5. 결론

이 포스트를 작성하면서 RestTemplate과 트랜잭션, 캐싱의 복잡한 상호작용을 이해하게 되었습니다.

문제를 사전에 예방하기 위해 충분한 고민과 기술적 고려가 필요하다는 것을 느꼈습니다. 기술적인 부분뿐만 아니라, 시스템 전반의 성능과 안정성을 높이기 위해서는 다양한 접근과 신중한 설계가 필요합니다.

특히, 실제 운영 환경에서 발생할 수 있는 다양한 문제를 예방하기 위해서는 사전에 충분한 검토와 계획이 필요하다는 점을 깊이 느꼈습니다.

RestTemplate을 사용할 때의 타임아웃 설정과 캐싱의 문제를 미리 고민하고 해결책을 마련하는 것이 얼마나 중요한지 실감했습니다.

기술적인 부분에서는 물론, 시스템의 전체적인 성능과 안정성을 고려해야 함을 깨달았습니다.

혹시 잘못된 부분이나 추가적인 피드백이 있으면 언제든지 알려주세요!

참고자료:
Baeldung - Spring Rest Timeout
Toss Tech - Cache Traffic Tip

profile
에러가 나도 괜찮아 — 그건 내가 배우고 있다는 증거야.

0개의 댓글