1. Overview

Spring Boot 에서 spring-data-redis 라이브러리를 활용해서 Redis 를 사용하는 방법을 알아봅니다.

Redis 에 대한 개념과 로컬 설치 방법은 Redis 설치 및 명령어 글을 확인해주세요.


2. Java 의 Redis Client

Java 의 Redis Client 는 크게 두 가지가 있습니다.

Jedis 와 Lettuce 인데요.

원래 Jedis 를 많이 사용했으나 여러 가지 단점 (멀티 쓰레드 불안정, Pool 한계 등등..) 과 Lettuce 의 장점 (Netty 기반이라 비동기 지원 가능) 때문에 Lettuce 로 추세가 넘어가고 있었습니다.

그러다 결국 Spring Boot 2.0 부터 Jedis 가 기본 클라이언트에서 deprecated 되고 Lettuce 가 탑재되었습니다.


3. Spring Boot 에서 Redis 설정

Spring Boot 에서 Redis 를 사용하는 방법은 RedisRepositoryRedisTemplate 두 가지가 있습니다.

그 전에 먼저 공통 세팅이 필요합니다.


implementation 'org.springframework.boot:spring-boot-starter-data-redis'
  • build.gradlespring-boot-starter-data-redis 추가하고 빌드해줍니다.

spring:
  redis:
    host: localhost
    port: 6379
  • application.yaml 에 host 와 port 를 설정합니다.
  • localhost:6379 는 기본값이기 때문에 만약 Redis 를 localhost:6379 로 띄웠다면 따로 설정하지 않아도 연결이 됩니다.
  • 하지만 일반적으로 운영 서버에서는 별도의 Host 를 사용하기 때문에 값을 이렇게 별도의 값을 세팅하고 Configuration 에서 Bean 에 등록해줍니다.

@Configuration
public class RedisConfig {

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

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

    @Bean
    public RedisConnectionFactory redisConnectionFactory() {
        return new LettuceConnectionFactory(host, port);
    }
}
  • Redis 사용을 위한 기본 Configuration 입니다.
  • application.yaml 에 설정한 값을 @Value 어노테이션으로 주입합니다.

3.1. RedisRepository

Spring Data Redis 의 Redis Repository 를 이용하면 간단하게 Domain Entity 를 Redis Hash 로 만들 수 있습니다.

다만 트랜잭션을 지원하지 않기 때문에 만약 트랜잭션을 적용하고 싶다면 RedisTemplate 을 사용해야 합니다.


Entity

@Getter
@RedisHash(value = "people", timeToLive = 30)
public class Person {

    @Id
    private String id;
    private String name;
    private Integer age;
    private LocalDateTime createdAt;

    public Person(String name, Integer age) {
        this.name = name;
        this.age = age;
        this.createdAt = LocalDateTime.now();
    }
}
  • Redis 에 저장할 자료구조인 객체를 정의합니다.
  • 일반적인 객체 선언 후 @RedisHash 를 붙이면 됩니다.
    • value : Redis 의 keyspace 값으로 사용됩니다.
    • timeToLive : 만료시간을 seconds 단위로 설정할 수 있습니다. 기본값은 만료시간이 없는 -1L 입니다.
  • @Id 어노테이션이 붙은 필드가 Redis Key 값이 되며 null 로 세팅하면 랜덤값이 설정됩니다.
    • keyspace 와 합쳐져서 레디스에 저장된 최종 키 값은 keyspace:id 가 됩니다.

Repository

public interface PersonRedisRepository extends CrudRepository<Person, String> {
}
  • CrudRepository 를 상속받는 Repository 클래스 추가합니다.

Example

@SpringBootTest
public class RedisRepositoryTest {

    @Autowired
    private PersonRedisRepository repo;

    @Test
    void test() {
        Person person = new Person("Park", 20);

        // 저장
        repo.save(person);

        // `keyspace:id` 값을 가져옴
        repo.findById(person.getId());

        // Person Entity 의 @RedisHash 에 정의되어 있는 keyspace (people) 에 속한 키의 갯수를 구함
        repo.count();

        // 삭제
        repo.delete(person);
    }
}
  • JPA 와 동일하게 사용할 수 있습니다.
  • 여기서는 id 값을 따로 설정하지 않아서 랜덤한 키값이 들어갑니다.
  • 저장할때 save() 를 사용하고 값을 조회할 때 findById() 를 사용합니다.

redis-cli 로 데이터 확인

  • id 를 따로 설정하지 않은 null 값이라 랜덤한 키값이 들어갔습니다.
  • 데이터를 저장하면 membermember:{randomKey} 라는 두개의 키값이 저장되었습니다.
  • member 키값은 Set 자료구조이며, Member 엔티티에 해당하는 모든 Key 를 갖고 있습니다.
  • member:{randomKey} 값은 Hash 자료구조이며 테스트 코드에서 작성한 값대로 field, value 가 세팅한 걸 확인할 수 있습니다.
  • timeToLive 를 설정했기 때문에 30초 뒤에 사라집니다. ttl 명령어로 확인할 수 있습니다.

3.2. RedisTemplate

RedisTemplate 을 사용하면 특정 Entity 뿐만 아니라 여러가지 원하는 타입을 넣을 수 있습니다.

