안녕하세요! 회사와 함께 성장하고 싶은 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입니다.

이번 포스팅은 RealMySQL8.0 (백은빈님 지음) 책을 읽고, 가상의 데이터를 생성하여 인덱스 쿼리 성능 비교를 하는 글을 작성하도록 하겠습니다.

 

Spring의 JPA나 QueryDsl을 사용할 때, index에 대한 개념을 명확하게 정리하지 않는 체 구현에만 급급하여 프로젝트를 진행했었습니다. RealMySQL8.0을 읽으면서 인덱스의 구조와 실행 계획을 확인하니, Like의 '%'의 위치에 따라 인덱스로 데이터 접근이 가능하거나 불가능하다는 사실을 확인할 수 있었습니다. 먼저 인덱스에 대한 개념과 B-Tree에 대한 설명, 그리고 쿼리를 통한 성능 분석을 진행하도록 하겠습니다.

 

1. 인덱스란?

DBMS는 테이블의 모든 데이터를 검색해서 원하는 정보를 가져오려면 시간이 오래 걸립니다. 그래서, 컬럼의 값과 해당 레코드가 저장된 주소를 키와 값의 쌍으로 삼아 인덱스를 생성하면, 시간을 단축할 수 있습니다. 또한, 정렬 기능이 추가된다면 사전을 찾을 때처럼 단어를 찾는 속도를 향상할 수 있는데, DBMS도 마찬가지로 컬럼의 값을 주어진 순서로 미리 정렬해서 보관합니다.

 

인덱스의 역할로 구분하기

- 프라이머리 키: 프라이머리키는 레코드를 대표하는 컬럼의 값으로 만들어진 인덱스입니다. 이 컬럼은 테이블에서 해당 레코드를 식별할 수 있는 기준값이 되기 때문에, 이를 식별자라고 부릅니다. 프라이머리 키는 NULL 값과 중복 값을 허용하지 않는 특징이 있습니다.

- 세컨더리 키: 프라이머리 키를 제외한 모든 인덱스를 세컨더리 인덱스라 부릅니다. 유니크 인덱스는 프라이머리 키와 성격이 비슷하고 프라이머리 키를 대체하여 사용할 수 있어 대체키로 부르기도 합니다.

 

 

2. B-Tree 인덱스

B-Tree는 Balanced Tree의 약자로 데이터베이스 인덱싱 알고리즘 가운데 가장 일반적으로 사용되고, 가장 먼저 도입된 알고리즘입니다. B-Tree의 변형된 알고리즘 형태는 B+-Tree와 B*-Tree가 있습니다.

RealMySQL8.0

- 구조 :

B-Tree는 트리구조의 최상위에 하나의 "루트 노드(Root node)"가 존재하고 그 하위에 자식 노드가 붙어 있는 행태입니다.  트리 구조의 가장 하위에 있는 노드를 "리프 노드(Leaf node)"라 하고, 트리 구조에서 루트 노드와 리프 노드가 아닌 중간 노드를 브랜치(Branch node)라 부릅니다. 리프 노드는 실제 데이터 레코드를 찾아가기 위한 주소값을 가지고 있습니다.

대부분 RDBMS의 데이터 파일에서 레코드는 특정 기준으로 정렬되지 않고 임의의 순서로 저장됩니다. 하지만 InnoDB 테이블에서 레코드는 클러스터 되어 디스크에 저장되므로 기본적으로 프라이머리 키 순서로 정렬되어 저장됩니다,!

 

 

3. 인덱스 사용과 성능

인덱스는 사용 방법에 따라, 성능이 달라집니다. 인덱스 테이블을 사용하더라도, 잘못 사용하면 인덱스의 주요 기능을 활용할 수 없습니다. 인덱스 성능 테스트를 위해 인텔리제이를 MySQL와 연동하여 약 1000만 개의 데이터를 생성하여 쿼리를 파악하는 시간을 가져보았습니다.

 

1) 인텔리제이 Mysql 콘솔 활용하기

 

오른쪽 상단에 데이터베이스를 클릭하고 생기는 데이터 소스 프로퍼티를 클릭하면, 인텔리제이와 MySQL을 연동할 수 있습니다.

호스트, 사용자, 비밀번호, URL을 입력하면 현재 root 계정에 있는 MySQL database에 연결할 수 있습니다.

 

 

 

2) 테이블 생성과 더미 데이터 저장

member의 테이블 명과 member_id (primary_key),  age, gender, user_id, username의 컬럼을 가진 테이블을 생성하였습니다.

다른 컬럼과 비교하기 위해 인덱스 컬럼은 user_id로 설정하였습니다.

# 인덱스 생성하기
CREATE INDEX member_user_id_idx ON member(user_id);

 

더미 데이터는 insert into table(컬럼) values(값)으로 일부 저장한 후,  아래 콘솔을 여러 번 반복하여 데이터를 추가하였습니다. insert into table 이후, select 절을 활용하면 select 된 데이터를 다시 insert 하는 데 사용할 수 있습니다. 이러한 방법을 활용하면 현재 있는 데이터 *2 만큼 데이터를 추가할 수 있습니다.

INSERT INTO member SELECT NULL, age, gender, user_id, username FROM member;

 

 

3) 성능 비교하기 인덱스 비교

 

먼저 user_id라는 인덱스를 활용하여 질의한 실행계획은 다음과 같습니다. type에 ref로 동등 조건이 실행되었고, possible_keys로 실행 가능한 인덱스 키를 확인할 수 있습니다. 즉, member_user_id_idx라는 인덱스로 ref(동등 조건) 비교를 실행할 수 있다는 의미입니다.

결과를 파악하면, 80ms를 기록하였습니다.

 

 

반면, 인덱스를 활용하지 않은 경우는 실행계획과 결과가 다음과 같습니다. type은 All 처리, pssible_keys는 null이 기록되었습니다. 인덱스로 활용 가능하지 않으므로 해당 값이 기록되지 않은 것입니다.

추가로, SQL_NO_CACHE는 실행되는 쿼리가 반복되더라도 캐시를 활용하지 않겠다는 의미입니다. 이는 여러 번 반복되는 행위에 MySQL이 캐시화하는 과정이 숨어있는데 이를 제거하지 않는다면 명확한 비교를 하기 어렵기 때문입니다. 결과를 확인하면 128ms로 인덱스보다 훨씬 느린 것을 볼 수 있었습니다.

 

5) 성능 비교하기 Like '단어%'

인덱스는 Like를 매우 효율적으로 활용할 수 있습니다. 하지만, 사용하는 방법에 따라, 인덱스를 활용하지 못할 수도 있습니다. 

전자는 '%'를 뒤에 적용하여 질의하는 것입니다. 실행계획을 살펴보면 member_user_id_idx로 range 질의가 실행되고 있습니다.

쿼리 실행 결과 90ms입니다.

 

 

인덱스를 활용하지 않은 경우는 다음과 같습니다. 적용가능한 possible_keys가 없으므로 all 질의가 진행되었고 성능은 3s가 기록되었습니다.

 

6) 성능 비교 Like '%단어'

