https://github.com/code-chobo/dev-api 프로젝트를 진행하며 쓰는 회고입니다.

문제점

  • 테스트 시 현재 로그인된 사용자가 있다는 전제로 테스트를 한다면?
    • 테스트 내부에서 SecurityContextHolder.context 등에 UsernamePasswordAuthenticationToken 을 set 해주고.. 어쩌구 저쩌구.. 테스트가 몇 개 없다면 상관없지만, 테스트가 여러 개 있다면? 일일히 다 해줘야하나?
    • WithSecurityContextFactory 를 이용하자.

코드

WithAccountSecurityContextFactory.class

/**
 * @author : Eunmo Hong
 * @since : 2020/07/26
 */

@RequiredArgsConstructor
public class WithAccountSecurityContextFactory implements WithSecurityContextFactory<WithAccount> {

    private final AccountService accountService;

    @Override
    public SecurityContext createSecurityContext(WithAccount annotation) {
        String nickname = annotation.value();

        String email = nickname + "@email.com";
        String password = "11111111";
        JoinAccountRequest joinAccountRequest = new JoinAccountRequest(email, nickname, password, password);
        accountService.join(joinAccountRequest);

        UserDetails userDetails = accountService.loadUserByUsername(email);
        Authentication authentication = new UsernamePasswordAuthenticationToken(userDetails, userDetails.getPassword(), userDetails.getAuthorities());
        SecurityContext context = SecurityContextHolder.createEmptyContext();
        context.setAuthentication(authentication);
        return context;
    }
}

WithAccount

@Retention(RetentionPolicy.RUNTIME)
@WithSecurityContext(factory = WithAccountSecurityContextFactory.class)
public @interface WithAccount {
    String value();
}

테스트

StudyApiControllerIntegrationTest

...
    @DisplayName("스터디 참가 성공")
    @WithAccount("joiner")
    @Test
    void joinStudyTest() throws Exception {
        Account account = accountRepository.findByNickname("joiner").get();

        CreateStudyRequest request = createStudyRequest(10, 12);
        Long studyAccountId = studyService.createStudy(request, account);

        StudyAccount studyAccount = studyAccountRepository.findById(studyAccountId).get();
        Long studyId = studyAccount.getStudy().getId();

        mockMvc.perform(post("/api/study/member")
                    .contentType(MediaType.APPLICATION_JSON)
                    .content(objectMapper.writeValueAsString(joinStudyRequest(studyId))))
                .andDo(print())
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.studyAccountId").exists());
    }
...
  • 이미 WithAccountSecurityContextFactory에서 가입도 진행했고, SecurityCOntextHolderAuthentication을 set 해줬기 때문에 이와 같은 코드를 작성할 수 있다.

https://github.com/code-chobo/dev-api 프로젝트를 진행하며 쓰는 회고입니다.

org.hibernate.dialect.MySQL5Dialect

application-test.yml

spring:
  jpa:
    open-in-view: false
    hibernate:
      ddl-auto: create
    database-platform: org.hibernate.dialect.MySQL5Dialect

상황 :

지금까지 작성한 테스트를 모두 한 번에 실행할 때
Caused by: javax.persistence.NonUniqueResultException: query did not return a unique result: 2
같은 에러가 뜬다.

문제점 :

테스트 클래스 위에 @Transactinal 을 붙여서 테스트가 끝난 뒤 Rollback되기를 기대했지만 되지 않아서 NonUniqueResultException 같은 에러가 났다. 확인해보니 org.hibernate.dialect.MySQL5DialectMySQLDialect 를 상속받는데, 이 클래스는 hibernate.dialect.storage_engine 환경 설정이 null 이라면 getDefaultMySQLStorageEngine()으로 MySQLStorageEngine 필드에 값을 넣어준다. MySQL5Dialect 은 다음과 같다.

MySQL5Dialect.java