template 을 선언한 후 원하는 타입에 맞는 Operations 을 꺼내서 사용합니다.


config 설정 추가

@Configuration
public class RedisConfig {

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

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

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

    @Bean
    public RedisTemplate<?, ?> redisTemplate() {
        RedisTemplate<?, ?> redisTemplate = new RedisTemplate<>();
        redisTemplate.setConnectionFactory(redisConnectionFactory());
        return redisTemplate;
    }
}
  • RedisTemplateLettuceConnectionFactory 을 적용해주기 위해 설정해줍니다.

Example

@SpringBootTest
public class RedisTemplateTest {

    @Autowired
    private RedisTemplate<String, String> redisTemplate;

    @Test
    void testStrings() {
        // given
        ValueOperations<String, String> valueOperations = redisTemplate.opsForValue();
        String key = "stringKey";

        // when
        valueOperations.set(key, "hello");

        // then
        String value = valueOperations.get(key);
        assertThat(value).isEqualTo("hello");
    }


    @Test
    void testSet() {
        // given
        SetOperations<String, String> setOperations = redisTemplate.opsForSet();
        String key = "setKey";

        // when
        setOperations.add(key, "h", "e", "l", "l", "o");

        // then
        Set<String> members = setOperations.members(key);
        Long size = setOperations.size(key);

        assertThat(members).containsOnly("h", "e", "l", "o");
        assertThat(size).isEqualTo(4);
    }

    @Test
    void testHash() {
        // given
        HashOperations<String, Object, Object> hashOperations = redisTemplate.opsForHash();
        String key = "hashKey";

        // when
        hashOperations.put(key, "hello", "world");

        // then
        Object value = hashOperations.get(key, "hello");
        assertThat(value).isEqualTo("world");

        Map<Object, Object> entries = hashOperations.entries(key);
        assertThat(entries.keySet()).containsExactly("hello");
        assertThat(entries.values()).containsExactly("world");

        Long size = hashOperations.size(key);
        assertThat(size).isEqualTo(entries.size());
    }
}
  • 위에서부터 차례대로 Strings, Set, Hash 자료구조에 대한 Operations 입니다.
  • redisTemplate 을 주입받은 후에 원하는 Key, Value 타입에 맞게 Operations 을 선언해서 사용할 수 있습니다.
  • 가장 흔하게 사용되는 RedisTemplate<String, String> 을 지원하는 StringRedisTemplate 타입도 따로 있습니다.

Reference

1. Redis 란?

Redis 는 Key-Value 형태로 데이터를 관리하는 오픈 소스입니다.

Redis 는 빠른 속도와 간편한 사용법으로 인해 캐시, 인증 토큰, 세션 관리 등등 여러 용도로 사용됩니다.

  • In-Memory Data Strucutre Store
  • Key - Value 형태로 데이터 저장
  • 여러 가지 Value 타입 저장 가능 (String, Set, Hash, List 등등..)
  • Single Thread
  • 데이터 만료 시간 지정 가능

2. Redis 설치

