안녕하세요 회사와 함께 성장하고 싶은 KOSE입니다.

 

Redis는 인메모리 데이터베이스로 Key-Value 형태로 데이터를 저장하고 조회할 수 있습니다.

Spring에서는 CrudRepository나 RedisTemplate 등으로 Redis에 데이터를 저장할 수 있습니다.

저는 토이 프로젝트에서 간편하게 값을 저장 및 조회할 수 있고 어노테이션으로 인덱스를 간편하게 생성할 수 있는 CrudRepository를 사용하였습니다. 하지만 프로젝트 규모가 커지다보니 MSA 아키텍처 간에 객체에 대한 유기적인 사용이 필요하였습니다.

 

그 예로 AccessToken과 같은 인증 객체가 있습니다. 인증 객체는 회원이 로그인에 성공하면 인증 토큰을 발급합니다. 인증 토큰을 발급하는 주체가 A 서버라고 한다면, 다른 B 혹은 C 등의 서버에서 인증 토큰 객체를 조회하여야 할 수 있습니다.  이 경우 " _class" 형태로 저장된 값은 다른 B, C 서버에서 객체로 역직렬화를 어렵게 합니다. 일반적으로 역직렬화는 클래스 정보가 매핑되어야 객체로의 변환이 가능합니다. 하지만 " _class"로 저장된 값을 서버별로 상이한 class로 매핑하는 과정은 어려운 일이고 해당 객체를 위해 통일된 객체를 선언하는 것도 한계가 있습니다.

 

따라서, 이번 글은 제가 겪었던 CrudRepository에서의 객체 저장 방식과 역직렬화의 한계를 살펴보고, Redistemplate로 수정하면서 자유롭게 객체로 역직렬화를 할 수 있었던 점, 나아가 분산락과 트랜잭션을 적용하고 ThreadLocal로 생명주기를 관리한 것에 대해서 정리하도록 하겠습니다.

 

 

 

1. 직렬화/역직렬화

 

직렬화(Serialization)는 자바 객체를 Byte 형태로 변환화는 과정을 의미합니다. Byte 형태로 객체를 데이터베이스에 저장하기 때문에 용량을 줄일 수 있고 데이터가 문자열 형태로 저장되기 때문에 Redis, MongoDB 등 다양한 저장소에 저장할 수 있습니다.

 

역직렬화는 직렬화 형태로 저장된 데이터를 자바(여기서는 스프링을 사용하므로 자바라고 하겠습니다) 객체로의 변환하는 것을 의미합니다. 직렬화된 데이터를 역직렬화할 때 장점은 데이터를 유연하게 객체화할 수 있다는 점입니다.

직렬화하는 대상에 사용되는 클래스 타입에 제약을 받지 않기에 필요하다면 에러가 발생하지 않는 선에서 유기적으로 객체를 수정할 수 있습니다.

 

 

 

2. CrudRepository의 저장방식

 

CrudRepository를 사용하면 객체를 데이터로 저장할 때 RedisHash 형태로 객체를 저장합니다. RedisHash는 Hash의 Field와 Value로 매핑되는 자료구조로 객체를 Hash화 하여 저장하는 개념입니다. 즉 key-value 구조 안에서 해시 구조를 만들어 저장하는데, 이러한 해시 구조를 활용할 때 장점은 객체의 field를 hash의 field로 저장하기 때문에 데이터를 빠르게 읽어올 수 있습니다.

 

<AccessToken.java>

 

@Getter
@NoArgsConstructor
public class AccessToken extends Token implements Serializable {

    private AccessToken(String id, String userId, long expiration) {
        super(id, userId, expiration);
    }
}

 

 

<AccessTokenRedisRepository.java>

 

public interface AccessTokenRedisRepository extends CrudRepository<AccessToken, String> {
}

 

<Redis-cli 결과>

 

 

객체가 Redis에 저장될 때, 각 필드에 매핑되는 값을 확인할 수 있습니다. CrudRepository를 활용할 때, @Indexed 어노테이션으로 간단하게 Index를 생성하여 값을 저장할 수 있습니다.

 

<Token.java>

 

@Getter
@NoArgsConstructor
@AllArgsConstructor
public abstract class Token {

    @Id
    private String id;

    @Indexed
    private String userId;

    @TimeToLive(unit = TimeUnit.MILLISECONDS)
    private long expiration;
}

 

인덱스는 RDBMS 데이터베이스에서 테이블 내 데이터를 빠른 검색과 정렬을 위한 데이터 구조입니다. 특정 열에 대한 인덱스를 생성하면 해당 열의 값들이 저장되어 빠르게 검색할 수 있습니다. 덱스가 아닌 컬럼에 대한 where 절의 검색 조건은 쿼리 실행 계획을 확인하면 전체 검색으로 실행되는 것을 볼 수 있습니다. 하지만, 인덱스로 선언된 컬럼을 검색하게 되면 값을 Hash화 하여 인덱스의 해시테이블에서 값을 가져오는 개념이기 때문에 빠르고 효율적인 검색이 가능합니다.

 

Redis에서 인덱스는 어떠한 개념일까요 ? RDBMS와 다르게 Redis는 Key-value 형태로 값을 저장합니다. 따로 인덱스 열을 생성하는 개념이 아니라 key-value로 인덱스 값과 해당 key가 참조하고 있는 객체의 Id를 value로 참조하고 있는 형태입니다. Sorted set, Set, Hash는 Redis에서 활용할 수 있는 인덱스의 자료구조 형태입니다.

 

AccessToken은 userId라는 필드를 가지고 있고 해당 필드가 @Indexed로 선언되어 있습니다. accessToken은 해당 토큰의 발급 주체가 누구이냐가 보안성에서 중요한 역할을 수행합니다. 뿐만 아니라 예상하지 못한 문제로 동일 userId로 여러 개의 토큰이 생성될 수 있으므로 이를 제어하기 위해 해당 값을 index 화해서 사용하였습니다.

 

이처럼 CrudRepository는 객체를 해시화하여 Redis에 저장하는 것과 Index를 활용하는 것을 용이하게 해주기 때문에 효율적으로 활용할 수 있습니다.

 

 

 

3. CrudRepository의 한계

 

CrudRepository의 단점은 객체를 해시화하여 저장하기 때문에 "_class"라는 값이 생성되게 됩니다. 이는 곧 유연한 확장성에 어려움을 줄 수 있습니다.  "_class"는  해시화된 데이터를 객체로 변환하는 과정에서 매핑 정보를 담고 있는 class 정보입니다. 만약 A 서버에서 Redis에 객체와 인덱스 정보까지 모두 저장하였다 하더라도 "_class"가 매핑되지 않게 구현된 B Server에서 해당 데이터를 CrudRepository.findById or findByUserId를 활용했을 때 객체를 찾을 수 없는 문제가 발생할 수 있습니다.

 

B 서버는 A 서버의 클래스 정보와 같지 않을 가능성이 매우 높으며 상황에 따라, 해시화되어 있는 필드에 정확하게 일치하는 필드가 없을 수도 있습니다. 즉 유동적으로 변경될 수 있는데, RedisHash로 저장된 값은 이러한 유연한 변경이 어렵습니다.

 

그렇다면 현재 주어진 프로젝트는 객체를 redis에 저장 가능해야하고, "_class"정보가 다르더라도 다른 객체에 매핑이 가능해야 하며, Index까지 활용할 수 있어야 한다면 어떠한 방식으로 객체를 저장해야 할까요?

 

 

 

4. Redistemplate 활용하기

 

Redistemplate를 활용하면 객체가 직렬화를 거치면서 클래스 매핑정보가 저장되지 않게 비교적 쉽게 구현할 수 있습니다. 그리고 역직렬화할 때는 매핑할 객체에 대한 class 정보를 추가할 수 있기에 유연하게 객체를 저장 및 조회할 수 있습니다. 또한 Redistemplate의 key-value를 활용하여 커스텀한 인덱스 형태로 값을 저장할 수 있습니다.

 

이제 코드로 적용해 보도록 하겠습니다.


<RedisConfig.java>

 

@Configuration
public class RedisConfig {

    @Value("${spring.data.redis.host}")
    private String host;

    @Value("${spring.data.redis.port}")
    private int port;

	-- 중략 --
    
    @Bean
    public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connectionFactory) {
        RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
        redisTemplate.setConnectionFactory(connectionFactory);
        redisTemplate.setKeySerializer(new StringRedisSerializer());
        redisTemplate.setValueSerializer(new StringRedisSerializer());
        redisTemplate.setHashKeySerializer(new StringRedisSerializer());
        redisTemplate.setHashValueSerializer(new StringRedisSerializer());
        return redisTemplate;
    }
}

 

먼저 RedisTemplate를 활용하여 String의 key값으로 Object를 저장하도록 설정합니다.

 

redisTemplate.setKeySerializer(new StringRedisSerializer());
redisTemplate.setValueSerializer(new StringRedisSerializer());
redisTemplate.setHashKeySerializer(new StringRedisSerializer());
redisTemplate.setHashValueSerializer(new StringRedisSerializer());

 

Redis는 기본적으로 Byte 형태로 Redis에 저장합니다. 하지만 가독성을 위해, 사용자의 입장에서는 문자열로 보이는게 좋습니다. 따라서, redistemplate는 다음과 같이 StringRedisSerializer()를 제공합니다. 이는 바이트 형태로 보여지는 문자열을 사용자가 쉽게 확인할 수 있도록 보여줍니다.

 

StringRedisSerializer()를 사용하지 않은 경우

 

StringRedisSerializer()를 사용한 경우

 

 

만약 사용하지 않더라도, RedisTemplate는 defaultSerializer로 JdkSerializationRedisSerializer를 제공합니다. 따라서 필수값은 아니지만, 디버깅할 때 매우 유용하게 사용할 수 있으므로 StringRedisSerializer를 적용하였습니다.

 

 

<TokenRedissonRepository.java>

 

@Component
public interface TokenRedisTemplateRepository<T extends Token> {

    void saveToken(String key, T t) throws JsonProcessingException;

    T findTokenByKey(String key, Class<T> clazz) throws JsonProcessingException;

    void saveTokenIdx(String key, T t) throws JsonProcessingException;

    String findTokenIdxValue(String key, Class<T> clazz) throws JsonProcessingException;

    T findTokenByIdx(String key, Class<T> clazz) throws JsonProcessingException;

    void deleteToken(String key, Class<T> clazz) throws JsonProcessingException;
    
    void deleteTokenIdx(String key, Class<T> clazz) throws JsonProcessingException;

}

 

먼저, 제네릭으로 선언하여 Token을 상속하는 AccessToken과 RefreshToken이 TokenRedisRepository를 활용할 수 있도록 선언하였습니다. Crud에 필요한 save, find, delete 등의 기본적인 메서드와 인덱스를 활용하기 위해 인덱스를 저장하고 인덱스로 값을 검색하는 커스텀 메서드를 추가하였습니다.

 

이후, 구현체에서 필요한 코드를 작성하면 다음과 같습니다.

 

@Slf4j
@Component
@RequiredArgsConstructor
public class TokenRedisTemplateRepositoryImpl<T extends Token> implements TokenRedisTemplateRepository<Token> {
    private final RedisTemplate<String, Object> redisTemplate;

    private final ObjectMapper objectMapper;

    private final static String IDX = "idx";

    @Override
    public void saveToken(String key, Token token) throws JsonProcessingException {
        key = getKey(token.getClass().getSimpleName(), key);
        redisTemplate.opsForValue().set(key, objectMapper.writeValueAsString(token));
        redisTemplate.expire(key, token.getExpiration(), TimeUnit.MILLISECONDS);
    }

    @Override
    public Token findTokenByKey(String key, Class<Token> clazz) throws JsonProcessingException {
        key = getKey(clazz.getSimpleName(), key);
        return getObjectValue(key, clazz);
    }

    @Override
    public void saveTokenIdx(String key, Token token) throws JsonProcessingException {
        key = getKey(token.getClass().getSimpleName(), key, IDX);
        redisTemplate.opsForValue().set(key, objectMapper.writeValueAsString(token.getId()));
        redisTemplate.expire(key, token.getExpiration(), TimeUnit.MILLISECONDS);
    }

    @Override
    public String findTokenIdxValue(String key, Class<Token> clazz) throws JsonProcessingException {
        key = getKey(clazz.getSimpleName(), key, IDX);
        return getIdxValue(key);
    }

    @Override
    public Token findTokenByIdx(String key, Class<Token> clazz) throws JsonProcessingException {

        key = findTokenIdxValue(key, clazz);
        if (key == null) return null;

        return findTokenByKey(key, clazz);
    }

    @Override
    public void deleteToken(String key, Class<Token> clazz) throws JsonProcessingException {
        key = getKey(clazz.getSimpleName(), key);
        redisTemplate.delete(key);
    }

    @Override
    public void deleteTokenIdx(String key, Class<Token> clazz) throws JsonProcessingException {
        key = getKey(clazz.getSimpleName(), key, IDX);
        redisTemplate.delete(key);
    }

    private Token getObjectValue(String key, Class<Token> clazz) throws JsonProcessingException {
        String value = (String) redisTemplate.opsForValue().get(key);

        if (value == null || value.isEmpty()) return null;
        return objectMapper.readValue(value, clazz);
    }

    private String getKey(String... keys) {
        return String.join(":", keys);
    }

    private String getIdxValue(String key) throws JsonProcessingException {
        String value = (String) redisTemplate.opsForValue().get(key);
        if (ObjectUtils.isEmpty(value)) return null;
        return objectMapper.readValue(value, String.class);
    }
}

 

redisTemplate.opsForValue(). set()은 key와 객체를 redis에 직렬화하여 값을 저장하는 역할을 수행합니다.

class정보는 objectMapper.writeValueAsString() 단계에서 제거되며 직렬화된 바이트로 redis에 저장됩니다.

값을 가져온 후 필요한 매핑 정보를 제공하기 위해 class 타입을 명시하여 캐스팅할 수 있도록 하였습니다.

redistemplate.delete() 메서드는 key 값에 저장된 value를 지우는 역할을 수행합니다.

 

 

 

6. RedisTemplate 트랜잭션과 동시성 제어하기 

 

accessToken과 accessToken의 index, refreshToken과 refreshToken의 index를 저장 혹은 삭제하는 메서드는 반드시 동일 트랜잭션 내에서 처리되어야 합니다.

뿐만 아니라 동시성 문제도 해결되어야 합니다. 만약 데이터 일관성이 깨지거나 동시성 문제가 발생한다면, index로 token의 key를 찾았지만 key의 값이 없는 문제가 발생하거나 accessToken 혹은 refreshToken만 저장되는 문제가 발생할 수 있습니다. 따라서, redistemplate의 트랜잭션 제어 메서드와 redissonClient를 활용하여 분산락을 적용하였습니다.

 

주의할 점은, 분산락과 트랜잭션의 순서입니다.

 

 

분산락으로 먼저 요청 토큰에 대한 락을 획득하고, redistemplate로 redis에 저장하기 전에 트랜잭션을 시작합니다.
이후, 값이 모두 온전히 저장되면 커밋하고 실패할 경우 롤백합니다 이후 분산락이 해제되는 순서가 보장되어야 합니다.

 

보통 이러한 과정은 횡단 관심사에 속합니다. 메인 핵심 비즈니스 로직은 token 정보를 redis에 저장하는 것입니다.

따라서, 분산락과 트랜잭션의 시작과 종료는 AOP로 적용할 수 있습니다.

 

AOP는 pointcuts과 aspect가 존재하며, pointcuts으로 aop에 적용할 프로젝트 경로, 클래스 혹은 메서드를 적용할 수 있습니다. 이후 aspect에서 부가 기능들을 작성하며 프록시 객체를 호출하여 처리할 수 있습니다.

 

저는 token 정보를 저장하기 위해 CustomAnnotation을 생성하였고 aspect 적용이 필요한 메서드는 어노테이션을 추가하여 aop를 적용하였습니다.

 

<RedisTransactional.java>

 

@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface RedisTransactional {
}

 

<Pointcuts.java> 

 

@Pointcut("@annotation(liar.memberservice.common.aop.anno.RedisTransactional)")
public void transactionMethod() {};

 

pointcut에서 해당 어노테이션이 저장된 프로젝트 경로를 설정합니다.
이후, Aspect는 transactionMethod()의 경로를 참조하여 aop를 적용할 수 있습니다.

 

여기서 앞 서 정의한 분산락과 트랜잭션의 순서를 고려하여 AOP를 적용해야 합니다.

Aspect는 클래스 단위로 @Order() 어노테이션으로 aop의 순서를 정의할 수 있습니다. 

먼저 시작되어야 할 분산락에 aop를 먼저 적용한 후, 이어서 트랜잭션 aop가 실행되도록 @Order로 순서를 정의하였습니다.

 

<RedissonClientLockAspect.java>

 

@Around("liar.memberservice.common.aop.Pointcuts.transactionMethod() && args(userId, authorities)")
    public AuthTokenDto saveToken(ProceedingJoinPoint joinPoint, String userId, List<Authority> authorities)
            throws Throwable {
        String lockKey = "SaveUserToken: " + userId;
        return getTokenIfExistOrSetAndGetPointTransactionRedissonRLock(joinPoint, lockKey);
    }


    private AuthTokenDto getTokenIfExistOrSetAndGetPointTransactionRedissonRLock(ProceedingJoinPoint joinPoint,
                                                                                 String lockKey) throws Throwable {
        RLock lock = redissonClient.getLock(lockKey);
        try {
            boolean isLocked = lock.tryLock(60, TimeUnit.SECONDS);
            if (!isLocked) throw new RedisLockException();
            Object proceed = joinPoint.proceed();
            if (proceed == null) {
                return null;
            }
            return (AuthTokenDto) proceed;
        } finally {
            if (lock.isHeldByCurrentThread()) lock.unlock();
        }
    }

 

먼저 userId로 락을 설정한 후 joinPoint.proceed()로 JDK dynamic proxy 혹은 CGLIB proxy 객체를 호출하여 로직을 수행합니다.

 

이어서 트랜잭션이 적용될 수 있도록 aop를 추가합니다.

 

	@Around("liar.memberservice.common.aop.Pointcuts.transactionMethod() && args(userId, authorities)")
    public AuthTokenDto runWithTx(ProceedingJoinPoint joinPoint, String userId, List<Authority> authorities) throws Throwable {
        if (!txActive.get()) {
            redisTemplate.multi();
            txActive.set(true);
        }

        try {
            Object proceed = joinPoint.proceed();
            if (proceed != null) {
                return (AuthTokenDto) proceed;
            }
        } catch (Throwable throwable) {
            throw throwable;
        }
        return null;
    }

    @AfterReturning("liar.memberservice.common.aop.Pointcuts.transactionMethod()")
    public void commitTx() {
        if (txActive.get()) {
            try {
                redisTemplate.exec();
            } catch (Exception e) {
                redisTemplate.discard();
            } finally {
                txActive.remove();
            }
        }
    }
    
    @AfterThrowing("liar.memberservice.common.aop.Pointcuts.transactionMethod()")
    public void rollback() {
        try {
            redisTemplate.discard();
        }  catch (Exception e) {
            e.printStackTrace();
        } finally {
            txActive.remove();
        }
    }

 

