ThreadLocal은 해당 쓰레드만 접근할 수 있는 특별한 저장소를 말한다. 쉽게 말해서 여러사람이 사용하는 물건 보관 창구를 의미하며 여러사람(여러 쓰레드)이 ThreadLocal이라는 공용 창구를 사용하고 ThreadLocal이 사용자(쓰레드)별로 자원을 구분해준다.
그렇다면 ThreadLocal은 왜 어떤경우에 사용할까? 예를 들어 다음과 같은 상황이 있다고 가정해보자.
thread-A가 먼저 userA라는 변수를 필드에 저장했지만 thread-B가 userB라는 변수를 같은 필드에 저장했더니 userA를 저장했던 변수에 덮어 씌워져 버렸다. 이렇게 되면 thread-A가 자신이 저장했던 userA라는 값을 사용하기 위해 필드를 호출했을 때 userA가 아닌 userB가 호출된다.
위와 같은 문제 상황을 동시성 문제라고 한다. 동시성 문제란 여러 쓰레드가 동시에 같은 자원에 접근하여 값을 변경할 때 발생하는 문제로 트래픽이 적은 상황에서는 잘 발생하지 않고 트래픽이 많아질수록 발생할 확률도 높아진다.
동시성 문제는 지역 변수에서는 쓰레드마다 각각 다른 메모리 영역이 할당되기 때문에 발생하지 않고 static 같은 공용 필드나 하나의 인스턴스를 생성해서 사용하는 싱글톤(인스턴스 필드)에서 자주 발생한다. 또한 값을 변경하지 않고 조회만 하는 경우에는 동시성 문제가 발생하지 않는다.
하지만 ThreadLocal을 사용하면 ThreadLocal이 쓰레드별로 자원들 구분해서 관리해주기 때문에 동시성 문제를 해결해 줄 수 있다.
thread-A가 ThreadLocal의 set() 메서드를 이용해 userA를 저장하면 ThreadLocal이 thread-A 전용 보관소에 userA라는 값을 저장해놓는다.
그런다음 thread-B가 똑같이 set() 메서드를 사용해 userB라는 값을 ThreadLocal에 저장하면 thread-B 전용 보관소에 userB라는 값이 저장된다.
그리고 나서 각 쓰레드가 ThreadLocal의 get() 메서드를 이용해 값을 조회하면 이렇게 쓰레드 별로 값을 따로 저장하기 때문에 자신이 저장했던 원하는 값을 조회해올 수 있게된다.
자바는 언어차원에서 쓰레드 로컬을 지원하기 위해 java.lang.ThreadLocal 클레스를 제공해준다.
ThreadLocal 사용 방법
private ThreadLocal<String> threadLocal = new ThreadLocal<>();
위와 같이 제네릭 타입을 지정해서 ThreadLocal 객체를 생성해 주고 아래 메서드를 사용해 값의 저장, 조회, 삭제를 수행할 수 있다.
값 저장 : ThreadLocal.set()
값 조회 : ThreadLocal.get()
값 제거 : ThreadLocal.remove()
ThreadLocal 예제 코드
ThreadLocalService
nameStore라는 ThreadLocal에 name을 저장하고 nameStore에서 값을 조회하는 메서드
SerlvetFilter는 서블릿 스펙에서 제공하는 서블릿 전체에 공통 기능을 추가할 수 있는 기능으로 DispatcherServlet 앞에서 사용자의 요청과 응답을 처리할 수 있다. ServletFilter는 여러 개도 등록이 가능하며 특정 URI에만 필터 기능을 걸 수 있도록 설정할 수도 있다.
ServletFilter 구현체를 만드려면 javax.servlet.Filter 인터페이스를 구현해야 한다.
public interface Filter {
public default void init(FilterConfig filterConfig) throws ServletException {}
public void doFilter(Servlet Request request,
ServletResponse response,
FilterChain chain) throws IOException, ServletException;
public default void destroy() {}
}
init()
웹 애플리케이션이 시작했을 때 서블릿 필터를 초기화하는 메서드이다. 메서드 인자로 FilterConfig를 받으며, 서블릿 필터를 설정하는 단계에서 설정한 파라미터나 서블릿 필터 이름 등이 포함되어 있다.
doFilter()
ServletFilter의 핵심 기능으로 필터링 역할을 수행한다. ServletRequest, ServletResponse, FilterChain을 인자로 받으며 ServletRequest와 ServletResponse 인자를 사용하여 사용자의 요청 메시지나 응답 메시지의 데이터를 처리하고 FilterChain 인자를 사용해 다음 로직을 계속해서 실행할 수 있다. 만약 FilterChain 클래스의 doFilter() 메서드를 호출했을 때 더이상 수행할 로직이 없다면 DispatcherServlet이 실행된다.
destroy()
웹 애플리케이션이 종료될 때 호출 되는 메서드로 서블릿 필터를 종료한다. 서블릿 필터의 기능을 정리하거나 종료하는 코드를 구현한다.
Interceptor
Interceptor는 스프링 웹 MVC 프레임워크에서 제공하는 기능으로 표준 스펙이 아니라 스프링 프레임워크에서만 사용할 수 있다. 구조상 스프링 프레임워크 내부, 즉 DispatcherServlet 뒤에서 동작한다. 또한 ServletFilter와 마찬가지로 특정 URI에만 Interceptor 기능을 설정할 수 있다.
Interceptor가 동작하는 방식은 가장 먼저 DispatcherServlet이 사용자 요청을 받으면 HandlerMapping 컴포넌트에 질의하여 어떤 컨트롤러 클래스의 메서드에 사용자 요청을 전달할지 찾는다. 그런 다음 사용자 요청에 적합한 Interceptor를 찾아 DispatcherServlet의 응답 객체인 HandlerExecutionChain에 응답하고 HandlerExcecutionChain에 등록된 Interceptor를 모두 거치게 되면 Controller가 실행된다.
preHandle() 메서드는 DispatcherServlet이 컨트롤러의 메서드를 실행하기 전에 실행되는 메서드로 인자로 받는 HttpServletRequest와 HttpServletResponse를 사용하여 사용자의 요청, 응답 메시지의 데이터를 처리할 수 있다. Object 인자는 컨트롤러 클래스의 핸들러 메서드를 참조하는 HandlerMethod 객체로 메서드 정보를 참조 할 수 있다.
postHandle()
postHandle() 메서드는 컨트롤러의 메서드가 비즈니스 로직을 실행 완료한 후 실행된다. 전달 받는 인자의 역할은 preHandle() 메소드와 같으며 추가로 ModelAndView 객체가 인자로 선언되어 있기 때문에 ModelAndView에 포함된 데이터를 참조할 수 있다.
afterCompletion()
afterCompletion() 메서드는 뷰가 실행 완료된 후 DispatcherServlet이 사용자에게 응답하기 직전에 실행되는 메서드로 ModelAndView 객체 대신 Exception 객체를 인자로 받기 때문에 이 에러 객체를 사용하여 예외 처리를 할 수 있다.
인터셉터(Interceptor)와 AOP의 비교
인터셉터와 AOP는 둘다 공통 기능을 분리하여 특정 클래스나 메소드를 호출할 때 부가 기능을 실행할 수 있지만 다음과 같은 이유로 컨트롤러의 호출 과정에서는 인터셉터를 사용하는 편이 낫다.
컨트롤러는 타입과 실행 메소드가 모두 제각각이라 포인트컷(적용할 메소드 선별)의 작성이 어렵다.
컨트롤러는 파라미터나 리턴 값이 일정하지 않다.
Filter와 Interceptor의 차이와 용도
Filter와 Interceptor의 차이
위 표를 보면서 차이점을 설명해 보면 먼저 필터의 경우 스프링 범위 밖에서 처리 되기 때문에 웹 컨테이너에 의해서 관리되고 인터셉터는 스프링 안에서 실행되므로 스프링 컨테이너에 의해서 관리된다.
두 번째는 필터는 Request/Response 객체 조작이 가능하지만 인터셉터는 불가능하다.
public class MyFilter implements Filter {
@Override
public void doFilter(ServletRequest request,
ServletResponse response,
FilterChain chain) throws IOException, ServletException {
chain.doFilter(request, response);
}
}
위 코드를 보면 필터는 다음 필터를 호출하기 위해 FilterChain의 doFilter() 메서드를 호출할 때 request와 response 객체를 넘겨주기 때문에 우리가 원하는 request와 response 객체를 넘겨줄 수 있다.
public class MyInterceptor implements HandlerInterceptor {
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler) throws Exception {
return true;
}
}
하지만 인터셉터는 DispatcherServlet의 응답객체인 HandlerExecutionChain에 인터셉터가 등록되어 있고 순차적으로 실행되기 때문에 위 코드와 같이 request와 response를 담아서 넘겨줄 수 없다.
Filter와 Interceptor의 용도
Filter의 용도
공통된 보안 및 인증/인가 관련 작업
모든 요청에 대한 로깅 또는 감사
이미지/데이터 압축 및 문자열 인코딩
Spring과 분리되어야 하는 기능
필터에서는 보통 스프링과는 무관하게 전역적으로 처리해야 하는 작업들을 처리할 수 있다. 또한 DispatcherServlet 앞 단에서 가장 먼저 사용자 요청을 받기 때문에 보안 검사를 수행할 수 있다.
프로젝트를 진행할 때 SpringSecurity와 JWT Token 기술이 적용된 스켈레톤 코드를 받아서 진행하는데 늘 사용하던 @AuthenticationPrincipal 어노테이션을 사용하니 UserDetails 인터페이스를 구현한 클래스가 계속 null 값이 떴다.
문제원인
사용자 요청이 들어오면 AuthenticationFilter가 요청을 가로챈 후 getAuthentication() 메서드로 UsernamePasswordAuthenticationToken 객체를 생성하는데 이때 토큰의 파라미터로 String 값인 userId를 넣어준 것이 원인이었다.
/**
* 요청 헤더에 jwt 토큰이 있는 경우, 토큰 검증 및 인증 처리 로직 정의.
*/
public class JwtAuthenticationFilter extends BasicAuthenticationFilter {
...
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
...
try {
Authentication authentication = getAuthentication(request);
// jwt 토큰으로 부터 획득한 인증 정보(authentication) 설정.
SecurityContextHolder.getContext().setAuthentication(authentication);
}
...
}
@Transactional(readOnly = true)
public Authentication getAuthentication(HttpServletRequest request) throws Exception {
...
// 식별된 정상 유저인 경우, 요청 context 내에서 참조 가능한 인증 정보(jwtAuthentication) 생성.
SsafyUserDetails userDetails = new SsafyUserDetails(user);
UsernamePasswordAuthenticationToken jwtAuthentication = new UsernamePasswordAuthenticationToken(userId,
null, userDetails.getAuthorities());
...
}
}
해결방법
아래와 같이 첫번째 파라미터인 userId를 UserDetails 인터페이스를 구현한 클래스로 바꿔주었더니 UserDetails 구현클래스가 잘 넘어온 것을 확인할 수 있었다.
@Transactional(readOnly = true)
public Authentication getAuthentication(HttpServletRequest request) throws Exception {
...
// 식별된 정상 유저인 경우, 요청 context 내에서 참조 가능한 인증 정보(jwtAuthentication) 생성.
SsafyUserDetails userDetails = new SsafyUserDetails(user);
UsernamePasswordAuthenticationToken jwtAuthentication = new UsernamePasswordAuthenticationToken(userDetails,
null, userDetails.getAuthorities());
...
}
@Entity
@NoArgsConstructor
@Getter
public class Stock {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private Long productId;
private Long quantity;
public Stock(Long productId, Long quantity) {
this.productId = productId;
this.quantity = quantity;
}
public void decrease(Long quantity) {
if (this.quantity - quantity < 0) {
throw new RuntimeException("재고 수량 부족");
}
this.quantity -= quantity;
}
}
StockService
@Service
@RequiredArgsConstructor
public class StockService {
private final StockRepository stockRepository;
@Transactional
public void decrease(Long id, Long quantity) {
Stock stock = stockRepository.findById(id).orElseThrow();
stock.decrease(quantity);
stockRepository.saveAndFlush(stock);
}
}
이커머스 서비스를 개발 중 위 코드를 아래와 같이 멀티 쓰레드 환경에서 테스트 했을 때 테스트에 실패했다. productId가 1인 상품을 100개 생성한 뒤 1번 상품을 1개씩 100번 감소 한다고 가정하고 테스트 했을때 남은 수량이 0일 것이라고 예상했지만 결과는 남은 수량이 96개로 예상했던 결과와 다르게 나왔다.
이유는 경쟁 상태(race condition)가 발생했기 때문인데 경쟁 상태란 각 프로세스, 스레드가 동시에 접근할 수 있는 공유 자원(shared resource)에 두 개 이상의 프로세스나 스레드가 동시에 읽거나 쓰는 상황을 말하며 공유 자원 접근 타이밍이나 순서 등이 결과값에 영향을 줄 수 있는 상태를 의미한다.
위 상황을 예로 들면 재고 수량이 100개 인 상태에서 동시에 2개의 스레드가 decrease 메소드를 테스트 하면 2개가 차감되어야 하지만 두 스레드 모두 재고 수량이 100개인 상태에서 접근하여 수량을 1개씩 차감하기 때문에 결과값이 모두 99개로 1개만 차감한 것이 된다. 이런 문제를 동시성 이슈라고 한다.
StockServiceTest
@SpringBootTest
class StockServiceTest {
@Autowired
private StockService stockService;
@Autowired
private StockRepository stockRepository;
@BeforeEach
public void before() {
Stock stock = new Stock(1L, 100L);
stockRepository.saveAndFlush(stock);
}
@AfterEach
public void delete() {
stockRepository.deleteAll();
}
@Test
public void 동시에_100개의_요청() throws InterruptedException {
int threadCount = 100;
// 비동기로 실행하는 작업을 단순화 하여 사용할 수 있게 도와주는 자바의 API
ExecutorService executorService = Executors.newFixedThreadPool(32);
// 다른 스레드에서 수행 중인 작업이 완료될 때까지 대기할 수 있도록 도와주는 클래스
CountDownLatch latch = new CountDownLatch(threadCount);
for (int i = 0; i < threadCount; i++) {
executorService.submit(() -> {
try {
stockService.decrease(1L, 1L);
} finally {
latch.countDown();
}
});
}
latch.await();
Stock findStock = stockRepository.findById(1L).orElseThrow();
assertThat(findStock.getQuantity()).isEqualTo(0L);
}
}
강의에서는 동시성 문제를 해결하기 위한 방법으로 synchronized 선언, 데이터베이스를 이용한 락(Pessimistic Lock, Optimistic Lock, Named Lock), redis를 이용한 락(Lettuce Lock, Redisson Lock)을 소개하고 있다.
Synchronized 선언
synchronized는 멀티스레드 환경에서 스레드간 데이터 동기화를 시켜주는자바에서 제공하는키워드로 선언 방법은 아래 코드와 같이 문제가 되는 메소드 선언부에 synchronized 선언만 해주면 된다.
@Service
@RequiredArgsConstructor
public class StockService {
private final StockRepository stockRepository;
@Transactional
public synchronized void decrease(Long id, Long quantity) {
Stock stock = stockRepository.findById(id).orElseThrow();
stock.decrease(quantity);
stockRepository.saveAndFlush(stock);
}
}
다시 테스트해보면synchronized를 선언했음에도 아래와 같이 테스트가 실패한 것을 확인할 수 있다. 이는 decrease() 메소드가 종료된 시점과 트랜잭션이 종료되는 시점 사이에 다른 스레드가 접근하게 되면 이 스레드는 변경사항이 디비에 반영되기 전의 재고 수량을 조회하여 decrease() 메소드를 호출하기 때문에 이와 같은 문제가 발생하는 것이다.
@Transactional 어노테이션을 지우고 테스트 하면 다음과 같이 테스트가 성공하는 것을 확인할 수 있다.
하지만 synchronized는 하나의 프로세스 안에서만 보장이 된다. 즉 서버가 한 대일 때는 decrease() 메소드에 하나의 스레드만 접근하는 것을 보장하지만 서버가 여러대일 때는 synchronized를 선언해도 동시에 여러 스레드가 접근할 수 있기 때문에 문제를 해결할 수 없다.
DB Lock
Pessimistic Lock
실제로 데이터에 락을 걸어서 정합성을 맞추는 방법, exclusive lock을 걸게 되면 다른 트랜잭션에서는 lock이 해제되기 이전에 데이터를 가져갈 수 없다.
exclusive lock : 쓰기 잠금(write lock)이라고도 불린다. 현재 트랜잭션이 완료될 때까지 다른 트랜잭션에서 lock이 걸린 테이블이나 레코드에 읽거나 쓰지 못한다.
shared lock : 읽기 잠금(read lock)이라고도 불린다. 읽기는 가능하지만 쓰기는 불가능하다.
장점
충돌이 빈번하게 일어난다면 Optimistic Lock 보다 성능이 좋을 수 있다.
Lock을 통해 update를 제어하기 때문에 데이터 정합성이 어느정도 보장된다.
단점
별도의 락을 잡기 때문에 성능 감소가 있을 수 있다.
데드락이 걸릴수 있기 때문에 주의해서 사용해야 한다.
데드락이란?
데드락(교착 상태)은 두 개 이상의 프로세스들이 서로가 가진 자원을 기다리며 중단된 상태를 말한다.
사용 방법
Spring Data JPA에서는 아래와 같이 @Lock 어노테이션을 이용해 쉽게 Pessimistic Lock을 구현할 수 있다.
public interface StockRepository extends JpaRepository<Stock, Long> {
@Lock(value = LockModeType.PESSIMISTIC_WRITE)
@Query("select s from Stock s where s.id=:id")
Stock findByIdWithPessimisticLock(@Param("id") Long id);
}
위와 같이 메소드를 구현하고 Pessimistic Lock 을 테스트해보기 위해 Service 클래스와 테스트 클래스를 아래와 같이 생성한 후 테스트 해보면 테스트에 성공한 것을 확인할 수 있다.
@Service
@RequiredArgsConstructor
public class PessimisticLockStockService {
private final StockRepository stockRepository;
@Transactional
public void decrease(Long id, Long quantity) {
Stock stock = stockRepository.findByIdWithPessimisticLock(id);
stock.decrease(quantity);
stockRepository.saveAndFlush(stock);
}
}
@SpringBootTest
class StockServiceTest {
@Autowired
private PessimisticLockStockService stockService;
...
// 나머지 코드는 맨 위의 테스트 코드와 같습니다.
}
Optimistic Lock
실제로 락을 이용하지 않고 Version을 이용함으로써 정합성을 맞추는 방법, 먼저 데이터를 읽은 후에 update를 수행할 때 현재 내가 읽은 버전이 맞는지 확인하여 update를 한다. 내가 읽은 버전에서 수정사항이 생겼을 경우에는 application에서 다시 읽은 후에 작업을 수행해야 한다.
장점
별도의 락을 잡지 않으므로 Pessimistic Lock 보다 성능상의 이점이 있다.
단점
Update에 실패했을 때를 대비하여 재시도 로직을 개발자가 따로 작성해 주어야 한다.
충돌이 빈번하게 일어나는 경우 Pessimistic Lock을 사용하는 것이 더 낫다.
사용 방법
Version을 확인하기 위해 아래와 같이 Stock Entity에 Version 컬럼을 추가하고 @Version 어노테이션을 붙여준다.
@Entity
@NoArgsConstructor
@Getter
public class Stock {
...
@Version
private Long version;
...
}
그런 다음 Pessimistic Lock과 마찬가지로 @Lock 어노테이션을 이용해 Optimistic Lock을 이용한 메소드를 구현해주고 이 메소드를 이용해 재고 감소 로직을 작성해준다. 그리고 Optimistic Lock 의 경우 실패했을 때 재시도를 해주어야 하므로 Facade 패턴을 이용해 아래와 같이 실패했을때 50 millisecond 있다가 재시도 해주는 클래스를 생성해 준다.
@Component
@RequiredArgsConstructor
public class OptimisticLockStockFacade {
private final OptimisticLockStockService optimisticLockStockService;
public void decrease(Long id, Long quantity) throws InterruptedException {
while (true) {
try {
optimisticLockStockService.decrease(id, quantity);
break;
} catch (Exception e) {
Thread.sleep(50);
}
}
}
}
그리고 나서 아래와 같이 Facade 패턴으로 구현한 Service의 decrease 메소드를 테스트해보면 테스트에 성공한 것을 확인할 수 있다.
@SpringBootTest
class OptimisticLockStockFacadeTest {
@Autowired
private OptimisticLockStockFacade stockService;
...
// 나머지 코드는 맨 위의 테스트 코드와 같습니다.
}
Named Lock
이름을 가진 metadata locking 이다. 이름을 가진 Lock을 획득한 후 해제할 때까지 다른 세션은 이 락을 획득할 수 없다. 주의할 점으로는 transaction이 종료될 때 lock이 자동으로 해제되지 않는다. 그렇기 때문에 별도의 명령어로 해제를 수행해주거나 선점시간이 끝나야 해제된다. Mysql에서는 getLock() 메소드를 통해 Lock을 획득할 수 있고 releaseLock() 메소드를 통해 락을 해제할 수 있다.
사용 방법
테스트 환경에서는 JPA의 Native Query를 활용하고 동일한 DataSource를 활용했지만 실무에서는 DataSource를 분리해서 사용하는 방법을 추천한다. 그 이유는 동일한 DataSource를 사용하게 되면 커넥션 풀이 부족하게 되는 현상이 발생하여 다른 서비스에도 영향을 끼칠 수 있기 때문이다.
먼저 LockRepository를 생성한 후 getLock() 메소드와 releaseLock() 메소드를 작성해 준다.
그런 다음 핵심 로직인 decrease() 메소드 호출 전후로 getLock()과 releaseLock() 메소드를 실행해 주어야 하기 때문에 아래와 같이 Facade 클래스를 생성해준다.
@Component
@RequiredArgsConstructor
public class NamedLockStockFacade {
private final LockRepository lockRepository;
private final StockService stockService;
@Transactional
public void decrease(Long id, Long quantity) {
try {
lockRepository.getLock(id.toString());
stockService.decrease(id, quantity);
} finally {
lockRepository.releaseLock(id.toString());
}
}
}
그리고 StockService는 부모의 Transaction과 별도로 실행이 되어야 하기 때문에 StockService의 decrease() 메소드에 작성되어있는 @Transactional 어노테이션에 아래와 같이 propagation 속성을 추가해준다.
public class StockService {
private final StockRepository stockRepository;
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void decrease(Long id, Long quantity) {
...
}
}
마지막으로 NamedLockStockFacade 클래스의 decrease() 메소드를 테스트해보면 아래와 같이 성공한 것을 확인할 수 있다.
@SpringBootTest
class NamedLockStockFacadeTest {
@Autowired
private NamedLockStockFacade stockService;
...
// 나머지 코드는 맨 위의 테스트 코드와 같습니다.
}
Redis Lock
Redis를 이용해서 Lock을 걸기 위해서는 먼저 build.gradle에 spring-data-redis 의존성을 추가해준다.
Lettuce Lock은 setnx 명령어를 활용하여 분산 락을 구현할 수 있다. setnx 명령어는 Key와 Value를 set할때 기존의 값이 없을 때만 set 하는 명령어이다. setnx를 활용하는 방식은 spin-lock 방식이므로 retry 로직을 개발자가 따로 작성해주어야 한다.
spin-lock 방식이란?
Lock을 획득하려는 쓰레드가 Lock을 사용할 수 있는 지 반복적으로 확인하면서 Lock 획득을 시도하는 방식이다.
장점
구현이 간단하다.
spring-data-redis 를 이용하면 기본이 Lettuce 방식을 사용하기 때문에 별도의 라이브러리를 사용하지 않아도 된다.
단점
spin-lock 방식을 사용하기 때문에 동시에 많은 쓰레드가 Lock을 획득하기 위해 대기 중이라면 Redis가 과부화가 걸릴 수도 있다.
사용 방법
먼저 RedisLockRepository 클래스를 생성한 뒤 Lock을 거는 lock 메소드와 로직이 끝났을때 Lock을 해제하는 unLock 메소드를 작성해 준다.
@Component
@RequiredArgsConstructor
public class RedisLockRepository {
private final RedisTemplate<String, String> redisTemplate;
public Boolean lock(Long key) {
return redisTemplate
.opsForValue()
.setIfAbsent(generateKey(key), "lock", Duration.ofMillis(3_000));
}
public Boolean unLock(Long key) {
return redisTemplate.delete(generateKey(key));
}
private String generateKey(Long key) {
return key.toString();
}
}
그런 다음 아래와 같이 Lock을 획득할 수 있을 때까지 RedisLockRepository의 lock 메소드를 반복 실행해주는 Facade 클래스를 생성해 준다.
@Component
@RequiredArgsConstructor
public class LettuceLockStockFacade {
private final RedisLockRepository redisLockRepository;
private final StockService stockService;
@Transactional
public void decrease(Long id, Long quantity) throws InterruptedException {
while (!redisLockRepository.lock(id)) {
Thread.sleep(100);
}
try {
stockService.decrease(id, quantity);
} finally {
redisLockRepository.unLock(id);
}
}
}
마지막으로 LettuceLockStockFacade 클래스의 decrease() 메소드를 테스트해주는 테스트 클래스를 작성한 후 테스트해보면 성공한 것을 확인할 수 있다.
@SpringBootTest
class LettuceLockStockFacadeTest {
@Autowired
private LettuceLockStockFacade stockService;
...
// 나머지 코드는 맨 위의 테스트 코드와 같습니다.
}
Redisson Lock
pubsub 기반의 락 구현, redisson의 경우 락 관련된 클래스를 제공해주기 때문에 별도의 Repository를 작성해 주지 않아도 된다.
pubsub 기반이란?
채널을 하나 만들고 Lock을 점유중인 쓰레드가 Lock을 획득하려고 대기 중인 쓰레드에게 해제를 알려주면 안내를 받은 쓰레드가 Lock 획득을 시도를 하는 방식이다.
장점
별도의 retry 로직을 작성하지 않아도 된다.
pub-sub 방식을 사용하기 때문에 Lettuce와 비교했을 때 redis에 부하가 덜 간다.
단점
별도의 라이브러리를 사용해야 한다.
Lock을 라이브러리 차원에서 제공해주기 때문에 사용법을 공부해야 한다.
사용방법
먼저 Redisson 방식을 사용하기 위해서는 아래와 같이 redisson 라이브러리를 추가해주어야 한다.
그런 다음 Redisson 방식은 별도의 Repository를 생성해 주지 않아도 되기 때문에 바로 Lock 획득을 시도하고 StockService의 decrease 메소드를 호출해주는 Facade 클래스를 생성해 준 뒤 같은 로직으로 테스트 해보면 성공한 것을 확인할 수 있다.
@Slf4j
@Component
@RequiredArgsConstructor
public class RedissonLockStockFacade {
private final RedissonClient redissonClient;
private final StockService stockService;
@Transactional
public void decrease(Long id, Long quantity) {
RLock lock = redissonClient.getLock(id.toString());
try {
boolean available = lock.tryLock(5, 1, TimeUnit.SECONDS);
if (!available) {
log.info("lock 획득 실패");
return;
}
stockService.decrease(id, quantity);
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
lock.unlock();
}
}
}
@SpringBootTest
class RedissonLockStockFacadeTest {
@Autowired
private RedissonLockStockFacade stockService;
...
// 나머지 코드는 맨 위의 테스트 코드와 같습니다.
}
DB Lock vs Redis Lock
Mysql
Mysql을 이미 사용하고 있다면 별도의 비용없이 사용이 가능하다.
어느정도의 트래픽까지는 문제없이 활용이 가능하지만 그래도 Redis를 이용한 방식보다는 성능이 좋지 않다.
public class Main {
public static int splitAndSum(String text) {
int result = 0;
if (text == null || text.isEmpty()) {
result = 0;
}
else {
String[] values = text.split("-");
for (String value : values) {
result += Integer.parseInt(value);
}
}
return result;
}
public static void main(String[] args) {
int ret = splitAndSum("11-22-33");
System.out.println(ret);
}
}
1. 한 단계의 들여쓰기를 한다.
한 단계 더 들여쓰기를 할때 함수로 빼서 들여쓰기를 없앤다.
...
else {
String[] values = text.split("-");
for (String value : values) {
result += Integer.parseInt(value);
}
}
...
...
else {
String[] values = text.split("-");
result += getSum(values);
}
...
public static int getSum(String[] values) {
int sum = 0;
for (String value : values) {
sum += Integer.parseInt(value);
}
return sum;
}
2. else를 없앤다.
return 을 사용하여, 필요없는 else를 지운다.
...
if (text == null || text.isEmpty()) {
result = 0;
}
else {
String[] values = text.split("-");
result += getSum(values);
}
...
...
if (text == null || text.isEmpty()) {
return 0;
}
String[] values = text.split("-");
result += getSum(values);
...
3. 하나의 역할을 하는 메소드로 만든다.
...
public static int splitAndSum(String text) {
int result = 0;
if (text == null || text.isEmpty()) {
return 0;
}
else {
String[] values = text.split("-");
result += getSum(values);
}
return result;
}
...
// 현재 getSum 메소드는 String 타입을 int로 바꿔주는 역할과 덧셈까지 수행하고 있다.
public static int getSum(String[] values) {
int sum = 0;
for (String value : values) {
sum += Integer.parseInt(value);
}
return sum;
}
...
public static int splitAndSum(String text) {
int result = 0;
if (text == null || text.isEmpty()) {
return 0;
}
String[] values = text.split("-");
int[] numbers = toInts(values);
result += getSum(numbers);
return result;
}
...
// String 타입을 int로 바꿔주는 메소드
public static int[] toInts(String[] values) {
int[] numbers = new int[values.length];
for (int i = 0; i < values.length; i++) {
numbers[i] = Integer.parseInt(values[i]);
}
return numbers;
}
// int로 바꾼 숫자를 더하는 메소드
public static int getSum(int[] numbers) {
int sum = 0;
for (int number : numbers) {
sum += number;
}
return sum;
}
4. 임시변수를 제거한다.
의미 파악에 도움이 되지 않는 변수를 제거한다.
...
int result = 0;
if (text == null || text.isEmpty()) {
return 0;
}
String[] values = text.split("-");
int[] numbers = toInts(values);
result += getSum(numbers);
return result;
...
...
public static int splitAndSum(String text) {
if (text == null || text.isEmpty()) {
return 0;
}
return getSum(toInts(text.split("-")));
}
...
...
public static int splitAndSum(String text) {
if (isEmpty(text)) return 0;
return getSum(toInts(text.split("-")));
}
...
public static boolean isEmpty(String text) {
if (text == null) return true;
return text.isEmpty();
}
6. 최종 코드
public class Main {
public static int splitAndSum(String text) {
if (isEmpty(text)) return 0;
return getSum(toInts(text.split("-")));
}
public static int[] toInts(String[] values) {
int[] numbers = new int[values.length];
for (int i = 0; i < values.length; i++) {
numbers[i] = Integer.parseInt(values[i]);
}
return numbers;
}
public static int getSum(int[] numbers) {
int sum = 0;
for (int number : numbers) {
sum += number;
}
return sum;
}
public static boolean isEmpty(String text) {
if (text == null) return true;
return text.isEmpty();
}
public static void main(String[] args) {
int ret = splitAndSum("11-22-33");
System.out.println(ret);
}
}
resources 폴더에 application.xml 파일을 생성한 후 PersonService 빈에 접근할 수 있도록 빈 id를 personService로 지정하고 <property> 태그를 이용해 PersonServiceImpl 클래스 객체의 name 속성에 <value> 태그의 값으로 초기화 합니다.
public class DemoApplication {
public static void main(String[] args) {
ApplicationContext context = new ClassPathXmlApplicationContext("application.xml");
PersonService person = (PersonService) context.getBean("personService");
person.sayHello();
}
}
위와 같이 application.xml 파일을 읽어와서 context 객체를 생성하고 getBean 메서드를 이용하여 id가 personService인 빈을 생성합니다. 그리고 나서 person 객체의 sayHello() 메서드를 실행하면 아래와 같이 출력되는 것을 확인할 수 있습니다.
생성자를 이용한 DI 기능
PersonServiceImpl
public class PersonServiceImpl implements PersonService {
private String name;
private int age;
public PersonServiceImpl(String name) {
this.name = name;
}
public PersonServiceImpl(String name, int age) {
this.name = name;
this.age = age;
}
@Override
public void sayHello() {
System.out.println("이름: " + name);
System.out.println("나이: " + age);
}
}
id가 personDAO인 빈을 personService 빈에 주입합니다. 주입되는 데이터가 기본형이 아닌 참조형인 경우 ref 속성으로 설정해야 합니다.
public class DemoApplication {
public static void main(String[] args) {
ApplicationContext context = new ClassPathXmlApplicationContext("application.xml");
PersonService person = (PersonService) context.getBean("personService");
person.listPersons();
}
}
context 객체를 생성하고 id가 personService인 빈을 생성한 후 person 객체를 이용해 listPersons() 메서드를 호출하면 아래와 같이 출력되는 것을 확인할 수 있습니다.
하지만 위와 같은 방법은 xml 파일에 bean을 일일이 등록해줘야 해서 매우 번거롭습니다. 그래서 xml 파일에 아래와 같이 component-scan을 등록해주면 base-package에서 지정한 패키지의 하위 패키지를 scanning해서 @Component 어노테이션이 붙은 클래스들을 자동으로 빈으로 등록해줍니다.
PersonServiceImple와 PersonRepository를 아래와 같이 작성하고 name이 personServiceImpl인 빈을 조회한 후 똑같이 실행하면 같은 결과가 출력됩니다.
@Service
public class PersonServiceImpl implements PersonService {
@Autowired
private PersonRepository personRepository;
public void setPersonRepository(PersonRepository personRepository) {
this.personRepository = personRepository;
}
@Override
public void listPersons() {
personRepository.listPersons();
}
}
@Repository
public class PersonRepository {
public void listPersons() {
System.out.println("listMembers 메서드 호출");
System.out.println("회원정보를 조회합니다.");
}
}
참고로 @Service와 @Repository 그리고 @Controller 어노테이션은 @Component 어노테이션을 확장시킨 어노테이션이므로 scanning시 스프링 빈으로 자동으로 등록됩니다.
2. JAVA 설정파일
xml 파일이 아닌 JAVA 클래스를 이용해서도 빈을 설정할 수 있습니다.
먼저 ApplicationConfig라는 클래스를 생성한 뒤 아래와 같이 작성해줍니다.
@Configuration
public class ApplicationConfig {
@Bean
public PersonRepository personRepository() {
return new PersonRepository();
}
@Bean
public PersonServiceImpl personServiceImpl() {
PersonServiceImpl personService = new PersonServiceImpl();
personService.setPersonRepository(personRepository());
return personService;
}
}
자바 설정 파일은 클래스위에 @Configuration 어노테이션을 작성하고 빈으로 등록할 객체를 리턴하는 메소드 위에 @Bean 어노테이션을 추가해줌으로써 빈으로 등록할 수 있습니다. 그리고 나서 PersonServiceImpl과 PersonRepository에 작성했던 @Component 확장 어노테이션을 지워주고 아래 코드를 실행해주면 xml파일로 테스트했을 때와 같은 결과가 출력됩니다.
public class DemoApplication {
public static void main(String[] args) {
ApplicationContext context = new AnnotationConfigApplicationContext(ApplicationConfig.class);
PersonService person = (PersonService) context.getBean("personServiceImpl");
person.listPersons();
}
}
3. JAVA 설정 파일 + Component Scan
xml 파일은 사용하기 싫은데 Component Scan은 사용하고 싶다면 자바 설정 파일에 Component Scan을 설정해주면 됩니다.
자바 설정 파일을 아래와 같이 작성해주고 다시 PersonServiceImpl과 PersonRepository 클래스에 각각 @Service와 @Repository 어노테이션을 붙여주고 테스트해보면 같은 결과가 출력됩니다.
@Configuration
@ComponentScan(basePackageClasses = ApplicationConfig.class)
public class ApplicationConfig {
}
웹 어플리케이션을 개발하기 위해서는 기본 기능과 많은 기능을 설계, 작성해야 합니다. (요청처리, 세션관리, 리소스 관리, 멀티 쓰레드 등) 하지만 기본적인 공통 구조(framework)를 제공한다면 개발자는 웹 어플리케이션 기능 자체 개발에만 신경 쓰면 되기 때문에 생산성이 높아집니다..
개발자 입장에서는 완성된 구조에 자신이 맡은 코드만 개발해서 넣어주면 되기 때문에 개발 시간을 단축할 수 있습니다.
Spring Framework의 특징
POJO(Plain Old Java Object) 방식의 프레임워크 - EJB가 기능 작성을 위해서 인터페이스를 구현하거나 상속하는 것에 비해 일반적인 자바 객체를 이용해서 그대로 사용할 수 있음을 의미합니다.
의존성 주입(DI, Dependency Injection)을 통한 객체관계 구성 - 프레임워크 내부에서 사용되는 객체간 의존성이 존재할 경우, 개발자는 의존성에 관련한 설정만 해주면 실제 의존성 생성은 프레임워크가 담당합니다.
관점지향 프로그래밍(AOP, Aspect Oriented Programming) 지원 - 트랜잭션, 로깅 등 여러 모듈에서 공통적으로 사용하는 기능에 대해서 별도로 분리하여 작성, 관리할 수 있는 기능을 제공합니다.
제어 역전(IoC, Inversion of Control) - 제어 역전을 통해 객체 및 프로세스의 제어를 프레임워크가 담당하고 필요에 따라 개발자의 코드를 호출한다.
높은 확장성과 다양한 라이브러리 지원 - 기존의 라이브러리를 스프링에서 사용할 수 있는 기능을 지원하고 있습니다. 특히 영속성 관련하여 MyBatis나 Hibernate 등 의 완성도 높은 데이터베이스 라이브러리와 연결가능한 인터페이스를 제공해 줍니다.
Spring Web MVC
MVC란 Model-View-Controller(모델-뷰-컨트롤러)의 약자로 웹 애플리케이션을 화면 부분, 요청 처리 부분, 로직 처리 부분으로 나누어 개발하는 방법입니다. 각 기능이 분리되어있어 개발 및 유지보수가 편리하고 각 기능의 재사용성이 높아진다는 장점이 있습니다.
Model
데이터베이스 연동과 같은 비즈니스 로직을 수행합니다.
Controller로 부터 넘어온 data를 이용하여 이를 수행하고 그에 대한 결과를 다시 Controller에 return 합니다.
일반적으로 DAO와 VO 클래스로 이루어져 있습니다.
View
Model에서 처리한 결과를 화면에 표시합니다.
JSP가 화면 기능을 담당합니다.
Controller
서블릿이 컨트롤러의 역할을 합니다.
클라이언트의 요청을 분석합니다.
요청에 대해서 필요한 모델을 호출합니다.
return 받은 결과 data를 필요에 따라 request, session등에 저장하고 redirect 또는 forward 방식으로 jsp(view) page를 이용하여 출력합니다.
Spring Web MVC는 Servlet API를 기반으로 구축된 웹프레임워크로 Spring Framework가 제공하는 DI, AOP 뿐만 아니라 Web 개발에 필요한 기능들을 제공해줍니다. DispatcherServlet(FrontController)를 중심으로 디자인 되었으며, View Resolver, Handler Mapping, Controller 와 같은 객체와 함께 요청을 처리하도록 구성되어 있습니다.
Spring MVC 구성요소
구성 요소
설명
DispatcherServlet
클라이언트의 요청을 전달받아 해당 요청에 대한 Controller를 선택하여 클라이언트의 요청을 전달합니다. 또한 Controller가 반환한 값을 View에 전달하여 알맞은 응답을 생성합니다.
HandlerMapping
클라이언트가 요청한 URL을 처리할 Controller를 지정합니다.
Controller
클라이언트의 요청을 처리한 후 그 결과를 DispatcherServlet에 전달합니다.
ModelAndView
Controller가 처리한 결과 및 View 선택에 필요한 정보를 저장합니다.
ViewResolver
Controller에 선언된 View의 이름을 기반으로 결과를 반환할 View를 결정합니다.
View
Controller의 처리 결과 화면을 생성합니다.
Spring MVC - 요청 처리 흐름
브라우저가 DispatcherServlet에 URL로 접근하여 해당 정보를 요청합니다.
HandlerMapping에서 해당 요청에 대해 매핑된 Controller가 있는지 요청합니다.
DispatcherServlet이 매핑된 Controller에 처리를 요청합니다.
Controller가 요청을 처리합니다.
결과(요청처리를 위한 data, 결과를 보여줄 view의 이름)를 ModelAndView에 담아 반환합니다.
Spring Security와 OAuth 2.0 프레임워크를 이용하여 소셜로그인을 구현하던 중 SecurityConfig 파일과 DefaultOAuth2UserService 클래스를 상속받은 OAuth2UserDetailsService 클래스 사이에서 순환 참조 오류가 발생했다.
오류 원인
SecurityConfig
@Configuration
@Log4j2
@EnableWebSecurity
@EnableGlobalMethodSecurity(securedEnabled = true)
@RequiredArgsConstructor
public class SecurityConfig {
private final OAuth2UserDetailsService oAuth2UserDetailsService;
private final JWTUtil jwtUtil;
@Bean
PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
...
OAuth2UserDetailsService
@Log4j2
@Service
@RequiredArgsConstructor
public class OAuth2UserDetailsService extends DefaultOAuth2UserService {
private final MemberRepository memberRepository;
private final PasswordEncoder passwordEncoder;
...
private Member saveSocialMember(String email) {
Optional<Member> result = memberRepository.findByEmail(email);
if (result.isPresent()) {
return result.get();
}
String password = getRandomPassword(12);
Member member = Member.builder()
.email(email)
.name(email)
.password(passwordEncoder.encode(password))
.fromSocial(true)
.build();
member.addMemberRole(MemberRole.USER);
memberRepository.save(member);
return member;
}
...
}
코드 리뷰를 해보니 SecurityConfig 클래스에서 참조한 OAuth2UserDetailsService 클래스에서 다시 SecurityConfig 클래스에서 빈으로 등록한 PasswordEncoder 인터페이스를 참조하면서 생긴 에러였다.
해결 방법
OAuth2UserDetailsService
@Log4j2
@Service
@RequiredArgsConstructor
public class OAuth2UserDetailsService extends DefaultOAuth2UserService {
private final MemberRepository memberRepository;
...
private Member saveSocialMember(String email) {
Optional<Member> result = memberRepository.findByEmail(email);
if (result.isPresent()) {
return result.get();
}
String password = getRandomPassword(12);
Member member = Member.builder()
.email(email)
.name(email)
.password(new BCryptPasswordEncoder().encode(password))
.fromSocial(true)
.build();
member.addMemberRole(MemberRole.USER);
memberRepository.save(member);
return member;
}
...
}
OAuth2UserDetailsService 클래스에서 passwordEncoder 객체를 사용한 곳이 한 곳 밖에 없어서 PasswordEncoder 인터페이스를 의존성 주입하지 않고 BCryptPasswordEncoder() 클래스를 새로 생성해서 문제를 해결했다.