도커로 설치 후 실행 가능합니다. (https://hub.docker.com/_/redis 참고)

# 이미지 다운 (docker images 로 확인 가능)
$ docker pull redis

# 컨테이너로 레디스 실행 (--name: 컨테이너 이름 설정, -p: 포트 포워딩, -d: 백그라운드에서 실행)
$ docker run --name some-redis -p 6379:6379 -d redis

# redis-cli 접속
$ docker exec -it some-redis redis-cli

3. Redis 명령어

Redis 는 여러 개의 데이터 타입을 저장할 수 있기 때문에 각각의 명령어가 여러개 존재합니다.

모든 명령어는 Redis Commands 를 참고하시고 여기에는 일부 자료구조의 간단한 명령어만 정리합니다.

다만 Redis 는 Single Thread 기반이기 때문에 keys, flushall, flushdb, getall 등 일반적으로 생각했을 때 O(N) 의 시간복잡도를 가질 것 같은 명령어는 운영 환경에서 사용하면 위험합니다.


3.1. String

가장 기본적인 Value 타입입니다.

  • 저장
    • set {key} {value} : key, value 를 저장
    • mset {key} {value} [{key} {value} ...] : 여러 개의 key, value 를 한번에 저장
    • setex {key} {seconds} {value} : key, seconds, value 저장 (설정한 시간 뒤에 소멸)
  • 조회
    • keys * : 현재 저장된 키값들을 모두 확인 (부하가 심한 명령어라 운영중인 서비스에선 절대 사용하면 안됨)
    • get {key} : 지정한 key 에 해당하는 value 를 가져옴
    • mget {key} [{key} ...] : 여러 개의 key 에 해당하는 value 를 한번에 가져옴
    • ttl {key} : key 의 만료 시간을 초 단위로 보여줌 (-1 은 만료시간 없음, -2 는 데이터 없음)
    • pttl {key} : key 의 만료 시간을 밀리초 단위로 보여줌
    • type {key} : 해당 key 의 value 타입 확인
  • 삭제
    • del {key} [{key} ...] : 해당 key 들을 삭제
  • 수정
    • rename {key} {newKey} : key 이름 변경
    • expire {key} {seconds} : 해당 키 값의 만료 시간 설정
  • 기타
    • randomkey : 랜덤한 key 반환
    • ping : 연결 여부 확인 ("ping" 만 입력하면 "PONG" 이라는 응답이 옴)
    • dbsize : 현재 사용중인 DB 의 key 의 갯수 리턴
    • flushall : 레디스 서버의 모든 데이터 삭제
    • flushdb : 현재 사용중인 DB 의 모든 데이터 삭제

3.2. Set

Redis 에서는 Set 에 포함된 값들을 멤버라고 표현합니다.

여러 멤버가 모여 집합 (Set) 을 구성합니다.

진짜 집합처럼 교집합, 차집합 등도 구할 수 있는데 여기선 간단하게 CRUD 만 알아봅니다.

  • sadd {key} {member} [{member} ...]
    • key 에 새로운 멤버들을 추가. key 가 없으면 새로 만듬
  • smembers {key}
    • key 에 설정된 모든 멤버 반환
  • srem {key} {member [{member} ...]}
    • key 에 포함된 멤버들 삭제. 없는 멤버 입력하면 무시됨
  • scard {key}
    • key 에 저장된 멤버 수를 반환
  • sismember {key} {member}
    • member 가 해당 key 에 포함되는지 검사

3.3. Hash

Redis 에서 저장가능한 자료구조 중에 Hash 도 있습니다.

Hash 자체를 나타내는 key 와 해당 key 에 포함된 field 까지 사용해서 값을 조회/저장할 수 있습니다.

  • hset {key} {field} {value} [{field} {value} ...]
    • key 를 이름으로 한 Hash 자료 구조에 field 와 value 값을 저장
  • hget {key} {field}
    • key Hash 값에 포함된 field 의 value 를 가져옴
  • hdel {key} {field} [{field} ...]
    • field 값으로 데이터 삭제
  • hlen {key}
    • Hash 가 갖고 있는 field 갯수 반환
  • hkeys {key}
    • Hash 가 갖고 있는 모든 field 출력
  • hvals {key}
    • Hash 가 갖고 있는 모든 value 출력
  • hgetall {key}
    • Hash 가 갖고 있는 모든 field 와 value 출력

Reference

'공부 > Database' 카테고리의 다른 글

MySQL Optimizer 와 USE INDEX vs FORCE INDEX  (0) 2022.06.21
MySQL Index 특징 및 유의사항 정리  (2) 2022.05.22
Cache 전략  (0) 2022.05.20

1. Swagger 란?

Swagger 는 OAS(Open Api Specification)를 위한 프레임워크입니다.

개발자들의 필수 과제인 API 문서화를 쉽게 할 수 있도록 도와주며, 파라미터를 넣어서 실제로 어떤 응답이 오는지 테스트도 할 수 있습니다.

또한, 협업하는 클라이언트 개발자들에게도 Swagger 만 전달해주면 API Path 와 Request, Response 값 및 제약 등을 한번에 알려줄 수 있습니다.


1.1. OpenAPI 와의 관계

Swagger 공식 블로그 포스팅을 보면 Swagger 와 OpenAPI 의 차이가 나와있습니다.

OpenAPI는 RESTful API 설계를 위한 업계 표준 사양을 나타내고 Swagger는 SmartBear 도구 세트를 나타냅니다

요약하면 Swagger 는 이제 OpenAPI 사양을 구현하기 위한 도구 세트 (Swagger Editor, Swagger UI, SwaggerHub) 가 되었으며, 브랜드명만 변경하지 않은 채 그대로 사용하기로 했습니다.


2. 적용

Swagger 2.x 버전을 적용할 수도 있고 3.x 버전을 적용할 수도 있습니다.

큰 차이는 없기 때문에 최신 버전인 Swagger 3.x 버전을 적용합니다.


2.1. 의존성 추가

/* build.gradle */

dependencies {
    // ..
    implementation 'io.springfox:springfox-boot-starter:3.0.0'
    // ..
}

2.2. Config 추가

@Configuration
public class SwaggerConfig {

    @Bean
    public Docket api() {
        return new Docket(DocumentationType.OAS_30)
                .useDefaultResponseMessages(false)
                .select()
                .apis(RequestHandlerSelectors.basePackage("com.example.springswagger.controller"))
                .paths(PathSelectors.any())
                .build()
                .apiInfo(apiInfo());
    }

    private ApiInfo apiInfo() {
        return new ApiInfoBuilder()
                .title("Practice Swagger")
                .description("practice swagger config")
                .version("1.0")
                .build();
    }
}
  • Docket: Swagger 설정의 핵심이 되는 Bean
  • useDefaultResponseMessages: Swagger 에서 제공해주는 기본 응답 코드 (200, 401, 403, 404). false 로 설정하면 기본 응답 코드를 노출하지 않음
  • apis: api 스펙이 작성되어 있는 패키지 (Controller) 를 지정
  • paths: apis 에 있는 API 중 특정 path 를 선택
  • apiInfo:Swagger UI 로 노출할 정보

2.3. Controller 에 적용

@RestController
public class HelloController {

    @Operation(summary = "test hello", description = "hello api example")
    @ApiResponses({
            @ApiResponse(responseCode = "200", description = "OK !!"),
            @ApiResponse(responseCode = "400", description = "BAD REQUEST !!"),
            @ApiResponse(responseCode = "404", description = "NOT FOUND !!"),
            @ApiResponse(responseCode = "500", description = "INTERNAL SERVER ERROR !!")
    })
    @GetMapping("/hello")
    public ResponseEntity<String> hello(@Parameter(description = "이름", required = true, example = "Park") @RequestParam String name) {
        return ResponseEntity.ok("hello " + name);
    }
}
  • 적용한 설정들이 어떻게 표현되는지는 아래에서 설명합니다.

2.4. 실행 후 접속

로컬 실행 후 URL 접속하면 되는데 Swagger 2.x 버전과 조금 다릅니다.


3. 적용된 설정 확인

  • SwaggerConfig 에서 설정한 정보들은 Swagger 상단에 나옵니다.

  • Controller 에서 세팅한 설정입니다.

4. SpringDoc ?

Swagger 는 SpringFox 외에 SpringDoc 으로도 설정할 수 있습니다.

특히 현재 Spring Boot 2.6 버전에서 SpringFox Swagger 3.0 의 사용하지 못하는 이슈가 존재합니다.

이럴 때 SpringDoc 공식 문서 를 참고하면 Spring Boot 를 2.5 버전대로 내리지 않아도 Swagger 를 적용할 수 있습니다.


Reference

1. Overview

JPA 를 사용할 때 ID 값으로 엔티티를 가져오는 두 가지 메소드가 존재합니다.

비슷하지만 다른 이 두가지 메소드의 차이점에 대해서 알아봅시다.


1.1. getById

@Override
public T getById(ID id) {

    Assert.notNull(id, ID_MUST_NOT_BE_NULL);
    return em.getReference(getDomainClass(), id);
}

getById() 는 원래 getOne() 이었으나 해당 메소드가 Deprecated 되고 대체되었습니다.

내부적으로 EntityManager.getReference() 메소드를 호출하기 때문에 엔티티를 직접 반환하는게 아니라 프록시만 반환합니다.

프록시만 반환하기 때문에 실제로 사용하기 전까지는 DB 에 접근하지 않으며, 만약 나중에 프록시에서 DB 에 접근하려고 할 때 데이터가 없다면 EntityNotFoundException 이 발생합니다.


1.2. findById

@Override
public Optional<T> findById(ID id) {

    Assert.notNull(id, ID_MUST_NOT_BE_NULL);

    Class<T> domainType = getDomainClass();

    if (metadata == null) {
        return Optional.ofNullable(em.find(domainType, id));
    }

    LockModeType type = metadata.getLockModeType();

    Map<String, Object> hints = new HashMap<>();
    getQueryHints().withFetchGraphs(em).forEach(hints::put);

    return Optional.ofNullable(type == null ? em.find(domainType, id, hints) : em.find(domainType, id, type, hints));
}

우리가 잘 알고 있는 메소드입니다.

실제 DB 에 요청해서 엔티티를 가져옵니다.

정확히 말하면 영속성 컨텍스트의 1차 캐시를 먼저 확인하고 데이터가 없으면 실제 DB 에 데이터가 있는지 확인합니다.


2. 차이점

getById() 는 해당 엔티티를 사용하기 전까진 DB 에 접근하지 않기 때문에 성능상으로 좀더 유리합니다.

따라서 특정 엔티티의 ID 값만 활용할 일이 있다면 DB 에 접근하지 않고 프록시만 가져와서 사용할 수 있습니다.


3. Test

실제로 테스트 하면서 SQL 쿼리문이 어떻게 날라가는지 확인해봅니다.


3.1. Domain

@Entity
public class Member {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "member_id")
    private Long id;
}
@Entity
@Setter
public class Post {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "post_id")
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "member_id")
    private Member member;
}
  • Member 엔티티와 Post 엔티티를 정의합니다.
  • MemberPost 를 작성할 수 있는 관계입니다.