txActive는 현재 트랜잭션을 나타내는 변수입니다. ThreadLocal로 스레드-세이프한 구조로 생성할 수 있습니다.

ThreadLocal.withInitial(() -> false)로 초기값을 false로 설정한 후 tx.Active가 아직 트랜잭션이 처리되지 않은 경우에
트랜잭션을 시작합니다.


이후 join.proceed()의 프록시 객체를 호출하며 문제가 없다면 @AfterReturning이 실행되며 트랜잭션을 종료하고 
메모리 누수를 막기 위해 생성한 ThreadLocal을 제거합니다. 

 

만약 이 과정에서 예외가 발생하면 rollback()메서드가 호출되거나 try-catch-finally 구문의

redistemplate.discard()로 요청을 롤백한 후 ThreadLocal을 제거합니다.

마지막으로 프록시 객체가 종료되면 먼저 요청했던 RedissonClient 분산락이 해제되며 메서드가 종료됩니다.

 

이제, 마지막으로 Service 로직을 작성하겠습니다.

 

@RedisTransactional
public AuthDto getTokenIfExistOrSetAndGet(String userId, List<Authority> authorities) throws JsonProcessingException {

    AuthDto authDto = findOrDeleteToken(userId);
    if (authDto != null) return authDto;

    return saveAndGetToken(userId, authorities);
}

private AuthDto findOrDeleteToken(String userId) throws JsonProcessingException {
    Token savedAccessToken = tokenRepository.findTokenByIdx(userId, AccessToken.class);
    Token savedRefreshToken = tokenRepository.findTokenByIdx(userId, RefreshToken.class);

    if (savedAccessToken != null && savedRefreshToken != null) {
        return new AuthDto(savedAccessToken.getId(), savedRefreshToken.getId(), userId);
    }

    else if (savedAccessToken != null) {
        tokenRepository.deleteToken(savedAccessToken.getId(), AccessToken.class);
        tokenRepository.deleteTokenIdx(savedAccessToken.getUserId(), AccessToken.class);
    }

    else if (savedRefreshToken != null) {
        tokenRepository.deleteToken(savedRefreshToken.getId(), RefreshToken.class);
        tokenRepository.deleteTokenIdx(savedRefreshToken.getUserId(), RefreshToken.class);
    }
    return null;
}

private AuthDto saveAndGetToken(String userId, List<Authority> authorities) throws JsonProcessingException {
    String accessToken = tokenProviderPolicy.createAccessToken(userId, authorities);
    String refreshToken = tokenProviderPolicy.createRefreshToken(userId, authorities);

    saveTokens(AccessToken.of(accessToken, userId, tokenProviderPolicy.getRemainingTimeFromToken(accessToken)),
            RefreshToken.of(refreshToken, userId, tokenProviderPolicy.getRemainingTimeFromToken(refreshToken)));

    return new AuthDto(accessToken, refreshToken, userId);
}

private void saveTokens(AccessToken accessToken, RefreshToken refreshToken) throws JsonProcessingException {
    tokenRepository.saveToken(accessToken.getId(), accessToken);
    tokenRepository.saveTokenIdx(accessToken.getUserId(), accessToken);
    tokenRepository.saveToken(refreshToken.getId(), refreshToken);
    tokenRepository.saveTokenIdx(refreshToken.getUserId(), refreshToken);
}

 

 

 

7. 테스트

 

먼저 싱글 스레드 방식에서 해당 코드가 문제가 없는지 테스트하고 멀티 스레드 환경으로 테스트를 마무리하도록 하겠습니다.

 

@Test
@DisplayName("토큰이 모두 없다면 생성하여 저장한다.")
public void getTokenIfExistOrSetAndGet_st() throws Exception {
    //given
    AuthTokenDto[] results = new AuthTokenDto[count];
    createMemberAndAuthorities();

    //when
    for (int i = 0; i < count; i++) {
        results[i] = tokenRedisService
                .getTokenIfExistOrSetAndGet(members[i].getUserId(), authorities.get(i));
    }

    //then
    assertionsGetTokenIfNotExists_And_notSameAllTokens(results);

}

private void assertionsGetTokenIfNotExists_And_notSameAllTokens(AuthTokenDto[] results) {
    Set<String> accessTokenSet = new HashSet<>();
    Set<String> refreshTokenSet = new HashSet<>();

    for (int i = 0; i < count; i++) {
        assertThat(results[i]).isNotNull();
        accessTokenSet.add(results[i].getAccessToken());
        refreshTokenSet.add(results[i].getRefreshToken());
    }
    assertThat(accessTokenSet.size()).isEqualTo(count);
    assertThat(refreshTokenSet.size()).isEqualTo(count);

}

private void createMemberAndAuthorities() {
    for (int i = 0; i < count; i++) {
        members[i] = memberRepository.save(Member.builder()
                        .email("kose" + i + "@naver.com")
                        .userId(UUID.randomUUID().toString())
                        .password(UUID.randomUUID().toString())
                .build());
        authorities
                .add(Arrays.asList(authorityRepository.save(new Authority(members[i], Authorities.ROLE_USER))));
    }
}

 

테스트를 진행한 결과, transaction에서 에러가 발생합니다. ㅜㅜ

눈물을 머금고 에러 로그를 분석해 보겠습니다. 

 

 

분명 aop에서 redisTemplate.multi() 실행 후, rollback 혹은 commit에서 redistemplate.discard() , redistemplate.exec()를 선언했는데 무슨 이유일까요?

 

스프링 Redis 공식문서에서 해당 문제를 해결할 수 있었습니다. 
redis에서 트랜잭션 지원을 받기 위해서는 명시적으로 redistemplate를 초기화하여 빈을 등록할 때, setEnableTransactionSupport(true)로 선언해주어야 한다고 합니다. 

 

 

 

 

@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connectionFactory) {
    RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
    redisTemplate.setConnectionFactory(connectionFactory);
    redisTemplate.setEnableTransactionSupport(true);

    redisTemplate.setKeySerializer(new StringRedisSerializer());
    redisTemplate.setValueSerializer(new StringRedisSerializer());
    redisTemplate.setHashKeySerializer(new StringRedisSerializer());
    redisTemplate.setHashValueSerializer(new StringRedisSerializer());
    return redisTemplate;
}

 

 

따라서, 코드를 수정하고 테스트를 다시 실행하면 분산락 실행 -> 트랜잭션 실행 -> 트랜잭션 커밋 or 롤백 -> 분산락 해제 순서로 적용된 것을 확인할 수 있습니다.

 

여러 개의 에러 테스트도 진행해야 하지만, 분량으로 인해 모든 테스트를 글로 남기기는 한계가 있어서 이 포스팅에서는 동시성 테스트 결과만 남기도록 하겠습니다.

 

@Test
@DisplayName("같은 아이디로 생성요청이 오면, 1회 토큰을 발급하고 나머지 토큰은 동일해야한다. mt")
public void getTokenIfExistOrSetAndGet_mt() throws Exception {
    //given
    ExecutorService executorService = Executors.newFixedThreadPool(32);
    CountDownLatch latch = new CountDownLatch(count);

    AuthTokenDto[] results = new AuthTokenDto[count];
    createMemberAndAuthorities();

    //when
    for (int i = 0; i < count; i++) {
        int finalIdx = i;
        executorService.submit(() -> {
            try {
                results[finalIdx] = tokenRedisService
                        .getTokenIfExistOrSetAndGet(members[0].getUserId(), authorities.get(0));
            }
            catch (Exception e) {
                e.printStackTrace();
            }
            finally {
                latch.countDown();
            }
        });
    }

    latch.await();
    //then

    assertionsCreateTokenOnlyFirstRequestAtSameUserId_and_sameTokensAtSameUserId(results);

}

private void assertionsCreateTokenOnlyFirstRequestAtSameUserId_and_sameTokensAtSameUserId(AuthTokenDto[] results) {
    Set<String> accessTokenSet = new HashSet<>();
    Set<String> refreshTokenSet = new HashSet<>();

    for (int i = 0; i < count; i++) {
        assertThat(results[i]).isNotNull();
        accessTokenSet.add(results[i].getAccessToken());
        refreshTokenSet.add(results[i].getRefreshToken());
    }
    assertThat(accessTokenSet.size()).isEqualTo(1);
    assertThat(refreshTokenSet.size()).isEqualTo(1);
}

 

 

 

동시성 테스트 결과 싱글 스레드에서 발생했던 에러가 동일하게 적용되었습니다. transaction 적용을 위해 redistemplate 초기화 과정에서 명시적으로 트랜잭션을 적용한다고 선언했지만 왜 이번에도 트랜잭션이 유효하지 않다는 문제가 발생한 것일까요?

 

아래부터는 제가 문제를 해결하는 과정에서 생각한 내용입니다. 사실과 다를 수 있습니다.!

 

이 문제는 매우 복잡한 문제로, 멀티 스레드 환경에서 발생하는 문제의 주요 원인은 스레드 간 공유되는 리소스에 대한 동시 접근으로 부터 기인합니다.

 

저는 ThreadLocal로 txActive 변수를 스레드별로 독립적으로 적용하고 있습니다. 하지만, redistemplate는 싱글톤으로 선언되었기 때문에 스레드 스위칭 과정에서 여전히 redistemplate가 공유되어 사용되고 있습니다. 따라서, 스레드 스위칭 과정에서 트랜잭션이 유효하지 않은 스레드가 다음 요청을 수행하고 있기 때문에 커밋이나 롤백되는 과정에서 트랜잭션이 유효하지 않다는 결과를 받은 것입니다.

 

그렇다면 RDBMS의 데이터 저장 기술을 제공하는 JPA는 어떻게 스레드 간 트랜잭션을 공유할 수 있는 것일까요?

Jpa로 RDBMS로 트랜잭션을 관리하면 스프링에서 제공하는 트랜잭션 기능을 사용하는데, 스프링 트랜잭션 매니저가 스레드에 대해서 개별적인 트랜잭션이 적용될 수 있도록 보장합니다. 따라서, 스레드 스위칭이 발생하더라도 이전 트랜잭션이 유효하기 때문에 커밋 및 롤백 시 유효한 데이터 관리가 가능한 것입니다.

 

하지만, Redis는 multi()로 명령어를 시작하는데, Jpa transaction과 다르게 스레드간 트랜잭션이 유효하지 않습니다. 따라서 스레드 스위칭이 발생하는 시점에 이전 트랜잭션이 유효하지 않기 때문에 이러한 에러가 발생합니다.

 

이 문제를 해결하기 위해서는 스레드간 개별적인 redistemplate 연결이 필요하고 개별적인 인스턴스 별로 각각 트랜잭션 연결이 유지되어야 했습니다.

 

 

 

8. ThreadLocal 범위 수정하기 & 테스트

 

멀티 스레드 환경에서 redistemplate에 적용되는 connection을 개별 스레드별로 관리되도록 처리하기 위해서는 ThreadLocal을 활용할 수 있습니다. 스레드 로컬은 주로 로깅이나 트랜잭션과 같이 스레드 간 변수 공유가 있으면 안되는 상황에 적용합니다. 즉 요청을 수행하는 스레드가 해당 요청을 마칠 때까지 단일 스레드 형식으로 처리됩니다. 이 기능을 활용한다면 멀티 스레드에서 발생하는 트랜잭션 공유 문제를 ThreadLocal의 개별 커넥션으로 해결할 수 있습니다.

 

 

 

이제 코드를 다시 수정하겠습니다.

 

@Slf4j
@Aspect
@Component
@Order(2)
@RequiredArgsConstructor
public class RedisTransactionCustom2Aspect {

    private final RedisConnectionFactory connectionFactory;
    private final ThreadLocal<RedisConnection> threadLocal = new ThreadLocal<>();

    private RedisConnection getRedisConnection() {
        RedisConnection redisConnection = threadLocal.get();
        if (redisConnection == null) {
            redisConnection = connectionFactory.getConnection();
            threadLocal.set(redisConnection);
        }

        return redisConnection;
    }

    @Around("liar.memberservice.common.aop.Pointcuts.transactionMethod() && args(userId, authorities)")
    public AuthTokenDto runWithTx(ProceedingJoinPoint joinPoint, String userId, List<Authority> authorities) throws Throwable {
        getRedisConnection().multi();

        try {
            Object proceed = joinPoint.proceed();

            if (proceed != null) {
                return (AuthTokenDto) proceed;
            }
        } catch (Throwable throwable) {
            throw throwable;
        }
        return null;
    }

    @AfterReturning("liar.memberservice.common.aop.Pointcuts.transactionMethod()")
    public void commitTx() {
        try {
            getRedisConnection().exec();
        } catch (Exception e) {
            getRedisConnection().discard();
        } finally {
            threadLocal.remove();
        }
    }
    
    @AfterThrowing("liar.memberservice.common.aop.Pointcuts.transactionMethod()")
    public void rollbackTx() {
        try {
            getRedisConnection().discard();
        }  catch (Exception e) {
            e.printStackTrace();
        } finally {
            threadLocal.remove();
        }
    }
}

 

먼저 ThreadLocal로 RedisConnection의 생명주기를 관리합니다. 현재 ThreadLocal이 비어있다면, RedisConnectionFactory에서 connection을 가져옵니다, 그리고 현재 ThreadLocal에 커넥션을 연결합니다. 이후, 트랜잭션이 시작되는 aop 프록시가 실행될 때, 개별적인 connection이 진행됩니다. 각 스레드는 개별적인 커넥션으로 트랜잭션을 유지하므로 스레드 간 스위칭이 발생하더라도 threadLocal을 획득한 스레드가 해당 요청을 전적으로 관리할 수 있게 됩니다

 

정리하면, 이전 코드는 커넥션은 그대로 유지하되, 스레드 별로 개별적인 트랜잭션을 적용하여 싱글 스레드에서는 트랜잭션이 유지되었지만 멀티 스레드에서는 스레드간 데이터 공유로 인해, 트랜잭션이 적용되지 않는 스레드가 접근하여 커밋이나 롤백을 수행하므로 트랜잭션이 유효하지 않기에 에러가 발생한 것입니다.

 

하지만 수정한 코드는 커넥션 자체부터 스레드 로컬을 적용하였으므로 redistemplate이 생성되는 시점에서 각 커넥션은 개별 스레드만 접근할 수 있도록 처리된 것입니다.

 

 

드디어 길고 길었던 테스트가 성공합니다 ㅠㅠ!

 

 

 

9. 느낀 점

 

백엔드에서 동시성 문제와 트랜잭션 관리는 고질적인 문제이며 가장 어려운 문제임이 틀림없습니다. 이번 문제를 해결하며, 거의 20시간 가까이 소모했던 것 같습니다. 


먼저 crudRepository -> redistemplate로 리펙토링 -> 분산락 트랜잭션 관리 -> 동시성 문제를 위한 ThreadLocal 생명주기 관리까지 하나의 에러를 해결하는 과정에서 정말 많은 부분을 수정하고 배울 수 있었던 것 같습니다.

 

사실 토이프로젝트에서 동시성 문제를 고려하지 않고 작성되는 코드도 많이 있습니다. 하지만, 소비자 입장에서 생각해 봤을 때, 내 중요한 데이터가 원자성이나 무결성을 보장받지 못한다면 그 서버는 경쟁사회에서 도태될 것입니다.

 

백엔드는 화려하게 보이는 것은 없지만 소비자의 신뢰도를 좌지우지하는 정말 중요한 역할을 수행하는 것 같습니다.

이번 문제를 해결하며, 정말 포기하고 싶다는 머리 끝까지 들었습니다. 하지만, 시간이 걸리더라도 반드시 해결하겠다는 마음이 있었고, 이 기회로 저 스스로 한층 더 성장할 수 있으리라 확신을 할 수 있었습니다.

 

나아가 제가 입사하게 될 회사에서 만약 비슷한 문제가 발생한다면, 이번 경험을 토대로 빠른 문제 해결에 이바지할 수 있을 것이라는 생각을 하게 되었습니다.

 

아직 해결되지 않은 문제도 많이 있습니다. ThreadLocal로 매번 커넥션을 연결하는 과정은 서버 내부에서 많은 부하를 일으킬 수 있습니다. 이 문제를 해결하기 위해 또 많은 연구를 할 예정이고 글로 남기도록 하겠습니다.

 

길었지만 읽어주셔서 감사드립니다.!!!

 

 

안녕하세요. 회사와 함께 성장하고 싶은 KOSE입니다.

 

이번 포스팅은 Contoller와 Service를 리펙토링 하여 Controller의 부하를 줄이고 계층 간 분리하는 과정을 작성하고자 합니다.

Spring에서 Controller와 Service는 각각 하는 역할이 다릅니다. Controller는 Service에 의존 관계 주입을 받고 특정 Uri로 오는 요청을 처리하여 응답을 보내는 역할을 수행합니다. Service는 Controller에서 받은 요청을 위임받아 핵심 비즈니스 로직을 수행하여 실행하거나 값을 리턴하는 역할을 수행합니다. 

 

저는 코드를 작성할 때 Controller와 Service를 서로 의존 관계 주입으로 설정할 때 고민되는 것들이 있습니다. 

첫째는 Controller의 역할 과중이고 둘 째는 Dto의 계층적 분리입니다.

따라서, 이번 글은 이 두가지를 해결하기 위해 제가 시도했었던 방식을 공유드리고자 합니다.!

 

 

1. Controller의 부담을 줄이는 Fillter와 Interceptor, ControllerAdvice

 

 

a. Filter

 

Filter는 Servlet 컨테이너에서 요청과 응답을 처리하기 전후에 동작하는 기능입니다. 인코딩 변환, 보안 체크, 로깅 등 작업을 수행합니다. Filter는 Servlet 컨테이너에 등록되며 SpringSecurity를 사용한다면 DispatcherServlet 앞 단에서 적용됩니다.

 

