1. Overview

JPA 의 Entity 는 그 자체로 테이블을 나타내기 때문에 어떻게 설계하는지가 중요합니다.

데이터는 테이블에 저장되어 있지만 그걸 사용하는 코드는 객체입니다.

객체와 테이블의 차이점을 알아야 제대로 된 엔티티 설계를 할 수 있습니다.

데이터베이스는 외래키를 이용해서 서로 다른 테이블끼리 상호 작용을 하는데, JPA 에서는 연관 관계라는 걸 이용합니다.

JPA 는 엔티티 사이의 연관 관계를 위해 @OneToOne, @OneToMany, @ManyToOne 라는 어노테이션을 제공합니다.


2. 연관 관계란?

JPA 에서 연관 관계를 매핑할 때 고려해야 할 점은 크게 두 가지가 있습니다.

  • 단뱡향, 양방향
  • 연관 관계의 주인

2.1. 단방향, 양방향

데이터베이스 관점에서는 Join 을 통해 여러 테이블을 한꺼번에 조회 가능하기 때문에 방향이라는 개념이 없습니다.

그러나 엔티티 (객체) 가 다른 연관된 엔티티를 조회하려면 필드값으로 참조해야 합니다.

예를 들어 Member (1) : Car (N) 관계의 테이블이 있다고 가정합니다.

Member 의 자동차 목록을 가져오기 위해선 List<Car> cars 필드값이 필요합니다.

이를 Member -> Car 단방향 참조라고 합니다.

반대로 Car 엔티티만 존재할 때, 이 자동차의 소유자를 알고 싶다면 Member member 필드값이 필요합니다.

이것 역시 Car -> Member 단방향 참조입니다.

이렇게 Member <-> Car 처럼 각 엔티티가 서로를 단방향으로 참조하고 있는 걸 양방향 참조 라고 합니다.

성능상 문제도 없는데 전부 양방향으로 참조 하면 되지 않을까? 하는 생각이 들 수도 있습니다.

하지만 양방향 참조를 한다는건 엔티티의 필드 갯수가 그만큼 늘어난다는 뜻입니다.

특히 사용자를 나타내는 User, Member, Account 등의 엔티티가 모든 엔티티에 대해 참조를 해버리면 필드 갯수가 어마어마하게 많은 복잡한 클래스가 될 겁니다.

그렇기 때문에 엔티티 설계를 할 때 참조가 꼭 필요하지 않은 상황이라면 단방향 참조만 하는 것을 추천합니다.


2.2. 연관 관계의 주인

두 엔티티가 양방향 관계일 때는 연관 관계의 주인를 정해야 합니다.

연관 관계의 주인이란 외래키를 관리하며 외래키 저장, 수정, 삭제의 권한을 갖는 실질적인 엔티티이고, 주인이 아닌 엔티티는 조회만 가능합니다.

주인이 아닌 엔티티에서 mappedBy 속성을 사용해서 주인 엔티티 필드에 붙이면 됩니다.

연관 관계의 주인은 mappedBy 속성을 사용하지 않습니다.

예를 들어 다대일에서 @ManyToOne 을 사용하는 다(N) 쪽은 항상 연관 관계의 주인이기 때문에 mappedBy 옵션을 지원하지 않습니다.

일반적으로 외래키가 있는 곳을 연관 관계의 주인로 많이 설정합니다. (다대일에서 다 쪽)


3. 일대일 (1:1)

요구사항

  • 도메인: 사람(Person), 회사(Compnay), 집(House)
  • 회사는 소유자가 존재한다 (Company -> Person)
  • 집은 주인이 존재한다 (House -> Person)
  • 사람은 집 정보를 갖고 있다 (Person -> House)

우선 사람이라는 공통 Domain Entity 가 존재합니다.

@Entity
public class Person {

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

    @Column(name = "name")
    private String name;

    @OneToOne(mappedBy = "person")
    private House house;
}

3.1. 일대일 (1:1) 단방향

@Entity
public class Company {

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

    @Column(name = "name")
    private String name;

    @OneToOne(cascade = CascadeType.PERSIST)
    @JoinColumn(name = "person_id")
    private Person person;
}
  • Company(1) -> Person(1)