'단어%' 형태로 질의한 경우, 많은 성능 차이를 보였지만, %가 앞에 위치하게 된다면 인덱스를 활용할 수 없습니다. 인덱스는 기본적으로 왼쪽 값을 기준으로 오른쪽 값이 정렬되어 있습니다. 따라서 '단어%'의 경우, 해당 단어가 기록되기 시작 한 곳부터 범위 계산을 할 수 있지만, '%단어' 형태가 된다면, 앞에서부터 해당 단어가 포함되는지 확인해야 하므로, 인덱스를 사용하는 의미가 없어지는 것입니다. 예시를 보면 다음처럼, 인덱스를 활용한 결과와 활용하지 않은 결과가 동일한 것을 보실 수 있습니다.

 

 

 

7) 함수를 이용한 인덱스 검색

MySQL8.0 버전은 함수형 인덱스를 제공한다는 장점이 있습니다. 데이터가 많을 때, 인덱스 컬럼을 추가한다면 비효율적일 수 있습니다. 이때, 자주 질의되는 형태를 함수형으로 구현한다면 인덱스를 활용할 수 있습니다. 

 

CONCAT(username, ' ',gender

 

member_username_gender_full_idx라는 인덱스로 ref(동등비교) 질의가 실행되는 것을 확인할 수 있었습니다.

 

 

하지만, 중요한 점은 같은 결과를 반환할지라도, 반드시 정의한 함수형 원형을 사용해야 합니다. 

저는 gender를 M, WM으로 대문자로 선언했기에 upper를 쓰더라도 결과는 같은 결과를 반환합니다.

하지만, 원형에 변형을 준다면 인덱스를 활용할 수 없습니다.

 

 

 

이상으로 인덱스를 활용한 예제 몇 가지를 실습하고 정리해 보았습니다.

이외에도 많은 index 활용 전략이 있습니다. 이는 추후 2편으로 나눠서 진행하도록 하겠습니다.

활용소스는 깃허브 링크 하단에 추가하도록 하겠습니다.

 

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

 

참고자료: RealMySQL8.0 (백은빈님 지음)

활용 소스: https://github.com/gosekose/MySQL-Study/blob/main/issue6/Issue6.sql

 

안녕하세요 회사와 함께 성장하고 싶은 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입니다.

 

오늘은 MySQL의 엔진 아키텍처에 대해서 정리하는 글을 작성하고자 합니다.

 

1. MySQL 전체 구조도

MySQL은 MySQL 엔진스토리지 엔진로 구성되어 있으며, 이를 MySQL 서버라고 부릅니다.

 

2. 구조도 요약

- MySQL 엔진 구성요소

  • 커넥션 핸들러
  • SQL 파서
  • 전처리기
  • 옵티마이저

- 스토리지 엔진

(실제 데이터르 디스크 스토리지에 저장하거나 디스크 스토리지로부터 데이터를 읽어오는 부분은 스토리지 엔진이 전담합니다.)

  • 키 캐시 (MyISAM 스토리지 엔진)
  • InnoDB 버퍼 풀

 

3. MySQL 스레딩 구조

 

MySQL 서버는 멀티스레드 프로그램입니다.

MySQL은 클라이언트 요청에 따라 스레드를 계속 생산하지 않고 정해진 개수를 상황에 따라 위임하여 처리합니다.

(과도한 멀티스레딩은 오히려 컨텍스트 스위칭 성능이 떨어뜨립니다 참고자료:  https://www.crocus.co.kr/1364

따라서, 멀티스레드 프로그램들은 스레드 풀(thread pool)을 이용해서  스레드의 개수를 제한합니다.

 

스레드 풀은 내부적으로 사용자의 요청을 처리하는 스레드의 개수를 줄여서 동시 처리되는 요청이 많더라도, MySQL 서버의 CPU가 제한된 개수의 스레드 처리에만 집중할 수 있게 합니다.

서버는 요청이 들어올 때마다 새 스레드를 생성하는 대신, 스레드 풀에게 작업을 위임하게 되고

작업은 내부적으로 큐에 저장하고 스레드들은 큐에서 작업을 위임받아 처리합니다.

- MySQL은 스레드 기반으로 작동하며, 크게 포그라운드백그라운드 스레드로 구분할 수 있습니다.

 

 

포그라운드 스레드 

- 클라이언트 사용자가 요청하는 쿼리 문장을 처리

- InnoDB 테이블은 데이터 버퍼나 캐시까지만 포그라운드 스레드가 처리, 나머지 버퍼로부터 디스크까지 기록하는 작업은 백그라운드 스레드가 처리

 

InnoDB의 백그라운드 스레드

- 인서트 버퍼를 병합하는 스레드

- 로그를 디스크로 기록하는 스레드

- InnoDB 버퍼 풀의 데이터를 디스크에 기록하는 스레드

- 데이터를 버퍼로 읽어 오는 스레드

- 잠금이나 데드락을 모니터링 하는 스레드 

 

백그라운드 스레드가 디스크에 기록하는 역할을 수행하는 이유는, 사용자의 'select' 요청은 지연될 수가 없지만, insert, update, delete는 해당 요청을 처리하되 바로 데이터 파일에 접근하여 수정하지 않고 일정 시간의 지연을 가지며 일괄적으로 처리를 수행하도록 할 수 있습니다. (비정상적인 종료를 방지한 언두, 로그 기록 등 필수)

 

쓰기 스레드는 아주 많은 작업을 백그라운드로 처리하기 때문에 읽기 스레드는 많이 설정할 필요가 없지만 쓰기 스레드는 아주 많은 작업을 백그라운드로 처리하기 때문에 일반적인 내장 디스크를 사용할 때는 2 ~ 4 정도로 설정하는 것이 좋습니다.

 

4. 쿼리 실행 구조

 

- 쿼리 파서

쿼리 파서는 사용자 요청으로 들어온 쿼리 문장을 토큰이 인식할 수 있는 최소 단위의 어휘나 기호로 분리해 트리 형태의 구조로 만들어 내는 작업을 의미합니다. 쿼리 문장의 기본적인 문법 오류는 이 과정에서 발견됩니다.

selecr * from member;
-> select 에러

 

- 전처리기

파서 과정에ㅓ 만들어진 파서 트리를 기반으로 쿼리 문장에 구조적인 문제점이 있는지 확인합니다.

각 토큰을 테이블 이름이나 칼럼 이름, 또는 내장함수와 같은 개체를 매핑해 해당 객체에 존재 여부와 객체의 접근 권한 등을 확인하는 과정을 이 단계에서 수행, 실제 존재하지 않거나 권한상 사용할 수 없는 개체의 토큰은 이 단계에서 걸러집니다.

select * from member;
-> SELECT command denied to user 'kose'@'localhosts' for table 'member'

 

- 옵티마이저

옵티마이저랑 사용자의 요청으로 들어온 쿼리 문장을 저렴한 비용으로 가장 빠르게 처리할지를 결정하는 역할을 수행

옵티마이저 튜닝에 따라 성능 차이가 발생할 수 있습니다.

 

- 실행 엔진

실행 엔진은 옵티마이저의 결과에 따라 만들어진 계획을 핸들러에게 요청, 요청 후 처리된 결과를 다른 핸들러의 요청의 입력으로 처리하는 중간 처리 단계 역할 수행합니다.

 

- 핸들러

핸들러는 MySQL 서버의 가장 밑단에서 MySQL 실행 엔진의 요청에 따라 데이터를 디스크로 저장하고 디스크로부터 읽어 오는 역할을 담당합니다. 

 

- 쿼리 캐시

MySQL 서버에서 쿼리 캐시는 빠른 응답을 필요로 하는 웹 기반의 응용 프로그램에서 매우 중요한 역할을 담당했지만, 쿼리 캐시는 테이블 데이터가 변경되면 캐시에 저장된 결과 중에서 변경된 테이블과 관련된 것들을 모두 삭제해야 합니다.

따라서, 심각한 동시 처리 성능 저하, 버그의 원인되어 쿼리 캐시는 MySQL 8.0에서 완전히 제거 

 

 

5. InnoDB 스토리지 엔진 아키텍처

프라이머리 키

- InnoDB의 모든 테이블은 기본적으로 프라이머리 키를 기준으로 클러스터링되어 저장됩니다.

- 프라이머리 키가 클러스터링 인덱스이기 때문에 프라이머리 키를 이용한 레인지 스캔은 상당히 빨리 처리될 수 있습니다.

- 쿼리의 실행 계획에서 프라이머리 키는 기본적으로 다른 보조 인덱스에 비해 비중이 높게 설정된다.

 

MVCC

- 레코드 레벨의 트랜잭션을 지원하는 DBMS가 제공하는 기능 MVCC의 가장 큰 목적은 잠금을 사용하지 않는 일관된 읽기를 제공하는데 있습니다.

- InnoDB는 언두 로그를 이용해 이 기능을 구현하고, 하나의 레코드에 대해 여러 개의 버전이 동시에 관리됩니다. 

- Row 수준의 락을 제공하므로, 다중 동시성 제어를 효율적으로 수행할 수 있습니다.

- 격리 수준 READ_UNCOMMITED -> InnoDB 버퍼 풀이 현재 가지고 있는 변경된 데이터를 읽어서 반환하고

  READ_COMMITED -> 변경되기 이전의 내용을 가지고 있는 언두 영역의 데이터 /반환

 

어댑티브 해시 인덱스

- 어댑티브 해시 인덱스는 사용자가 수동으로 생성하는 인덱스가 아니라 InnoDB 스토리지 엔진에서 사용자가 자주 요청하는 데이터에 대해 자동으로 생성하는 인덱스입니다.

- InnoDB 스토리지 엔진은 자주 읽히는 데이터 페이지의 키 값을 이용해 해시 인덱스를 만들고, 필요할 때마다 어댑티브 해시 인덱스를 검색해서 레코드가 저장된 데이터 페이지를 즉시 찾아갈 수 있습니다.

- 해시 인덱스는 인덱스 키 값과 해당 인덱스 키 값이 저장된 데이터 페이지 주소의 쌍으로 관리합니다.

- 인덱스 키 값은 B-Tree 인덱스의 고유번호와 B-Tree 인덱스의 실제 키 값 조합으로 생성합니다.

 

언두로그

- InnoDB 스토리지 엔진은 트랜잭션 격리 수준을 보장하기 위해 DML 변경되기 이전 버전의 데이터를 별도로 백업합니다.

- 트랜잭션 롤백 대비용, 트랜잭션의 격리 수준을 유지하면서 높은 동시성을 제공합니다.

 

 

체인지 버퍼

- RDMBS에서 레코드가 insert 되거나 update 될 때, 데이터 파일을 변경하는 작업과 해당 테이블에 포함된 인덱스를 업데이트하는 작업도 필요합니다.

- 랜덤하게 디스크를 읽는 작업이 필요하므로 테이블에 인덱스가 많다면 이 작업은 상당히 많은 자원을 소모합니다.

- 버퍼 풀에 있다면 바로 업데이트를 수행하지만, 임시 공간에 값을 저장해 두고 사용자에게 결과를 반환합니다.

- 반드시 중복 여부를 체크해야하는 유니크 제약 조건 인덱스는 사용 불가합니다.

 

 

리두 로그

- 리두 로그는 하드웨어나 소프트웨어 등 여러 가지 문제점으로 인해 MySQL 서버가 비정상적으로 종료됐을 때 데이터 파일에 기록되지 못한 데이터를 잃지 않게 해주는 안전장치입니다.

- MySQL 서버를 포함한 대부분 데이터베이스 서버는 데이터 변경 내용을 로그에 먼저 기록하고, 쓰기 비용이 낮은 자료 구조를 가진 리두 로그, 비정상 종료가 발생하면 리두 로그의 내용을 이용해 데이터 파일을 다시 서버가 종료되기 직전 상태로 복구합니다.

 

6. MyISAM 아키텍처

정적인 데이터를 저장하고 자주 읽기 작업이 일어나는 테이블에 적합한 엔진입니다

 

- 구조가 단순하고 속도가 빠르고 데이터 저장에 실제적인 제한이 없고 매우 효율적으로 저장합니다.

- 테이블 작업시 특정 행을 수정하려고 하면 테이블 전체에 락이 걸려서  다른 사람이 작업할 수 없습니다.

- 트랜잭션에 대한 지원이 없기때문에 작업 도중의 에러 발생 시, 데이터 무결성이 깨질 수 있습니다.

 

7. MyISAM vs InnoDB 차이점 표 비교

 

 

참조: https://velog.io/@sweet_sumin/DB-%EC%8A%A4%ED%86%A0%EB%A6%AC%EC%A7%80-%EC%97%94%EC%A7%84-%EB%B3%84-%EC%B0%A8%EC%9D%B4

'DB' 카테고리의 다른 글

[DB] MySQL8.x 리플리카 서버 적용하기(1)  (0) 2023.06.10
[DB] 락(잠금)  (0) 2023.04.24
[DB] MySQL8.0 Index  (0) 2023.02.06
[DB] MySQL 계정 생성 및 권한 부여 (Real MySQL 8.0)  (0) 2023.01.07
[DB] 칼럼형 DBMS VS 로우형 DBMS  (0) 2022.12.28

안녕하세요.

회사와 함께 성장하고 싶은 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

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

밤이 늦었지만,  AOP에 관한 내용을 나눠서 정리하고자 글을 작성하게 되었습니다.

 

AOP를 알기 전에는, 핵심 로직과 부가 기능을 분리하지 않고 작성하다 보니, 추후 리팩토링 하는 과정에서 난관에 부딪친 적이 있습니다. 영한님 스프링 핵심 원리 고급편을 수강하고 나니, AOP를 활용하는 방법을 배울 수 있었고, 앞 선 문제를 해결하는데 많은 도움을 받을 수 있었습니다.

 

이제 본격적으로 SpringBoot AOP과 AOP를 사용하는데 활용되는 어노테이션을 정리하는 글을 이어 나가도록 하겠습니다.

 

1. AOP(Aspect-Oriented Programming)란 ?

https://tecoble.techcourse.co.kr/post/2021-06-25-aop-transaction/

AOP란 애플리케이션을 바라보는 관점을 하나하나의 기능에서 횡단 관심사 관점으로 보는 것을 의미합니다. 전통적인 객체 지향 설계 방식을 따라 기능을 설계하면 높은 응집성과 낮은 결합도를 유지할 수 있습니다. 하지만, 각 객체에 걸쳐서 공통적으로 사용되는 부가 기능들이 존재합니다. 로그를 남기거나 트랜잭션 처리를 하는 등 애플리케이션 전반에 걸쳐 흩어져 있는 공통적인 관심사를 하나로 모듈화 하여 관리하는 것을 관점 지향 프로그래밍 AOP라고 부릅니다. 

 

 

2.  AOP 적용 방법

컴파일 실제 대상 코드에 애스펙트를 통한 부가 기능 호출 코드가 포함됩니다. (AspectJ 직접 사용)
클래스 로딩 실제 대상 코드에 애스펙트를 통한 부가 기능 호출 코드가 포함됩니다. (AspectJ 직접 사용)
런타임  실제 대상 코드는 크대로 유지하되, 프록시를 통해 부가 기능이 적용됩니다. (스프링 AOP는 이 방식을 사용)

 

 

3.  AOP 적용 위치

AOP가 적용될 수 있는 적용 가능한 지점을 조인 포인트라 부르는데, 생성자, 필드값 접근, static 메서드 접근, 메서드 실행이 조인포인트가 될 수 있습니다.

AspectJ를 사용해서 컴파일 시점과 클래스 로딩 시점에 적용하는 AOP는 바이트 코드를 실제 조작하기 때문에 해당 기능을 모두 적용할 수 있습니다.  하지만 프록시 방식을 사용하는 스프링 AOP는 메서드 실행 시점에만 AOP를 적용할 수 있습니다.

--> 프록시는 메서드 오버라이딩 개념으로 동작합니다. 따라서, 생성자, static 메서드, 필드 값 접근에는 프록시 개념이 적용될 수 없습니다.

 

 

4. 용어 정리

포인트컷(Pointcut) 조인 포인트 중에서 어드바이스가 적용될 위치, AspectJ 표현식을 사용해서 지정
타겟(Target) 어드바이스를 받는 객체, 포인트컷으로 결정
어드바이스(Advice) 특정 조인 포인트에서 Aspect에 의해 취해지는 조치 (Around, Before 등 어드바이스 존재)
애스팩트(Aspect) 어드바이스와 포인트컷을 모듈화한 기능
어드바이저(Advisor) 하나의 어드바이스에 하나의 포인트컷으로 구성
위빙(Weaving) 포인트컷으로 결정한 타겟의 조인 포인트에 어드바이스를 적용한 것
위빙으로 핵심 기능 코드에 영향을 주지 않고 부가 기능을 추가할 수 있음

 

 

5. 스프링에 AOP 적용하기

스프링에 AOP를 적용하기 위해, 크게 사용될 기능은 @Aspect와 @Around (어드바이스 종류)입니다.

@Aspect는 어노테이션 기반 프록시를 적용할 때 필요합니다.

@Around는 표현식을 value의 값으로 설정하고,  해당 어노테이션을 받는 메서드는 어드바이스가 됩니다.

이를 바탕으로, BlogRepository와 BlogService(Impl)에 적용되는 AOP를 확인하도록 하겠습니다.

 

<Blog를 저장하고 로드하는 기능을 가진 간단한 Repository, Service>

@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 저장]");
    }

    public String load(Long blogId) {

        log.info("[BlogRepository.load 실행]");

        if (blogId == null) {
            log.info("[BlogRepository.load 에러]");
            throw new IllegalArgumentException();
        }

        log.info("[BlogRepository.load 성공]");
        return "안녕하세요";
    }
}
public interface BlogService {
    String load(Long blogId);
    void save(String content);
}
@Slf4j
@Service
@RequiredArgsConstructor
public class BlogServiceImpl implements BlogService {