@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {

    http
            .authorizeHttpRequests((requests) -> requests
                    .requestMatchers(memberServiceWhiteList)
                    .permitAll()
                    .anyRequest()
                    .authenticated()
            )

 

 

b. Interceptor

 

Interceptor는 Spring MVC 요청 전 후에 동작하는 기능입니다. Interceptor도 권한 혹은 인증 등을 처리할 수 있고, 로깅 용도로도 사용됩니다. DispatcherServlet과 Controller 사이에 위치하여 중간 단계에서 요청을 인터셉터하는 역할을 수행합니다.

 

Interceptor는 HandlerInterceptor를 구현하여 생성할 수 있습니다. preHandle은 요청 처리 이전에 적용하는 것으로 보통 preHandle에서 인증 혹은 보안 처리를 담당하고  있습니다.

 

간단한 예시로 하단에는 Authorization이라는 헤더가 존재하는지 여부를 파악하고 없다면 예외처리하여 더 이상 요청이 이뤄지지 않도록 하는 역할을 수행합니다. 주의할 점은 Component 대상으로 Interceptor를 등록하여야 합니다.

 

@Slf4j
@Component
public class WebInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {

        String userId = request.getHeader("Authorization");
        if (userId == null) {
            log.info("Exception");
            throw new NotFoundUserException();
        }

        log.info("request = {}", request.getRequestURI());
        return true;
    }
}

 

 

@Configuration
public class WebConfig implements WebMvcConfigurer {

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new WebInterceptor());
    }
}

 

 

C. ControllerAdvice

 

ControllerAdvice는 Controller처럼 MVC 패턴에서 컨트롤러의 역할을 수행합니다, ControllerAdvice는 예외처리에 특화된 기능으로 다수의 컨트롤러에서 발생한 공통적인 예외처리를 담당하는 역할을 수행합니다. 

선언은 @RestControllerAdvice 어노테이션을 활용하여 Rest전용 ControllerAdvice를 생성하겠다고 선언합니다.

이 경우, 예외처리를 공통적으로 ControllerAdvice에서 처리하게 됩니다.

 

@Slf4j
@RestControllerAdvice(annotations = RestController.class)
public class ExceptionControllerAdvice {
    
    /**
     *  권한 없는 사용자 접근
     */
    @ResponseStatus(UNAUTHORIZED)
    @ExceptionHandler
    public ResponseEntity<ErrorDto> notExistsRefreshTokenHandler(NotExistsRefreshTokenException e) {
        return new ResponseEntity<>(new ErrorDto(e.getErrorCode(), e.getMessage()), UNAUTHORIZED);
    }

 

ControllerAdvice는 DispatcherServlet과 Controller 사이에 위치하기 때문에 만약 Controller 다음 단에서 예외가 발생한 경우, ControllerAdvice가 해당 요청을 위임받을 수 있습니다.

 

 

만약 Interceptor에서 예외가 발생하면 어떻게 될까요?

이때는 Interceptor에서 예외가 발생하면 DispatcherServlet으로 이동해서 DispatcherServlet에서 예외 처리를 위해 ControllerAdvice를 호출합니다. 즉, Interceptor를 통과해야만 요청이 전달되는 Controller에는 예외가 전달되지 않는 것입니다. 따라서 Interceptor를 도입할 경우 예외처리기를 ControllerAdvice를 이용하는 것이 좋습니다.!

 

 

그렇다면 Filter에서 예외가 발생한 경우는 어떻게 될까요? 
Filter는 Servlet - Filter - DispathcherServlet 사이에 존재하므로 먼저 Servlet에서 예외 처리기를 찾고 없다면 DispathcherServlet의 ControllerAdvice에서 예외처리를 찾습니다. 없다면 마지막으로 다시 Servlet으로 이동하여 예외처리를 진행합니다.

 

그림으로 정리하면 다음과 같습니다.

 

 

 

 

2. Dto 계층적 분리하기

 

Controller에서 계층적으로 역할을 분리하는 과정을 수행했다면, 다음에 발생하는 문제는 Controller와 Service를 연결해 주는 Dto의 존재입니다. Dto는 Data Transfer Object로 계층 간 데이터 교환 혹은 데이터 전송 객체를 의미합니다. 

계층적인 모델링을 구축할 때 조심해야하는 부분이 바로 Service는 Controller에 의존하지 않아야 하는 것입니다.

즉, Service 레이어는 핵심 비즈니스 로직을 수행해야 하는 곳이기 때문에 Controller가 바뀌더라도 Service 단에서 이루어지는 핵심 비즈니스 로직이 영향을 받으면 안 됩니다. 즉 Controller -> Service로의 접근은 가능하더라도 Service -> Controller로의 접근은 지양되며 순환참조를 발생시킬 수도 있습니다.

 

계층으로 확인하면 다음과 같습니다.

 

 

클라이언트의 요청(Request)는 Controller로 전달이 되며 Controller -> Service -> Repository 순서로 Dto가 전달이 되고 다시 역순으로 Dto로 전달이 됩니다.

 

이 과정에서 생기는 트레이드 오프는 Request요청과 Dto가 서로 동일할 때입니다.

가령, Login을 담당하는 Dto가 있다면, 이는 Client의 Request도 동일할 것입니다.

이 경우 "과연 Dto 두 계층을 분리해야 할까요?"입니다.

 

@Getter
@NoArgsConstructor
@AllArgsConstructor
public class LoginDto {

    @NotNull
    private String email;

    @NotNull
    private String password;

}

 

정답은 없는 것 같습니다. 먼저 그대로 사용할 경우 Dto를 다시 Dto로 변환하는 과정이 생략되고 코드가 간단해지기 때문에 불필요한 코드 중복을 줄일 수 있습니다. 반면 Request 요청과 Dto를 분리할 경우 계층적 역할을 구분할 수 있고 무엇보다 만약 Controller에서 요구하는 Request 요청이 수정되었을지라도 핵심 비즈니스 로직을 처리하는 Service에서도 코드를 수정해야 하는 불상사를 줄일 수 있습니다.

 

저는 Controller와 Service 계층의 구분을 명확하게 하고, 추후 생길 수 있는 코드 수정에서 Service단이 영향을 받는 것을 최소화하기 위해 Dto를 분리하는 선택을 하였습니다. 

 

이제 코드로 리펙토링 하는 과정을 정리하도록 하겠습니다.

 

 

 

3. Controller 리펙토링 

 

먼저 이전에 작성했던 코드는 다음과 같습니다.

 

    /**
     *
     * 회원 가입 요청
     * BAD_REQUEST: 400 (요청한 파라미터의 타입 에러 혹은 바인딩 에러)
     * CONFLICT: 409 (요청한 회원가입 이메일 이미 존재)
     * OK: 200 (가입 완료)
     *
     * @return
     */
    @PostMapping("/register")
    public ResponseEntity formRegister(@Valid @RequestBody FormRegisterUserDto dto,
                                       BindingResult bindingResult) {

        if (bindingResult.hasErrors()) throw new BindingInvalidException();

        if (!memberService.registerForm(dto)) {
            throw new UserRegisterConflictException();
        }

        return ResponseEntity.ok(SendSuccess.of());
    }

 

이 코드는 Json으로 폼 회원가입을 요청을 처리하는 Controller입니다.

이 코드에는 BindingError를 처리하는 코드와 registerForm()을 처리하는 코드로 구성되어 있습니다.

Controller에서는 다음과 같이 바인딩 에러를 처리하는 부가기능과 핵심 비즈니스 로직을 수행해야 하는 코드가 같이 있기 때문에 이를 분리하는 과정이 필요하였습니다.

 

먼저, 바인딩 에러와 관련된 부분은 Dto에 작성된 @NotNull과 같은 어노테이션의 제약 조건에 위배되지 않는지 판단하는 로직입니다. 이 코드에서 @Validated 어노테이션을 적용하면 Dto의 제약 조건에 위배된다면 MethodArgumentNotValidException() 예외를 발생시킵니다.

 

앞 서 정의한 ExceptionControllerAdvice를 활용하면, Controller에서 BindingResult를 처리하여 Exception 예외를 발생시키는 코드를 제거할 수 있습니다.

 

코드를 수정하면 다음과 같습니다.

 

@PostMapping("/register")
public ResponseEntity formRegister(@Validated @RequestBody FormRegisterUserDto dto) {
    
    if (!memberService.registerForm(dto)) {
        throw new UserRegisterConflictException();
    }

    return ResponseEntity.ok(SendSuccess.of());
}

 

앞 서 정의한 bindingResult.hasError()를 제외함으로써 코드가 간결해졌습니다.

이제 bindingError를 ExceptionControllerAdvice에서 예외를 처리하도록 하겠습니다.

 

/**
 *  바인딩 에러
 */
@ResponseStatus(BAD_REQUEST)
@ExceptionHandler
public ResponseEntity<ErrorDto> MethodArgumentNotValidExceptionHandler(MethodArgumentNotValidException e) {

    List<String> errorMessages = new ArrayList<>();
    for (FieldError fieldError : e.getBindingResult().getFieldErrors()) {
        errorMessages.add(fieldError.getDefaultMessage());
    }

    return new ResponseEntity<>(new ErrorDto("400", String.join(".", errorMessages)), BAD_REQUEST);
}

 

여기서 확인할 부분은, e.getBindingResult(). getFieldErrors()입니다.

기본적으로 e.getMessage()는 해당 예외에 대한 모든 로그를 제공합니다. 클라이언트는 실제로 잘못 작성한 예외 로그만 보길 원하지만 g.getMessage()로 전송하게 되면 복잡한 예외 메시지까지 전부 전달되게 됩니다.

 

{ "code": "400", "message": "Validation failed for argument [0] in public org.springframework.http.ResponseEntity liar.memberservice.auth.controller.controller.AuthController.formRegister(liar.memberservice.auth.service.dto.FormRegisterUserDto): [Field error in object 'formRegisterUserDto' on field 'password': rejected value [kose1234]; codes [Length.formRegisterUserDto.password,Length.password,Length.java.lang.String,Length]; arguments [org.springframework.context.support.DefaultMessageSourceResolvable: codes [formRegisterUserDto.password,password]; arguments []; default message [password],100,10]; default message [password는 10자 이상입니다.]] " }

 

따라서, FiledError에 해당하는 값을 받은 후 getDefaultMessage()를 호출하여 실제 실패한 예외 메시지만 받도록 처리합니다.

 

List<String> errorMessages = new ArrayList<>();
for (FieldError fieldError : e.getBindingResult().getFieldErrors()) {
    errorMessages.add(fieldError.getDefaultMessage());
}

 

 

그 결과 응답 메시지가 제가 원하는 형태로 전달되는 것을 확인할 수 있습니다.

 

 

 

4. FacadeService 도입하여 Dto 리펙토링 

 

현재 컨트롤러는 다양한 Service에 의존성 주입을 받고 있습니다. 실제로 코드상 문제는 없지만, 많은 의존성은 코드의 복잡성을 가중시킬 수 있습니다. 무엇보다 코드를 수정해야할 때, "이 서비스가 무슨 서비스더라...?" 생각하는 시간이 많게 되었던 것 같습니다.

 

따라서, Controller는 FacadeService에만 의존 관계 주입을 받도록 처리하여 FacadeService에서 기타 다른 서비스로 의존성 주입을 받아 처리할 수 있도록 하였습니다.

 

 

FacadeService를 중간단계에 놓은 후, 얻을 수 있었던 장점은 Controller와 Service 사이에 Dto를 분리할 수 있었다는 점입니다.

코드로 확인해 보면 다음과 같습니다.

 