3.2. Service Layer

@Service
@Transactional
@RequiredArgsConstructor
public class PostService {

    private final MemberRepository memberRepository;
    private final PostRepository postRepository;

    // Member 생성
    public Member createMember() {
        Member member = new Member();
        return memberRepository.save(member);
    }

    // getById 로 Member 를 가져와서 Post 생성
    public void createPostByGet(Long memberId) {
        Member member = memberRepository.getById(memberId);
        Post post = new Post();
        post.setMember(member);
        postRepository.save(post);
    }

    // findById 로 Member 를 가져와서 Post 생성
    public void createPostByFind(Long memberId) {
        Member member = memberRepository.findById(memberId).get();
        Post post = new Post();
        post.setMember(member);
        postRepository.save(post);
    }
}
  • getById() 를 사용하는 버전과 findById() 를 사용하는 두 가지 버전의 메소드를 정의합니다.

3.3. Test Code

@SpringBootTest
public class PostServiceTest {

    @Autowired
    private PostService postService;

    @Test
    void testGetById() {
        Member member = postService.createMember();
        postService.createPostByGet(member.getId());
    }

    @Test
    void testFindById() {
        Member member = postService.createMember();
        postService.createPostByFind(member.getId());
    }
}
  • 두 개의 메소드를 따로 실행해서 SQL 쿼리문을 확인해본다.

3.4. SQL Query

-- getById() 사용한 경우 member 테이블 조회하는 쿼리가 날아가지 않음