...
/**
     * Constructs a MySQLDialect
     */
    public MySQLDialect() {
        super();

        String storageEngine = Environment.getProperties().getProperty( Environment.STORAGE_ENGINE );
        if ( storageEngine == null ) {
            this.storageEngine = getDefaultMySQLStorageEngine();
        }
...

protected MySQLStorageEngine getDefaultMySQLStorageEngine() {
        return MyISAMStorageEngine.INSTANCE;
    }

MyISAMStorageEngine 을 쓴다!!!!!!!!!

Table 들이 MyISAM엔진으로 설정된 것을 볼 수 있다. MyISAMTransaction을 지원하지 않는다!
참고 : https://gracelove91.tistory.com/102

따라서 Rollback이 되지 않기 때문에 NonUniqueResultException 같은 에러가 난 것이다.

해결 :

  1. hibernate.dialect.storage_engine 를 설정해준다
  2. getDefaultMySQLStorageEngine()InnoDBdialect를 쓴다.

나는 2번으로 해결했다.

application-test.yml

spring:
  jpa:
    open-in-view: false
    hibernate:
      ddl-auto: create
    database-platform: org.hibernate.dialect.MySQL57Dialect
  • MySQL57DialectMySQL55Dialect 를 상속받는다.

MySQL55Dialect

public class MySQL55Dialect extends MySQL5Dialect {

    @Override
    protected MySQLStorageEngine getDefaultMySQLStorageEngine() {
        return InnoDBStorageEngine.INSTANCE;
    }
}
  • InnoDBStorageEngine 이 기본 디폴트 스토리지 엔진임을 볼 수 있다.

https://github.com/code-chobo/dev-api 프로젝트를 진행하며 쓰는 회고입니다.

@DataJpaTest

application-test.yml

spring:
  datasource:
    url: jdbc:mysql://localhost:3306/codechobo_test?serverTimezone=Asia/Seoul&useSSL=false
    username: codechobo_user
    password: password
    driver-class-name: com.mysql.cj.jdbc.Driver
  jpa:
    open-in-view: false
    hibernate:
      ddl-auto: create
    database-platform: org.hibernate.dialect.MySQL57Dialect

상황 :

@DataJpaTest 테스트를 실행했을 때
Caused by: org.h2.jdbc.JdbcSQLSyntaxErrorException: Table "ACCOUNT" not found; SQL statement:...
같은 에러가 뜬다.

문제점 :

분명히 application-test.ymldatasourcemysql로 잡아주었는데도 임베디드디비인 h2 에서 에러가 난다.
왜 이런 건지 알아보니, @DataJpaTest는 데이터 소스가 저렇게 되있어도 임베디드 디비를 쓴다!

해결 :

따라서 @DataJpaTest 를 사용하는 테스트 클래스 위에 @AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)를 붙여줘서 임베디드 디비로 대체하지 않고, 설정 파일에 적시된 데이터소스를 사용하게끔 했다.

개요

본 포스팅의 DB환경은 MySQL 5.7.30 입니다. 테스트를 원하신다면 AUTO_COMMIT을 false로 하고 진행해주세요.

트랜잭션 격리 수준이란 ?

  • 동시에 여러 트랜잭션이 처리될 때, 특정 트랜잭션이 다른 트랜잭션에서 변경하거나 조회하는 데이터를 볼 수 있도록 허용할 지 말지를 결정한다.

트랜잭션 격리 수준의 종류

  • READ UNCOMMITTED, READ COMMITTED, REPEATABLE READ, SERIALIZABLE 등으로 나뉜다.

왜 트랜잭션 격리 수준을 알아야 하는 가?

  • 동시성 문제가 있다. 데이터 부정합 문제점이 있는데, 이는 트랜잭션 격리 수준에 따라 달라진다. 아래 그림은 트랜잭션 격리 수준에 따른 데이터 부정합 표이다. 각각의 데이터 부정합의 대한 설명은 아래에서 다시 다룰 것이다.

테스트를 위한 준비

  • AUTO_COMMIT을 false로 설정한다.
  • 아래와 같이 간단한 테이블을 만들고, 데이터를 집어넣자.
create table account (
    account_id int AUTO_INCREMENT,
    login_name varchar(255) not null,
    primary key(account_id)
)charset='utf8';

insert into account(login_name)
values ('eunmo');
  • 트랜잭션 격리 수준을 설정하는 SQL과 확인하는 SQL은 다음과 같다.

    • ex ) READ UNCOMMITTED 로 설정하고 싶을 때.

      SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
      
      SHOW VARIABLES LIKE 'TX_ISOLATION';
      
    • 결과

주의!!! 테스트 할 때는 서로 다른 세션에서 수행해야한다! 현재 세션 확인하는 법 : select connection_id();

트랜잭션 격리 수준의 종류

READ UNCOMMITED

  • 각 트랜잭션에서의 변경 내용이 COMMIT이나 ROLLBACK 여부에 상관없이 다른 트랜잭션에서 보여진다. 실험을 해보자.

    1번 세션

    SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
    
    UPDATE ACCOUNT
    SET LOGIN_NAME = 'GRACE LOVE'
    WHERE ACCOUNT_ID = 1;
    

    2번 세션

    begin;
    select * from account;
    

    결과

    문제

    • 결론적으로 1번 세션 트랜잭션에서 작업을 아직 완료하지 않았는데도 다른 트랜잭션이 이를 볼 수 있다.
    • 이렇게 어떤 트랜잭션에서 처리한 작업이 완료되지 않았는데도 다른 트랜잭션이 볼 수 있게 되는 현상을 Dirty read라고 한다.

    결론

    • 1번 세션에서 UPDATE 를 하고 COMMIT 하지 않았는데도 2번 세션에서는 UPDATE한 'GRACE LOVE' 가 보인다.
    • READ UNCOMMITED는 이런 데이터 부정합을 초래하므로 권장하지 않는다. 최소한 READ COMMITED 를 사용하자.