    /**
     *
     * 회원 가입 요청
     * BAD_REQUEST: 400 (요청한 파라미터의 타입 에러 혹은 바인딩 에러)
     * CONFLICT: 409 (요청한 회원가입 이메일 이미 존재)
     * OK: 200 (가입 완료)
     *
     * @return
     */
    @PostMapping("/register")
    public ResponseEntity formRegister(@Validated @RequestBody FormRegisterRequest request) {
        facadeService.register(request);
        return ResponseEntity.ok(SendSuccess.of());
    }

 

@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class FacadeService {

    private final MemberService memberService;
    private final TokenService tokenService;

    @Transactional
    public void register(FormRegisterRequest request) {
        if (!memberService.registerForm(RequestMapperFactory.mapper(request))) {
            throw new UserRegisterConflictException();
        }
    }

 

@Component
public class RequestMapperFactory {

    public static FormRegisterUserDto mapper(FormRegisterRequest request) {
        return FormRegisterUserDto.builder()
                .email(request.getEmail())
                .password(request.getPassword())
                .username(request.getUsername())
                .build();
    }

 

컨트롤러는 FacadeService에만 의존하게 되고, FacadService는 memberService, tokenService와 의존 관계 주입을 받습니다. 이 단계에서 Request와 Dto를 변형해 주는 Factory클래스의 mapper 메서드를 도입함으로써 Dto를 분리할 수 있었습니다.

 

그 결과, Dto에 있었던 검증 로직은 FormRegisterRequest에서 처리하게 되고, FormRegisterDto는 Dto 임무만 처리할 수 있도록 할 수 있었습니다.

 

@Getter
@NoArgsConstructor
public class FormRegisterRequest {

    @NotNull
    private String username;

    @NotNull
    private String email;

    @NotNull
    @Length(min = 10, max = 100, message = "password는 10자 이상입니다.")
    private String password;

    @Builder
    public FormRegisterRequest(String username, String email, String password) {
        this.username = username;
        this.email = email;
        this.password = password;
    }

}

 

 

@Getter
@NoArgsConstructor
public class FormRegisterUserDto {
    
    private String username;
    private String email;
    private String password;

    @Builder
    public FormRegisterUserDto(String username, String email, String password) {
        this.username = username;
        this.email = email;
        this.password = password;
    }
}

 

다시 테스트를 진행하면 테스트를 성공한 것을 확인할 수 있습니다.

 

 

 

5. 느낌점

 

리펙토링하는 과정에서 Filter, Interceptor, ControllerAdvice 등의 역할을 한 번 더 짚고 넘어갈 수 있었습니다.

크게 문제가 없다고 넘길 수 있는 부분이었지만, 이 코드보다 더 좋은 선택지는 없을지 고민하고, 한 번 이 방법을 적용해볼까?

하며 코드를 작성하니, 이전에 미처 생각하지 못했던 예외 전파 순서를 깊게 공부해볼 수 있었습니다.

 

Dto는 항상 코드를 작성할 때마다 고민이 되는 부분입니다. mapper를 도입하는 것이 오히려 복잡성을 가중시키는 것 같아보이지만, 추후 인증의 문제로 다른 field가 추가되어야 하는 상황이라면 Dto를 분리한 코드는 이러한 문제에 직면하더라도 수정해야할 코드의 수가 매우 줄어들 것이라고 생각하게 되었습니다.

 

 

부족하지만 오늘도 읽어주셔서 감사드립니다.!

안녕하세요. 회사와 함께 성장하고 싶은 KOSE입니다.

 

이번 포스팅은 관계형 데이터베이스에서 발생하는 동시성 문제를 해결하는 과정을 작성하려고 합니다.

 

다른 서비스들은 모두 동시성 문제를 해결하여 완성시켰지만, member-service 프로젝트는 동시성 문제를 해결하지 않았습니다.

따라서 리펙토링을 수행하며 동시성 문제가 해결되는 과정을 정리하고자 합니다.

 

 

1. 동시성 문제

 

동시성 문제는 가장 까다롭고 어려운 문제입니다. 성능과 안정성을 모두 고려해야 하는 난제로, 관계형 데이터베이스 혹은 인메모리 데이터베이스 등 다양한 분야에서 발생하는 문제입니다. 동시성 문제는 다수의 스레드가 동시에 공유 자원에 접근할 때 발생합니다. Read 모드로 접근하는 경우, 락의 상황에 따라 다르지만 일반적으로 동시성 문제가 발생하지 않는다고 판단합니다.

하지만 Write 모드로 데이터베이스에 접근할 때, 다수의 쓰레드가 동시에 Write 요청을 수행하면 원하지 않는 결과가 발생할 수 있습니다.

 

단일 Thread에서 요청을 수행할 경우, 만약 수량을 10개씩 3번 빼라는 요청이 오면 10개씩 정상적으로 수량이 감소합니다. 

하지만 멀티 쓰레드에서는 수량을 10개씩 3번 빼라는 요청이 오면 어떻게 될까요? 맨 오른쪽 그림과 같이 70이 나온다는 보장을 하기 어렵습니다. 동시에 스레드가 접근할 경우 제일 먼저 나온 100개라는 공유 자원에 동시에 접근할 수 있기 때문입니다. 따라서 A 스레드와 B 스레드는 동시에 100개 자원에 접근하게 되고 수량을 제거하고 90이라는 값을 리턴하게 됩니다. C 스레드는 A, B의 결과 이후에 수행된다고 가정하면 90이라는 값을 읽고 80을 리턴하게 됩니다.

 

 

만약, 선착순이나 한정된 개수만 판매해야하는 비즈니스 로직이 있다면, 혹은 단 하나의 유니크한 값만 가질  수 있는 제약조건이 있는 데이터베이스라면, 동시성 문제가 발생할 때 큰 장애가 일어날 수 있습니다. 따라서 이러한 문제는 Lock을 활용하거나, RedissonClient 등 다양한 방법을 통해 동시성 문제를 제어할 수 있습니다.

 

 

2. 동시성 발생 확인하기

 

먼저, 제가 작성했던 이전 코드는 Member 객체를 저장하는 일반적인 코드입니다.

 

@Getter
@RequiredArgsConstructor
@Entity
public class Member extends BaseEntity implements Persistable {

    @Id
    @Column(name = "member_id")
    private String id;
    
    --- 중략 ---
    
    @Builder
    public Member(String userId, String password, String registrationId, String registerId,
                  String email, String picture, String username) {
        this.id = UUID.randomUUID().toString();
        this.userId = userId;
    
    --- 중략 ---
    
     /**
     * Returns if the {@code Persistable} is new or was persisted already.
     *
     * @return if {@literal true} the object is new.
     */
    @Override
    public boolean isNew() {
        return super.getCreatedAt() == null;
    }
@Repository
public interface MemberRepository extends JpaRepository<Member, String> {

    Member findByEmail(String email);

    Optional<Member> findByRegisterId(String registerId);
    Optional<Member> findByUserId(String userId);
}

 

먼저 싱글 스레드 환경의 테스트에서는 동일한 email로 여러 번 계정 생성 요청이 오면 성공합니다.

 

@Test
@DisplayName("단일 스레드 save")
public void save_single() throws Exception {
    //given
    int result = 0;
    int count = 5;

    Member[] members = new Member[count];
    Member kose = Member.builder().email("kose@naver.com").password("1234").username("kose").build();

    //when
    for (int i = 0; i < count; i++) {
        try{
            Member member = memberRepository.findByEmail("kose@naver.com");

            if (member == null) members[i] = memberRepository.save(kose);
            else members[i] = null;

        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    for (int i = 0; i < count; i++) if (members[i] != null) result++;

    //then
    assertThat(result).isEqualTo(1);

}

 

 

하지만, 아쉽게도 이 코드는 쓰레드에 세이프하지 않은 코드입니다. 멀티 스레드 테스트로 확인해 보겠습니다. 멀티 스레드를 구축하는 데는 다양한 방법이 있습니다. new Thread(){}를 선언하여 내부에 익명 클래스 형태 혹은 람다 형태로 스레드 환경을 만들 수 있습니다.

 

new Thread() {
    
}.start();

new Thread(() -> {
    
}).start();

 

저는 countDownLatch()를 활용하여 테스트 환경을 구축하였습니다.

 

int threadCount = 5;
ExecutorService executorService = Executors.newFixedThreadPool(32);
CountDownLatch countDownLatch = new CountDownLatch(threadCount);

//when
for (int i = 0; i < threadCount; i++) {
    int finalIdx = i;
    executorService.submit(() -> {
        try{

        } catch (Exception e) {

        } finally {
            countDownLatch.countDown();
        }
    });
}

countDownLatch.await();

 

coundDownLatch.countDown()은 스레드 카운트 개수를 줄이는 역할을 수행하고 coundDownLatch.await()는 모든 카운트 개수가 줄어들 때까지 대기하는 역할을 수행합니다.
따라서, await() 이후에 테스트 검증을 위한 처리작업이나 //then 에 해당하는 검증 로직을 추가할 수 있습니다.

 

@Test
@DisplayName("멀티 스레드 save")
public void save_mt() throws Exception {
    //given
    int threadCount = 5;
    ExecutorService executorService = Executors.newFixedThreadPool(32);
    CountDownLatch countDownLatch = new CountDownLatch(threadCount);

    Member[] members = new Member[threadCount];
    Member kose = Member.builder().email("kose@naver.com").password("1234")
            .username("kose").build();

    //when
    for (int i = 0; i < threadCount; i++) {
        int finalIdx = i;
        executorService.submit(() -> {
            try{
                Member member = memberRepository.findByEmail("kose@naver.com");

                if (member == null) members[finalIdx] = memberRepository.save(kose);
                else members[finalIdx] = null;

            } catch (Exception e) {
                e.printStackTrace();
            } finally {
                countDownLatch.countDown();
            }
        });
    }

    countDownLatch.await();

    int result = 0;
    for (int i = 0; i < threadCount; i++) if (members[i] != null) result++;

    //then
    assertThat(result).isEqualTo(1);

}

 

익명 클래스나 람다식에서는 암묵적 fianl 타입의 값이 입력되어야 합니다. 즉, 배열에 선언되는 인덱스는 final 타입으로 선언하여야 의도하지 않은 값 변경을 막고 생명 주기를 명확하게 할 수 있습니다.

 

결과는 다음과 같이 실패합니다. 실패 발생 로그는 id는 primary 키이므로 유니크 제약조건이 적용되는데 같은 3개의 유니크한 값이 리턴되었기 때문입니다. 실제 서비스 환경에서는 큰 장애로 이어질 수 있습니다.

 

 

 

3. 동시성 문제 해결하기 - Lock 비교

 

동시성 문제를 해결하기 위해서는 락 기능을 활용할 수 있습니다. 락은 기본적으로 특정 자원에 대한 변경 요청을 할 때 락을 획득하고 변경을 실행한 후 락을 해제하는 개념입니다. 따라서, 락이 걸려있는 자원에 대한 변경 요청이 오면 락이 해제될 때까지 대기해야 합니다.

 

동시성 문제를 해결하기 위해서는 Pessimistic Lock 혹은 RedissonClient의 분산락 등을 활용할 수 있습니다. 하지만, 이는 간단한 문제가 아니며 작성한 코드와 환경에 따라 달라질 수 있습니다.
(OptimissticLock 도 사용할 수 있는데 이는 버전 관리에 적절하기 때문에 두 가지 방법으로 적용하였습니다)

 

Pessimistic Lock은 비관적인 방법으로 락을 적용하는 방식입니다. 트랜젝션 도중에 다른 트랜젝션이 데이터를 변경할 가능성이 있으면 해당 값에 락을 걸어 변경을 막도록 하는 방법입니다. 따라서, 여러 트랜잭션이 동시에 데이터에 접근하더라도, 락을 걸게 됨으로써 데이터의 동시적 변경 요청을 방지하는데 도움을 줄 수 있습니다.

하지만, 데이터베이스 자체에 락을 걸어버리는 개념이기 때문에 성능 문제나 데드락의 가능성이 높기 때문에 Pessimistic Lock을 사용할 때 충분한 점검이 반드시 필요합니다.

 

두 번째 방법으로, RedissonClient의 분산락을 활용할 수 있습니다.  분산락은 기본적으로 락을 획득하여 락이 유지되는 시간 동안 비즈니스 로직을 처리한 후 락을 해제하는 개념입니다. 실제 분산환경에서 사용하기에 적합하다는 평가를 받고 있습니다. 

하지만, 실제 비즈니스 로직이 수행되는 기간 동안 Redis 클러스터 지연이 발생한다면 락이 해제되는 시점에 다른 요청이 락을 획득하여 공유 자원 변경을 요청할 수도 있습니다. 이 경우 동시성 문제가 발생할 수 도 있습니다. 따라서, Redis로 요청이 수행되는 기간동안 지연이 발생할 가능성을 배제할 수 없기에 적절한 추가 대응 조치가 필요합니다. 

또한, 분산락의 경우는 외부의 Redis를 사용해야 한다는 단점이 있습니다. AWS에 배포하는 경우에 ElasticCache 같은 서버를 하나 더 추가하는 것은 비용적 부담이 될 수밖에 없습니다. 정리하면, Redis 클러스터의 지연 문제가 발생하지 않는지, 또한 외부 Redis 서버를 운영할 수 있는 비용적인 문제가 해결되었는지를 판단하여 적용하여야 합니다.

 

하지만, 아쉽게도 Pessimistic 락 혹은 RedissonClient를 사용하더라도, 동시성 문제가 발생할 수 있습니다.

 

 

실제로 JPA를 이용하여 데이터베이스와 커넥션 풀을 연결하면 데이터베이스 커밋이 완료되면 커넥션이 끊어집니다. 하지만 이는 데이터베이스에 그 값이 바로 저장되었다는 것을 의미하지 않습니다. 실제 DB는 내부적으로 복잡한 과정을 거치고 I/O 연산에서 시간이 소요될 수 있습니다. 만약 DB에 저장되는 과정에 바로 다음 요청이 동시적으로 발생한다면 DB에는 값이 온전히 저장되지 않았기 때문에, findById와 같은 요청으로 값을 찾을 때, 값이 없다고 나올 수 있는 것입니다.

 

따라서, 이 경우에는 두 가지 방법을 모두 사용하여 문제를 해결할 수 있습니다. 하지만 이 두 가지 방법을 모두 사용하는 것은 데드락을 발생시킬 수 있으므로 트랜젝션 관리를 적절하게 처리해주어야 합니다.

 

 

4. 문제 파악 및 문제 해결하기

 

 

@Transactional
    public boolean registerForm(FormRegisterUserDto dto) {

        Member findMember = memberRepository.findByEmail(dto.getEmail());

        if (findMember == null)  {
            Member user = Member.builder()
                    .userId(UUID.randomUUID().toString())
                    .username(dto.getUsername())
                    .password(passwordEncoder.encode(dto.getPassword()))
                    .email(dto.getEmail())
                    .build();

            memberRepository.save(user);
            authorityRepository.save(Authority.builder().member(user).authorities(ROLE_USER).build());

            return true;
        }
        throw new UserRegisterConflictException();
    }

 

이 소스는 email 값을 가져와서 member 객체를 찾은 후, 값이 null이라면, member와 authority를 저장하는 로직입니다.

싱글 스레드에서는 다음과 같이 정상적으로 성공합니다.

 

 

@Test
@DisplayName("싱글 스레드에서 같은 email 저장 요청이 오면  맨처음 이외에 요청은 실패한다.")
public void registerForm_single() throws Exception {
    //given
    int count = 5;
    FormRegisterUserDto dto = new FormRegisterUserDto("kose", "kose@naver.com", "12345678910");

    //when
    memberService.registerForm(dto);

    //then
    for (int i = 0; i < count; i++) {
        Assertions.assertThatThrownBy(() -> {
            memberService.registerForm(dto);
        }).isInstanceOf(UserRegisterConflictException.class);
    }
}

 

 

하지만, 동시성 테스트에서는 실패합니다.

 

@Test
@DisplayName("멀티 스레드 save")
@Transactional
public void save_mt() throws Exception {
    //given
    int result = 0;
    int threadCount = 5;
    boolean[] registers = new boolean[threadCount];

    ExecutorService executorService = Executors.newFixedThreadPool(32);
    CountDownLatch countDownLatch = new CountDownLatch(threadCount);

    FormRegisterUserDto dto = new FormRegisterUserDto("kose", "kose@naver.com", "12345678910");

    //when
    for (int i = 0; i < threadCount; i++) {
        int finalIdx = i;
        executorService.submit(() -> {
            try{
                registers[finalIdx] = memberService.registerForm(dto);
            } catch (Exception e) {
                e.printStackTrace();
            } finally {
                countDownLatch.countDown();
            }
        });
    }

    countDownLatch.await();


    for (int i = 0; i < threadCount; i++) {
        if (registers[i]) result++;
    }

    //then
    assertThat(result).isEqualTo(1);
}

 

이는 해당 코드가 스레드 세이프 하지 않기 때문입니다.

 

 

 

4. 코드 수정하기 

 

먼저, 분산락을 적용하기 위해 RedissonClient를 추가하여야 합니다.

 

//redissonClient
implementation 'org.redisson:redisson-spring-boot-starter:3.17.7'
@Configuration
@EnableAspectJAutoProxy
public class RedisAopConfig {

    @Bean
    public RedisLockAspect redisLockAspect(RedissonClient redissonClient) {
        return new RedisLockAspect(redissonClient);
    }

    @Bean
    public RedissonClient redissonClient() {
        Config config = new Config();
        config.useSingleServer().setAddress("redis://localhost:6379");
        return Redisson.create(config);
    }
}

 

 

이후 문제가 되는 코드에 RedissonClient를 적용하여 락을 걸어줍니다.

RegisterUserDto에 email은 고유한 값을 가져야 하므로, 해당 값으로 Key를 생성한 후 try - catch -finally로 
내부 로직을 작성합니다.

 

락을 획득하면 내부 비즈니스 로직이 실행이 되며, 락을 획득하지 못하면 예외가 발생합니다.
finally에서는 락을 획득한 경우에는 락을 해제하는 과정을 추가하여 데드락이 발생하는 문제를 해결하고자 하였습니다.

 

@Transactional
public boolean registerForm(FormRegisterUserDto dto) {
    String lockKey = "registerForm: " + dto.getEmail();
    RLock lock = redissonClient.getLock(lockKey);
    boolean isLocked = false;
    try {
        isLocked = lock.tryLock(20, 3, TimeUnit.SECONDS);
        if (!isLocked) {
            throw new RedisLockException();
        }
        log.info("lockKey = {} 락 획득", lockKey);
        Member findMember = memberRepository.findWithMemberForSave(dto.getEmail());
        if (findMember == null) {
            Member user = Member.builder()
                    .userId(UUID.randomUUID().toString())
                    .username(dto.getUsername())
                    .password(passwordEncoder.encode(dto.getPassword()))
                    .email(dto.getEmail())
                    .build();
            memberRepository.save(user);
            authorityRepository.save(Authority.builder().member(user).authorities(ROLE_USER).build());
            return true;
        }
        throw new UserRegisterConflictException();
    } catch (InterruptedException e) {
        throw new RedisLockException();
    } finally {
        if (isLocked) {
            lock.unlock();
            log.info("lockKey = {} 락 반환", lockKey);
        }
    }
}

 

이후 Pessimistic Lock을 적용하기 위해 Repository를 수정합니다.

 

@Repository
public interface MemberRepository extends JpaRepository<Member, String> {

	--- 중략 ---
    
    @Lock(value = LockModeType.PESSIMISTIC_WRITE)
    @Query("select m from Member m where m.email = :email")
    Member findWithMemberForSave(@Param("email") String email);
}

 

@Lock(value = LockModeType.PESSIMISTIC_WRITE)를 사용하여 락을 획득할 수 있고 내부에는 JPQL로 쿼리를 작성합니다. 이경우 findWithMEmberForSave()가 실행되는 트랜젝션에서는 쓰기 락을 획득하게 되어 다른 요청이 수행될 때 락이 해제될 때까지 기다리게 됩니다.

 

이후 테스트를 진행하면 성공하였습니다 하지만, 로그를 보면 데드락이 발생한 것을 확인할 수 있습니다.

 

 

"이유는 바로, save() 메서드에 있습니다."

 

제가 작성한 로직은

RedissonClient Lock 획득 -> Pessimistic Lock 획득 -> Pessimistic Lock 해제 -> RedissonClientLock 해제입니다.

save()는 해당 엔티티를 영속화시키기는 과정이 추가되지만 실제 데이터베이스에는 전송되지 않습니다. 이는 곧,
member 객체에 대한 데이터를 데이터베이스에 써서 락을 해제하는 과정이 실행되어야 하는데 실제로 쓰기 작업이 수행되지 않았기 때문에 영속성은 있으나 락이 해제되지 않은 것입니다. 따라서, 데드락으로 이어진 것입니다.

 

이것을 해결하기 위해서는 save() -> saveAndFlush()로 데이터베이스에 쓰기 작업을 즉각 수행하도록 처리합니다.

코드를 수정하면 다음과 같습니다.

 

@Transactional
public boolean registerForm(FormRegisterUserDto dto) {
    String lockKey = "registerForm: " + dto.getEmail();
    RLock lock = redissonClient.getLock(lockKey);
    boolean isLocked = false;
    try {
        isLocked = lock.tryLock(20, 3, TimeUnit.SECONDS);
        if (!isLocked) {
            throw new RedisLockException();
        }
        log.info("lockKey = {} 락 획득", lockKey);
        Member findMember = memberRepository.findWithMemberForSave(dto.getEmail());
        if (findMember == null) {
            Member user = Member.builder()
                    .userId(UUID.randomUUID().toString())
                    .username(dto.getUsername())
                    .password(passwordEncoder.encode(dto.getPassword()))
                    .email(dto.getEmail())
                    .build();
            memberRepository.saveAndFlush(user);
            authorityRepository.saveAndFlush(Authority.builder().member(user).authorities(ROLE_USER).build());
            return true;
        }
        throw new UserRegisterConflictException();
    } catch (InterruptedException e) {
        throw new RedisLockException();
    } finally {
        if (isLocked) {
            lock.unlock();
            log.info("lockKey = {} 락 반환", lockKey);
        }
    }
}

 

테스트 결과 데드락이 발생하지 않고 테스트에 성공한 것을 확인할 수 있습니다.

 

 

혹시 그렇다면, saveAndFlush()가 문제였을 수 있다는 생각과 함께 pessimisticLock을 해제해보았습니다.

 

@Query("select m from Member m where m.email = :email")
Member findWithMemberForSave(@Param("email") String email);

 

결과는 테스트 성공하였습니다 ㅜㅜ!!

saveAndFlush는 변경 사항을 즉시 데이터베이스에 반영하는 기능을 수행하기 때문에 락이 해제되어 변경 사항이 즉시 반영되지 않는 경우에 해결할 수 있습니다.!!!

 

 

 

마지막으로 해당 로직을 AOP로 분리하여 횡단 관심사와 핵심 로직을 분리하는 과정을 처리하도록 하겠습니다.

 

@Transactional
public boolean registerForm(FormRegisterUserDto dto) {
    Member findMember = memberRepository.findWithMemberForSave(dto.getEmail());
    if (findMember == null) {
        Member user = Member.builder()
                .userId(UUID.randomUUID().toString())
                .username(dto.getUsername())
                .password(passwordEncoder.encode(dto.getPassword()))
                .email(dto.getEmail())
                .build();
        memberRepository.saveAndFlush(user);
        authorityRepository.saveAndFlush(Authority.builder().member(user).authorities(ROLE_USER).build());
        return true;
    }
    throw new UserRegisterConflictException();
}

 

@Around("execution(* liar.memberservice.auth.service.MemberService.registerForm(..)) && args(dto)")
public boolean registerForm(ProceedingJoinPoint joinPoint, FormRegisterUserDto dto) throws Throwable {

    String lockKey = "registerForm: " + dto.getEmail();
    return (boolean) executeWithRedisLock(joinPoint, lockKey);
}

public Object executeWithRedisLock(ProceedingJoinPoint joinPoint, String lockKey) throws Throwable {
    RLock lock = redissonClient.getLock(lockKey);

    try {
        boolean isLocked = lock.tryLock(20, 3, TimeUnit.SECONDS);
        if (!isLocked) {
            throw new RedisLockException();
        }
        log.info("lockKey = {} 락 획득", lockKey);

        return joinPoint.proceed();

    } finally {
        if (lock.isHeldByCurrentThread()) {
            lock.unlock();
            log.info("lockKey = {} 락 반환", lockKey);
        }
    }
}

 

 

테스트가 정상으로 성공하였습니다. ㅠㅠ !!

 

 

5. 느낀점 

 

가장 좋은 방법은 좋은 데이터베이스 환경을 써서 RedissonClient를 적용하여 분산락을 적용하는 것이라고 합니다.

하지만, 제 로컬 환경이나 AWS에 배포된 데이터베이스 환경은 프리티어로 성능이 좋지 않습니다. 이 경우에는 어쩔 수 없이 다른 대안을 적용하여 동시성 문제를 해결하는 과정이 필요하였습니다. 

 

하지만, 락을 여러 개 거는 것은 데드락을 발생시킬 수 있기 때문에, saveAndFlush() 기능을 활용하여 
즉시 업데이트 시키는 과정을 수행할 수 있었습니다. 그 결과, pessimistic 락과 분산락을 함께 적용하지 않고 문제를 해결할 수 있었습니다.

 

물론 AWS에 배포할 때 제 로컬환경에서 적용한 테스트가 다시 실패할 수도 있습니다. ㅜㅜ

이 경우 다시 리팩터링 하며, 다시 추가 글을 작성하도록 하겠습니다.

 

 

부족하지만, 오늘도 읽어주셔서 감사드립니다.!

 

안녕하세요. 회사와 함께 성장하고 싶은 KOSE입니다.
이번 포스팅은 AWS로 서버를 배포하는 과정에서 발생했던 Spring Cloud Gateway의 Authentication 문제에 대해서 다뤄보려고 합니다.

 

 

1. Sprong Cloud Gateway 역할 


Gateway Server는 Spring Cloud로 구성할 수 있으며 MSA 아키텍처에서 중요한 역할을 수행하는 서버입니다. Spring Cloud Gateway는 각 서비스별 요청을 라우팅 하고, 보안 및 로드밸런싱 기능을 제공할 수 있습니다.

 

먼저 라우팅 기능은 MSA 아키텍처로 구성되어 있는 다양한 서비스가 gateway에 연결되어 요청 라우팅을 처리할 수 있습니다.

보안적인 측면에서는 다양한 필터를 통해 요청에 대한 응답이나 보안 처리, 승인 및 거부 등을 할 수 있습니다. 또한 로드 밸런싱의 기능도 포함하고 있습니다. 라운드 로빈, 가중치 로빈 밸런싱 등의 알고리즘을 제공하고 있습니다.

Spring Cloud Gateway는 Discovery Client를 제공합니다. 이를 통해 Eureka 서버에 등록하여 라우팅 되는 서비스 등을 자동화하여 처리할 수 있습니다.

 

제가 적용하고자 한 아키텍처 구조도에서는 GateWay Server의 역할을 확인할 수 있습니다.
각 비즈니스 서비스는 Gateway-server에 라우팅 되어 있고 인증 인가 처리를 Gateway-server에서 담당하고 있습니다.

 

 

 

2. Spring Cloud Gateway 설정의 어려움과 에러 발생

 

저는 Spring Cloud가 익숙하지 않아서 Gateway를 개발하는데 많은 어려움이 있었습니다. ReactiveWebFlux 형태로의 코드 작성이 기반이 되어야 하다 보니 익숙하지 않았고, 도중에 MVC 관련 코드도 많이 있었습니다. 하지만 배우면서 여러 번 작성해 보고 에러를 통해 성장할 수 있기 때문에 이를 꾸준히 리펙토링 하면서 에러를 해결하고자 하였습니다.


제가 겪었던 문제는 Authentication 인증 관련 부분입니다. 저는 Gateway-Server에서 라우팅 역할과 Jwt 인증을 처리하는 역할을 수행하도록 코드를 작성하였습니다. Local 상에서는 문제가 발생하지 않았지만, AWS로 Dev용 Config Server와, Eureka Server를 배포하고 Gateway 서버를 연결하는 과정에서 많은 에러에 봉착하였습니다. 

 

첫 번째는, 모든 Uri에 401 Error가 발생하였습니다. 이는 곧 잘못된 설정 정보로 인한 Gateway-Server가 작동되지 않는다는 것을 의미하였습니다. 디버깅 모드로 Authentication 관련 코드를 찍어보아도, 디버깅을 거치지 않은 채로 Intellij에서 401 에러를 반환하였습니다.

 

두 번째는, 회원가입과 로그인을 마친 유저가 Jwt 토큰으로 인증을 처리한 후 라우팅된 서비스로 요청이 위임되어 로직을 수행할 때, 401 Error가 발생한 것이었습니다. 저는 Gateway에서 인증 정보를 SecurityContext에 저장하지 않고 라우팅된 서비스에서 SecurityContext를 저장하도록 코드를 작성하였습니다. 따라서, Gateway에서는 JWT 토큰을 검증하고 검증된 유저라면 서비스 로직으로 이동하여 SecurityContext에 저장이 되어 비즈니스 로직을 수행할 수 있었어야 했습니다.

하지만,  Jwt 인증이 되어 라우팅이 되어야하는 요청이 401 Error가 발생하였습니다.

 

마지막 문제는 이 모든 것을 해결했을 때, 발견한 문제입니다. gateway의 AuthorizationHeaderFilter를 거치지 않고 요청이 처리되고 있었다는 점입니다. 분명 Jwt 요청이 실패하면 400 error 코드가 발견되어야 하는데 500 에러가 발생하였습니다.

 

3. 문제 해결 과정 - Filter에 대한 이해

 

이 과정에서 먼저 각 필터들의 역할을 정리하는 단계를 수행하였습니다. 이것저것 적용하다 보니 어떠한 역할을 수행하는지 이해하지 못하여 복합적인 문제가 발생하였습니다. 따라서 먼저 Spring Cloud Gateway에서 적용할 수 있는 필터를 정리하고자 합니다.

 

 

a. GatewayFilter

 

GatewatFilter는 Spring Cloud Gateway에서 Http 요청 및 응답을 필터링하는 역할을 수행합니다.
구체적으로 <인증 및 권한 부여, 요청 및 응답 수정, 요청 거부> 등의 역할을 수행하며, LoggingFilter로 로그 기록을 남기거나 ReWritePathFilter로 요청 경로를 수정할 수 있습니다. GatewayFilter의 인터페이스를 구현한다면 추가적인 로직을 작성할 수 있습니다.

 

 

b. AbstractGatewayFilterFactory

 

AbstractGatewatFilterFactory는 Spring Cloud Gateway에서 GatewayFilter를 생성하는 팩토리 클래스입니다.
이 팩토리를 활용하면, 필터 인스턴스를 생성 및 초기화, 구성을 할 수 있습니다. 
구체적으로 , 팩토리로 생성된 인스턴스는 SpringBean으로 등록되며, Spring ApplicationContext에서 사용할 수 있습니다.

또한 인스턴스의 설정 값을 지정할 수 있고, 인스턴스의 종속성을 주입하거나 필터 인스턴스 간의 관계를 설정할 수 있습니다.

 

 

c. SecurityWebFilterChain


SecurityWebFilterChain는 Spring WebFlux에서 사용하는 SecurityFitlerChain입니다.
Spring Security 최신 버전은 SecurityFilterChain을 활용하여 SecurityConfig를 작성하도록 하고 있습니다.
이때, 주의할 점은 SecurityFilterChain은 Spring MVC에서 적용하는 보안 필터 체인이기 때문에 
현재 사용하고 있는 프레임워크에 따라 다른 필터를 적용하여야 합니다.

 

 

4. 문제 해결 과정 - DEBUG 로그 확인

 

logging:
  level:
    org.springframework.security: DEBUG

 

application.yml을 활용하면, 발생 원인을 DEBUG 모드로 확인할 수 있습니다. 이를 적용하여 발생한 원인에 대해서 파악해 보도록 하였습니다. 

 

2023-03-09T22:36:15.512+09:00 DEBUG 52106 --- [ parallel-1] a.DelegatingReactiveAuthorizationManager : Checking authorization on '/member-service/users' using org.springframework.security.authorization.AuthenticatedReactiveAuthorizationManager@840d33d 2023-03-09T22:36:15.512+09:00 DEBUG 52106 --- [ parallel-1] ebSessionServerSecurityContextRepository : No SecurityContext found in WebSession: 'org.springframework.web.server.session.InMemoryWebSessionStore$InMemoryWebSession@3274fd3d' 2023-03-09T22:36:15.512+09:00 DEBUG 52106 --- [ parallel-1] o.s.s.w.s.a.AuthorizationWebFilter : Authorization failed: Access Denied 2023-03-09T22:36:15.513+09:00 DEBUG 52106 --- [ parallel-1] ebSessionServerSecurityContextRepository : No SecurityContext found in WebSession: 'org.springframework.web.server.session.InMemoryWebSessionStore$InMemoryWebSession@3274fd3d'

 

이 문제는 WebSession과 Jwt는 다른 인증 방식이지만, 현재 WebSession에 대한 처리를 하지 않았기 때문에 Security 인증 이후 WebSession에 값을 입력하지 않아 발생한 에러입니다. 즉 SecurityContext가 WebSession에서 값을 찾을 수 없기에 발생한 것이라고 추측할 수 있습니다.

 

2023-03-09T23:05:58.053+09:00 DEBUG 54193 --- [ parallel-4] a.DelegatingReactiveAuthorizationManager : Checking authorization on '/member-service/users' using org.springframework.security.authorization.AuthenticatedReactiveAuthorizationManager@310d058b 2023-03-09T23:05:58.053+09:00 DEBUG 54193 --- [ parallel-4] o.s.s.w.s.a.AuthorizationWebFilter : Authorization failed: Access Denied

 

다음 에러는 Access Denied로 인가 문제가 발생한 부분입니다. Jwt 토큰이 인증되었음에도 불구하고 인가 처리가 되지 않아 Gateway에서 member-service로 라우팅이 되지 않는 문제가 발생하였습니다. 

 

 

5. 문제 해결 과정 - 코드 수정하기 

 

먼저 No SecurityContext found in WebSession 관련 부분입니다.

@Bean
    SecurityWebFilterChain securityFilterChain(ServerHttpSecurity http) throws Exception {

        http
                .authorizeExchange()
//                .pathMatchers(memberServiceWhiteList).permitAll()
                .anyExchange().permitAll()
                .and()
                .securityContextRepository(new StatelessWebSessionSecurityContextRepository())
                .csrf(csrf -> csrf.disable())
                .cors().disable()
                .headers()
                .contentSecurityPolicy("script-src 'self'");

        return http.build();

    }

    private static class StatelessWebSessionSecurityContextRepository implements ServerSecurityContextRepository {

        private static final Mono<SecurityContext> EMPTY_CONTEXT = Mono.empty();

        @Override
        public Mono<Void> save(ServerWebExchange exchange, SecurityContext context) {
            return Mono.empty();
        }

        @Override
        public Mono<SecurityContext> load(ServerWebExchange exchange) {
            return EMPTY_CONTEXT;
        }
    }

 

저는 Jwt 토큰을 인증하는 역할만 Gateway에 부여했기 때문에, WebSession에서 인증 정보를 관리할 필요가 없다고 판단하였습니다. 따라서, StatelessWebSessionSecurityContextRepository라는 CustomRepository를 생성하여 Mono.empty() 빈 값을 주입하고 빈 값을 load 하는 코드를 추가하였습니다. 그 결과, WebSession 관련 문제를 해결할 수 있었습니다.

 

 

다음 문제는 AccessDenied 문제입니다.
저는 Jwt 토큰 인증 처리를 앞 서 설명한 AbstractGatewayFilterFactory를 상속한 AuthorizationHeaderFilter에서 이 역할을 수행하도록 구현하였습니다. 따라서 만약 Jwt 토큰에 인증에러가 발생하면 앞 단에서 요청 거부를 처리하기 때문에 SecurityWebFilterChain에서는 이 역할을 수행할 필요가 없었습니다. 뿐만 아니라, 저는 WebSession에 값을 넣지 않는 방법을 이용했기 때문에 Spring은 특정 permitAll()로 선언된 whiteList가 아닌 anyRequest.authenticated()에서 인증이 되지 않았다고 판단이 되어 요청을 거부한 것이었습니다.


따라서 코드를 수정한 결과는 다음과 같습니다.

현재 주석된 부분을 제거한 것입니다. 즉, 현재 모든 요청은 permitall()로 되어 있지만 앞 단의 GatewayFilter에서 jwt 토큰을 검증하여 요청을 승인 및 거부하고 있기 때문에 WebSession에서 에러가 발생하지 않고 Access Denied도 발생하지 않았습니다.

 

    @Bean
    SecurityWebFilterChain securityFilterChain(ServerHttpSecurity http) throws Exception {

        http
                .authorizeExchange()
//                .pathMatchers(memberServiceWhiteList).permitAll()
                .anyExchange().permitAll()
                .and()
                .securityContextRepository(new StatelessWebSessionSecurityContextRepository())
                .csrf(csrf -> csrf.disable())
                .cors().disable()
                .headers()
                .contentSecurityPolicy("script-src 'self'");

        return http.build();

    }

 

마지막 문제는 RouteLocater의 위치 문제였습니다.
routes()는 선언된 라우터에 일치하는 라우터에 먼저 적용됩니다.
저는 기존에는 다음과 같이 작성하였습니다. 하지만 이 경우 만약 /member-service/users의 요청이 오면 하단의 요청이
수행되는 것이 아니라 /**에 포함되기 때문에 상단 요청이 먼저 처리되는 것이었습니다. 

 

.route("member-service", r -> r
        .path("/member-service/**")
        .uri("lb://member-service")
)

.route("member-service", r -> r
        .path("/member-service/users")
        .filters(spec -> spec.filter(authorizationHeaderFilter.apply(new AuthorizationHeaderFilter.Config())))
        .uri("lb://member-service"))

 

저는 이 문제를 해결하기 위해 라우터 하는 path를 재조정하여 filter 처리가 필요한 uri를 먼저 선언하여 필터에 적용되도록 수정하였습니다. 

 

@Configuration
public class FilterConfig {

    @Bean
    public RouteLocator gatewayRoutes(RouteLocatorBuilder builder, AuthorizationHeaderFilter authorizationHeaderFilter) {
        return builder.routes()

                .route("member-service", r -> r
                        .path("/member-service/users")
                        .filters(spec -> spec.filter(authorizationHeaderFilter.apply(new AuthorizationHeaderFilter.Config())))
                        .uri("lb://member-service"))
                

                .route("member-service", r -> r
                        .path("/member-service/**")
                        .uri("lb://member-service"))

 

 

6. 코드 테스트

 

이것을 바탕으로 Intellij에서 코드를 검증하는 단계를 거쳤습니다.

 

먼저 gateway-server와 member-service가 정상 작동하는 것을 확인할 수 있습니다.

 

 

### 1. 폼 로그인 성공

POST {{baseUrl}}/login
Content-Type: application/json
Accept: */*
Connection: keep-alive

{
  "email": "kose@naver.com",
  "password": "kose123456"
}

> {%
    client.test("폼 로그인 회원 가입 성공, 토큰 클라이언트 저장", function() {
        client.assert(response.status === 200, "Response status is OK");
        client.global.set("accessToken", response.body.accessToken);
        client.global.set("refreshToken", response.body.refreshToken);
        client.global.set("userId", response.body.userId);
        client.log(client.global.get("accessToken"));
        client.log(client.global.get("refreshToken"));
        client.log(client.global.get("userId"));

    });
%}

 

만약 아이디나 패스워드를 달리하면 다음의 결과가 나옵니다.

 

 

이제 유저 정보를 확인하는 uri입니다.

해당 요청은 accessToken, refreshToken, userId를 필요로 합니다.

 

### 1. 유저 정보 요청

GET {{baseUrl}}/users
Content-Type: application/json
Authorization: Bearer {{accessToken}},
RefreshToken: {{refreshToken}},
userId: {{userId}}
Accept: */*
Connection: keep-alive


> {%
    client.test("유저 정보 요청", function() {
        client.assert(response.status === 200, "Response status is OK");
    });
%}

 

인증 정보를 달리하면 다음과 같이 테스트를 실패합니다. 

 

### 2. 유저 정보 요청 인증 실패

GET {{baseUrl}}/users
Content-Type: application/json
Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIwNTEyZGQxZi04ZTQ5LTRkYzItYTExZ23fklZTAxODYwYTkxNjIiLCJhdXRoIjpbIlJPTEVfVVNFUiJdLCJpYXQiOjE2NzgzNzkxOTcsImV4cCI6MTY3ODQwMDc5N30.gaz-hMxYNLNd0sNfmiMswYz8FF0t4I7zoGW6gifbfe85iuuGMsdfLdTewbNgQBRZ85z_RA4R_knoHPu8Ym_ftQ
RefreshToken: eyJhbGciOiJIUzUxMiJ9.eyJzdWIiOiIwNTEyZGQxZi04ZTQ5LTRkYzItYTExZ23fklZTAxODYwYTkxNjIiLCJhdXRoIjpbIlJPTEVfVVNFUiJdLCJpYXQiOjE2NzgzNzkxOTcsImV4cCI6MTY3ODQwMDc5N30.gaz-hMxYNLNd0sNfmiMswYz8FF0t4I7zoGW6gifbfe85iuuGMsdfLdTewbNgQBRZ85z_RA4R_knoHPu8Ym_ftQ,
userId: {{userId}}
Accept: */*
Connection: keep-alive


> {%
    client.test("유저 정보 요청", function() {
        client.assert(response.status === 400, "Response status is OK");
    });
%}

 

7. 느낀 점과 반성

 

최근에 개발을 하며, 좋아 보이는 것 혹은 있어 보이는 설계에 많은 관심이 있었던 것 같습니다.

야생적으로 이것저것 시도해 보는 것도 중요하지만, 그러한 시도 속에서 섬세한 부분에 대한 명확한 이해가 바탕이 되었어야 하는데, 정작 중요한 기술에 대한 견고함이 많이 부족했던 것 같습니다.

Dev 서버를 운영하며 local에서 이상 없었던 부분에 대해서 다시 테스트하고 코드의 견고함을 추가할 수 있었고,

이전 테스트에서 확인하지 못했던 부분까지 확인하니 정말 뿌듯한 하루가 될 수 있었던 것 같습니다.

 

혼자 프로젝트를 설계하고 배포까지 진행하다 보니 많이 힘들고 지치지만,

개발자로 살아갈 수 있다는 것이 내일 또 힘내서 개발할 수 있게 하는 원동력이 되는 것 같습니다.

 

존경하는 영한님의 말씀인 "기술적 겸손함"을 항상 되새기도록 하겠습니다.

오늘은 이만 글을 마치도록 하겠습니다.!

읽어주셔서 감사드립니다.!

안녕하세요.! 회사와 함께 성장하고 싶은 KOSE입니다.

이번 포스팅은 앞 선 포스팅에서 정리한 RedissonClient로 멀티스레드 환경 동시성 극복하기 2편을 작성하고자 합니다.

(앞선 RedissonClient로 repository에 동시성을 제어하는 포스팅 링크입니다. : https://gose-kose.tistory.com/24)

 

프로젝트를 진행할 때 복잡한 비즈니스 로직 작성은 피할 수 없습니다. 싱글 스레드 환경에서 데이터를 조회하고 수정하는 과정은 순차적으로 이루어지기 때문에 데이터의 정합성을 보장할 수 있습니다. 하지만 멀티 스레드 환경에서는 데이터에 대한 접근과 수정은 데이터의 정합성을 보장하기 어렵습니다.  따라서, synchronized 키워드로 순차처리가 진행되도록 하거나 RedissonClient의 lock을 이용해 분산락 처리로 이를 보완하는 해결책을 생각해 볼 수 있습니다.

이번 글은 제가 프로젝트를 진행하며 겪었던 멀티 스레딩 환경에서 redis에 저장된 값을 조회하고 값을 수정하여 다시 저장하는 메소드의 동시성 문제를 극복한 과정을 정리하도록 하겠습니다.

 

 

1. 공통 코드 

 

@Getter
@AllArgsConstructor
@RedisHash(value = "Vote")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Vote {

    @Id
    private String id;
    @Indexed
    private String gameId;
    private List<VotedResult> votedResults;

	''' 중략 '''
    public void updateVoteResults(String userId, String liarId) {
        votedResults.stream()
                .filter(vote -> vote.getLiarId().equals(liarId))
                .findFirst()
                .ifPresent(votedResult -> {
                    votedResult.addUserId(userId);
                    votedResult.updateCnt();
                });
    }

    public List<VotedResult> getMaxVotedResult() {
        return votedResults.stream()
                .collect(Collectors.groupingBy(VotedResult::getCnt))
                .entrySet()
                .stream()
                .max(Map.Entry.comparingByKey())
                .map(Map.Entry::getValue)
                .orElse(Collections.emptyList());
    }

	''' 중략 '''
}
@Getter
@AllArgsConstructor
@NoArgsConstructor
public class VotedResult {
    private String liarId;
    private List<String> userIds = new ArrayList<>();
    private int cnt;

    public VotedResult(String liarId, int cnt) {
        this.liarId = liarId;
        this.cnt = cnt;
    }

	''' 중략 '''

}
public interface VoteRepository extends CrudRepository<Vote, String> {
    Vote findVoteByGameId(String gameId);
}
@Component
public interface VotePolicy {

    /**
     * game의 모든 턴이 끝나면, vote 객체를 생성하여 저장한다.
     */
    String saveVote(Game game) throws InterruptedException;

    /**
     * 유저가(userId)가 라이어(liar)를 투표하여
     * Vote 객체 값을 저장한다.
     */
    void voteLiarUser(String gameId, String userId, String liarId) throws InterruptedException;

    /**
     * 가장 많은 LiarId 투표를 받은 결과를 출력한다.
     */
    List<VotedResult> getMaxVotedLiarUser(String gameId);


}
@Component
@Slf4j
@Transactional
@RequiredArgsConstructor
public class VotePolicyImpl implements VotePolicy {

    private final VoteRepository voteRepository;
    private final RedissonClient redissonClient;

	''' 중략 '''

    /**
     * 유저가(userId)가 라이어(liar)를 투표하여
     * Vote 객체 값을 저장한다.
     */
    @Override
    public void voteLiarUser(String gameId, String userId, String liarId) {
        Vote vote = voteRepository.findVoteByGameId(gameId);
        vote.updateVoteResults(userId, liarId);
        voteRepository.save(vote);
    }

    /**
     * 가장 많은 LiarId 투표를 받은 결과를 출력한다.
     */
    @Override
    public List<VotedResult> getMaxVotedLiarUser(String gameId) {
        Vote vote = voteRepository.findVoteByGameId(gameId);
        return vote.getMaxVotedResult();
    }
}

 

먼저 CrudRepository를 확장하여 Redis에 값을 저장하는 방식을 사용하였습니다.

앞선 Vote 클래스의 gameId는 @Indexed로 선언되어 있습니다.

만약 Vote가 저장된다면 해당 어노테이션이 해당 필드의 값으로 Index를 Redis에 저장합니다.

 

 

 

2. 싱글 스레드에서 테스트 코드 작성하기

 

제가 테스트 하고자 하는 메서드는 votePolicy의 voteLiarUser()입니다. 해당 메서드에는 findVoteByGameId()로 redis에서 값을 조회하여 객체 인스턴스로 선언한 후, vote 인스턴스의 값을 바꾸고 다시 저장하는 복잡한 로직이 있습니다. 이를 싱글 스레드 환경에서 테스트하면, 다음의 결과를 얻을 수 있습니다.

 

@Test
@DisplayName("단일 스레드 환경에서 liar를 투표한다")
public void voteLiarUser() throws Exception {
    //given
    num = 5;
    votePolicy.saveVote(game);

    //when
    for (int i = 0; i < num; i++) {
        votePolicy.voteLiarUser(game.getId(), String.valueOf(i + 1), "2");
    }
    List<VotedResult> maxVotedLiarUser = votePolicy.getMaxVotedLiarUser(game.getId());

    //then
    assertThat(maxVotedLiarUser.size()).isEqualTo(1);
    assertThat(maxVotedLiarUser.get(0).getLiarId()).isEqualTo("2");
    assertThat(maxVotedLiarUser.get(0).getCnt()).isEqualTo(5);
}

 

즉, 해당 메소드는 싱글 스레드 환경에서 검증된 코드라고 판단할 수 있습니다.

이제, 멀티 스레딩 환경에서 voteLiarUser() 메서드를 테스트하도록 하겠습니다.

 

 

2. Synchronized로 테스트 코드 작성하기

 

@Autowired
    VotePolicy votePolicy;

    @Autowired VotePolicyImpl votePolicyImpl;
    @Autowired VoteRepository voteRepository;
    @Autowired RedissonClient redissonClient;

    private Game game;
    private int num;
    private Thread[] threads;

	''' 중략 '''
    

@Test
@DisplayName("멀티 스레딩 환경에서 liar를 투표한다. : synchronized")
public void voteUser_synchronized() throws Exception {
    //given
    num = 5;
    votePolicy.saveVote(game);

    //when
    for (int i = 0; i < num; i++) {
        int finalIndex = i;
        threads[i] = new Thread(() -> {
            voteLiarUser(game.getId(), String.valueOf(finalIndex + 1), "2");
        });
    }

    runThreads();
    List<VotedResult> maxVotedLiarUser = votePolicy.getMaxVotedLiarUser(game.getId());

    //then
    assertThat(maxVotedLiarUser.size()).isEqualTo(1);
    assertThat(maxVotedLiarUser.get(0).getLiarId()).isEqualTo("2");
    assertThat(maxVotedLiarUser.get(0).getCnt()).isEqualTo(5);
}


private void runThreads() throws InterruptedException {
    for (int i = 0; i < num; i++) threads[i].start();
    for (int i = 0; i < num; i++) threads[i].join();
}

/**
 * Syncronized와 성능 비교를 위한 테스트
 */
private synchronized void voteLiarUser(String gameId, String userId, String liarId) {
    Vote vote = voteRepository.findVoteByGameId(gameId);
    vote.updateVoteResults(userId, liarId);
    voteRepository.save(vote);
}

 

자바에서 synchronized는 반환 타입 앞에 선언하여 해당 메소드를 synchronized 한 방식으로 변경할 수 있습니다.

 

멀티 스레딩에서 해당 키워드는 동시성을 보장하는 기능을 수행할 수 있지만, 치명적인 단점 또한 존재합니다. 

Synchronized는 해당 메서드나 블록이 한번에 한 스레드씩 수행하도록 처리합니다. 따라서, 배타적 실행을 보장합니다. 

하지만, 메소드 단위가 한 스레드씩 수행하도록 하다 보니, 만약 gameId가 다른 요청이 왔을 때도 앞 선 요청이 수행될 때까지 

대기해야 하는 문제가 발생합니다. 이는 곧 성능 문제와 직결될 수 있습니다.

 

 

 

3. RedissonClient로 문제 해결하기

 

해당 문제점을 해결하는 방법은 locKey로 "VoteLiarUser:" + gameId로 설정함으로써, gameId에 따라 분산락을 설정할 수 있습니다.

 

 

RedissonClient를 적용하여 코드를 수정하면 다음과 같습니다.

@Override
public void voteLiarUser(String gameId, String userId, String liarId) throws InterruptedException {
    String lockKey = "VoteLiarUser: " + gameId;
    RLock lock = redissonClient.getLock(lockKey);

    try {
        boolean isLocked = lock.tryLock(2, 3, TimeUnit.SECONDS);
        if (!isLocked) {
            throw new RedisLockException();
        }

        Vote vote = voteRepository.findVoteByGameId(gameId);
        vote.updateVoteResults(userId, liarId);
        voteRepository.save(vote);

    } finally {
        if (lock.isHeldByCurrentThread()) {
            lock.unlock();
        }
    }
}
@Test
@DisplayName("멀티 스레딩 환경에서 liar를 투표한다. : RedissonClient")
public void voteLiarUser_multiThread() throws Exception {
    //given
    num = 5;
    votePolicy.saveVote(game);

    //when
    for (int i = 0; i < num; i++) {
        int finalIndex = i;
        threads[i] = new Thread(() -> {
            try {
                votePolicy.voteLiarUser(game.getId(), String.valueOf(finalIndex + 1), "2");
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        });
    }

    runThreads();
    List<VotedResult> maxVotedLiarUser = votePolicy.getMaxVotedLiarUser(game.getId());

    //then
    assertThat(maxVotedLiarUser.size()).isEqualTo(1);
    assertThat(maxVotedLiarUser.get(0).getLiarId()).isEqualTo("2");
    assertThat(maxVotedLiarUser.get(0).getCnt()).isEqualTo(5);
}

 

 

4.  Aop를 적용하여 분산락 과정을 비즈니스 로직에서 분리하기

 

try - catch - finally 코드는 분산락을 처리하는 과정에서 반복될 수 있습니다. 또한, 핵심 비즈니스 로직을 파악하는데 어려움을 줄 수 있으므로 AOP로 분리하여 사용할 수 있습니다.

@Configuration
@EnableAspectJAutoProxy
public class RedisAopConfig {

    @Bean
    public RedisLockAspect redisLockAspect(RedissonClient redissonClient) {
        return new RedisLockAspect(redissonClient);
    }

}

먼저 RedisLockAspect를 사용하기 위해 config를 작성합니다.

 

@Aspect
@Slf4j
@RequiredArgsConstructor
@Component
public class RedisLockAspect {

    private final RedissonClient redissonClient;

    @Around("execution(* liar.gamemvcservice.game.service.vote.VotePolicy.voteLiarUser(..)) && args(gameId, userId, liarId)")
    public void voteLiarUserWithRedisLock(ProceedingJoinPoint joinPoint, String gameId, String userId, String liarId) throws Throwable {
        String lockKey = "VoteLiarUser: " + gameId;
        RLock lock = redissonClient.getLock(lockKey);

        try {
            boolean isLocked = lock.tryLock(2, 3, TimeUnit.SECONDS);
            if (!isLocked) {
                throw new RedisLockException();
            }
            joinPoint.proceed();
        } finally {
            if (lock.isHeldByCurrentThread()) {
                lock.unlock();
            }
        }
    }
}

 

@Around(pattern)을 사용하여 지정된 패턴에 해당되는 메서드가 실행되기 전, 후 해당 메서드가 실행되도록 구성합니다.

패턴에 해당하는 부분은 pointcut 표현식으로 와일드 카드를 이용하여 작성할 수 있습니다.

  • *: 모든 것
  • ..: 0개 이상

특정 메서드를 지칭하는 것으로 execution을 활용하여 해당 메서드에 대한 접근 제어자, 메서드 인자 등을 설정할 수 있습니다.

중요한 포인트는 joinPoint.proceed();를 호출하여야 aop 프록시가 적용된 후, 해당 메서드를 이어서 진행할 수 있습니다.

@Override
public void voteLiarUser(String gameId, String userId, String liarId) {
    Vote vote = voteRepository.findVoteByGameId(gameId);
    vote.updateVoteResults(userId, liarId);
    voteRepository.save(vote);
}

 

 

5. 결론 

멀티 스레딩 환경에서 동시성 문제를 해결하기 위한 방법은 정말 어려운 문제입니다.

프로젝트를 진행하면서 정말 많은 에러에 부딪쳤고, 어느 정도 해결하는 방법을 찾았지만,

완전한 해결책이 아니며 부수적인 문제 또한 발생할 수 있습니다.

이번 글을 포스팅하며 정말 테스트 코드 작성의 중요성을 다시 한번 느낄 수 있었습니다.

 

부족하지만 읽어주셔서 감사드립니다.! 많은 피드백 부탁드립니다.! 수정 보완하고 많이 배우겠습니다.!

 

 

안녕하세요! 회사와 함께 성장하고 싶은 KOSE입니다.

이번 포스팅은 프로젝트를 진행하며 발생했던 멀티스레드 환경의 동시성 문제를 극복하는 과정을 작성하고자 합니다.

 

1. 멀티 스레딩이란?

 

 

Thread란 프로세스 내에서 작업을 수행하는 단위로 프로세스의 자원을 이용해 작업을 수행합니다.

Multi Threading은 하나의 프로세스 내에서 여러 스레드가 동시에 작업을 수행하는 것입니다. CPU코어는 한 번에 하나의 작업만 수행할 수 있으므로, 실제로 동시에 처리되는 작업의 개수와 일치합니다.

하지만, 코어가 매우 빠른 시간 동안 여러 작업을 번갈아 가며 수행하므로 우리가 느끼기에 동시에 수행되는 것처럼 보이도록 합니다. 멀티 스레드를 이용하면 공유하는 영역이 많아 프로세싱 방식보다 Context Swithcing의 오버헤드가 적어, 메모리 리소스가 소비가 상대적으로 적다는 장점이 있습니다.

 

 

2. 멀티스레딩과 동시성 문제

 

멀티 스레딩은 프로세스 내부에 있는 리소스를 공유해서 사용합니다. 프로세스 내에서 여러 스레드가 공유 리소스에 동시에 액세스를 할 경우, 경합 조건, 교착 상태 및 기타 동기화 문제가 발생합니다.

여기서 말하는 공유 리소스는 스레드간 공유가 가능한 힙이나 파일 혹은 데이터베이스 등이 해당할 수 있습니다. 
(스레드 내부에 있는 스택은 스레드 별로 개별적으로 관리되기 때문에 동시성 문제에 영향을 주지 않습니다)

각 스레드가 공유 리소스에 접근하여 값을 변경하는 요청을 수행한다면, 개발자가 의도한 방식대로 변수의 값이 바뀌지 않고 예기치 않은 오류를 발생시킬 수 있습니다.

이번에는 공유 리소스 중에서도 Redis를 데이터베이스로 활용하여 값을 처리할 때 생기는 동시성 문제를 중점적으로 다루겠습니다.

 

 

3, 공통 코드 작성

 

먼저 Redis 객체로 저장할 Game 클래스와 리포지토리 역할을 수행할 GameRepository입니다.

 

@Getter
@RedisHash("Game")
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Game {

    @Id
    private String id;
    private String roomId;
    private String hostId;
    private String gameName;
    private List<String> playerIds;
    private Topic topic;

    protected Game (SetUpGameDto setUpGameDto) {
        this.id = UUID.randomUUID().toString();
        this.roomId = setUpGameDto.getRoomId();
        this.hostId = setUpGameDto.getHostId();
        this.gameName = setUpGameDto.getRoomName();
        this.playerIds = setUpGameDto.getUserIds();
    }

    public static Game of(SetUpGameDto setUpGameDto) {
        return new Game(setUpGameDto);
    }
}
public interface GameRepository extends CrudRepository<Game, String> {
}

 

 

4. 멀티 스레드에 취약한 테스트 코드 

 

먼저 멀티 스레드를 활용하지 않고 단일 스레드 내에서 처리하는 순차적으로 값을 처리하는 테스트 코드입니다.

 

@SpringBootTest
class GameRepositoryTest {

    @Autowired
    GameRepository gameRepository;

    private int duplicatedTotalCnt = 0;

    @AfterEach
    public void tearDown() {

        gameRepository.deleteAll();
        duplicatedTotalCnt = 0;
    }

    @Test
    @DisplayName("Game을 저장하면, redis에 저장되어야 한다.")
    public void save_game_success() throws Exception {
        //given
        SetUpGameDto setUpGameDto = new SetUpGameDto("1", "1", "1", Arrays.asList("2", "3", "4"));
        Game game = Game.of(setUpGameDto);

        //when
        Game savedGame = gameRepository.save(game);

        //then
        assertThat(savedGame.getId()).isEqualTo(game.getId());
        assertThat(savedGame.getGameName()).isEqualTo(game.getGameName());
        assertThat(savedGame.getHostId()).isEqualTo(game.getHostId());
        assertThat(savedGame.getRoomId()).isEqualTo(game.getRoomId());
        assertThat(savedGame.getPlayerIds()).isEqualTo(game.getPlayerIds());
    }
}

 

일반적으로 문제없이 처리되는 것을 볼 수 있습니다. 

하지만, 멀티 스레드 환경에서는 어떨까요?

아래는 멀티 스레딩 환경을 구축하여, gameRepository에 gameId의 키 값으로 저장된 game이 없다면 game을 redis에 저장하는 코드를 테스트하는 코드입니다.

(자바에서 스레드 환경을 구축하기 위해서는 Thread 객체를 생성한 후, start() 메소드를 호출하여 사용할 수 있습니다. )

 

@Test
@DisplayName("스레드 safe 하지 않은 상태에서 game이 있다면 값을 저장하지 않고, 없다면 값을 저장한다.")
public void saveIfNotExistsGame_ThreadNotSafe() throws Exception {
    //given
    Thread[] threads = new Thread[100];

    SetUpGameDto setUpGameDto = new SetUpGameDto("1", "1", "1", Arrays.asList("2", "3", "4"));
    Game game = Game.of(setUpGameDto);

    //when

    for (int i = 0; i < 100; i++) {
        threads[i] = new Thread(() -> {

            if (gameRepository.findById(game.getId()).isEmpty()) {
                System.out.println("repository에 저장합니다.");
                gameRepository.save(game);
                duplicatedTotalCnt++;
            }
        });
    }

    for (int i = 0; i < 100; i++) threads[i].start();
    for (int i = 0; i < 100; i++) threads[i].join();

    //then
    System.out.println("duplicatedTotalCnt = " + duplicatedTotalCnt);
    assertThat(duplicatedTotalCnt).isEqualTo(1);
}

 

결과는 다음과 같습니다. 하나의 game만 저장되어야 하지만 다수의 game이 저장되어 duplicatedTotalCnt의 값이 100이 처리된 것을 볼 수 있습니다.

 

redis를 활용할 때 동시성을 확보하기 위해서는 어떠한 절차를 거쳐야 할까요?

 

 

5. 멀티 스레드를 극복하기 위한 RedissonClient

 

RedissonClient는 데이터베이스, 캐시, 메시지 브로커 등 일반적으로 사용되는 오픈 소스 인메모리 데이터 구조인 Redis에 사용할 수 있는 자바 클라이언트 라이브러리입니다. RedisClient는 Redis와 함께 사용하는데 도움을 주는 고급 인터페이스를 제공하는데, 분산 잠금, 스케줄링 등 기능을 제공합니다.

저는 RedisClient의 분산락 기능을 활용하여 해당 문제를 해결하였습니다.

 

먼저, Spring에서 RedisClient를 사용하기 위해서는 해당 라이브러리 의존성을 주입하고 config 설정을 해야 합니다.

 

implementation 'org.redisson:redisson-spring-boot-starter:3.17.7'
@Bean
public RedissonClient redissonClient() {
    Config config = new Config();
    config.useSingleServer().setAddress("redis://localhost:6381");
    return Redisson.create(config);
}

 

RedissonClient에서 분산락 설정을 위해 사용한 기능은 아래와 같습니다.

 

RLock: RLock은 여러 스레드 또는 프로세스 간에 공유 리소스에 대한 액세스를 동기화하는 방법을 제공하는 Redisson 분산락 기능입니다. 

 

lock.tryLock(): RLock의 tryLock() 메서드는 즉시 잠금을 획득하려고 시도하여 잠금을 획득한 경우, true를 반환하고, 잠금을 획득하지 않은 경우 false를 반환합니다.

 

lock.unlock(): 해당 메소드는 이전에 획득한 잠금을 해제합니다. 보통 try-catch-finally의 finally에 작성하여 해당 락이 종료되도록 하여 데드락이 발생하는 것을 방지할 수 있습니다.

 

lock.isHoldByCurrentThread(): 잠금이 현재 스레드에 의해 유지되고 있는지 확인하여, 맞다면 true, 아니라면 false를 반환합니다.

 

 

6. RedissonClient를 이용한 테스트 코드 수정

private <T> String getLockKey(T arg) {

    if (arg instanceof String) {
        return (String) arg;
    }

    else if (arg instanceof Game) {
        return ((Game) arg).getId();
    }

    throw new IllegalArgumentException();
}

 

먼저 getLockKey()라는 key를 반환하는 제네릭 메소드를 선언하였습니다.

getLockKey()는 인스턴스를 입력받으면 인스턴스의 타입에 따라 getId() (여기서는 key)를 반환하여 key값에 대한 lockKey를 반환하는 역할을 수행합니다.

보통 redis에 값을 저장하는 방법은 RedisTemplate을 사용하거나 CrudRepository를 이용합니다. 저는 인덱스 키워드를 사용하여 간편하게 값을 저장할 수 있는 후자의 방식을 사용했으므로 클래스 인스턴스에서 getId()를 받아오는 방식을 사용했습니다.

 

이 코드는, AOP를 도입할 때 중요한 역할을 수행할 수 있습니다. aop는 횡단 관심사를 묶어서 한 번에 처리할 수 있도록 돕는 기술인데, repository 클래스 혹은 save의 메서드 명을 사용하는 redis repository에 해당 메소드를 사용한다면 타입 변환에 이점을 얻을 수 있습니다.

String lockKey = getLockKey(game.getId());
RLock lock = redissonClient.getLock(lockKey);

try {
    boolean isLocked = lock.tryLock(2, 3, TimeUnit.SECONDS);
    if (!isLocked) {
        System.out.println("락을 획득할 수 없습니다.");
        throw new RedisLockException();
    }

    if (gameRepository.findById(game.getId()).isEmpty()) {
        System.out.println("game = " + game);
        gameRepository.save(game);
        duplicatedTotalCnt++;
    }

} catch (InterruptedException e) {
    e.printStackTrace();
    System.out.println("런타임 에러");
    throw new RuntimeException(e);
} finally {
    if (lock.isHeldByCurrentThread()) {
        lock.unlock();
    }
}

락을 생성하고 try - catch - finally 내부에서 락을 획득에 대한 boolean 값을 리턴 받습니다.

현재 락을 획득한 상태라면 game을 저장하는 메소드가 실행됩니다.

락을 획득하지 못한 스레드가 해당 요청을 수행한다면, 예외가 발생합니다. 
최종적으로 finally에 최종적으로 해당 스레드가 락을 가지고 있다면 unlock() 메소드를 호출하여 락을 해제합니다.

따라서, 특정한 값을 저장해야 하는 redis의 save() 메소드는 이러한 방법으로 분산락을 적용한다면, 동시성을 제어하여 

원하지 않는 값의 변경을 예방할 수 있습니다.

 

<최종코드>

@Test
@DisplayName("스레드 safe 한 상태로 game 단건 저장하기")
public void saveIfNotExistsGame_ThreadSafe() throws Exception {
    //given
    Thread[] threads = new Thread[100];

    SetUpGameDto setUpGameDto = new SetUpGameDto("1", "1", "1", Arrays.asList("2", "3", "4"));
    Game game = Game.of(setUpGameDto);

    //when
    for (int i = 0; i < 100; i++) {
        threads[i] = new Thread(() -> {

            String lockKey = getLockKey(game.getId());
            RLock lock = redissonClient.getLock(lockKey);

            try {
                boolean isLocked = lock.tryLock(2, 3, TimeUnit.SECONDS);
                if (!isLocked) {
                    System.out.println("락을 획득할 수 없습니다.");
                    throw new RedisLockException();
                }

                if (gameRepository.findById(game.getId()).isEmpty()) {
                    System.out.println("game = " + game);
                    gameRepository.save(game);
                    duplicatedTotalCnt++;
                }

            } catch (InterruptedException e) {
                e.printStackTrace();
                System.out.println("런타임 에러");
                throw new RuntimeException(e);
            } finally {
                if (lock.isHeldByCurrentThread()) {
                    lock.unlock();
                }
            }

        });
    }

    for (int i = 0; i < 100; i++) threads[i].start();
    for (int i = 0; i < 100; i++) threads[i].join();

    //then
    System.out.println("duplicatedTotalCnt = " + duplicatedTotalCnt);
    assertThat(duplicatedTotalCnt).isEqualTo(1);

}

private <T> String getLockKey(T arg) {

    if (arg instanceof String) {
        return (String) arg;
    }

    else if (arg instanceof Game) {
        return ((Game) arg).getId();
    }

    throw new IllegalArgumentException();
}

 

7. 결론

이번 포스팅은 멀티스레딩 환경에서 발생하는 동시성 문제를 제어하기 위해 RedisClient를 사용하는 것을 정리하였습니다.

다음 포스팅은 분산락은 횡단 관심사로 분류할 수 있으므로 AOP를 적용하여 다른 Repository에도 적용하는 과정을 정리하도록 하겠습니다.

 

읽어주셔서 감사드립니다.!

 

 

참고:

https://devwithpug.github.io/java/java-thread-safe/

안녕하세요 회사와 함께 성장하고 싶은 KOSE입니다.

이번 프로젝트는 MSA 아키텍처를 적용하여 서비스를 구축해 보는 활동을 진행하고 있습니다.

Gateway 역할을 수행하는 서버를 기준으로 각 서비스가 개별적으로 운영되는 아키텍처를 구상하고 있습니다.

그중, wait-service는 대기실 관련 서비스를 Redis를 통해 구현하고자 하였습니다.

 

이번 포스팅은, Redis를 활용하여 redis-server에 값을 저장, 수정, 삭제하는 테스트 코드를 작성하고자 합니다.

 

1. Redis-Server Ubuntu20.04에 추가로 적용하기

저는 기존에 Token을 저장하는 Redis-Server를 6379 port에서 활용하고 있었습니다. 

token 관련 운영은 게이트웨이에서 진행하도록 프로젝트를 구성하여, wait-server에는 다른 redis-server를 적용하고자 redis-server를 하나 더 추가하였습니다.

 

cd /etc/redis

// 권한 설정 에러가 발생하면 아래 명령어를 입력합니다.
chmod 777 redis
chmod 777 redis.conf

// redis 복사하기
mv redis.conf redis_6380.conf

// 포트 수정
sudo vim redis_6380.conf
port 6379 <- 수정 대상
port 6380 <- 변경 후 저장

// redis 실행하기
redis-server --port 6380
redis-cil -h 127.0.0.1 -p 6380

 

 

2. SpringBoot Redis 적용하기

예시를 작성하는 SpringBoot 및 Java version입니다.

springboot = 3.0.2
java = 17

 

먼저, build.gradle에 의존성을 주입합니다.

implementation 'org.springframework.boot:spring-boot-starter-data-redis'

 

다음은 application.yml에 redis 데이터 서버로 활용할 호스트명과 포트명을 작성합니다.

spring:
  data:
    redis:
      host: localhost
      port: 6380

 

Redis를 사용하기 위해 Redis전용 config 파일을 작성합니다.

import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.repository.configuration.EnableRedisRepositories;

@Configuration
@EnableRedisRepositories
public class RedisConfig {

    @Value("${spring.data.redis.host}")
    private String host;

    @Value("${spring.data.redis.port}")
    private int port;

    @Bean
    public RedisConnectionFactory redisConnectionFactory() {
        return new LettuceConnectionFactory(host, port);
    }

    @Bean
    public RedisTemplate<?, ?> redisTemplate() {
        RedisTemplate<byte[], byte[]> redisTemplate = new RedisTemplate<>();
        redisTemplate.setConnectionFactory(redisConnectionFactory());
        return redisTemplate;
    }
}

application.yml에 있는 host와 port 값을 가져오기 위해, org.springframework.beans.factory.annotation.Value 패키지에서 @Value 어노테이션을 활용합니다.

 

RedisConnectionFactory 인터페이스로 LettuceConnectionFactory 구현체를 생성하여 반환합니다.

Lettuce는 Netty(Netty는 비동기 이벤트 기반 고성능 네트워크 프레임워크) 기반의 Redis 클라이언트입니다.

(Lettuce의 자세한 내용은 향로님 블로그와 lettuce.io 공식 문서를 하단 링크 첨부하였습니다.)

 

RedisTemplate는 Spring 공식문서에서 다음과 같이 설명하고 있습니다.

 

While RedisConnection offers low-level methods that accept and return binary values (byte arrays), the template takes care of serialization and connection management, freeing the user from dealing with such details.
RedisTemplate uses a Java-based serializer for most of its operations. This means that any object written or read by the template is serialized and deserialized through Java.
From the framework perspective, the data stored in Redis is only bytes. While Redis itself supports various types, for the most part, these refer to the way the data is stored rather than what it represents. It is up to the user to decide whether the information gets translated into strings or any other objects.

정리하면,  RedisConnection은 이진 배열을 처리하여 반환하는 역할을 수행하고 template는 직렬화와 연결 관리를 처리하는 역할을 수행합니다. RedisTemplate는 대부분 작업에 Java 기반 serializer를 사용하는데, 이는 자바를 통한 직렬화 역직렬화 기능을 수행하는 것을 의미합니다. 추가로 String-focused Convenience Classes를 제공하기도 하는데 문자열 작업을 위한 편리한 기능을 제공하기도 합니다.

이번 포스팅에서는 해당 RedisTemplate 주석처리하여도 redis-cli에 이상 없이 처리되는 것을 확인하였습니다.

 

 

3. 코드 작성하기 (Domain, Repository)

@Getter
@RedisHash("waitRoom")
@AllArgsConstructor
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class WaitRoom implements Serializable {

    @Id
    private String id;
    private String roomName;
    private String hostId;

    private int limitMembers;
    private List<String> members = new LinkedList<>();

    private LocalDateTime createdAt;
    private LocalDateTime modifiedAt;

    protected WaitRoom (CreateWaitRoomDto roomDto) {
        id = UUID.randomUUID().toString();
        roomName = roomDto.getRoomName();
        hostId = roomDto.getUserId();
        limitMembers = roomDto.getLimitMembers();
        createdAt = now();
        modifiedAt = now();
        members.add(hostId);
    }

    public static WaitRoom of(CreateWaitRoomDto createWaitRoomDto) {
        return new WaitRoom(createWaitRoomDto);
    }

    /**
     * 대기방 인원이 여유가 있고, 요청이 호스트가 아니라면 회원 추가
     */
    public boolean joinMembers(String userId) {

        if (isNotFullMembers() && !isHost(userId)) {
            members.add(userId);
            modifiedAt = now();
            return true;
        }
        return false;
    }

    /**
     * 대기방에 있는 유저 나가기
     */
    public void leaveMembers(String userId) {
        if (!isHost(userId)) {
            members.remove(userId);
            modifiedAt = now();
        }
    }

    /**
     * 요청 유저 아이디가 방의 호스트와 같은지 파악
     */
    public boolean isHost(String userId) {
        return userId.equals(this.hostId);
    }

    /**
     * 대기방 만석 파악
     */
    private boolean isNotFullMembers() {
        return members.size() < limitMembers;
    }

}
public interface WaitRoomRepository extends CrudRepository<WaitRoom, String> {
}

RedisHash를 사용하기 위해서는 @RedisHash()를 작성하여 집계루트를 설정합니다.

@Id 어노테이션은 해당 객체가 redis에 저장될 때, RedisHash의 value와 @Id의 값으로 key가 형성됩니다.

(테스트 코드를 작성할 때, @Id 어노테이션을 제거한 후 실행했을 때도 테스트가 통과되었습니다. 서비스 테스트 과정에서도 문제가 없는지 추후 다시 테스트하겠습니다.) 

 

Redis 전용 Repository는 CrudRepository 인터페이스를 상속받아 도메인과 도메인의 id 타입으로 구성할 수 있습니다.

 

 

4. 테스트 실행하기

먼저 저장 관련 테스트 하나는 다음과 같습니다. 

waitRoomRepository에 값을 저장한 후, uuid로 생성된 id로 repository에서 값을 찾아오면 테스트에 성공하는 것을 확인할 수 있습니다.

@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class WaitRoomRepositoryTest {

    @Autowired WaitRoomRepository waitRoomRepository;
    private WaitRoom waitRoom;

    @BeforeEach
    void init() {
        CreateWaitRoomDto roomDto = new CreateWaitRoomDto("kose", "koseTest1", 5);
        waitRoom = WaitRoom.of(roomDto);
    }

    @AfterEach
    void tearDown() {
        waitRoomRepository.deleteAll();
    }

    @Test
    @DisplayName("Redis에 createWaitRoom 요청이 오면, 저장되어야 한다.")
    public void saveWaitRoom() throws Exception {
        //given
        waitRoomRepository.save(waitRoom);

        //when
        WaitRoom findWaitRoom = findById(waitRoom.getId());

        //then
        assertThat(findWaitRoom.getRoomName()).isEqualTo("koseTest1");
        assertThat(findWaitRoom.getHostId()).isEqualTo("kose");
        assertThat(findWaitRoom.getLimitMembers()).isEqualTo(5);
        assertThat(findWaitRoom.getMembers().size()).isEqualTo(1);
    }

}

실제 redis-cli을 활용하여 값을 확인하면 다음과 같습니다.

먼저, Redis는 저장되는 타입에 따라 다른 방법으로 Value를 확인할 수 있습니다.

keys * // 모든 키 가져오기

type waitRoom // type [key명]

smembers waitRoom // type이 set인 경우 value 가져오기

HGETALL [key명] // type이 hash인 경우 value 가져오기

 

 

 

같은 방법으로 수정 및 삭제하는 코드는 다음과 같습니다.

 

<수정>

@Test
@DisplayName("대기방에 입장 요청이 오면, 인원을 추가하여 값을 변경하여 저장 해야 한다.")
public void joinMembers() throws Exception {
    //given
    waitRoomRepository.save(waitRoom);
    WaitRoom findRoom = findById(waitRoom.getId());

    //when
    findRoom.joinMembers("kose2");
    findRoom.joinMembers("kose3");
    findRoom.joinMembers("kose4");
    waitRoomRepository.save(findRoom);
    WaitRoom result = findById(findRoom.getId());

    //then
    assertThat(result.getRoomName()).isEqualTo("koseTest1");
    assertThat(result.getHostId()).isEqualTo("kose");
    assertThat(result.getLimitMembers()).isEqualTo(5);
    assertThat(result.getMembers().size()).isEqualTo(4);
    assertThat(result.getMembers().get(1)).isEqualTo("kose2");
    assertThat(result.getMembers().stream().filter(f -> f.startsWith("kose")).collect(Collectors.toList()).size()).isEqualTo(4);
}

@Test
@DisplayName("대기방에 있던 유저가 퇴장하면, 인원 변동되어 저장해야 한다.")
public void leaveMembers() throws Exception {
    //given
    waitRoomRepository.save(waitRoom);
    WaitRoom findRoom = findById(waitRoom.getId());
    findRoom.joinMembers("kose2");
    findRoom.joinMembers("kose3");
    findRoom.joinMembers("kose4");
    waitRoomRepository.save(findRoom);

    //when
    WaitRoom room = findById(waitRoom.getId());
    room.leaveMembers("kose2");
    room.leaveMembers("kose4");
    WaitRoom result = waitRoomRepository.save(room);

    //then
    assertThat(result.getRoomName()).isEqualTo("koseTest1");
    assertThat(result.getHostId()).isEqualTo("kose");
    assertThat(result.getLimitMembers()).isEqualTo(5);
    assertThat(result.getMembers().size()).isEqualTo(2);
    assertThat(result.getMembers().get(1)).isEqualTo("kose3");
}

 

<삭제>

삭제를 확인하는 과정은 Exception 처리가 될 수 있으므로 AssertJ의 assertThatThrownBy 메서드로, 

assertThatThrownBy([에러 가능성이 있는 처리상황 람다식]).isInstanceOf([클래스명])

 등으로 표현할 수 있습니다.

@Test
@DisplayName("호스트가 대기방을 나가면, 방이 종료된다.")
public void leaveHost() throws Exception {
    //given
    waitRoomRepository.save(waitRoom);
    WaitRoom findRoom = findById(waitRoom.getId());
    findRoom.joinMembers("kose2");
    findRoom.joinMembers("kose3");
    findRoom.joinMembers("kose4");
    findRoom.joinMembers("kose5");
    WaitRoom result = waitRoomRepository.save(findRoom);

    //when
    waitRoomRepository.delete(result);

    //then
    Assertions.assertThatThrownBy(() -> findById(result.getId()))
            .isInstanceOf(NotExistsRoomIdException.class);
}

해당 테스트도 진행하면, 테스트가 성공하는 것을 확인할 수 있습니다.

최종적으로 작성한 모든 테스트를 전부 실행했을 때, 성공 메시지를 확인할 수 있었습니다.

 

이번 포스팅은 Redis로 해시 테이블을 구성하여 Repository 테스트를 작성하는 과정을 정리하였습니다.

다음 포스팅은 Service 로직과 Controller 로직에서 해당 redis를 활용하여 다른 외부 서비스 서버와 연동하는 과정을 작성하도록 하겠습니다.

 

감사합니다.!

 

 

참고 자료:

https://pearlluck.tistory.com/727

https://jojoldu.tistory.com/297

https://jojoldu.tistory.com/418

https://lettuce.io/core/release/reference/

안녕하세요.

회사와 함께 성장하고 싶은 KOSE입니다.

 

이번 포스팅은 SpringBoot에서 Entity를 생성할 때, 자주 사용하는 @GeneratedValue에 대해 분석하는 글을 작성하고자 합니다.

 

1. @GeneratedValue 일반적 사용

@GeneratedValue는 필드의 생성 전략에 활용되는 기술로 데이터 베이스의 Sequence Object를 사용하여 데이터베이스가 자동으로 기본키를 생성하도록 합니다.

해당 어노테이션을 받는 필드가 객체라면 null을, primitive 타입이라면 0으로 판단하여 적용됩니다. (default)

 

Book 객체를 생성할 때, id 없이 객체를 생성하면 GeneratedValue로 인해 객체에 id가 생성되어 주입이 되는 것을 확인할 수 있습니다.

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Book {

    @Id @GeneratedValue
    private Long bookId;

    private String bookName;

    public Book(String bookName) {
        this.bookName = bookName;
    }
}
@Repository
public interface BookRepository extends JpaRepository<Book, Long> {
}
@DataJpaTest
class BookRepositoryTest {

    @Autowired
    private BookRepository bookRepository;

    @Autowired
    private EntityManager em;

    @Test
    @DisplayName("책 저장")
    public void save() throws Exception {
        //given
        Book book = new Book("JPA 원리");

        //when
        Book saveBook = bookRepository.save(book);
        em.flush();

        //then
        Assertions.assertThat(saveBook.getBookId()).isNotNull();

    }
}

 

 

 

2. 원리 파악하기

JpaRepository의 구현체에 해당하는 SimpleJpaRepository는 repository.save가 호출되면 다음의 절차가 실행됩니다.

 

 

이를 디버깅 모드로 확인하면, 다음의 절차에 따라 id가 생성됩니다.

 

- entityInformation.isNew(entity)

만약 해당 객체가 새로 생성된 객체라고 판단을 하면, em.persist(entity)가 실행됩니다.

해당 객체를 새로 생성된 객체라고 판단할 수 있는 절차는 isNew메소드에 설정한 필드에 따라 바뀌는데, @GeneratedValue를 설정한 필드가 기본값이 되어 판단되는 원리입니다.

 

- em.persist()

@GeneratedValue는 JPA 이벤트가 실행이 되면 persist를 거치면서 영속성과 더불어 객체의 id를 생성하고 주입받습니다.

따라서, 최종적으로 entity가 반환이 되면 id가 생성되면서 반환되는 것을 확인할 수 있습니다.

 

3. 만약 @GeneratedValue를 안 쓴다면 어떻게 될까?

@Entity
@Getter
@AllArgsConstructor
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Music {

    @Id
    private String musicSeq;

    private String musicName;

}
@Repository
public interface MusicRepository extends JpaRepository<Music, String> {
}
@DataJpaTest
class MusicRepositoryTest {

    @Autowired
    MusicRepository musicRepository;

    @Autowired
    EntityManager em;

    @Test
    @DisplayName("GeneratedValue를 안쓰고 repository에 저장하기")
    public void save() throws Exception {
        //given
        Music hypeBoy = new Music("music-3001", "Hype boy");

        //when
        Music saveHypeBoy = musicRepository.save(hypeBoy);
        em.flush();

        //then
        Assertions.assertThat(saveHypeBoy.getMusicSeq()).isEqualTo("music-3001");
    }

}

만약 Music 객체를 생성할 때, Id를 직접 입력하여 저장한다면, 다음과 같이 조회(select) 후 저장(insert)이 실행되는 것을 확인할 수 있습니다.

 

 

 

4. Select 후 Insert 되는 이유는 무엇일까?

 

디버깅 모드를 통해, MusicRepository의 save() 메서드가 실행되는 과정을 살펴보면 다음과 같습니다.

Entity 내부에 musicSeq가 "music-3001"로 값이 있으므로

em.persist를 거치지 않고 바로 merge하는 과정을 수행하게 됩니다.

따라서, merge할 대상을 조회해야 하므로 쿼리가 select문과 insert문이 동시에 나가게 되는 것입니다.

 

5. 해결법!

 

만약 @GeneratedValue를 쓰지 않고 @Id를 적절한 상황에 맞게 설정하려면 어떻게 해야 할까요? 이때는 isNew()메소드에 적용되는 필드를 오버라이딩하여 해결할 수 있습니다.

@EntityListeners(AuditingEntityListener.class)
@MappedSuperclass
@Getter
public class BaseTimeEntity {

    @CreatedDate
    @Column(updatable = false)
    private LocalDateTime createdDate;

    @LastModifiedDate
    private LocalDateTime lastModifiedDate;

}

 

@Entity
@EntityListeners(AuditingEntityListener.class)
@AllArgsConstructor
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Computer extends BaseTimeEntity implements Persistable<String> {

    @Id
    private String computerSeq;

    private String computerName;

    @Override
    public String getId() {
        return computerSeq;
    }

    @Override
    public boolean isNew() {
        return super.getCreatedDate() == null;
    }
}

여기서 중요한 점이, Persistavle 인터페이스를 구현하는 과정에서 isNew() 메서드를 오버라이딩 하는 과정입니다.

BaseTimeEntity는 객체를 데이터베이스에 저장할 때, 생성 시간 및 변경 시간을 저장하는데 사용하는 엔티티입니다. @CreadDate의 어노테이션은 Jpa 이벤트가 실행되고 나서 persist가 진행될 때, 해당 값이 입력이 됩니다.

 

이를 활용하면, SimpleJpaRepository 구현체에서 isNew() 메소드가 null 인지 판단할 때는 해당 값이 null 이므로 persist의 영향을 받아 값이 입력되면서 자연스럽게 insert 단일 쿼리만 실행이 됩니다. 또한, @CreatedValue는 그 과정에서 값이 입력되므로  추후 해당 값이 null인지 판단하는 과정에서는 false가 됩니다.

 

@Repository
public interface ComputerRepository extends JpaRepository<Computer, String> {
}

따라서, 해당 쿼리를 확인하면 insert 단일 쿼리만 생성된 것을 확인할 수 있습니다.

 

정리하면, @GeneratedValue는 데이터베이스에 저장되는 PK값 자동 생성 전략에 사용할 수 있으며, 만약 쓰지 않을 경우는 Persistable 인터페이스를 구현하여 isNew 메서드를 오버라이딩 하는 과정이 필요하다고 할 수 있습니다.

 

 

소스는 제가 만들었지만 해당 내용에 대한 좋은 지식과 베이스 소스는 영한님께서 공유해주셨습니다.

JPA에 대해 더 깊게 공부하실 수 있는 링크 공유해 드리겠습니다.

감사합니다.!

 

참고자료: 영한님 실전! 스프링 데이터 JPA https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81-%EB%8D%B0%EC%9D%B4%ED%84%B0-JPA-%EC%8B%A4%EC%A0%84

안녕하세요. 회사와 함께 성장하고 싶은 KOSE입니다.

 

이번 포스팅은 SpringBoot의 Data JPA 벌크연산에 대한 글을 작성하고자 합니다.

 

1. 벌크 연산

벌크 연산은 여러 건의 데이터를 한 번에 수정하거나 삭제하는 방법으로 대용량 데이터를 한 번에 처리할 때 유용합니다.

 

현재 주어진 Member table에는 6개의 행이 존재합니다. 만약 25살 이상의 나이에 모두 1살을 더하라는 요청이 오면 , 다음과 같은 쿼리를 생성할 수 있습니다.

update member m
set m.age = m.age + 1
where m.age >= 25;

혹은 Member table에서 member_id가 5 이상인 행을 제거하라는 요청이 오면 쿼리는 다음과 같습니다.

delete from member m
where m.member_id >= 5;

 

2. 스프링 DATA JPA의 벌크연산

이처럼 한 번에 다량의 데이터를 수정하는 것을 벌크연산이라고 하는데, 스프링 데이터 JPA에서는 벌크연산을 지원합니다.

@Modifying 어노테이션과 함께 벌크 연산 쿼리를 작성하면 해결할 수 있습니다.

@Entity
@Getter
@AllArgsConstructor
@NoArgsConstructor(access = PROTECTED)
public class Member {

    @Id @GeneratedValue
    @Column(name = "member_id")
    private Long id;

    private String username;

    private int age;
}
@Repository
public interface MemberRepository extends JpaRepository<Member, Long> {

	@Modifying(clearAutomatically = true)
    @Query("update Member m set m.age = m.age + 1 where m.age >= :age")
    int bulkAgePlus(@Param("age") int age);
    
}

 

3. 테스트 코드로 확인하기

@Test
public void bulkUpdate() {
    //given
    memberJpaRepository.save(new Member("member1", 9));
    memberJpaRepository.save(new Member("member2", 9));
    memberJpaRepository.save(new Member("member3", 9));
    memberJpaRepository.save(new Member("member4", 15));
    memberJpaRepository.save(new Member("member5", 40));

    //when
    int resultCount = memberJpaRepository.bulkAgePlus(15);
    
    //then
    assertThat(resultCount).isEqualTo(2);
}

벌크 연산을 수행하면 15살 이상의 나이가 수정이 되는 것을 확인할 수 있습니다.

 

4. 주의점

이 글의 목적이기도 한 벌크 연산의 주의점입니다. 벌크 연산은 영속성 컨텍스트를 참조하지 않고 DB에 직접 접근하기 때문에 1차 캐시에 남아있는 캐시와 값이 다를 수 있습니다. 따라서, 만약 영속성 컨텍스트를 초기화하지 않는다면, 변경 이전의 값을 참조할 수 있다는 위험성이 존재합니다.

 

@Repository
public interface MemberRepository extends JpaRepository<Member, Long> {

	@Modifying
    @Query("update Member m set m.age = m.age + 1 where m.age >= :age")
    int bulkAgePlus(@Param("age") int age);
    
}

@Modifying(clearAutomatically = true)는 해당 쿼리가 실행 된 후, 영속성 컨텍스트를 초기화하는 역할을 수행합니다.  만약 (clearAutomatically = true)가 없다면 벌크 연산 이후에 영속성 컨텍스트에 남아있는 이전의 값이 활용되는 문제점 발생합니다.

@Test
public void bulkUpdate2() {
    //given
    memberRepository.save(new Member("member1", 9));
    memberRepository.save(new Member("member2", 9));
    memberRepository.save(new Member("member3", 9));
    memberRepository.save(new Member("member4", 15));
    memberRepository.save(new Member("member5", 40));

    //when
    int resultCount = memberRepository.bulkAgePlus(15);

    Member member5 = memberRepository.findMemberByUsername("member5");

    //then
    assertThat(resultCount).isEqualTo(2);
    assertThat(member5.getAge()).isEqualTo(40);
}

따라서 이러한 문제점을 예방하기 위해  벌크연산 이후에 값을 flush 하고, 초기화 해주어야 합니다.

 

@Test
public void bulkUpdate2() {
    //given
    memberRepository.save(new Member("member1", 9));
    memberRepository.save(new Member("member2", 9));
    memberRepository.save(new Member("member3", 9));
    memberRepository.save(new Member("member4", 15));
    memberRepository.save(new Member("member5", 40));

    //when
    int resultCount = memberRepository.bulkAgePlus(15);
    em.flush();
    em.clear();

    Member member5 = memberRepository.findMemberByUsername("member5");

    //then
    assertThat(resultCount).isEqualTo(2);
    assertThat(member5.getAge()).isEqualTo(41);
}

 

그런데, 이 과정에서 들었던 궁금증은 flush()하게 되면 다시 영속성 컨텍스트에 있는 40이 데이터베이스에 반영되어 41 -> 40이 되지 않을까?라는 생각을 하게 되었습니다.

 

이에 대한 해답은 영한님께서 제공해주셨습니다.

인프런에 올라온 질문에 대한 답글을 참조하면, 다음과 같습니다.

엔티티에 직접적인 변경내용이 있어야 flush() 시점에 변경 감지의 대상이 되어서, 변경된 내용을 반영합니다. 그런데 벌크 연산은 엔티티에 영향을 주지 않아서 이런 결과가 나옵니다.

즉, 영속성 컨텍스트에 영향을 받는 엔티티가 직접적으로 수정이 되지 않았으므로 더티 체킹에 해당하지 않습니다. 따라서, 업데이트 쿼리가 실행되더라도 영속성 컨텍스트의 flush()가 다시 16 -> 15로 수정하지 않는 것입니다.

 

5. 정리

벌크 연산을 수행할 때는, 다른 데이터에 영향을 주지 않는지 파악하여 영속성 컨텍스트를 clear 하는 것을 기억해야 합니다.!

 

이상입니다.

부족한 부분에 대해서 댓글 부탁드립니다.

감사합니다!!!

 

참고 자료: 영한님 실전! 스프링 데이터 JPA https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81-%EB%8D%B0%EC%9D%B4%ED%84%B0-JPA-%EC%8B%A4%EC%A0%84

안녕하세요. 회사와 함께 성장하고 싶은 KOSE입니다.

 

이번 포스팅은 SpringBoot의 프록시 방식의 AOP에서 발생하는 내부 호출 문제를 해결하는 방법에 대해서 정리하는 글을 작성하도록 하겠습니다.

 

스프링에서는 AOP를 적용하기 위해 프록시를 통해 대상 객체를 호출하는 방법을 따릅니다. 프록시에서 먼저 어드바이스를 호출하고 대상 객체를 호출합니다.

 

saveRepository라는 테스트 메서드를 실행하면 다음의 절차에 따라 진행됩니다.

blogRepository는 인터페이스가 아닌 구현체에 해당하므로 스프링의 AOP 프록시 정책에 따라 CGLIB 방식의 프록시가 적용됩니다.

 

1. 프록시 처리 과정 코드 예시  

1) Pointcuts

@Slf4j
public class Pointcuts {

    @Pointcut("execution(* hello.aop.blog.*.save(..))")
    public void allSave() {}
    
}

 

2) BlogRepository

@Slf4j
@Repository
public class BlogRepository {

    public void save(String content) {

        log.info("[BlogRepository.save 실행]");
        if (content == null) {
            log.info("[BlogRepository.save 에러]");
            throw new IllegalArgumentException();
        }

        log.info("[BlogRepository.save 저장]");
    }
}

 

3) ApplyAspect

@Slf4j
public class ApplyAspect {

    @Aspect
    public static class AllSave {
        @Around("hello.aop.blog.Pointcuts.allSave()")
        public Object saveAround(ProceedingJoinPoint joinPoint) throws Throwable {

            try{
                log.info("[Around AllSave ProceedingJoinPoint]");
                Object result = joinPoint.proceed();
                log.info("[Around AllSave ProceedingJoinPoint] result = {}", result);
                return result;

            } catch (Exception e) {
                log.info("[Around AllSave Exception] message = {}", e.getMessage());
                throw e;
            }
        }
    }
}

 

4) TestCode

@Slf4j
@SpringBootTest
@Import({ApplyAspect.AllLoad.class, ApplyAspect.AllSave.class, ApplyAspect.AllService.class})
class ApplyAspectTest {

    @Autowired
    BlogRepository blogRepository;

    @Autowired
    BlogService blogService;

    @Test
    @DisplayName("BlogRepository save AOP 로그 확인")
    public void saveRepository() throws Exception {
        blogRepository.save("안녕하세요");
    }
}

 

2. 디버깅 모드로 프록시 처리 과정 확인하기 

 

1) CGLIB 형태의 프록시 생성

 

2) 어드바이저가 호출

- 부가 기능인 로그를 기록하는 기능이 실행된 후, ProceedingJoinpoint 실행(실제 비즈니스 로직)

 

3) 프록시 객체에서 타겟 객체의 비즈니스 로직 실행

- 완료 후, 다시 어드바이저 진행 (proceed() 이후 로직 실행)

 

이렇듯, 프록시가 직접 객체를 호출한다면, 정상적으로 부가 기능(로그)이 실행되는 것을 확인할 수 있습니다. \

하지만, 프록시 객체가 직접 객체를 호출하지 않고 호출된 객체 내부에서 다른 메서드를 호출한다면, 프록시가 적용되지 않습니다.

 

3. 내부 호출 프록시 미적용

Pointcuts을 수정한 후, BlogRepository 내부에 saveInternal 메소드를 추가한 후, 테스트를 진행하면 다음과 같습니다.

 

1) 메서드 이름 매칭 포인트컷을 수정 (save*)

 

2) BlogRepository에 public 접근 제어자로 설정하여 메서드 추가

 

