[내배캠_단기 게임 서버 개발 부트캠프] Spring 숙련 정리 - Bean, RestClient

오수호·2026년 9월 14일

TIL

목록 보기
72/75

이번 주는 Spring 숙련 파트에서 Bean 생명주기/스코프, AOP, 프로퍼티 바인딩, Filter, Interceptor, 스케줄링, RestClient까지 7개 주제를 배웠다. 따로 보면 별개 같지만 정리하다 보니 상당수가 "공통 로직을 핵심 비즈니스 로직에서 어떻게 분리하고, 요청 처리 흐름의 어느 시점에 끼워 넣을 것인가"라는 하나의 질문으로 이어진다는 걸 느꼈다.


1. Bean 생명주기와 스코프

빈 생명주기는 컨테이너가 빈을 만들고, 의존성을 넣고, 초기화하고, 종료 시 정리하는 정해진 순서다. 사람을 뽑아서 → 장비를 주고 → 교육시키고 → 퇴사 처리하는 절차와 같다.

생성 → 주입 → 초기화 → 소멸

  • 생성: 생성자 호출. 이 시점엔 아직 아무 초기화도 안 됨
  • 주입: 생성자 주입이면 생성과 동시에 끝남
  • 초기화: @PostConstruct 실행. 모든 의존성이 준비된 뒤
  • 소멸: 컨테이너 종료 시 @PreDestroy 실행
@Service
@RequiredArgsConstructor
public class BadWordFilter {

    private final PostProperties properties;
    private Set<String> words;

    @PostConstruct
    void prepare() {
        words = properties.getBadWords().stream()
                .map(w -> w.strip().toLowerCase(Locale.ROOT))
                .filter(w -> !w.isBlank())
                .collect(Collectors.toUnmodifiableSet());

        if (words.isEmpty()) {
            throw new IllegalStateException("board.post.bad-words 설정이 비어 있습니다.");
        }
    }

    public boolean contains(String content) {
        String lower = content.toLowerCase(Locale.ROOT);
        return words.stream().anyMatch(lower::contains);
    }
}

@PostConstruct에서 할 작업을 생성자에서 하면 안 되는 이유는, 생성자 시점엔 아직 주입이 끝나지 않은 다른 빈을 건드릴 수 있기 때문이다. 초기화 로직은 항상 @PostConstruct에 둔다.

빈 스코프: 빈 하나가 몇 개 만들어지고 얼마나 오래 사는지를 정하는 값.

  • singleton: 기본값. 컨테이너에 하나. @Service, @Repository, @Component, @RestController 모두 기본이 싱글턴이라, 요청이 100개 들어와도 인스턴스는 1개다. 실무에서는 거의 싱글턴만 쓴다.
  • prototype: 사용할 때마다 새 인스턴스를 만듦
  • request / session: HTTP 요청 하나, 또는 세션 하나 동안만 살아있음. 웹 애플리케이션에서만 사용하고, 실무에서 거의 쓸 일이 없다.

여기서 실무에서 은근히 자주 부딪히는 함정이 하나 있는데, 싱글턴 빈 안에 프로토타입 빈을 필드로 그냥 주입하면 문제가 생긴다. 주입은 싱글턴이 생성될 때 딱 한 번만 일어나기 때문에, 이후로는 계속 같은 프로토타입 인스턴스를 재사용하게 된다. "쓸 때마다 새 인스턴스"라는 프로토타입의 취지가 무색해지는 것. 매번 새 인스턴스가 필요하다면 필드 주입 대신 ObjectProvider<T>를 주입받아 getObject()를 호출 시점마다 부르거나, Lookup 메서드 주입을 써야 한다.

순환 참조: A가 B를 필요로 하고 B가 다시 A를 필요로 해서 어느 쪽도 먼저 만들 수 없는 상태.

@Service
@RequiredArgsConstructor
public class MemberService {
    private final PostService postService;
}

@Service
@RequiredArgsConstructor
public class PostService {
    private final MemberService memberService;
}

