본문 바로가기

Programming/Java & Spring 관련 내용 정리

[Spring] 멀티 쓰레드 환경에서 자원이 공유돼 버렸다.

 

이번에는 어떤 상황이 발생했을까?!

 

최근에 특정 데이터를 스크래핑하는 개발을 했다.

 

 

Java + Selenium 을 통해 서버에서 크롬창을 연 뒤 클릭이벤트를 발생시켜 데이터를 스크래핑 한 후,

그 데이터를 Redis에 저장하는 방식이었다.

 

 

AWS SQS (Simple Queue Service) 기술도 사용했다.


"C"라는 서버에서는 SQS 메세지를 Publishing 하는 역할을 하고

=> "D"라는 스크래핑 서버에서는 AWS SQS를 구독하고 있다가 메세지가 생기면 수신하여 처리하는 방식이다.

 

 

 

이때 "D"라는 스크래핑 서버에는 아래와 같은 서비스가 존재한다.

 

(1) SqsService (큐 서비스)

(2) ScraperService (스크래퍼 서비스)

 

 

(1) SqsService(큐 서비스)는 

SQS 큐로부터 메시지를 수신하여 처리하는 로직을 담당하고,

ScraperService를 사용하여 메시지를 처리한다.

 

 

(2) ScraperService(스크래퍼 서비스)는 

크롬을 통해 접속한 웹페이지 내 사용자 데이터 및

다운로드 받은 PDF에서 데이터를 스크래핑하는 로직을 담당한다.

 

 


 

먼저 전체 코드를 보자면 아래와 같다.

 

 

A-1 코드 ( 큐 서비스 :  변경 전)

@Slf4j
@RequiredArgsConstructor
@SqsBinder
public class SqsService {

    // 이 부분을 주목 (1)
    private final ScraperService scraperService;

    @SqsConsumer(value             = "${app.sqs.queue.name}",
                 concurrentString  = "${app.sqs.queue.concurrent:1}",
                 waitSecondsString = "${app.sqs.queue.waitSeconds:10}")
    public void requestScrap(SqsPublishRequest sqsPublishRequest) throws Exception {
        
        // 이 부분을 주목 (2)
        scraperService.scrap(sqsPublishRequest);
    }
}

 

A-2 코드 ( 큐 서비스 : 변경 후)

@Slf4j
@RequiredArgsConstructor
@SqsBinder
public class SqsService {

    // 이 부분을 주목 (1)
    private final ApplicationContext applicationContext;

    @SqsConsumer(value             = "${app.sqs.queue.name}",
                 concurrentString  = "${app.sqs.queue.concurrent:1}",
                 waitSecondsString = "${app.sqs.queue.waitSeconds:10}")
    public void requestScrap(SqsPublishRequest sqsPublishRequest) {

	// 이 부분을 주목 (2)
        ScraperService scraperService = applicationContext.getBean(ScraperService.class);
        scraperService.scrap(sqsPublishRequest);
    }
}

 

 

B 코드 ( 스크래퍼 서비스 )

@Slf4j
@RequiredArgsConstructor
@Service
@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE) // 이 부분을 주목
public class ScraperService {

	// ..... 스크래핑 코드
    
}

 

 


[ 😎 포인트 1 ]

B 코드 (스크래퍼 서비스)의 @Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE) 어노테이션

 

@Slf4j
@RequiredArgsConstructor
@Service
@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE) // 이 부분을 주목
public class ScraperService {

	// ..... 스크래핑 코드
    
}

 

 

 

예를들어 '제니'라는 사용자와 '지수'라는 사용자가 있다.

 

'제니'의 정보를 이용해 특정 사이트의 스크래핑을 먼저 시작한 후,

바로 이어서 '지수'라는 정보를 이용해 스크래핑을 한다는 상황이 있다고 가정해 보겠다.

 

이 둘의 정보는 다르기 때문에, 스크래핑 해온 데이터를 처리할 때 둘의 데이터가 섞여서는 안 된다.

(= 코드적으로 같은 자원을 사용하면 안된다.)

 

이는 '멀티쓰레드 환경에서 자원이 공유되면 안된다.' 는 의미다.

 

 

이 부분을 테스트하기 위해

'제니'와 '지수'의 정보를 가지고 거의 동시에 스크래핑을 시작하는 SQS 메세지를 발행하고 

로그를 확인해 보았다.

 

 

 

스크래핑을 담당하는 ScraperService에 

@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE) 어노테이션을 썼기 때문에

자원이 공유되지 않을 줄 알았다.

 

먼저 시작한 '제니'는 '제니'의 정보대로 스크래핑 되길 원했고,

'지수'는 '지수'의 정보대로 스크래핑 되길 원했다.

 

 

 

한 명 씩 끊어서 스크래핑 할 때는 이상없이 스크래핑에 성공 했는데....

두 명의 정보를 거의 동시에 스크래핑 시작하니

우려하던 상황이 발생했다.....!

 

먼저 시작한 ' 제니'의 정보가 결국 무시되고 '지수'의 스크래핑 정보로 덮어씌워지는...? 상황이 발생했다.

(그러다가 결국 스크래핑은 둘 다 최종 실패를 하게되고 마는데........)

 

 

 

 

[ 😎 포인트 2 ]

 

위 상황을 어떻게 해결했을까?

 

결국 포인트는 '자원이 공유되고 있다.'라는 것이었다.

 

그래서 A-1 코드에서 A-2 코드로 변경해서 최종적으로 해결을 했다.

 

A-1 코드 ( SqsService :  변경 전)

@Slf4j
@RequiredArgsConstructor
@SqsBinder
public class SqsService {