    private final BlogRepository blogRepository;

    @Override
    public String load(Long blogId) {

        log.info("[BlogService.load 실행]");
        String content = blogRepository.load(blogId);
        log.info("[BlogService.load 완료] content = {}", content);

        return content;
    }


    @Override
    public void save(String content) {

        log.info("[BlogService.save 실행]");
        blogRepository.save(content);
        log.info("[BlogService.save 완료]");

    }
}

 

@Around  어노테이션의 AspectJ 표현식에 사용할 Pointcut을 클래스로 분리하여 Repository와 Service에 중복되는 메서드에 공통적으로 처리하는 Pointcuts 클래스입니다.

@Slf4j
public class Pointcuts {

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

    @Pointcut("execution(* hello.aop.blog.*.load(..))")
    public void allLoad() {}

    @Pointcut("execution(* hello.aop.blog.BlogService.*(..))")
    public void allService() {}

}

 

Pointcuts을 생성한 후 AOP를 적용할 어드바이스에 내부 static 클래스를 생성하였습니다.

@Around와 @Before @After 등의 다양한 어드바이스 어노테이션을 활용하여 차이점을 확인하였습니다. @Around는 ProceedingJoinPoint를 파라미터로 받는데, 반드시 joinPoint의 proceed()를 실행하여, 해당 핵심 로직이 수행될 수 있도록 설정하여야 합니다. @Around가 아닌 어드바이스 종류의 어노테이션은 부가 기능을 활용하는 데 사용됩니다. 이는 기능을 세분화하여 런타임 시점에 발생할 수 있는 장애를 방지하는 역할을 수행할 수 있습니다.

 