Hibernate: 
    insert 
    into
        member
        (member_id) 
    values
        (null)
Hibernate: 
    insert 
    into
        post
        (post_id, member_id) 
    values
        (null, ?)
  • getById() 는 실제 테이블을 조회하는 대신 프록시 객체만 가져옵니다.
  • 프록시 객체만 있는 경우 ID 값을 제외한 나머지 값을 사용하기 전까지는 실제 DB 에 액세스 하지 않기 때문에 SELECT 쿼리가 날아가지 않습니다.

-- findById() 사용한 경우 post 테이블을 조회

Hibernate: 
    insert 
    into
        member
        (member_id) 
    values
        (null)
Hibernate: 
    select
        member0_.member_id as member_i1_2_0_ 
    from
        member member0_ 
    where
        member0_.member_id=?
Hibernate: 
    insert 
    into
        post
        (post_id, member_id) 
    values
        (null, ?)
  • findById() 를 사용하면 DB 에 바로 액세스해서 데이터를 가져옵니다.

4. Conclusion

실제 DB 에 접근하냐 하지 않냐는 성능에 영향이 갈 수 있습니다.

위의 예시처럼 단순히 특정 엔티티의 ID 값만 필요한 경우에는 모든 데이터를 가져올 필요가 없습니다.

따라서 상황에 따라 적절한 메소드를 사용하면 됩니다.


Reference

Overview

Ruby 에는 Class 를 Json 으로 표현하는 두 가지 방법이 있습니다.

as_jsonto_json 인데 이 두가지는 약간의 차이가 존재합니다.

to_json 은 String 을 반환하고 as_json 은 Hash 를 반환합니다.


Example

class User
  attr_accessor :name, :age
end

user = User.new("Alice", 22)

# to_json
puts user.to_json       # {"name":"Alice", "age":22}
puts user.to_json.class # String

# as_json
puts user.as_json       # {"name"=>"Alice", "age"=>22}
puts user.as_json.class # Hash

Reference

Overview

Spring 에서 @Transactional 을 사용할 때 지정할 수 있는 옵션들을 알아봅니다.

  • isolation
  • propagation
  • readOnly
  • rollbackFor
  • timeout

1. isolation

데이터베이스 트랜잭션이 안전하게 수행된다는 것을 보장하기 위한 성질 네가지가 존재합니다. (ACID)

  • 원자성(Atomicity): 한 트랜잭션 내에서 실행한 작업들은 하나로 간주함 (모두 성공 또는 모두 실패)
  • 일관성(Consistency): 트랜잭션은 일관성 있는 데이터베이스 상태를 유지해야 함
  • 격리성(Isolation): 동시에 실행되는 트랜잭션들이 서로 영향을 미치지 않아야 함
  • 지속성(Durability): 트랜잭션을 성공적으로 마치면 결과가 항상 저장되어야 함

@Transactionalisolation동시에 여러 사용자가 데이터에 접근할 때 어디까지 허용할까? 를 정하는 옵션이라고 생각하면 됩니다.

트랜잭션의 격리 수준 (Isolation) 과 데이터의 일관성 (Consistency) 는 비례합니다.

격리 수준이 약할수록 데이터 접근 및 수정이 자유롭지만 일관성이 떨어지고 격리 수준이 강해진다면 데이터의 일관성이 증가합니다.

Spring 의 @Transactional 에서는 총 5가지 isolation 옵션을 제공합니다.


1.1. DEFAULT

사용하는 DB 의 기본 격리 수준을 따름


1.2. READ_UNCOMMITTED

한 트랜잭션이 처리 중인 커밋되지 않은 데이터를 다른 트랜잭션에서 접근 가능합니다.

DB 에 커밋하지 않은, 즉 존재하지 않는 데이터를 읽는 현상을 Dirty Read 라고 합니다.

데이터 정합성에 문제가 많아서 웬만하면 권장되지 않고 아예 지원하지 않는 경우도 있습니다.

Dirty Read 가 가능하기 때문에 잘못된 데이터를 읽을 수 있습니다.

  • A 트랜잭션이 데이터 1 을 조회하여 2 로 변경하고 아직 커밋하지 않음
  • B 트랜잭션이 동일한 데이터를 조회해서 2 라는 값을 받음 (Dirty Read)
  • A 트랜잭션에서 오류가 발생해서 데이터를 롤백 (2 -> 1)
  • 실제 데이터는 1 이지만 B 트랜잭션은 2 라는 잘못된 데이터를 읽은 셈

1.3. READ_COMMITTED

트랜잭션은 커밋한 데이터만 읽을 수 있습니다.

A 트랜잭션이 데이터를 변경해도 커밋하기 전이라면 B 트랜잭션은 변경되기 전의 데이터를 조회할 수 있습니다.

이 때, B 트랜잭션은 Undo 영역에서 데이터를 가져옵니다. (MVCC - Multi Version Concurrency Control 참조)

매 조회 시마다 새로운 스냅샷을 뜨기 때문에 다른 트랜잭션이 커밋한 후 다시 조회하면 변경된 데이터를 볼 수 있습니다.

대부분의 DB 기본 격리 수준이며 REPEATABLE_READ 와 함께 가장 많이 사용되는 방식입니다.

Non-Repeatable Read 현상이 발생할 수 있습니다.