두 테이블은 일대일 관계이기 때문에 어느 쪽에서 외래키를 관리할 지만 정하면 됩니다.

어느 쪽에서 참조하냐에 따라 외래키 위치가 정해지기 때문에 설계할 때 신중하게 정하는게 좋습니다.

예를 들어, 사람은 회사를 소유하지 않을 수도 있지만 주인 없는 회사는 일반적으로 없습니다.

객체 지향 관점에서 보면 사람이 회사를 소유하고 있기 때문에 PersonCompany 필드를 갖는게 자연스럽습니다.

하지만 그렇게 되면 person 테이블이 company_id 라는 외래키 컬럼을 갖게 되는데, 회사를 소유하지 않은 사람은 해당 컬럼값이 null 로 세팅됩니다.

null 값을 갖는 건 데이터베이스 관점에서는 바람직하지 않기 때문에 company 테이블에 person_id 컬럼을 만들면 null 데이터는 사라집니다.

이처럼 객체 지향 관점이냐 데이터베이스 관점이냐에 따라 외래키의 위치가 달라집니다.

또한 나중에 회사를 여러 사람이 공동 소유하거나, 한 사람이 소유할 수 있는 회사가 많아지면서 일대일 관계가 다대일 관계로 확장될 수 있습니다.

어느 쪽이 옳다고 정해진 것은 없으므로 여러 가지 상황과 확장성을 고려해서 테이블 및 엔티티를 설계하는게 좋습니다.


3.2. 일대일 (1:1) 양방향

@Entity
public class House {

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

    @Column(name = "address")
    private String address;

    @OneToOne(cascade = CascadeType.PERSIST)
    @JoinColumn(name = "person_id")
    private Person person;
}
  • House(1) <-> Person(1)

양쪽에 @OneToOne 으로 모두 참조값을 넣어주고 mappedBy 로 연관 관계의 주인을 설정해주면 됩니다.

두 엔티티가 서로를 참조할 수 있기 때문에 객체지향 관점에서는 걱정할 것이 없고 데이터베이스 관점으로 정하거나 추후 확장성을 고려해서 연관 관계의 주인을 설정해주는게 좋습니다.

여기서는 Person 객체에서 mappedBy 가 설정되어 있고 House 는 설정하지 않았기 때문에 House 가 연관 관계의 주인이 되어 외래키를 관리합니다.


4. 다대일 (N:1)

요구사항

  • 도메인: 학교(School), 학생(Student), 선생(Teacher)
  • 한 학교에 여러 학생이 다닐 수 있다 (Student -> School)
  • 한 학교에 여러 선생이 근무할 수 있다 (Teacher -> School)
  • 학교는 선생님들의 정보를 갖고 있다 (School -> Teacher)

우선 학교라는 공통 Domain Entity 가 존재합니다.

@Entity
public class School {

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

    @OneToMany(mappedBy = "school")
    private List<Teacher> teachers = new ArrayList<>();
}

4.1. 다대일 (N:1) 단방향

@Entity
public class Student {

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

    @Column(name = "name")
    private String name;

    @ManyToOne(cascade = CascadeType.PERSIST)
    @JoinColumn(name = "school_id")
    private School school;
}
  • Student(N) -> School(1)

다대일은 다(N) 쪽에 외래키가 있는 게 일반적입니다.

@ManyToOne 을 사용해서 School 필드값을 참조하면 되며, School 엔 별다른 설정을 하지 않아도 됩니다.

코드상으로는 학생은 학교의 정보가 있지만 학교 입장에서는 학생들의 정보를 직접적으로 알 수 없습니다.


4.2. 다대일 (N:1) 양방향

@Entity
public class Teacher {

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

    @Column(name = "name")
    private String name;

    @ManyToOne(cascade = CascadeType.PERSIST)
    @JoinColumn(name = "school_id")
    private School school;
}
  • Teacher(N) <-> School(1)

단방향과 동일하지만 School 도메인에 @OneToMany 적용이 필요합니다.

다대일이기 때문에 참조하는 필드는 List 로 설정합니다.

학교는 teachers 를 통해 선생들의 정보를 알 수 있습니다.


