기록하며 성장한다 - 개발, 회고

개발 회고록

[ 개발 회고록 ] 배타적 잠금 문제와 Validation

전대홍 2023. 11. 22. 00:09

 

해당 포스팅은 JPA native Query와 Spring Boot를 이용한 CRUD 개발 2편이다.

F-lab 멘토링 과제를 위한 개발을 진행중이며,

멘토님께 피드백을 받으며 개발을 할 때 신경써야 하는 부분을 찾아가고 있다.

 

이전 글 <<< 클릭하기

 


멘토님의 피드백을 받고 수정한 부분

1. 비즈니스 로직이 Controller 등에 산발적으로 존재하였고, 전부 Service단으로 옮겨 수정해주었음.

2. 리팩토링 과정을 통해 반복되는 코드나, 줄일 수 있는 코드를 최대한 줄였음.

3. validation 메서드를 직접 만든 대신 @Valid를 이용하도록 코드를 수정

4. 메서드 명을 동사 형태로 수정

5. Transaction 처리가 미흡한 부분 수정

6. 불필요한 주석 제거

7. 조회수를 조회할 때 배타적 잠금 문제를 해결하는 방안을 찾고, 개발 진행

 


이슈 해결

1. Redis에 key 값으로 이상한 문자가 저장되어, 이후 key를 가져오는데 문제가 발생하는 부분 해결

 


개발 Main 내용

1. 배타적 잠금 해결

저번 글에서는 스프링부트로 프로젝트를 만들고, MySQL을 연결했으며, 회원과 게시판에 대한 간단한 구현을하였다.

이번에는 그렇게 구현한 코드에서 중요한 부분을 수정할 예정이다.

필자는 아래와 같은 코드를 이용하여, 게시글을 클릭하고 조회할 때마다 조회수가 1씩 증가하게끔 로직을 만들었다.

@Modifying
@Query (value = "UPDATE TB_BOARD SET BOARD_VIEW = BOARD_VIEW + 1 WHERE BOARD_ID = :boardId", nativeQuery = true)
void addBoardView(@Param("boardId") Long boardId);

 

이렇게 될 경우, 정상적으로 동작하는데에는 문제가 없다.

무엇보다 MySQL은 Update와 Delete문에 대해서는 배타적 잠금을 하기 때문에, 멀티 스레드 환경에서 접근을 하더라도 순차적으로 쿼리문을 수행하여 손실되는 부분을 없게 한다.

그럼 무엇이 문제이기에 이 부분을 수정할 예정이라는 걸까?

바로 그 배타적 잠금 때문이다.

배타적 잠금은 Row 단위로 Lock을 걸어서 순차적으로 쿼리문을 실행한다. 그런데 조회수를 올리는 것도 Update문을 사용하여 해당 Row에 접근하고, 게시글을 수정하는 것도 Update문을 사용하여 해당 게시글에 접근한다.

적은 사람들이 해당 글을 본다면 상관이 없겠지만, 1만명에서 10만명 정도로 유저가 늘어날 경우 단순 조회를 하는거만으로 성능이 크게 떨어질 우려가 생긴다.

이 부분을 해결하기 위해 생각한 해결책이 바로 Redis를 사용하는 것이다.

 


Redis 사용

Redis를 사용하기 위해서는 먼저 Window에 레디스를 다운 받아야 한다.

다운 받는 방법과 링크는 여기를 들어가보면 된다.

이 분이 정말 설명을 깔끔하게 잘 해놨기에, 필자도 참고하여 레디스를 다운 받았다.

 

아무튼 Redis를 다운 받았고, 링크에 나와있는대로 비밀번호까지 설정을 완료 한 후, PING PONG까지 완료되었으면 이제 IDE로 돌아오면 된다.

properties에 Redis와 관련한 정보를 추가한다.

spring.cache.type = redis
spring.redis.host = localhost
spring.redis.port = 6379

여기에 레디스 패스워드가 있다면, 그것도 입력해주면 된다.

 

이후 Config를 만들어 Redis를 관리한다.

@Configuration
public class RedisConfig {

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

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

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

    @Bean
    public RedisConnectionFactory redisConnectionFactory() {
        RedisStandaloneConfiguration redisConfiguration = new RedisStandaloneConfiguration();
        redisConfiguration.setHostName(host);
        redisConfiguration.setPort(port);
        redisConfiguration.setPassword(password);
        return new LettuceConnectionFactory(redisConfiguration);
    }