@Slf4j
public class ApplyAspect {

    // --- 중략 ---

    @Aspect
    public static class AllLoad {

        @Before("hello.aop.blog.Pointcuts.allLoad()")
        public void loadBefore(JoinPoint joinPoint) throws Throwable {
            log.info("[Before AllLoad JoinPoint]");
        }

        @AfterReturning(value = "hello.aop.blog.Pointcuts.allLoad()", returning = "result")
        public void loadReturn(JoinPoint joinPoint, Object result) {
            log.info("[AfterReturning AllLoad ProceedingJoinPoint]");
            log.info("[AfterReturning AllLoad joinPoint] Signature = {}", joinPoint.getSignature());
         	// 로그 중략 
            log.info("[AfterReturning AllLoad joinPoint] class = {}", joinPoint.getClass());
            log.info("[AfterReturning AllLoad Result] result = {}", result.toString());
        }

        @AfterThrowing(value = "hello.aop.blog.Pointcuts.allLoad()", throwing = "e")
        public void loadThrowing(JoinPoint joinPoint, Exception e) {
            log.info("[AfterThrowing AllLoad Exception] message = {}", e);
        }

        @After(value = "hello.aop.blog.Pointcuts.allLoad()")
        public void loadAfter(JoinPoint joinPoint) {
            log.info("[After AllLoad joinPoint] Signature = {}", joinPoint.getSignature());
        }
    }

    @Aspect
    public static class AllService {