5. 다대일 양방향 관계 데이터 저장

다대일 양방향 관계에서 데이터를 저장할 때는 Teacher.setSchool() 메소드와 School.getTeachers().add() 메소드를 모두 호출해서 객체끼리 동기화를 해주는 것이 중요합니다.

특히 주의할 점은 연관 관계의 주인이 데이터를 세팅해야 외래키에 값이 제대로 들어간다는 사실입니다.


5.1. 연관 관계의 주인이 아닌 일(1) 쪽에서만 세팅

School school = new School();
Teacher teacher = new Teacher();

school.getTeachers().add(teacher);  // 주인이 아닌 엔티티가 세팅

schoolRepository.save(school)
teacherRepository.save(teacher);
  • 위 코드처럼 List 에만 데이터를 넣고 저장하면 외래키가 세팅되지 않습니다.
  • school, teacher 각각 데이터는 들어가지만 teacher.school_id 값이 null 이 됩니다.

5.2. 연관 관계의 주인인 다(N) 쪽에서만 세팅

School school = new School();
Teacher teacher = new Teacher();

teacher.setSchool(school);  // 주인인 엔티티가 세팅

schoolRepository.save(school)
teacherRepository.save(teacher);
  • 위 코드를 실행하면 외래키까지 데이터가 제대로 저장됩니다.
  • 연관 관계의 주인이 외래키의 저장, 수정, 삭제를 담당하기 때문입니다.
  • 다만, 순수한 객체끼리의 데이터 동기화를 위해 school.getTeachers().add(teacher) 도 호출하는 것이 좋습니다.
    • 만약 List 에 데이터를 넣지 않으면 school.getTeachers() 를 호출해도 리스트가 비어 있습니다.
    • 물론 다른 트랜잭션에서 호출하면 DB 를 조회해서 가져오긴 하지만 같은 트랜잭션 내에서 추가로 작업을 한다면 혼란이 생길 수 있습니다.

Reference

'Framework > Spring' 카테고리의 다른 글

JPA Batch Size  (0) 2021.09.14
JPA Entity 삭제: orphanRemoval vs CascadeType.REMOVE  (0) 2021.08.28
Spring Boot 에서 Redis 사용하기  (4) 2021.08.11
Spring Boot Swagger 3.x 적용  (4) 2021.07.26
JPA 의 getById() vs findById()  (0) 2021.07.24

Overview

Java Coding Best Practices And Standards 에 있는 글을 번역한 글입니다.

여러 개의 항목이 있었는데, 그 중에서 제가 개발하면서 공감되었던 부분들만 간단하게 정리했습니다.

굉장히 기본적인 내용도 있는데 초심을 잊지 말자는 의미에서 기록합니다.

  1. NullPointerException 을 고려하자
  2. String 생성할 때 new 키워드를 사용하지 말자
  3. 반복문 내에서 새로운 객체를 생성하지 말자
  4. Collections 을 반복하는 동안 수정하지 말자
  5. Switch-Case 문에서 break 키워드를 뺴먹지 말자
  6. 객체 비교 "==" 와 "eqauls()" 의 차이를 알자
  7. 무작정 StringBuffer 를 사용하지 말자
  8. Java 파일 작성 시 표준

1. NullPointerException 을 고려하자

개발할 때 NullPointerException, 즉 NPE 를 고려하지 않는 경우가 있습니다.

항상 발생가능한 상황을 고려해서 null 체크를 해주어야 합니다.

Java 8 부터는 Optional 을 사용할 수도 있습니다.


가장 흔한 케이스가 Stringequals() 입니다.

// Bad Case : name 으로 null 값이 넘어오면 NPE 발생
public boolean isKim(String name) {
    return name.equals("Kim");
}

// Good Case 1 : name 이 null 값이어도 NPE 발생하지 않음
public boolean isKim(String name) {
    return "Kim".equals(name);
}

// Good Case 2 : org.apache.commons.lang 에서 제공하는 StringUtils 클래스 사용해서 비교
// (내부적으로 null 체크해서 안전함)
public boolean isKim(String name) {
    return StringUtils.equals(name, "Kim");
}