트랜잭션에서 조회한 데이터가 트랜잭션이 끝나기 전에 다른 트랜잭션에 의해 변경되면 다시 읽었을 때 새로운 값이 읽히며 데이터 불일치하는 현상을 말합니다.

하나의 트랜잭션 내에서 똑같은 SELECT 쿼리를 실행했을 때 항상 같은 결과를 가져와야 한다는 REPEATABLE READ 정합성 정의에 어긋납니다.

  • A 트랜잭션이 데이터 (row) 를 읽음
  • B 트랜잭션이 같은 데이터를 수정하고 커밋
  • A 트랜잭션이 다시 같은 데이터를 읽었는데 데이터가 달라짐

1.4. REPEATABLE_READ

간단히 말하면 하나의 트랜잭션은 하나의 스냅샷만 사용하는 겁니다.

A 트랜잭션이 시작하고 처음 조회한 데이터의 스냅샷을 저장하고 이후에 동일한 쿼리를 호출하면 스냅샷에서 데이터를 가져옵니다.

따라서 중간에 B 트랜잭션이 새로 커밋해도 A 트랜잭션이 조회하는 데이터는 변하지 않습니다.

Phantom Read 라는 다른 트랜잭션에서 수행한 작업에 의해 안보였던 데이터가 보이는 현상이 발생할 수 있습니다.

REPEATABLE_READ 격리 수준은 조회한 데이터에 대해서만 Shared Lock 이 걸리기 때문에 다른 트랜잭션이 새로운 데이터를 추가할 수 있습니다.

  • A 트랜잭션이 조회한 데이터는 0 건
  • B 트랜잭션이 새로운 데이터를 추가하고 커밋
  • A 트랜잭션이 같은 쿼리로 다시 조회했더니 B 트랜잭션이 추가한 데이터까지 같이 조회됨

1.5. SERIALIZABLE

가장 단순하고 엄격한 격리 수준입니다.

이름 그대로 순차적으로 트랜잭션을 진행시키며 읽기 작업에도 잠금을 걸어 여러 트랜잭션이 동시에 같은 데이터에 접근하지 못합니다.

가장 안전하지만 성능 저하가 발생하기 때문에 극도의 안정성을 필요로 하지 않으면 자주 사용되지 않습니다.


2. propagation

현재 진행중인 트랜잭션 (부모 트랜잭션) 이 존재할 때 새로운 트랜잭션 메소드를 호출하는 경우 어떤 정책을 사용할 지에 대한 정의입니다.

예를 들어, 기존 트랜잭션에 참여해서 그대로 이어갈 수도 있고, 새로운 트랜잭션을 생성할 수도 있으며 non-transactional 상태로 실행할 수도 있습니다.

처음에 non-transactional 상태로 실행한다라는 개념에 대해 착각을 했었는데 트랜잭션은 존재하지만 커밋, 롤백이 되지 않는 상태입니다.

그래서 NOT_SUPPORTED 같은 트랜잭션은 TransactionSynchronizationManager.getCurrentTransactionName() 메소드로 조회했을 때 이름이 존재하지만 JPA Dirty Checking 은 동작하지 않습니다.


Spring 의 @Transactional 에서는 다음과 같은 propagation 옵션을 제공합니다.

  • REQUIRED: 기본값이며 부모 트랜잭션이 존재할 경우 참여하고 없는 경우 새 트랜잭션을 시작
  • SUPPORTS: 부모 트랜잭션이 존재할 경우 참여하고 없는 경우 non-transactional 상태로 실행
  • MANDATORY: 부모 트랜잭션이 있으면 참여하고 없으면 예외 발생
  • REQUIRES_NEW: 부모 트랜잭션을 무시하고 무조건 새로운 트랜잭션이 생성
  • NOT_SUPPORTED: non-transactional 상태로 실행하며 부모 트랜잭션이 존재하는 경우 일시 정지시킴
  • NEVER: non-transactional 상태로 실행하며 부모 트랜잭션이 존재하는 경우 예외 발생
  • NESTED:
    • 부모 트랜잭션과는 별개의 중첩된 트랜잭션을 만듬
    • 부모 트랜잭션의 커밋과 롤백에는 영향을 받지만 자신의 커밋과 롤백은 부모 트랜잭션에게 영향을 주지 않음
    • 부모 트랜잭션이 없는 경우 새로운 트랜잭션을 만듬 (REQUIRED 와 동일)
    • DB 가 SAVEPOINT 를 지원해야 사용 가능 (Oracle)
    • JpaTransactionManager 에서는 지원하지 않음

3. readOnly

  • 기본값: false
  • 사용법: @Transactional(readOnly = true)

기본값은 false 이며 true 로 세팅하는 경우 트랜잭션을 읽기 전용으로 변경합니다.

만약 읽기 전용 트랜잭션 내에서 INSERT, UPDATE, DELETE 작업을 해도 반영이 되지 않거나 DB 종류에 따라서 아예 예외가 발생하는 경우도 있습니다.

성능 향상을 위해 사용하거나 읽기 외의 다른 동작을 방지하기 위해 사용하기도 합니다.


3.1. JPA 에서 Dirty Checking 무시

JPA 에는 Dirty Checking 이라는 기능이 있습니다.