가장 정석적인 해결법은 둘 다 필요로 하던 로직을 제3의 클래스(MemberReader)로 빼는 것이다.

@Service
@RequiredArgsConstructor
public class MemberReader {
    private final MemberRepository memberRepository;
}

@Service
@RequiredArgsConstructor
public class PostService {
    private final MemberReader memberReader;
}

@Service
@RequiredArgsConstructor
public class MemberService {
    private final MemberReader memberReader;
}

반면 아래 방법들은 근본적인 해결책이 아니다:

  • spring.main.allow-circular-references=true로 덮기 → 필드·세터 주입 순환만 허용될 뿐, 생성자 주입은 그대로 실패해서 결국 생성자 주입을 포기하게 만드는 설정
  • 필드 주입(@Autowired)으로 바꿔 에러만 사라지게 하기 → 운영 코드에는 필드 주입을 쓰지 않는다
  • @Lazy로 한쪽 주입을 지연시키기 → 순환 구조 자체를 없애는 게 아니라 초기화 시점만 미루는 것이라, 설계상의 결합은 그대로 남는다

세 방법 모두 "에러가 안 나게" 만들 뿐이지 "설계가 잘못됐다"는 신호 자체를 없애주진 않는다.


2. AOP

처리 속도가 느린 API를 찾으려고 서비스 메서드마다 시작/종료 시각을 재는 코드를 넣는다고 해보자.

public List<PostResponse> findRecent(int limit) {
    long start = System.nanoTime();
    try {
        return postRepository.findRecent(limit).stream().map(PostResponse::from).toList();
    } finally {
        log.warn("{}ms", (System.nanoTime() - start) / 1_000_000);
    }
}

측정 대상이 20개면 같은 코드가 20번, 200개면 200번 들어간다. 조금만 수정해도 전부 고쳐야 하고, 게시글을 읽어오는 게 메서드의 본질인데 시간 측정 코드가 본문 절반을 차지해 가독성도 나빠진다.

  • AOP = 여러 곳에 반복되는 공통 기능을 핵심 비즈니스 로직과 분리해 적용하는 프로그래밍 방식
  • Aspect = AOP에서 공통 기능과 그 기능이 적용될 대상·시점을 하나로 묶은 모듈

AOP는 개념이고, Aspect는 그 개념의 구현체다.

@Slf4j
@Aspect
@Component
public class ExecutionTimeAspect {

    @Around("execution(public * com.example.board..service..*.*(..))")
    public Object measure(ProceedingJoinPoint joinPoint) throws Throwable {
        long start = System.nanoTime();
        try {
            return joinPoint.proceed();
        } finally {
            long tookMs = (System.nanoTime() - start) / 1_000_000;
            if (tookMs >= 100) {
                log.warn("느린 호출 {} — {}ms", joinPoint.getSignature().toShortString(), tookMs);
            }
        }
    }
}

GET /posts를 호출하면 PostService.findRecent()엔 시간을 재는 코드가 한 줄도 없는데 WARN 로그가 남는다. 측정 기준을 200ms로 바꾸고 싶으면 서비스 20곳이 아니라 ExecutionTimeAspect 한 곳만 고치면 된다.

  • JoinPoint: 부가 기능을 끼워 넣을 수 있는 지점
  • Advice: 그 지점에서 할 일. @Before(전), @After(후), @Around(앞뒤 전부) 등
  • Pointcut: 여러 JoinPoint 중 어디에 적용할지 고르는 식. execution(...)이 그것

커스텀 어노테이션을 만들어서 @Around("@annotation(ExecutionTime)")처럼 지정하면, 원하는 메서드에만 어노테이션을 붙여서 선택적으로 적용할 수도 있다.