빈 컬렉션에 null 값을 세팅하는 것도 나쁜 습관입니다.

null 값 대신에 Collections.EMPTY_LIST 같은 값을 넣어줍시다.

// Bad Case : null 세팅
List<String> strings = null;

// Good Case : EMPTY_LISTY 세팅
List<String> strings = Collections.EMPTY_LIST

2. String 생성할 때 new 키워드를 사용하지 말자

// Slow
String s = new String("hello");

// Fast
String s = "hello";

new 키워드를 사용하면 항상 새로운 객체를 만들고 힙에 추가합니다.

반면, 사용하지 않으면 String pool 을 먼저 확인하고 없는 경우에만 추가하기 때문에 좀더 효율적입니다.


3. 반복문 내에서 새로운 객체를 생성하지 말자

for (int i = 0; i < 5; i++) {
    Foo f = new Foo();
    f.getMethod();
}

루프 반복횟수 만큼 많은 객체가 생성되기 때문에 지양해야 합니다.


4. Collections 을 반복하는 동안 수정하지 말자

List<String> names = new ArrayList<>();
names.add("Kim");
names.add("Lee");
names.add("Park");

for (String name : names) {
    if ("Kim".equals(name)) {
        names.remove(name);
    }
}

예를 들어 이름 목록에서 Kim 을 삭제하려고 합니다.

위 코드는 names 라는 리스트를 반복하는 동시에 remove() 로 요소를 삭제하고 있습니다.

이러면 ConcurrentModificationException 가 발생할 수 있습니다.


5. Switch-Case 문에서 break 키워드를 빼먹지 말자

int index = 0;

switch (index) {
    case 0:
        System.out.println("Zero");
    case 1:
        System.out.println("One");
        break;
    case 2:
        System.out.println("Two");
    break;
    // ...
    default:
        System.out.println("Default");
}

위 코드를 보면 case 0 에서 break 문을 빼먹었기 때문에 "Zero" 이후에 "One" 까지 출력됩니다.


6. 객체 비교 "==" 와 "eqauls()" 의 차이를 알자

== 연산자는 객체 참조가 같은 지 비교합니다.

equals() 메소드는 객체의 값을 비교합니다.

대부분은 equals() 메소드를 사용해서 비교하지만, 이 둘의 차이점을 잘 모르고 사용하는 경우가 있습니다.


7. 무작정 StringBuffer 를 사용하지 말자

StringBuilderStringBuffer 의 차이를 잘 모르고 무작정 StringBuffer 를 사용하는 경우가 있습니다.

StringBuffer 는 기본적으로 동기화되기 때문에 많은 오버헤드를 생성할 수 있습니다.

따라서 동기화가 우선순위가 아니라면 StringBuilder 를 사용하는 것도 고려해보아야 합니다.


8. Java 파일 작성 시 표준

  • 변수는 public, protected, private 순으로 정의합니다.
  • 생성자는 필드 수가 적은 생성자를 먼저 정의합니다.
  • 메소드는 접근성 (accessibility) 이 아닌 기능별 (functionality) 로 그룹화되어야 합니다. 예를 들어 public 메소드 사이에 private 메소드가 올 수도 있습니다.
  • 코드 설명을 위해 주석을 사용할 수도 있지만, 최대한 주석을 줄이고 가독성 있는 코드를 작성하는 것이 좋습니다.

Reference

해결하고 나니 굉장히 사소한 실수였습니다.

Error creating bean with name 'tokenRedisRepository' defined in TokenRedisRepository defined in @EnableRedisRepositories declared on RedisRepositoriesRegistrar.EnableRedisRepositoriesConfiguration: 
Invocation of init method failed; 
nested exception is org.springframework.data.mapping.MappingException: Entity com.example.login.entity.
Token requires to have an explicit id field. Did you forget to provide one using @Id?
  • RedisRepository 를 적용하는 도중 위와 같은 에러가 발생
  • Redis 에 사용할 객체의 @Id 어노테이션 임포트를 잘못해서 발생 (JPA 에서 사용하는 @Id 를 임포트함)
  • javax.persistence.Id 대신 org.springframework.data.annotation.Id 을 임포트 해서 해결

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

+ Recent posts