개발자가 임의로 UPDATE 쿼리를 사용하지 않아도 트랜잭션 커밋 시에 1차 캐시에 저장되어 있는 Entity 와 스냅샷을 비교해서 변경된 부분이 있으면 UPDATE 쿼리를 날려주는 기능입니다.

하지만 readOnly = true 옵션을 주면 스프링 프레임워크가 하이버네이트의 FlushMode 를 MANUAL 로 설정해서 Dirty Checking 에 필요한 스냅샷 비교 등을 생략하기 때문에 성능이 향상됩니다.


4. rollbackFor

  • 기본값: RuntimeException, Error
  • 사용법: @Transactional(rollbackFor = {IOException.class, ClassNotFoundException.class})

사용할 때 @Transactional(rollbackFor = IOException.class) 처럼 Exception 을 하나만 지정한다면 중괄호를 생략할 수 있습니다.

기본적으로 트랜잭션은 종료 시 변경된 데이터를 커밋합니다.

하지만 @Transactional 에서 rollbackFor 속성을 지정하면 특정 Exception 발생 시 데이터를 커밋하지 않고 롤백하도록 변경할 수 있습니다.

기본값은 {} 라고 나와있지만 사실 RuntimeExceptionError 가 세팅되어 있습니다.

내부 로직으로 들어가 설명을 보면 둘 다 예측 불가능한 예외 상황이기 때문에 기본값으로 들어가 있다고 합니다.

중요한 점은 이 값은 그냥 기본값이 아니라 아예 지정된 값이기 때문에 rollbackFor 속성으로 다른 Exception 을 추가해도 RuntimeException 이나 Error 는 여전히 데이터를 롤백합니다.

만약 강제로 데이터 롤백을 막고 싶다면 noRollbackFor 옵션으로 지정해주면 됩니다.


5. timeout

  • 기본값: -1
  • 사용법: @Transactional(timeout = 2)

지정한 시간 내에 해당 메소드 수행이 완료되이 않은 경우 JpaSystemException 을 발생시킵니다.

JpaSystemExceptionRuntimeException 을 상속받기 때문에 데이터 역시 롤백 처리 됩니다.

초 단위로 지정할 수 있으며 기본값인 -1 인 경우엔 timeout 을 지원하지 않습니다.

지정된 timeout 을 초과하면 다음과 같은 에러 로그를 보여줍니다.

org.springframework.orm.jpa.JpaSystemException: transaction timeout expired; nested exception is org.hibernate.TransactionException: transaction timeout expired

5.1. timeout 과 noRollbackFor 옵션을 같이 사용한다면?

호기심에 noRollbackFor = {RuntimeException.class, JpaSystemException.class} 옵션을 추가하고 타임아웃 테스트를 해보았습니다.

Exception 은 발생하지만 롤백 처리가 됩니다.


Reference

1. JWT 란 (Json Web Token)

JSON 객체를 사용해서 토큰 자체에 정보를 저장하는 Web Token 입니다.

Header, Payload, Signature 3 개의 부분으로 구성되어 있으며 쿠키나 세션을 이용한 인증보다 안전하고 효율적입니다.

일반적으로는 Authorization: <type> <credentials> 형태로 Request Header 에 담겨져 오기 때문에 Header 값을 확인해서 가져올 수 있습니다.


1.1. 장단점

  • 장점
    • 중앙 인증 서버, 저장소에 대한 의존성이 없어서 수평 확장에 유리
    • Base64 URL Safe Encoding 이라 URL, Cookie, Header 어떤 형태로도 사용 가능
    • Stateless 한 서버 구현 가능
    • 웹이 아닌 모바일에서도 사용 가능
    • 인증 정보를 다른 곳에서도 사용 가능 (OAuth)

  • 단점
    • Payload 의 정보가 많아지면 네트워크 사용량 증가
    • 다른 사람이 토큰을 decode 하여 데이터 확인 가능
    • 토큰을 탈취당한 경우 대처하기 어려움
      • 기본적으로는 서버에서 관리하는게 아니다보니 탈취당한 경우 강제 로그아웃 처리가 불가능
      • 토큰 유효시간이 만료되기 전까지 탈취자는 자유롭게 인증 가능
      • 그래서 유효시간을 짧게 가져가고 refresh token 을 발급하는 방식으로 많이 사용

1.2. Token 구성요소

  • Header

    • alg: Signature 를 해싱하기 위한 알고리즘 정보를 갖고 있음
    • typ: 토큰의 타입을 나타내는데 없어도 됨. 보통 JWT 를 사용
  • Payload

    • 서버와 클라이언트가 주고받는, 시스템에서 실제로 사용될 정보에 대한 내용을 담고 있음
    • JWT 가 기본적으로 갖고 있는 키워드가 존재
    • 원한다면 추가할 수도 있음
      • iss: 토큰 발급자
      • sub: 토큰 제목
      • aud: 토큰 대상
      • exp: 토큰의 만료시간
      • nbf: Not Before
      • iat: 토큰이 발급된 시간
      • jti: JWT의 고유 식별자
  • Signature

    • 서버에서 토큰이 유효한지 검증하기 위한 문자열
    • Header + Payload + Secret Key 로 값을 생성하므로 데이터 변조 여부를 판단 가능
    • Secret Key 는 노출되지 않도록 서버에서 잘 관리 필요