    // 이 부분을 주목 (1)
    private final ScraperService scraperService;

    @SqsConsumer(value             = "${app.sqs.queue.name}",
                 concurrentString  = "${app.sqs.queue.concurrent:1}",
                 waitSecondsString = "${app.sqs.queue.waitSeconds:10}")
    public void requestScrap(SqsPublishRequest sqsPublishRequest) throws Exception {
        
        // 이 부분을 주목 (2)
        scraperService.scrap(sqsPublishRequest);
    }
}

 

A-2 코드 (SqsService : 변경 후)

@Slf4j
@RequiredArgsConstructor
@SqsBinder
public class SqsService {

    // 이 부분을 주목 (1)
    private final ApplicationContext applicationContext;

    @SqsConsumer(value             = "${app.sqs.queue.name}",
                 concurrentString  = "${app.sqs.queue.concurrent:1}",
                 waitSecondsString = "${app.sqs.queue.waitSeconds:10}")
    public void requestScrap(SqsPublishRequest sqsPublishRequest) {

	// 이 부분을 주목 (2)
        ScraperService scraperService = applicationContext.getBean(ScraperService.class);
        scraperService.scrap(sqsPublishRequest);
    }
}

 

 

 


 

위 코드에 대해 챗지피티에게 문의한 내용에 대한 답변이

개인적으로 정리가 명확하게 되서 가져와 봤다. 

 

 

🔑💻 챗 지피티 왈 💻🔑

 

B 코드에서 @Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE)를 사용하여

ScraperService를 프로토타입 스코프로 설정했을 때,

멀티스레드 환경에서 자원이 공유되지 않는 것은 기대할 수 있습니다.



그러나 A-1 (변경 전) 코드에서 SqsService에서 ScraperService를 필드 주입하여 사용할 경우,

스프링은 ScraperService를 싱글톤으로 관리하고 있기 때문에

멀티스레드 환경에서 동일한 ScraperService 인스턴스가 공유되어 사용됩니다.

이는 프로토타입 스코프의 특성을 무시하고, 멀티스레드 환경에서의 자원 공유 문제가 발생할 수 있습니다.

 


A-2 (변경 후) 코드에서는 ApplicationContext를 통해 ScraperService를 매번 새로운 인스턴스로 가져와서 사용하므로,

프로토타입 스코프의 특성을 제대로 활용할 수 있습니다.

매번 새로운 인스턴스를 가져오기 때문에 멀티스레드 환경에서 동일한 ScraperService 인스턴스가 공유되지 않으며,

각 스레드에서 독립적인 ScraperService 인스턴스를 사용할 수 있게 됩니다.

이를 통해 멀티스레드 환경에서 자원을 안전하게 공유하지 않을 수 있습니다.



따라서, 멀티스레드 환경에서 프로토타입 스코프를 사용하려면 A-2 (변경 후) 코드와 같이

ApplicationContext를 통해 매번 새로운 인스턴스를 가져와서 사용하는 방법을 적용해야 합니다.

이를 통해 각 스레드에서 독립적인 인스턴스를 사용하여 자원 공유 문제를 회피할 수 있습니다.

 

 


 

🪓챗 지피티에게 ( 추가 질문 ) 🪓

@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE)를 사용하여

 ScraperService를 프로토타입 스코프로 설정해도 
 ScraperService를 필드 주입하여 사용하면 싱글톤으로 바뀌는거야?

 

 

🪓( 답변 ) 🪓

정확히 말씀드리면, @Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE)를 사용하여 

ScraperService를 프로토타입 스코프로 설정해도, 

SqsService에서 ScraperService를 필드 주입하여 사용하면 ScraperService는 여전히 싱글톤으로 관리됩니다.

 


스프링의 빈 스코프는 빈을 생성하고 관리하는 방식을 결정하는데 사용됩니다. @Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE)은 프로토타입 스코프를 설정하는 것이지만,

필드 주입을 통해 다른 빈에 주입되는 경우

스프링 컨테이너는 해당 필드에 싱글톤 인스턴스를 주입합니다.

이유는 스프링의 기본 동작 방식 때문입니다. 

스프링은 싱글톤 빈을 생성하고 주입할 때 해당 빈의 인스턴스를 캐싱하여 재사용합니다. 

따라서 필드 주입을 통해 ScraperService를 주입하면 

스프링 컨테이너는 한 번 생성된 ScraperService 인스턴스를 계속해서 재사용하게 됩니다.

 


프로토타입 스코프를 사용하여 매번 새로운 인스턴스를 얻으려면

SqsService에서 ScraperService를 직접 생성하거나, 

ApplicationContext를 통해 매번 새로운 인스턴스를 요청해야 합니다.

 

 예를 들면, A-2(변경 후) 코드에서와 같이 ApplicationContext의 getBean() 메서드를 호출하여 프로토타입 스코프의 ScraperService 인스턴스를 얻을 수 있습니다.

 

 

 

 


 

🎃 결론 

멀티쓰레드 환경에서는 정신을 똑바로 차리자! 

 

 

위 내용을 정리하면서

기존에 작성했던 블로그 글도 다시 한 번 살펴보며 기억을 상기시켰다.

 

블로깅이 이래서 중요하다!

 

 

https://domean.tistory.com/308

 

 

[Spring] 스프링 빈은 멀티쓰레드 환경에서 Thread-safe 할까?

개발 실장님께서 아래 코드에 버그가 있는데, 무엇이 버그인지 알겠냐고 물어보셨다. ( 회사코드를 그대로 가져다 쓸 수 없어 예시를 들기 위해 클래스명, 변수명 등을 조금 변경했다. ) 아래 코

domean.tistory.com