3) BlogRepository에 save() 메서드에 saveInternal 메서드 호출 추가

public void save(String content) {

    log.info("[BlogRepository.save 실행]");
    saveInternal();   // 메서드 호출 추가
    if (content == null) {
        log.info("[BlogRepository.save 에러]");
        throw new IllegalArgumentException();
    }

    log.info("[BlogRepository.save 저장]");
}

 

4) 실행 후, 로그 확인

로그를 확인하면 saveInternal에 해당하는 프록시 로그가 기록되지 않은 것을 확인할 수 있습니다.

 

4. 내부 호출 프록시 적용하기

대안은 자기 자신을 주입하는 방법과 지연 조회 방식, 구조 변경 방법을 사용 것입니다.

이 중, 저는 구조 변경 방법을 활용하여 로그가 기록되지 않는

saveInternal 메서드에 프록시가 적용되도록 수정하는 작업을 수행하겠습니다.

 

1) 코드 수정 BlogSaveInternalRepository를 생성하여

- 별도의 클래스 내부에 saveInternal() 메서드 생성

@Slf4j
@Component
public class BlogSaveInterRepository {

    public void saveInternal() {
        log.info("[BlogRepository.saveInternal 실행]");
    }
}

 

2) BlogRepository는 BlogSaveInternalRepository를 의존 관게 주입을 받음