    @Bean
    public RedisTemplate<?, ?> redisTemplate() {
        RedisTemplate<?, ?> redisTemplate = new RedisTemplate<>();
        redisTemplate.setKeySerializer(new StringRedisSerializer());
        redisTemplate.setValueSerializer(new GenericJackson2JsonRedisSerializer());
        redisTemplate.setHashKeySerializer(new StringRedisSerializer());
        redisTemplate.setHashValueSerializer(new GenericJackson2JsonRedisSerializer());
        redisTemplate.setConnectionFactory(redisConnectionFactory());
        return redisTemplate;
    }

}

필자는 이렇게 만들었다.

여기서 중요한건

redisTemplate.setKeySerializer(new StringRedisSerializer());
redisTemplate.setValueSerializer(new GenericJackson2JsonRedisSerializer());
redisTemplate.setHashKeySerializer(new StringRedisSerializer());
redisTemplate.setHashValueSerializer(new GenericJackson2JsonRedisSerializer());
redisTemplate.setConnectionFactory(redisConnectionFactory());

이 부분인데, 그 이유는 레디스에 Key값을 입력할 경우 이상한 16진수의 숫자와 함께 저장이 되므로, 이후 그 key를 다시 꺼내올 때 오류가 발생한다. 그걸 막기 위한 방법이다.

 


코드 추가

/**
 * Redis 에 조회수 추가 후 반환
 */
@Transactional
@Override
public int getBoardViewRedisIncrement(Long boardId) {
    String redisKey = RedisStringCode.BOARD_KEY_CODE;
    String redisHashKey = String.valueOf(boardId);

    HashOperations<String, Object, Integer> hashOperations = redisTemplate.opsForHash();
    Integer redisView = hashOperations.get(redisKey, redisHashKey);
    if ( redisView != null ) {
        return Math.toIntExact(hashOperations.increment(redisKey, redisHashKey, 1));
    } else {
        hashOperations.put(redisKey, redisHashKey, 1);
        return 1;
    }
}


/**
 * Redis 에 담긴 조회수 그대로 반환
 */
@Override
public int getBoardViewRedis(Long boardId) {
    String redisKey = RedisStringCode.BOARD_KEY_CODE;
    String redisHashKey = String.valueOf(boardId);

    HashOperations<String, Object, Integer> hashOperations = redisTemplate.opsForHash();
    Integer redisView = hashOperations.get(redisKey, redisHashKey);
    return Objects.requireNonNullElse(redisView, 0);
}

서비스에 이러한 로직을 추가하였다.

레디스에 해당 게시글의 BoardID를 가지고 Key를 만들고, Value로 조회수를 입력하고 가져오는 것이다.

그 후,

@Scheduled(fixedDelay = 1000 * 60 * 60) // 1시간(60분)에 한 번 실행
public void transferBoardViewRedisToDB() {
    boardService.transferBoardViewRedisToDB();
}
/**
 * Redis 의 조회수를 전부 DB로 이동
 */
@Transactional
@Override
public void transferBoardViewRedisToDB() {

    Map<Object, Object> hashEntries = redisTemplate.opsForHash().entries(RedisStringCode.BOARD_KEY_CODE);
    for (Map.Entry<Object, Object> entry : hashEntries.entrySet()) {
        String key = (String) entry.getKey();
        Integer addView = (Integer) entry.getValue();
        if (addView != null) boardRepository.addBoardView(Long.parseLong(key), addView, LocalDateTime.now());
    }

    // Remove key
    redisTemplate.delete(RedisStringCode.BOARD_KEY_CODE);
}

이렇게 Scheduled를 활용하여, 1시간에 한 번씩 레디스에 쌓여있는 조회수를 DB에 밀어주는 방법을 이용하였다.

 

해당 방법을 통해 배타적 잠금으로 일어나는 성능 문제를 해결하였고, 멀티 스레드에서 접근하여도 문제 없이 동작할 수 있게 하였다.

단, 여기서 주의해야 하는 경우가 있는데,

hashOperations.increment

를 사용하지 않고, 단순히 +1을 사용하면 멀티 스레드 환경에서 값이 유실될 수 있다.

 

이는 아래와 같은 테스트 환경에서 테스트를 진행하였는데,