READ COMMITED

  • 어떤 트랜잭션에서 데이터를 변경했더라도 COMMIT이 완료된 데이터만 다른 트랜잭션에서 볼 수 있다.

  • 오라클에서 기본적으로 사용되는 격리수준이다.

    1번 세션

    SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
    
    UPDATE ACCOUNT
    SET LOGIN_NAME = 'GRACE LOVE'
    WHERE ACCOUNT_ID = 1;

    2번 세션

    begin;
    select * from account;
    

    결과 - COMMIT 실행 이전

    결과2 - COMMIT 실행 이후

    • 설명한 대로 COMMIT을 하지 않았기 때문에 UPDATE 한 값인 'GRACE LOVE' 가 다른 트랜잭션에선 보이지 않는다.
    • 1번 세션에서 COMMIT을 수행한 뒤 다시 2번 세션에서 조회해보자.

    문제

    • Dirty Read와 같은 부정합 문제는 발생하지 않는다.
    • 하지만 Non-Repeatable Read문제가 발생한다.
    • Non-Repeatable Read란 하나의 트랜잭션 내에서 똑같은 SELECT 쿼리를 실행했을 때 항상 같은 결과를 가져와야하는 Repeatable Read 정합성에 어긋나는 것이다.

    결론

    • SELECT 쿼리 결과는 ACCOUNT 테이블에서 가져온 것이 아니라, UNDO 영역에 백업된 컬럼 값을 가져오는 것이다.

REPEATABLE READ

  • InnoDB 스토리지 엔진의 기본 전략이다.
  • READ COMMITTED 와의 차이점은 UNDO 영역에 백업된 레코드의 여러 버전 가운데 몇 번째 이전 버전까지 찾아 들어가냐가 다르다.
  • 1번 세션이 UPDATE후 COMMIT 해도 다른 트랜잭션은 여전히 UNDO 영역을 바라보고있다.
  • PHANTOM READ가 발생할 수 있다고 하는데 InnoDB에선 발생하지 않는다 한다. 실제로 InnoDB 엔진에서 재현해보려했으나 실패했다.
    • PHANTOM READ ? 다른 트랜잭션에서 수행한 변경작업에 의해 레코드가 보였다 안보였다 하는 현상을 말한다.

SERIALIZABLE

  • 가장 엄격한 격리수준. 그만큼 동시처리성능도 가장 떨어진다. 어차피 InnoDB는 PHANTOM READ가 발생하지 않기 때문에 굳이 쓸 필요가 없다.

레퍼런스

Real MySQL - 이성욱 지음. 책 구매 링크 : http://www.yes24.com/Product/Goods/6960931

'DB' 카테고리의 다른 글

[ACID] Atomicity - 원자성  (0) 2024.02.28
[MySQL] InnoDB와 MyISAM 차이 (트랜잭션)  (0) 2020.07.07
UNION  (0) 2019.12.07
GROUP BY  (0) 2019.12.07

가장 큰 차이는 트랜잭션의 유무가 아닌가 싶다.
직접 테스트해보고 확인한 결과를 기록한다.

create table tab_myisam (
    fdpk int not null,
    primary key(fdpk)
)engine=MyISAM;


create table tab_innodb (
    fdpk int not null,
    primary key(fdpk)
)engine=InnoDB;


insert into tab_myisam (fdpk) values (3);
insert into tab_innodb (fdpk) values (3);
  • tab_myisam과 tab_innodb를 생성했다. 스토리지엔진은 이름에 따라 각각 MyISAM과 InnoDB로 설정해줬다.
  • 값을 넣어준다. 두 테이블 모두 fdpk 라는 컬럼에 3 이라는 값을 넣어줬다. 이제 실험!

insert into tab_myisam (fdpk) values(1), (2), (3);
insert into tab_innodb (fdpk) values(1), (2), (3);
  • fdpk에 1, 2, 3을 넣는다.
  • 우리가 예상하기로는 1, 2를 넣고 3을 넣는 순간 unique 제약 조건에 걸려서 모두 롤백되어야 한다.
  • myisam엔진을 쓰는 테이블인 tab_myisam을 select 해보면, (select * from tab_myisam)
    1,2,3 모두 값이 들어가있다. 트랜잭션을 지원하지 않기 때문!
  • 그에 반해 innodb 엔진을 쓰는 테이블인 tab_innodb를 select해보면, (select * from tab_innodb)
    처음에 값을 넣어준 3 외에는 없는 걸로 나온다. 트랜잭션을 지원하기 때문이다!

```

'DB' 카테고리의 다른 글

[ACID] Atomicity - 원자성  (0) 2024.02.28
트랜잭션 격리 수준(isolation level)  (0) 2020.07.14
UNION  (0) 2019.12.07
GROUP BY  (0) 2019.12.07

+ Recent posts