@Slf4j
@Repository
@RequiredArgsConstructor
public class BlogRepository {

    private final BlogSaveInterRepository blogSaveInterRepository;
    public void save(String content) {

        log.info("[BlogRepository.save 실행]");
        blogSaveInterRepository.saveInternal();
        if (content == null) {
            log.info("[BlogRepository.save 에러]");
            throw new IllegalArgumentException();
        }

        log.info("[BlogRepository.save 저장]");
    }
}

 

3) 로그 출력 결과 확인

 

 

5. 내부 구조 확인하기

 

기존의 클라이언트의 요청이 BlogRepository의 AOP proxy에서 타겟 메서드인 save()를 호출하고 save() 메서드 내부에서 saveInternal() 메서드를 호출하는 구조였다면,

save()에서 BlogInternalRepository의 saveInternal() AOP Proxy를 호출하는 방식으로 변경됨으로써, saveInternal도 프록시 객체의 호출을 받아 로그를 출력할 수 있는 구조가 되었습니다. 

 

이상으로 SpringBoot에서 내부 호출 프록시 미적용 문제를 해결하기 포스팅을 마치겠습니다.

더 많은 설명과 자세한 정보는 영한님의 스프링 핵심 원리 고급편에서 확인하실 수 있습니다.

감사합니다.!!!

 

참고 자료 : 영한님 스프링 핵심 원리 고급편 https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81-%ED%95%B5%EC%8B%AC-%EC%9B%90%EB%A6%AC-%EA%B3%A0%EA%B8%89%ED%8E%B8

+ Recent posts