        @Around("hello.aop.blog.Pointcuts.allService()")
        public Object saveAndLoadAround(ProceedingJoinPoint joinPoint) throws  Throwable {

            try{

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

                return result;

            } catch (Exception e) {

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

 

 

6. 테스트 확인하기

@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("안녕하세요");
    }

    @Test
    @DisplayName("BlogRepository save 실패 AOP 로그 확인")
    public void failSaveRepository() throws Exception {
        Assertions.assertThatThrownBy(() -> blogRepository.save(null))
                        .isInstanceOf(IllegalArgumentException.class);
    }

   /*
   * 중략 
   */
   

    @Test
    @DisplayName("BlogService load AOP 로그 확인")
    public void loadService() throws Exception {
        String load = blogService.load(14L);
        assertThat(load).isEqualTo("안녕하세요");
    }

    @Test
    @DisplayName("BlogService load 실패 AOP 로그 확인")
    public void failLoadService() throws Exception {
        Assertions.assertThatThrownBy(() -> blogService.load(null))
                .isInstanceOf(IllegalArgumentException.class);
    }

}

AOP를 설정한 결과, 핵심 로직에는 작성하지 않았던 부가 기능 로그가 런타임 시점에 프록시가 적용되어 로그가 기록된 것을 확인할 수 있습니다. 또한 @Around ->  @Before -> @AfterReturing 순서로 출력된 것을 확인할 수 있었습니다.

[Around AllService ProceedingJoinPoint]
[BlogService.load 실행]
[Before AllLoad JoinPoint]
// 중략
[AfterReturning AllLoad joinPoint] Signature //중략

 

 

7.  로그 기록 분석하기 

<제 생각이 많이 담긴 부분이라, 정확하지 않을 수 있습니다 !!

제가 잘못 분석한 부분이 있다면 댓글 적어주시면 감사히 배우겠습니다.!!>

 

Signature: 반환 타입, 경로, 클래스 정보, 메서드 정보, 파라미터 타입을 확인할 수 있었습니다.

[AfterReturning AllLoad joinPoint] Signature = String hello.aop.blog.BlogServiceImpl.load(Long)

>> [AfterReturning AllLoad joinPoint] Signature = String hello.aop.blog.BlogServiceImpl.load(Long)

 

Target: 어드바이스를 받는 객체가 BlogServiceImpl의 프록시 형태로 출력되었는데,

이는 런타임 시점에 핵심 로직에 프록시가 적용되어 부가 기능이 추가되는 것과 연관 지어 생각할 수 있었습니다.

>> [AfterReturning AllLoad joinPoint] Target = hello.aop.blog.BlogServiceImpl@7d979d34

 

Args: BlogService.load()의 파라미터로 14를 입력하였는데,

joinPoint에서도 파라미터 정보를 얻을 수 있었습니다.

이를 확장하면 앞 선 security나 transaction 처리 등에도 활용될 수 있습니다.

>> [AfterReturning AllLoad joinPoint] Args = 14

 

class: 한 가지 놀라운 점은 ProceedingJoinPoint와 JoinPoint 모두 동일한

MethodInvocationProceedingJoinPoint 라는 구현체를 출력하였습니다.

>>[Around AllService ProceedingJoinPoint] class = class org.springframework.aop.aspectj.MethodInvocationProceedingJoinPoint
>>[AfterReturning AllLoad joinPoint] class = class org.springframework.aop.aspectj.MethodInvocationProceedingJoinPoint

 

이는, 비록 같은 구현체를 사용하지만 파라미터로 사용되는 인터페이스가 @Around는 ProceedingJoinPoint 이므로 다형성의 원리로 인해 ProceedingJoinPoint로 캐스팅된 MethodInvocationProceedingJoinPoint만 proceeding() 메서드를 호출할 수 있는 방식이라고 생각하게 되었습니다.

 

스프링 AOP는 정말 멋진 스프링의 기능인 것 같습니다. 어느새 정리하다 보니 두 시간이 훌쩍 지나갔지만 예제를 작성하고 로그를 분석하다보니, 새롭게 더 많은 부분을 알게 되었습니다.!

감사합니다.!

 

참고 자료: 인프런 김영한 님 스프링 핵심 원리 고급편 

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

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

이번 포스팅은 MySQL의 계정 생성 및 권한 부여에 관한 내용을 정리하고자 합니다.

내용 및 예제 소스는 백은빈 님, 이성욱 님의 Real MySQL 8.0를 기반으로 구성하였습니다.

 

제 운영체제 환경은 ubuntu 20.04이고, MySQL 버전은 8.0이므로 맥 OS, Windows 사용자분들이나, MySQL이 8.0대 버전이 아니신 분들은 명령어가 다를 수 있습니다.

 

1. MySQL 계정 생성하기

 

계정을 생성하기 전에 먼저 MySQL이 활성화되어 있지 않다면 아래 명령어를 통해 MySQL을 실행시켜주어야 합니다.

sudo systemctl start mysql

 

MySQL이 설치되어 있다고 가정하고, root로 로그인을 하기 위한 명령어는 다음과 같습니다.

mysql -u root -p

 

root 계정으로 이동 후, db에 접근 가능한 권한을 분리하여 적용하기 위해 계정을 생성합니다.

create user 'kose1'@'%'
identified with 'mysql_native_password' by 'password'
require none
password expire interval 30 day
account unlock
password history default
password reuse interval default
password require current default;

이때, 저는 다음과 같은 에러가 발생하였습니다.

--> ERROR 1819 (HY000): Your password does not satisfy the current policy requirements

에러를 분석하면, 현재 작성한 'password'라는 패스워드가 mysql에 설정되어 있는 비밀번호 정책에 맞지 않다는 것입니다.

 

MySQL 서버의 비밀번호는 유효기간이나 이력관리를 통한 재사용 금지 기능뿐만 아니라, 비밀번호를 쉽게 유추할 수 있는 단어들이 사용되지 않도록 금칙어를 설정할 수 있습니다.

MySQL 서버에서 비밀번호의 유효성 체크 규칙을 적용하려면 validate_password 컴포넌트를 사용해야 합니다.

 

비밀번호 정책은 크게 3가지가 있으며, 기본은 MEDIUM입니다.

 

- LOW: 비밀번호의 길이만 검증
- MEDIUM: 비밀번호의 길이를 검증, 숫자와 대소문자, 특수문자의 배합을 검증
- STRONG: MEDIUM 레벨의 검증을 모두 수행, 금칙어의 포함 여부 검증 

 

현재, Mysql에서 medium으로 설정되어 있기 때문에 low로 바꾸고 다시 유저를 생성하면, 에러를 해결할 수 있습니다.

 

show variables like 'validate_password%';

//출력

+--------------------------------------+--------+
| Variable_name                        | Value  |
+--------------------------------------+--------+
| validate_password.check_user_name    | ON     |
| validate_password.dictionary_file    |        |
| validate_password.length             | 8      |
| validate_password.mixed_case_count   | 1      |
| validate_password.number_count       | 1      |
| validate_password.policy             | MEDIUM |
| validate_password.special_char_count | 1      |
| validate_password_check_user_name    | ON     |
| validate_password_dictionary_file    |        |
| validate_password_length             | 8      |
| validate_password_mixed_case_count   | 1      |
| validate_password_number_count       | 1      |
| validate_password_policy             | MEDIUM |
| validate_password_special_char_count | 1      |
+--------------------------------------+--------+
//mysql8 기준
set global validate_password.policy=LOW;
set variables like 'validate_password%';

+--------------------------------------+--------+
| Variable_name                        | Value  |
+--------------------------------------+--------+
| validate_password.check_user_name    | ON     |
| validate_password.dictionary_file    |        |
| validate_password.length             | 8      |
| validate_password.mixed_case_count   | 1      |
| validate_password.number_count       | 1      |
| validate_password.policy             | LOW    |
| validate_password.special_char_count | 1      |
| validate_password_check_user_name    | ON     |
| validate_password_dictionary_file    |        |
| validate_password_length             | 8      |
| validate_password_mixed_case_count   | 1      |
| validate_password_number_count       | 1      |
| validate_password_policy             | MEDIUM |
| validate_password_special_char_count | 1      |
+--------------------------------------+--------+

이후 다시 root 계정으로 계정 등록을 하면, 계정이 정상적으로 등록됩니다.

mysql> create user 'kose1'@'%'
    -> identified with 'mysql_native_password' by 'password'
    -> require none
    -> password expire interval 30 day
    -> account unlock
    -> password history default
    -> password reuse interval default
    -> password require current default;
Query OK, 0 rows affected (0.04 sec)

 

--> 계정 생성에 사용한 설정 정보는 다음과 같은 역할을 수행합니다.

 

● IDENTIFIED WITH

 

- 사용자 인증 방식과 비밀번호를 설정

- mysql 서버의 기존 인증 방식을 사용하고자 한다면, identified by 'password'형식으로 명시

 

 