@Test
void redisMultiThreadTest() throws InterruptedException {

    final int THREAD_COUNT = 100;
    final String KEY = "TestRedis";

    ValueOperations<String, Integer> ops = redisTemplateBoardView.opsForValue();
    ops.set(KEY, 0);

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

    for (int i=0; i<THREAD_COUNT; i++) {
        executorService.submit(() -> {
            try {
                long j = Objects.requireNonNullElse(ops.increment(KEY), 1L);
                System.out.println("Thread : " + j);
            } finally {
                countDownLatch.countDown();
            }
        });
    }
    countDownLatch.await();

    System.out.println("Value : " + ops.get(KEY));
    assertEquals(THREAD_COUNT, ops.get(KEY), "The boardView should be equal to 101");

    redisTemplateBoardView.delete(KEY); // @After

}

increment 대신 +1을 사용할 경우 100번 호출 중 13번만 입력되었다.

그러나 increment를 사용하면 100번 호출 시 조회수가 100 전부 저장되는 것을 확인하였다.

 

 


2. @Valid 추가

/*
 * Validation Check Overload Method
 */
public boolean validationCheck(String loginId, String userName, String password) {
    if (loginId.equals("") || userName.equals("") || password.equals("")) return false;
    return true;
}
public boolean validationCheck(String loginId, String userName) {
    if (loginId.equals("") || userName.equals("")) return false;
    return true;
}

필자는 이런식으로 Validation 체크 메서드를 직접 만들었었다.

그러나, 멘토님께서 @Valid 라는 어노테이션을 활용해보라 하셨고, 공부한 끝에 구현하는데에 성공하였다.

 

@Getter
@Setter
@ToString
public class BoardWriteDto {

    @NotNull
    private Long boardId;

    @NotNull(message = "타이틀은 최소 한 글자 이상, 최대 40글자 이하여야합니다.")
    @Size(min = 1, max = 40)
    private String title;

    @NotNull(message = "내용은 최소 한 글자 이상이어야합니다.")
    @Size(min = 1)
    private String content;

    private int boardView;
    private LocalDateTime createDateTime;
    private LocalDateTime updateDateTime;

    @NotNull(message = "게시글에 유저 ID는 반드시 들어가야합니다.")
    private Long userId;
}
@PostMapping("/board/write")
public ResponseEntity<?> writeBoard(@Valid BoardWriteDto boardDto,
                                    HttpSession session) {
    // Session 끊킬 시 redirect
    if (SessionUtil.checkSession(session)) return SessionUtil.redirect(); // Session 끊킬 시 redirect
    return ResponseEntity.ok(boardService.writeBoard(boardDto)); // insert
}


@PostMapping("/board/update")
public ResponseEntity<?> updateBoard(@Valid BoardWriteDto boardDto,
                                     HttpSession session) {
    if (SessionUtil.checkSession(session)) return SessionUtil.redirect(); // Session 끊킬 시 redirect
    return ResponseEntity.ok(boardService.updateBoard(boardDto)); // update
}

그 중 Board를 예시로 가져와보았다.

JPA는 영속성을 해제하는 방안을 적용하지 않는 이상, Entity를 그대로 수정해버리면 DB에 반영이 되어버린다. 그래서 JPA를 사용할 때는 Entity를 직접 Setter 하는 것을 권장하지 않는다.

그래서 필자는 Dto -> Vo -> Entity 순서로 적용하는 방법을 생각하였고, 위는 상황에 맞는 Dto를 만들어서 @Valid를 적용한 모습이다.

 

 

개발을 진행하면서 블로그에 포스팅까지 하는 것이 생각보다 어려웠지만, 완벽하게는 못해도 이렇게 다시 둘러보았을 때 기억에 남을 정도로는 작성을 하는게 좋다고 생각이 들었다.

읽는 분들께는 부족할 수 있는 글이지만, 필자의 성장을 위한 회고록으로 그 발자취를 남긴다.

 

다음에도 추가 피드백을 받아 수정하게 되다면, 3편을 작성하겠다.

 


해당 CRUD 개발 깃허브 링크 : 

https://github.com/JeonDaehong/JpaNativeQuery-Redis-CRUD-Project

 

GitHub - JeonDaehong/JpaNativeQuery-Redis-CRUD-Project

Contribute to JeonDaehong/JpaNativeQuery-Redis-CRUD-Project development by creating an account on GitHub.

github.com