여기서 한 가지 실무에서 자주 걸리는 함정은 자기 자신 호출(self-invocation)이 프록시를 우회한다는 것이다. Spring AOP는 기본적으로 프록시 기반이라, @Around가 걸린 메서드를 외부에서 호출할 때만 프록시를 거친다. 같은 클래스 안에서 this.측정대상메서드()처럼 내부 호출을 하면 프록시를 거치지 않고 원본 메서드가 바로 실행되기 때문에 Aspect가 아예 동작하지 않는다. (참고로 인터페이스가 있으면 JDK 동적 프록시, 없으면 CGLIB 기반 서브클래스 프록시를 쓰는데, 최근 스프링 부트는 기본적으로 CGLIB을 우선한다.)


3. 프로퍼티 바인딩

프로퍼티 바인딩은 설정 파일이나 환경 변수에서 읽은 값을 자바 객체의 타입에 맞춰 연결하는 과정이다.

@Value와 기본값: 설정값 하나만 필요할 때 사용. ${설정키:기본값} 형식으로 쓰면 키가 없을 때 콜론 뒤 기본값을 쓴다.

@Service
public class PostService {
    private final int maxPostCount;

    public PostService(@Value("${board.post.max-count:3}") int maxPostCount) {
        this.maxPostCount = maxPostCount;
    }
}

기본값은 설정이 없어도 안전하게 동작할 수 있을 때만 둔다. 반드시 입력받아야 하는 비밀번호나 외부 주소에는 기본값을 두지 않아서, 누락을 애플리케이션 기동 시점에 바로 발견하도록 한다.

@ConfigurationProperties: 같은 주제의 환경 변수를 한꺼번에 묶어서 관리할 때 사용.

@ConfigurationProperties(prefix = "board.post")
@Validated
@Getter
public class PostProperties {

    @Min(1)
    private final int maxCount;
    @Min(1)
    private final int defaultPageSize;
    @Min(1)
    private final int maxPageSize;

    public PostProperties(int maxCount, int defaultPageSize, int maxPageSize) {
        this.maxCount = maxCount;
        this.defaultPageSize = defaultPageSize;
        this.maxPageSize = maxPageSize;
    }
}

@Configuration
@EnableConfigurationProperties(PostProperties.class)
public class BoardConfig {
}

@EnableConfigurationProperties(PostProperties.class)를 쓰면 해당 클래스가 빈으로 등록되고, DI로 바로 주입받아 쓸 수 있다. 참고로 여기선 생성자 바인딩 방식을 썼는데, 최근에는 이런 불변 설정 객체를 아예 Java record로 선언하는 방식도 많이 쓴다. 생성자를 직접 안 써도 되고 getter/불변성이 record 자체에서 보장되기 때문에 더 간결하다.

프로필별 설정: 공통값은 application.properties에, 로컬 전용 값은 application-local.properties에 둔다. SPRING_PROFILES_ACTIVE=local 또는 --spring.profiles.active=local로 활성화한다. 파일만 만들어 놓는다고 프로필이 선택되진 않는다.

프로필로 빈 등록 여부도 정할 수 있다. @Component@Profile("local")을 같이 붙이면 local 프로필이 활성화됐을 때만 등록되고, 이건 실행 환경 이름이 아니라 실제 활성 프로필을 기준으로 판단한다.


4. Filter

Filter는 HTTP 요청이 DispatcherServlet에 도달하기 전과 후에 실행되는 서블릿 표준 기능이다. '전'뿐 아니라 '후'까지 실행된다는 점이 중요하고, 여러 Filter를 순서대로 연결한 걸 Filter Chain이라 부른다. chain.doFilter(request, response) 호출 앞에 코드를 쓰면 전처리, 뒤에 쓰면 후처리가 된다.

@Slf4j
public class RequestLoggingFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(
            HttpServletRequest request,
            HttpServletResponse response,
            FilterChain chain
    ) throws ServletException, IOException {
        long start = System.nanoTime();
        log.info("BEFORE {} {}", request.getMethod(), request.getRequestURI());
        try {
            chain.doFilter(request, response);
        } finally {
            long tookMs = (System.nanoTime() - start) / 1_000_000;
            log.info("AFTER {} {} -> {} ({}ms)", request.getMethod(),
                    request.getRequestURI(), response.getStatus(), tookMs);
        }
    }
}