 REQUIRE

 

- mysql 서버에 접속할 때, 암호화된 SSL/TLS 채널을 사용할지 여부를 설정

- 만약 require 옵션을 ssl로 설정하지 않았더라도, identified with에서 사용하는 사용자 인증방식을 Caching SHA-2 Authentication(mysql8 default) 인증 방식을 사용하면 암호화된 채널만으로 MySql 접속이 가능

 

 

 PASSWORD EXPIRE

 

- 비밀번호의 유효 기간을 설정하는 옵션

- 별도로 명시하지 않으면 defaulr_password_lifetime 시스템 변수에 저장된 기간으로 유효 기간이 설정

 

 

 PASSWORD HISTORY

 

- 한 번 사용했던 비밀번호를 재사용하지 못하게 설정하는 옵션

- password history default: password_history 시스템 변수에 저장된 개수만큼 비밀번호의 이력을 저장

- password history n: 비밀번호의 이력을 최근 n개까지만 저장

 

 PASSWORD REUSE INTERVAL

 

- 한 번 사용했던 비밀번호의 재사용 금지 기간을 설정

- 별도로 명시하지 않으면 password_reuse_interval 시스템 변수에 저장된 기간으로 설정

- password reuse interval default: 시스템 변수에 기입된 기간으로 설정

- password reuse interval n day: n일자 이후에 비밀번호를 재사용할 수 있도록 설정

 

 PASSWORD REQUIRE

 

- 비밀번호가 만료되어 새로운 비밀번호로 변경할 때, 현재 비밀번호를 필요로 할지 말지를 결정하는 옵션

- 별도로 명시되지 않으면 password_require_current 시스템 변수값으로 설정

- password require current: 비밀번호를 변경할 때, 현재 비밀번호를 먼저 입력하도록 설정

- password require optional: 비밀번호를 변경할 때, 현재 비밀번호를 입력하지 않아도 되도록 설정

- passowrd require default: password_require_current: 시스템 변수의 값으로 사용

 

 ACCOUNT ROCK / UNLOCK

 

- 계정 새성 시 또는 alter user 명령을 사용해 계정 정보를 변경할 때 계정을 사용하지 못하게 잠글지 여부를 결정

- account rock: 계정을 사용하지 못하도록 잠금

- account unlock: 잠긴 계정을 다시 사용 가능한 상태로 잠금 해제

 

 

2. DB 권한 부여하기

현재, 저의 MySQL에는 Employees라는 테이블이 구성되어 있습니다. 새로 생성한 계정에 select, insert, update 권한을 부여하기 위해  다음과 같은 명령어를 수행할 수 있습니다.

mysql> grant select, insert, update on employees.* to 'kose1'@'%';
Query OK, 0 rows affected (0.02 sec)

각 계정에 따라 다른 권한을 부여할 수 있는 것을 확인하기 위해 새로운 계정을 생성하고 권한을 부여하면 다음과 같습니다.

mysql> create user 'kose2'@'%'
    -> identified with 'mysql_native_password' by 'password'
    -> require none
    -> password expire interval 30 day
    -> account unlock
    -> password history default
    -> password reuse interval default
    -> password require current default;

mysql> grant select on employees.* to 'kose2'@'%';
Query OK, 0 rows affected (0.02 sec)

 

 

--> 생성한 계정으로 로그인하여 권한 적용 확인하기

mysql -u kose1 -p
Enter password:
//(아까 생성할 때 사용한 password 입력)

root 계정에서, kose1에 접근 가능한 권한을 select, insert, update만 부여했으므로,

delete 명령어는 실행되지 않는 것을 확인할 수 있습니다.

 

 

3. 역할(ROLE) 부여하기

MySQL에서는 '역할'을 담당하는 계정을 생성할 수 있는데, 이는 사용자 계정에 권한 부여의 목적으로 사용이 됩니다.

mysql> create ROLE
    -> role_emp_read,
    -> role_emp_write;
Query OK, 0 rows affected (0.04 sec)

mysql> grant select on employees.* to role_emp_read;
Query OK, 0 rows affected (0.04 sec)

mysql> grant insert, update, delete on employees.* to role_emp_write;
Query OK, 0 rows affected (0.02 sec)

 

생성한 kose1, kose2의 계정에 각각 다른 권한을 부여하면 다음과 같습니다.

mysql> grant role_emp_read, role_emp_write to 'kose1'@'%';
Query OK, 0 rows affected (0.03 sec)

mysql> grant role_emp_read to 'kose2'@'%';
Query OK, 0 rows affected (0.04 sec)

이때, 특정 계정을 로그아웃하면, root 계정에서 설정한 권한이 수행되지 않는 상황이 발생하였습니다.

이는 MySQL에서 설정한 권한 정보가 로그인 상태에서만 유효하도록 설정되어 있기 때문입니다.

이를 로그아웃되더라도 유지하도록 설정하려면 다음과 같은 명령어를 작성하면 됩니다.

-> root 계정
mysql> set global activate_all_roles_on_login=on;
Query OK, 0 rows affected (0.00 sec)

 

--> 권한 적용 확인하기

mysql> delete from departments where dept_no = 'd001';
ERROR 1142 (42000): DELETE command denied to user 'kose2'@'localhost' for table 'departments'

-> kose1 계정
mysql> delete from departments where dept_no = 'd001';
Query OK, 1 row affected (0.02 sec)

 

 

read (select) 권한만 부여 받은 kose2 계정은 delete 명령어를 수행하면 명령이 거부된 것을 확인할 수 있습니다.

반면, kose1 계정은 CRUD가 모두 적용된 기능을 수행할 수 있는 것을 확인할 수 있습니다.

 

역할 테이블을 생성하는 이유는 무엇일까?
역할과 계정은 내외부적으로 동일한 객체인데, CREATE ROLE 명령과 CREATE USER 명령을 구분하는 이유는
계정 생성과 권한 부여가 모두 가능한 사용자와 권한 부여만 가능한 사용저를 분리하여, 보안을 높이기 위함입니다.

 

이상으로, MySQL에서 계정을 생성하고 권한을 부여하는 과정을 정리하는 글을 마치도록 하겠습니다.

자세한 사항은 Real MySQL에서 확인하실 수 있습니다.

감사합니다.!

 

출처: Real MySQL (백은빈님, 이성욱 님 지음)

'DB' 카테고리의 다른 글

[DB] MySQL8.x 리플리카 서버 적용하기(1)  (0) 2023.06.10
[DB] 락(잠금)  (0) 2023.04.24
[DB] MySQL8.0 Index  (0) 2023.02.06
[DB] MySQL 엔진 아키텍처 (Real MySQL 8.0)  (0) 2023.01.22
[DB] 칼럼형 DBMS VS 로우형 DBMS  (0) 2022.12.28

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

 

이번 포스팅은 SpringBoot의 Data JPA에서 제공하는 NativeSQL, JPQL을 활용하여 CASE WHEN을 활용한 집계 함수를 적용하는 과정을 정리하고자 합니다. (수정)

 

Data Jpa를 활용하면 다양한 로직을 구현할 수 있습니다. find(), delete(), save(), update() 등 CRUD 로직을 간단한 jpa 용법을 활용하여 구현할 수 있습니다. 하지만, 복잡한 쿼리를 구현해야 할 때 사용자 정의 메서드를 구현해야 할 필요성이 있습니다.

 

이번에 구현하는 비즈니스 로직은, 다음과 같습니다.

A라는 게임을 참여한 사용자들의 게임 결과를 바탕으로, 사용자 id,  게임에서 이긴 횟수, 게임에서 진 횟수, 게임에서 이긴 비율, 마지막 게임 참여 시간의 데이터를 가져와서 클라이언트에 제공해야 합니다.

 

이때는, SQL의 case when, 집계 함수, round(), group by 등을 활용하여 구현하여야 합니다.

따라서, 비즈니스 로직을 구현하기 위한 클래스와 h2 raw query, Data Jpa의 NativeSQL을 활용하여 해당 비즈니스 로직을 구현하는 과정을 정리하도록 하겠습니다.

 

1. Domain, Repository, Service

 

@Entity
@Getter
@AllArgsConstructor
@NoArgsConstructor
public class Game extends BaseEntity {

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