1.3. 토큰 인증 타입

Authorization: <type> <credentials> 형태에서 <type> 부분에 들어갈 값입니다.

엄격한 규칙이 있는건 아니고 일반적으로 많이 사용되는 형태라고 생각하면 됩니다.

  • Basic
    • 사용자 아이디와 암호를 Base64 로 인코딩한 값을 토큰으로 사용
  • Bearer
    • JWT 또는 OAuth 에 대한 토큰을 사용
  • Digest
    • 서버에서 난수 데이터 문자열을 클라이언트에 보냄
    • 클라이언트는 사용자 정보와 nonce 를 포함하는 해시값을 사용하여 응답
  • HOBA
    • 전자 서명 기반 인증
  • Mutual
    • 암호를 이용한 클라이언트-서버 상호 인증
  • AWS4-HMAC-SHA256
    • AWS 전자 서명 기반 인증

2. Refresh Token

JWT 역시 탈취되면 누구나 API 를 호출할 수 있다는 단점이 존재합니다.

세션은 탈취된 경우 세션 저장소에서 탈취된 세션 ID 를 삭제하면 되지만, JWT 는 서버에서 관리하지 않기 때문에 속수무책으로 당할 수밖에 없습니다.

그래서 탈취되어도 피해가 최소화 되도록 유효시간을 짧게 가져갑니다.

하지만 만료 시간을 30분으로 설정하면 일반 사용자는 30 분마다 새로 로그인 하여 토큰을 발급받아야 합니다.

사용자가 매번 로그인 하는 과정을 생략하기 위해 필요한 게 Refresh Token 입니다.


Refresh Token 은 로그인 토큰 (Access Token) 보다 긴 유효 시간을 가지며, Access Token 이 만료된 사용자가 재발급을 원할 경우 Refresh Token 을 함께 전달합니다.

서버는 Access Token 에 담긴 사용자의 정보를 확인하고 Refresh Token 이 아직 만료되지 않았다면 새로운 토큰을 발급해줍니다.

이렇게 하면 사용자가 매번 로그인해야 하는 번거로움 없이 로그인을 지속적으로 유지할 수 있습니다.


Refresh Token 은 사용자가 로그인 할 때 같이 발급되며, 클라이언트가 안전한 곳에 보관하고 있어야 합니다.

Access Toekn 과 달리 매 요청마다 주고 받지 않기 때문에 탈취 당할 위험이 적으며, 요청 주기가 길기 때문에 별도의 저장소에 보관 합니다. (정책마다 다르게 사용)


2.1. Refresh Token 저장소

Refresh Token 은 서버에서 별도의 저장소에 보관하는 것이 좋습니다.

  • Refresh Token 은 사용자 정보가 없기 때문에 저장소에 값이 있으면 검증 시 어떤 사용자의 토큰인지 판단하기 용이
  • 탈취당했을 때 저장소에서 Refresh Token 정보를 삭제하면 Access Token 만료 후에 재발급이 안되게 강제 로그아웃 처리 가능
  • 일반적으로 Redis 많이 사용

2.2. Refresh Token 으로 Access Token 재발급 시나리오

  1. 클라이언트는 access token 으로 API 요청하며 서비스 제공
  2. access token 이 만료되면 서버에서 access token 만료 응답을 내려줌
  3. 클라이언트는 access token 만료 응답을 받고 재발급을 위해 access token + refresh token 을 함께 보냄
  4. 서버는 refresh token 의 만료 여부를 확인
  5. access token 으로 유저 정보 (username 또는 userid) 를 획득하고 저장소에 해당 유저 정보를 key 값으로 한 value 가 refresh token 과 일치하는지 확인
  6. 4~5번의 검증이 끝나면 새로운 토큰 세트 (access + refresh) 발급
  7. 서버는 refresh token 저장소의 value 업데이트

Reference

'공부 > Web' 카테고리의 다른 글

웹 접근성과 WAI-ARIA  (1) 2021.11.10
URI 란?  (0) 2020.08.26

Overview

textarea 내부에는 <b> 와 같은 HTML Tag 적용이 되지 않습니다.

구글링 해보니 textarea 내부에 부분적으로 변화를 주는 건 불가능하고 textarea 를 사용하지 않고 <div> 태그를 사용해서 꼼수 부리는 방식들이 나와있었습니다.


1. contenteditable 로 편집 기능 추가

contenteditable 이라는 값을 이용하면 다른 태그에도 편집 기능을 추가할 수 있습니다.


1.1. HTML

<div class="editable" contenteditable="true"></div>

HTML 옵션으로 contenteditable 값을 주면 편집기능이 추가됩니다.


1.2. JavaScript or JQuery 로 편집 기능 추가

$('.editable').each(function(){
    this.contentEditable = true;
});

JavaScript 로도 contentEditable 옵션을 사용해서 내부에 text 를 입력가능하게 할지 말지 설정할 수 있습니다.

만약 값을 false 로 준다면 readonly 처럼 동작합니다.


2. CSS 로 textarea 처럼 변경

div.editable {
    width: 300px;
    height: 200px;
    border: 1px solid #dcdcdc;
    overflow-y: auto;
}

textarea 처럼 보이기 위한 방법입니다.


Reference

+ Recent posts