Filter는 서블릿 표준 인터페이스라 직접 구현해도 되지만, 보통은 OncePerRequestFilter를 extends해서 쓴다. 같은 요청 안에서 필터가 중복 실행되지 않도록 스프링이 관리해주는 편의 클래스이기 때문이다.

등록은 두 가지 방식이 있다.

@Bean
public FilterRegistrationBean<RequestLoggingFilter> requestLoggingFilter() {
    FilterRegistrationBean<RequestLoggingFilter> registration = new FilterRegistrationBean<>();
    registration.setFilter(new RequestLoggingFilter());
    registration.addUrlPatterns("/posts", "/posts/*");
    registration.setOrder(1);
    return registration;
}

addUrlPatterns로 적용 범위를 좁힐 수 있고 setOrder로 여러 Filter 사이의 순서를 지정한다. 반면 @Component + @Order를 쓰면 FilterRegistrationBean 없이 등록할 수 있지만, 이 경우 기본적으로 모든 API에 전역 적용되므로 범위를 좁히고 싶다면 첫 번째 방식이 낫다.

여기서 중요한 건, Filter는 서블릿 컨테이너 레벨에서 동작하기 때문에 DispatcherServlet이 어떤 컨트롤러 메서드를 호출할지 정하기 전에 실행된다는 점이다. 그래서 Filter 안에서는 @PathVariable이나 핸들러 메서드 정보처럼 스프링 MVC가 파악한 정보에 접근할 수 없다. 이 지점의 차이가 바로 다음 Interceptor와 갈리는 지점이다.


5. Interceptor

HandlerInterceptor는 컨트롤러 실행 전후에 공통 처리를 넣는 Spring MVC 인터페이스다. DispatcherServlet이 요청을 처리할 핸들러(컨트롤러 메서드)를 이미 선택한 뒤에 실행되기 때문에, Filter와 달리 컨트롤러 메서드 정보를 확인할 수 있다.

  • preHandle = 컨트롤러 전
  • postHandle = 컨트롤러 후
  • afterCompletion = 요청 처리 완전 종료 후
@Slf4j
@Component
public class RequestLoggingInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
        log.info("preHandle: {}", request.getRequestURI());
        return true;
    }

    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response,
                            Object handler, ModelAndView modelAndView) {
        log.info("postHandle: {} -> {}", request.getRequestURI(), response.getStatus());
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response,
                                 Object handler, Exception ex) {
        log.info("afterCompletion: {} -> {}", request.getRequestURI(), response.getStatus());
    }
}

preHandletrue를 반환하면 다음 인터셉터나 컨트롤러로 계속 진행하고, false면 컨트롤러 로직을 실행하지 않고 멈춘다. 등록은 Filter와 마찬가지로 별도로 해줘야 한다.

@Configuration
@RequiredArgsConstructor
public class WebConfig implements WebMvcConfigurer {

    private final RequestLoggingInterceptor requestLoggingInterceptor;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(requestLoggingInterceptor)
                .addPathPatterns("/posts/**")
                .excludePathPatterns("/posts/public/**");
    }
}

한 가지 놓치기 쉬운 부분은, 컨트롤러에서 예외가 터지면 postHandle은 건너뛰지만 afterCompletion은 예외 발생 여부와 상관없이 항상 실행된다는 점이다. 그래서 리소스 정리처럼 "무조건 실행돼야 하는" 로직은 postHandle이 아니라 afterCompletion에 둬야 한다.

Filter와 헷갈리기 쉬운데, 실행 위치와 접근 가능한 정보 기준으로 정리하면 이렇다.