    @ManyToOne(fetch = LAZY)
    @JoinColumn(name = "member_id")
    private Member host;

    public Game(Member host) {
        this.host = host;
    }

    public static Game of (Member host) {
        return new Game(host);
    }
}
@Entity
@Getter
@AllArgsConstructor
@NoArgsConstructor
@Table(name = "gameresult")
public class GameResult extends BaseEntity {

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

    @ManyToOne(fetch = LAZY)
    @JoinColumn(name = "game_id")
    private Game game;

    @ManyToOne(fetch = LAZY)
    @JoinColumn(name = "join_member_id")
    private Member member;

    @Enumerated(STRING)
    private Result result;

    @Builder
    public GameResult(Game game, Member member, Result result) {
        this.game = game;
        this.member = member;
        this.result = result;
    }
}
@Repository
public interface GameResultRepository extends JpaRepository<GameResult, Long> {

    
    // to do
    
    }
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class GameResultService {

    private final GameResultRepository repository;

    public Page<AllMemberGameResultAnalysisInterface> fetchAllMembersGameResultAnalysis(Pageable pageable) {
        return repository.fetchAllMembersGameResultAnalysis(pageable);
    }

}

구현해야 하는, Repository는 먼저 설명을 위해 //to do 빈칸으로 작성하였습니다. 

 

가상의 환경을 구현하기 위해, initDb라는 클래스를 작성하여, SpringBoot가 런타임 될 때, 임시 데이터를 입력하였습니다.

gameresult 테이블을 확인하면, 우리가 구현해야 하는 비즈니스 로직에 해당하는 데이터들이 입력된 것을 확인할 수 있습니다. 

비즈니스 로직을 구현하기 위해, Result 컬럼의 데이터를 상황에 맞게 정제하고, 그룹화하는 쿼리를 구현해야 합니다.

 

 

2. Raw Query 작성하기

바로 nativeQuery를 작성하면 좋지만, h2 데이터베이스 콘솔을 활용하여 구현해야 하는 로직을 먼저 작성하여 베이스 라인을 구축하였습니다.

 

- case when 

sql 에서는 조건에 따라 서로 다른 값을 반환할 수 있도록 case 표현식을 제공합니다. 자바에서의 if else if else와 비슷한 용법으로 sql 내에서 활용할 수 있는 구문입니다.

 

- group by

집계 함수를 활용해야 한다면, group by를 활용하여, 특정 컬럼을 기준으로 그룹화하여 작성할 수 있습니다.

 

- round()

라운드 함수는 반올림 기능을 제공하는 함수로, round(칼럼명/표현식, n)으로 작성하며, 해당 표현식의 결과를 n(소수점)의 자리까지 반올림하는 기능을 제공합니다.

소수점을 나타낼 때 데이터가 정수형이라면, 표현식 * 1.0을 하여 해당 타입을 실수형으로 바꿔주어야 합니다.

 

이를 바탕으로, Sql 쿼리를 작성하면 다음과 같습니다. 목표가 이긴 횟수, 진 횟수, 이긴 비율을 구해야 하므로 이겼을 경우의 케이스를 count 한 값, 진 케이스를 count 한 값, 이긴 횟수를 count 한 결과에서 총 result 개수를 나누고 * 100 한 후, 첫째 자리까지 반올림하여 표현하였습니다.

select  
join_member_id,
count(case when result = 'WIN' then 1 end) win, 
count(case when result = 'LOSE' then 1 end) lose,
round(count(case when result = 'WIN' then 1 end) / (count(result) * 1.0) * 100,  1) as rate,
max(modified_at) as lastjointime
from gameresult
group by join_member_id;

 

그 결과 비즈니스 로직에서 구현해야하는 쿼리를 작성할 수 있습니다.

 

3. Data Jpa에 적용하기

 

-  Wrapper.class를 적용할 interface를 구현하기

처음에는 native query로 구현할 때, Dto에 매핑되도록 구현하였습니다. 하지만, 매핑 에러가 반복되었고, 구글링 결과 인터페이스로 선언한 Dto에 매핑하여야 한다는 설명이 있었습니다. 따라서, 인터페이스를 Dto로 활용하기 위해 작성하였습니다.

public interface AllMemberGameResultAnalysisInterface {