구분FilterInterceptor
실행 위치서블릿 컨테이너 (DispatcherServlet 전/후)Spring MVC 내부 (핸들러 선택 후)
접근 가능 정보HttpServletRequest/Response 수준핸들러(컨트롤러 메서드) 정보까지
등록 방식FilterRegistrationBean 또는 @Component+@OrderWebMvcConfigurer.addInterceptors
주 용도인코딩, CORS처럼 스프링과 무관한 저수준 처리인증/인가처럼 컨트롤러 정보가 필요한 처리

6. 스케줄링

정해진 간격이나 시각에 자동으로 로직을 실행하는 기능. 매일 자정에 랭킹을 집계하는 경우 등에 쓸 수 있다.

@Configuration
@EnableScheduling
public class SchedulingConfig {
}
// 매 5초마다
@Scheduled(cron = "*/5 * * * * *")
public void task() { }

// 매일 오전 9시
@Scheduled(cron = "0 0 9 * * *")
public void task() { }

// 평일 오전 9시
@Scheduled(cron = "0 0 9 * * MON-FRI")
public void task() { }

cron 표현식 외에도 fixedRate(이전 실행 시작 시각 기준 일정 간격)와 fixedDelay(이전 실행 종료 시각 기준 일정 간격)로도 지정할 수 있다.

여기서 실무에서 잘 모르고 지나가기 쉬운 부분: @Scheduled기본적으로 스레드 하나짜리 스케줄러로 동작한다. 즉 @Scheduled가 붙은 메서드가 여러 개거나, 하나의 작업이 오래 걸리면 다른 스케줄 작업이 그만큼 밀려서 실행된다. 여러 스케줄 작업을 병렬로 돌리고 싶다면 TaskScheduler 빈을 스레드 풀 크기와 함께 직접 등록해줘야 한다. 당장 문서를 외울 필요는 없지만, "왜 두 번째 스케줄 작업이 늦게 도는지" 디버깅할 때 이 기본 동작을 모르면 한참 헤맬 수 있는 부분이다.


7. RestClient

RestClient는 HTTP 요청을 보내는 스프링 도구다.

@Slf4j
@Service
public class ExchangeRateClient {

    private final RestClient restClient;

    public ExchangeRateClient(RestClient.Builder builder, @Value("${rates.base-url}") String baseUrl) {
        this.restClient = builder.baseUrl(baseUrl).build();
    }

    public ExchangeRateResponse fetch(String code) {
        log.info("환율 API 호출: {}", code);
        return restClient.get()
                .uri("/rates/{code}.json", code)
                .retrieve()
                .body(ExchangeRateResponse.class);
    }
}

get()은 GET 요청, uri()는 호출 경로를 정한다. 참고로 RestClient는 예전에 널리 쓰이던 RestTemplate을 대체하는 최신 방식이고, RestTemplate은 현재 유지보수 모드(더 이상 신규 기능 추가 없음)라 신규 코드에서는 RestClient를 쓰는 게 맞다.

HTTP 에러 처리: 4xx(요청한 자원이 없거나 요청이 잘못됨)는 HttpClientErrorException, 5xx(외부 서버가 요청을 처리하지 못함)는 HttpServerErrorException으로 던져진다. 존재하지 않는 주소(GET /rates/MISSING)를 호출해서 404를 받으면 HttpClientErrorException이 나는 걸 직접 확인해볼 수 있다.

타임아웃

# 연결 대기 최대 2초
spring.http.clients.connect-timeout=2s
# 연결 후 응답 대기 최대 2초
spring.http.clients.read-timeout=2s

재시도: @EnableResilientMethods를 활성화하고 @Retryable을 붙이면 실패 시 자동 재시도가 가능하다.

@Retryable(
    includes = {HttpServerErrorException.class, ResourceAccessException.class},
    maxRetries = 2,
    delay = 200)
public ExchangeRateResponse fetch(String code) {
    log.info("환율 API 호출: {}", code);
    return restClient.get()
            .uri("/rates/{code}.json", code)
            .retrieve()
            .body(ExchangeRateResponse.class);
}
profile
게임개발자 취준생입니다

0개의 댓글