    Long getId();
    Long getWin();
    Long getLose();
    Double getRate();
    LocalDateTime getLastjointime();

}
public interface AllMemberGameResultAnalysisJpqlInterface {

    Long getId();
    Long getWin();
    Long getLose();
    Double getRate();
    LocalDateTime getLastJoinGameTime();

}

이를 바탕으로 Repository에 native 쿼리를 구현한 결과는 다음과 같습니다.

count() -> sum()으로 수정하여 작성하였고, 매핑하는 과정에서 일반 jpql과 달리 실제 데이터베이스에 적용된 컬럼명을 적어줘야 하는 한계가 존재했습니다. 만약 paging 처리를 해야 한다면 @Query() 안에 countQuery = "" 로직을 구현해야 합니다.

@Repository
public interface GameResultRepository extends JpaRepository<GameResult, Long> {

	@Query(value =
            "select g.member.id as id, " +
                    "sum(case when (g.result = 'WIN') then 1 else 0 end) as win, " +
                    "sum(case when g.result = 'LOSE' then 1 else 0 end) as lose, " +
                    "round(sum(case when (g.result = 'WIN') then 1 else 0 end) / (count(g.result) * 1.0) * 100, 1) as rate, " +
                    "max(g.modifiedAt) as lastJoinGameTime " +
                    "from GameResult g " +
                    "join Member m on g.member.id = m.id " +
                    "group by g.member.id")
    Page<AllMemberGameResultAnalysisJpqlInterface> fetchAllMembersGameResultAnalysisJpql(Pageable pageable);



    @Query(value =
            "select g.join_member_id as id, " +
                    "sum(case when (g.result = 'WIN') then 1 else 0 end) as win, " +
                    "sum(case when g.result = 'LOSE' then 1 else 0 end) as lose, " +
                    "round(sum(case when (g.result = 'WIN') then 1 else 0 end) / (count(g.result) * 1.0) * 100, 1) as rate, " +
                    "max(g.modified_at) as lastjointime " +
                    "from GameResult g " +
                    "join Member m on g.join_member_id = m.member_id " +
                    "group by g.join_member_id " +
                    "order by g.join_member_id ASC",
            countQuery = "select count(*) from GameResult g",
            nativeQuery = true)
    Page<AllMemberGameResultAnalysisInterface> fetchAllMembersGameResultAnalysis(Pageable pageable);

}

 

4. 테스트 하기

(@BeforEach에 사용한 구문은 제거하였습니다)

 

@SpringBootTest
@Transactional
class GameResultServiceTest {

    @Autowired
    EntityManager em;

    @Autowired
    GameResultService gameResultService;
    
    @Test
    @DisplayName("각 회원의 이긴 횟수, 진 횟수, 승리 확률을 페이징하여 가져 온다.(JPQL)")
    public void fetchAllMemberGameResultAnalysisJpql() throws Exception {
        //given
        Pageable page = PageRequest.of(0, 10);

        //when
        Page<AllMemberGameResultAnalysisJpqlInterface> result = gameResultService.fetchAllMembersGameResultAnalysisJpql(page);

        //then
        assertThat(result.getContent().size()).isEqualTo(10);
        assertThat(result.getContent().get(0).getWin()).isEqualTo(1L);
        assertThat(result.getContent().get(1).getLose()).isEqualTo(4L);
        assertThat(result.getContent().get(2).getLose()).isEqualTo(4L);
        assertThat(result.getContent().get(3).getLose()).isEqualTo(4L);
        assertThat(result.getContent().get(4).getLose()).isEqualTo(4L);
        assertThat(result.getContent().get(2).getRate()).isEqualTo(20.0);
        assertThat(result.getContent().get(2).getLastJoinGameTime()).isBefore(LocalDateTime.now());

    }

    

    @Test
    @DisplayName("각 회원의 이긴 횟수, 진 횟수, 승리 확률을 페이징하여 가져 온다.")
    public void fetchAllMemberGameResultAnalysis() throws Exception {
        //given
        Pageable page = PageRequest.of(0, 10);
        
        //when
        Page<AllMemberGameResultAnalysisInterface> result = gameResultService.fetchAllMembersGameResultAnalysis(page);
        
        //then
        assertThat(result.getContent().size()).isEqualTo(10);
        assertThat(result.getContent().get(0).getWin()).isEqualTo(1L);
        assertThat(result.getContent().get(1).getLose()).isEqualTo(4L);
        assertThat(result.getContent().get(2).getLose()).isEqualTo(4L);
        assertThat(result.getContent().get(3).getLose()).isEqualTo(4L);
        assertThat(result.getContent().get(4).getLose()).isEqualTo(4L);
        assertThat(result.getContent().get(2).getRate()).isEqualTo(20.0);
        assertThat(result.getContent().get(2).getLastjointime()).isBefore(LocalDateTime.now());

    }

}

페이징 작업을 수행하기 위해 Pageable 구현체인 PageRequest를 활용하여 테스트한 결과, 이상 없이 원하는 비즈니스 로직을 구현할 수 있었습니다.

 

5. 한계 및 DataJpa 장점

실제 Native Query를 작성하는 과정에서 많은 에러가 발생하였습니다.

기본적인 컬럼명이 맞지 않아 에러가 발생하였고, 자바에서 추구하는 camel case 용법이 아니라, 데이터베이스에서 사용하는 snake case 방식으로 직접 쿼리를 작성해야 하다 보니 Dto 역할을 하는 interface의 메서드와 매핑이 되지 않는 문제가 발생하였습니다. 또한, 파일 시점에 에러를 확인할 수 없어서 테스트를 돌리는 과정을 반복하는 문제가 발생하였습니다.

 

DataJpa에서 제공하는 @Query를 활용하여, JPQL을 작성하니, 앞에서 발생한 에러를 해결할 수 있었고, Wrapper로 선언한 인터페이스가 nativeQuery에서는 camel case를 적용하기 어려웠는데, 인터페이스명까지 camelCase 형태로 mapping 될 수 있었습니다. 마지막에 쿼리가 나가는 것을 확인하면 다음과 같습니다.

 

select
        gameresult0_.join_member_id as col_0_0_,
        sum(case 
            when gameresult0_.result='WIN' then 1 
            else 0 
        end) as col_1_0_,
        sum(case 
            when gameresult0_.result='LOSE' then 1 
            else 0 
        end) as col_2_0_,
        round(sum(case 
            when gameresult0_.result='WIN' then 1 
            else 0 
        end)/(count(gameresult0_.result)*1.0)*100,
        1) as col_3_0_,
        max(gameresult0_.modified_at) as col_4_0_ 
    from
        gameresult gameresult0_ 
    inner join
        member member1_ 
            on (
                gameresult0_.join_member_id=member1_.member_id
            ) 
    group by
        gameresult0_.join_member_id limit ?

 

처음 글을 작성하였을 때에는, JPQL 작성 시 에러가 났었는데, JPQL도 인터페이스를 Dto로 받으니 에러가 해결되었습니다.!

역시 JPA는 정말 대단하고 아름다운 것 같습니다.ㅎㅎ!

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

+ Recent posts