프로젝트 리팩토링 중 역방향으로 발생하는 N + 1 문제를 확인하게 되었다.

역 N + 1이라고 명칭하기에는 정식 명칭이 아니라고 하지만, 그렇다고 마땅한 명칭이 있는것도 아니라고 하기에 그냥 편하게 역 N + 1이라고 생각하고자 한다.

 

 

문제점과 해결방안

기본적으로 N + 1 문제가 발생하는 이유는 상위 엔티티 리스트를 조회한 뒤 연관관계에 있는 하위 엔티티 필드에 접근 시 상위 엔티티 리스트 크기만큼의 하위 엔티티 조회 쿼리가 발생하는 것을 말한다.

 

N + 1이 발생하는 원인으로는 상위 엔티티 리스트 조회 시 하위 엔티티들에 대한 프록시 객체를 생성해두었다가 접근 시 초기화하는 구조로 동작하게 된다.

N + 1 문제는 FETCH JOIN으로 한번에 조회를 해오는 방법을 택하거나, 상위 엔티티 리스트에서 id 값들만을 리스트화 해 하위 엔티티에 대한 IN 절을 사용한 쿼리로 문제를 해결할 수 있는 방법이 있다.

로직을 어떻게 작성하느냐에 따라 동일한 조회를 하면서도 N + 1을 회피할 수 있는 방법이 존재한다는건데 이 역 N + 1 문제는 상황이 조금 달랐다.

 

우선 문제가 된 부분을 보기 위해 간단한 엔티티 코드를 예시로 든다.

 

@Entity
@Getter
//...
public class ProductOrder {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    @ManyToOne
    @JoinColumn(name = "userId")
    private Member member;
    
    @OneToMany(mappedBy = "productOrder", cascade = {CascadeType.PERSIST, CascadeType.MERGE})
    private final List<ProductOrderDetail> productOrderDetailList = new ArrayList<>();
    
    //...
}


@Entity
@Getter
//...
public class ProductOrderDetail{
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    @ManyToOne
    @JoinColumn(name = "productId")
    private Product product;
    
    @ManyToOne
    @JoinColumn(name = "productOptionId")
    private ProductOption productOption;
    
    @ManyToOne
    @JoinColumn(name = "orderId")
    private ProductOrder productOrder;
    
    //...
}

 

이렇게 주문 관련된 두 엔티티가 존재한다.

서로 양방향 매핑 구조이며, 1 : N 관계에 있다.

그리고 ProductOrderDetail 엔티티의 경우 단방향 매핑으로 Product, ProductOption을 참조하고 있는 상황이다.

 

여기서 기능상 List<ProductOrderDetail> 을 조회하는 쿼리가 있었다.

SELECT *
FROM productOrderDetail
WHERE orderId IN (?, ?, ?, ?, ?)

 

그럼 여기서 예상할 수 있는 JPA의 쿼리 수행은 위 쿼리 단 하나였다.

하지만 실제 로그에 찍히는 쿼리는 달랐다.

 

orderId 리스트 크기에 해당하는 만큼 productOrder를 조회하는 쿼리가 발생했고, 이후 Product, ProductOption을 조회하는 쿼리가 발생했다.

즉, 조회하는 orderId 리스트 크기가 20이었다면 productOrder를 20번 조회하고, Product, ProductOption을 조회하는 쿼리가 발생해 실제 수행된 쿼리 개수는 23개가 됐다.

 

이런 현상때문에 역 N + 1이라는 생각을 하게 된 것이다.

이 문제는 일반적인 N + 1 문제와 다르게 코드레벨에서의 접근이 전혀 없었는데도 불구하고 쿼리 수행 하나만으로 이런 문제가 발생했다는 것이다.

 

N + 1은 코드레벨에서 접근하지 않는 방법 또는 애초에 같이 조회하는 방법을 통해 문제를 회피할 수 있었지만 이건 쿼리 실행 즉시 발생하기 때문에 당황스러웠다.

 

하지만 문제 해결은 의외로 간단하게 처리할 수 있는데 엔티티 리스트를 조회하는 것이 아닌 DTO 리스트로 매핑하도록 처리하면 이 문제는 해결된다.

일반적인 N + 1처럼 엔티티 자체를 조회하는 경우에 발생하는 문제이기 때문에 DTO 매핑으로 처리한다면 이런 문제는 발생하지 않는다.

 

 

발생 원인

그럼 이 문제는 왜 발생하는걸까?

 

이건 영속성 컨텍스트의 특징때문에 발생하는 문제다.

우선 N + 1을 예로 들어보자면 10의 크기를 갖는 ProductOrder 리스트를 조회했고 그 리스트에서 productOrderDetailList 필드에 접근하려고 하면 조회한 모든 ProductOrder의 하위 엔티티를 조회하게 된다.

이때 ProductOrder 리스트 조회 시점에서 이미 각 ProductOrderDetailList에 대한 프록시 객체가 생성되고 접근 시 그 프록시 객체가 초기화 되면서 발생하는 이슈다.

 

그럼 결과적으로 리스트만 조회한 시점에서 영속성 컨텍스트는 10개의 각 ProductOrder를 관리하면서 하위 엔티티인 ProductOrderDetail에 접근할때를 대비해 준비를 하고 있는 상태까지 만들어놓는 것이다.

 

각 ProductOrder 하나 당 3개의 ProductOrderDetail을 갖고 있다고 가정한다면

N + 1 문제가 발생한 이후 영속성 컨텍스트에서는 10개의 ProductOrder와 30개의 ProductOrderDetail의 id와 필드들을 갖고 있는 상태로 관리하게 되는 것이다.

 

그럼 하위 엔티티를 조회하는 시점에서는 영속성 컨텍스트가 어떻게 관리하려고 할까? 라는 것을 알아야 한다.

IN절을 제외하고 하나의 ProductOrderDetail을 조회한다고 가정해보자.

그럼 ProductOrderDetail에서는 ProductOrder, Product, ProductOption을 참조하고 있는 상황이다.

 

ProductOrderDetail이 조회된 시점에 영속성 컨텍스트에서는 이 엔티티에 대한 관리를 시작하게 된다.

근데 이 내부에는 연관관계에 있는 상위 엔티티가 3개가 존재한다.

그럼 영속성 컨텍스트는 이 상위 엔티티 데이터가 무결성을 위배하지 않는지 확인하기 위해 전부 조회해서 체크해보게 된다.

그래서 이렇게 리스트 크기만큼의 추가적인 조회가 발생하게 되는 것이다.

 

여기서 좀 더 들어가보면, N + 1문제의 경우 상위 엔티티 리스트 조회 시 하위 엔티티들에 대한 프록시 객체를 생성하고 접근 시 초기화를 수행해 조회를 처리한다.

역 N + 1의 경우도 동일하게 하위 엔티티를 조회했을 때 상위 엔티티들에 대한 프록시 객체를 생성한다.

그러나, 무결성 위배를 체크하기 위해 프록시 객체를 즉시 초기화하고 체크하기 때문에 접근이라는 트리거가 없더라도 프록시 객체가 초기화 되는 것이다.

 

 

이건 JSON으로 작성해보니까 좀 더 이해하기가 편했다.

 

{
    "productOrderId1" : {
        "productOrderField1" : "productOrderField1 value",
        "productOrderField2" : "productOrderField2 value",
        "productOrderDetailList" : [
            ?
        ]
    }
}


{
    "productOrderId1" : {
        "productOrderField1" : "productOrderField1 value",
        "productOrderField2" : "productOrderField2 value",
        "productOrderDetailList" : [
            "productOrderDetailId1" : {
                "productOrderDetailField1" : "productOrderDetailField1 value",
                "productOrderDetailField2" : "productOrderDetailField2 value"
            },
            "productOrderDetailId2" : {
                "productOrderDetailField1" : "productOrderDetailField1 value",
                "productOrderDetailField2" : "productOrderDetailField2 value"
            },
            "productOrderDetailId3" : {
                "productOrderDetailField1" : "productOrderDetailField1 value",
                "productOrderDetailField2" : "productOrderDetailField2 value"
            }
        ]
    }
}

 

이렇게 보면 영속성 컨텍스트에서 어떻게 관리하는지 이해하기가 좀 편하다.

 

ProductOrder를 조회한다면 첫번째 블록과 같은 구조로 관리하게 된다.

? 부분을 프록시 객체라고 볼 수 있으며 접근 시 초기화 되어 조회가 발생하고 영속성 컨텍스트에 올라가게 되는 것이다.

 

하지만 ProductOrderDetail을 조회한다면?

구조상 productOrder 블록 부분이 전부 ? 가 될수는 없다.

 

이런 구조처럼 영속성 컨텍스트에서도 엔티티 관리를 하기 위한 필수적인 절차를 거치는 것이라고 볼 수 있다.

애초에 테이블 구조만 놓고 보더라도 상위 테이블인 ProductOrder 테이블에서는 ProductOrderDetail의 값이 뭔지 관심도 없다. 데이터 자체도 안갖고 있고.

단지 JPA Entity에서만 양방향 매핑으로 ' 나 이 하위 엔티티 리스트 갖고 있을거야 ' 라고 정의한 것 뿐.

그렇기에 상위 엔티티 조회시에는 당장 이 데이터를 확인해야 할 필요가 없지만,

ProductOrderDetail 테이블에서 외래키로 ProductOrder의 id를 갖고 있는 것 처럼 영속성 컨텍스트에서도 이 상위 데이터가 있는 데이터 맞나? 하고 바로 확인할 수 밖에 없으므로 발생하는 문제다.

 

ProductOrder에 대한 예시만 들었지만 마찬가지로 Product, ProductOption도 같은 이유에서 조회한다고 볼 수 있다.

 

 

마무리

제목은 뭔가 거창하게 역 N + 1이라고 지었지만 이해하고 보면 당연한 처리구나 싶기도 한 문제였다.

하지만 분명히 지연시간이 발생할 수 있는 충분한 원인이 되기 때문에 확실하게 이해하고 왜 이런 문제가 발생하는지에 대해 알아야 한다고 생각했다.

이전에 정리했던 CascadeType에 대한 문제, 그리고 이번 역 N + 1 문제를 거치면서 JPA 사용에 대한 생각이 많아졌다.

분명 JPA를 사용함으로써 편의성에서 오는 이점이 크긴 하지만, 무분별하게 사용했다가는 오히려 독이 될 수 있겠다는 생각을 많이 하게 되었다.

특히 엔티티 자체를 조회하는 경우에 대해 좀 더 여러 상황을 고려해보고 사용해야겠다.

 

1. 연관관계 엔티티 동시 저장 및 주의사항

2. 동시저장에서 발생하는 이슈에 대한 대응책

 


 

이전 포스트에서는 CascadeType.ALL로 인해 얻을 수 있는 이점인 동시 저장과 동시에 그에 따라오는 단점들을 정리했다.

 

결과적으로는 ALL을 사용함으로써 동시 저장을 통해 Write 성능을 높일 수 있지만, 오히려 자신을 참조하고 있는 엔티티의 Write에서의 성능 저하로 이어질 수 있으며, save() 외에도 REMOVE 옵션으로 인한 삭제 제어를 주의해야 한다는 점을 알 수 있었다.

 

그래서 ALL 또는 몇몇 옵션만 사용하되 문제를 해결할 수 있는 방법은 없을까?

아니면 다른 방법이라도 따로 없을까? 하는 고민을 하게 되었고, 그 고민을 통해 알게된 점을 정리한다.

 

 

CascadeType.PERSIST만을 사용하는 방법. 그리고 문제점

 

CascadeType.PERSIST는 부모 엔티티를 ' 새로 저장 ' 할때 하위 엔티티도 같이 저장한다는 옵션이다.

그래서 이 옵션이 문제의 해결책이 될 것이라고 생각했다.

 

하지만 그렇지 않았다.

 

이전 포스트와 동일한 예시를 들어본다.

 

@Entity
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Table(name = "member")
public class Member {
    @Id
    private String userId;
    
    private String userPw;
    
    private String userName;
    
    private String nickname;
    
    @OneToMany(mappedBy = "member", cascadeType = CascadeType.PERSIST)
    private final List<Auth> auths = new ArrayList<>();
    
    public void addMemberAuth(Auth auth) {
        auths.add(auth);
        auth.setMember(this);
    }
}


@Entity
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Table(name = "auth")
public class Auth {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    private String auth;
    
    @ManyToOne
    @JoinColumn(name = "userId")
    private Member member;
    
    public Auth(String auth) {
        this.auth = auth;
    }
    
    public void setMember(Member member) {
        this.member = member;
    }
}

 

동일하게 Member와 Auth가 존재하고 1 : N 관계로 매핑되어 있다.

그리고 Member에서 cascade 옵션으로 ALL이 아닌 PERSIST를 정의했다.

 

이후 아래 테스트 코드를 실행해봤다.

@SpringBootTest
public class QueryTest {
    
    @Autowired
    private MemberRepository repository;
    
    @Test
    void name() {
        Member member = Member.builder()
                            .userId("tester1")
                            .userPw("1234")
                            .userName("testerName")
                            .nickname("testerNickname")
                            .build();
        Auth auth = new Auth("ROLE_MEMBER");
        member.addMemberAuth(auth);
        
        repository.save(member);
    }
}

 

테스트 결과는 실패였다.

오류 로그로는 PropertyValueException이 발생했고, not-null property references a null or transient value: com.example.....Auth.auth 였다.

 

즉, Auth의 auth 필드는 Not Null로 선언되어 있는데 auth가 null이기 때문에 발생한 오류다.

이 로그를 보고 auth가 없을리가 없는데?? 하면서 체크해본 결과 auth는 존재하고 있다는 점이었다.

 

이 문제는 생각보다 복잡했다. 이해하는데도 시간이 좀 걸렸고..

 

우선 원인은 Auth 엔티티의 id 생성 전략이 IDENTITY라는 점이다.

이전 포스트에서 정리했듯이 두 엔티티 모두 저장이 되기 위해서는 PERSIST와 MERGE가 필요하다.

하지만 PERSIST는 ' 부모 엔티티 저장 시 자식 엔티티를 같이 저장한다 ' 라는 개념이었다.

 

그럼 MERGE가 왜 필요했던걸까? 라는 질문으로 되돌아 갈 필요가 있다.

내 프로젝트 구조에서 MERGE가 필요했던 이유는 IDENTITY 전략 때문이었다.

 

JPA에서는 IDENTITY 전략을 사용하는 엔티티에 대해 flush 이후 id 값을 반환받아 영속성 컨텍스트에서 관리하게 된다.

그래서 id값을 반환받는 처리가 필요하게 되는데 PERSIST만 사용하는 경우 flush가 마무리되지 않은 상태이기 때문에 Auth의 id값을 반환받지 못하게 되는 것이다.

그래서 Auth를 별개로 처리한 뒤 MERGE를 통해 부모 엔티티에 병합을 해줘야 할 필요성이 생기게 되고, 그로 인해 PERSIST과 MERGE를 같이 사용해야 한다는 것이다.

 

복잡하니까 순서를 좀 더 간소화 해보자면

select Member -> insert Member -> flush ( PERSIST 감지로 인해 Auth 동시 저장 시도 -> select Auth -> insert Auth -> MERGE ) -> flush 마무리.

이런 과정을 거쳐 처리된다는 것이다.

PERSIST만 정의한 상태에서는 병합이 수행될 수 없기 때문에 영속성 컨텍스트는 Auth에 대해 null로 처리하게 된다.

이유는 JPA는 영속성 컨텍스트에서 관리할 때 해당 엔티티의 ID 값을 꼭 갖도록 하는데 마치 Key값과 같기 때문이다.

그러나, Auth는 flush가 마무리되지 않았으므로 id 값을 반환받지 못하게 되고 영속성 컨텍스트는 id 값이 없는 Auth에 대해 관리하기를 포기하게 되는 것이다.

 

그래서 이런 생각이 들었다. flush가 마무리되어야 id 값을 반환받는다면 IDENTITY 전략인 단일 엔티티 저장에서는 정상적으로 처리되면서 하위 엔티티의 IDENTITY 전략은 왜 영향을 받는거지??

위 처리 순서에서도 Member의 flush는 마무리된 상태가 아니었기 때문에 이해가 잘 되지 않았다.

 

근데 순서를 잘 보면 그 속에 답이 있었다.

저 순서의 경우 MERGE가 포함된 경우이지만, PERSIST만 정의된 경우의 순서가 좀 달라지기 때문이다.

select Member -> insert Member -> flush ( PERSIST 감지로 인해 Auth 동시 저장 시도 -> select Auth ) -> flush 마무리

 

이 순서를 보면 Auth에 대해서는 select만 시도하고 insert를 처리하지 않는다.

즉, 하위 엔티티의 insert는 MERGE에 의해 수행된거지 PERSIST에 의해 수행된게 아니라는 의미다.

select Auth 까지 PERSIST가 수행한 뒤, 데이터가 없고, IDENTITY 전략이기 때문에 MERGE를 찾게 되는데 이때, MERGE가 존재한다면 이 수행과정을 이어받아 insert를 처리한 뒤 Member 와의 관계를 갱신하면서 영속성 컨텍스트에 올라가게 되는것이다.

 

하지만, PERSIST만 정의된 경우 영속성 컨텍스트는 ' 조회했더니 데이터가 없는데, IDENTITY 전략이니까 id 값 반환 받을 방법이 없네.. 근데 MERGE도 없잖아? 그럼 포기 ' 이렇게 되는것이다.

 

그래서 IDENTITY 전략이 문제라면 Member 처럼 String 타입이면 되겠네? 라는 생각이 들었다.

근데 결과부터 말하자면 안된다 이것도..

 

테스트코드에서 Auth를 생성하는 생성자에 대해 id 값을 같이 받도록 하나 만들어주고 String 타입의 id를 갖도록 했다.

그럼 Auth도 Member처럼 새로운 데이터이긴 하지만 id값을 애플리케이션에서 직접 생성하는 형태가 되는 것이다.

 

실행했을 때 오류 로그는 아래와 같았다.

JpaObjectRetrievalFailureException.

Unable to find com.example....Auth with id authId

 

authId라는 id 값을 가진 Auth 데이터는 존재하지 않는다는 오류다.

여기서 authId는 테스트 코드에서 Auth의 id로 넣어준 문자열이다.

즉, 이 Auth 객체는 데이터베이스에 없는데? 이건 처리 못해줘. 이런거다.

 

이거 보고 정신 나가는줄 알았다.

 

JPA 기본 동작 자체가 select 해서 없으면 insert를 수행해주고, 있으면 update 해주는게 맞지 않나? 내가 findById를 수행한 것도 아닌데 데이터가 없다는 소리를 왜..?

 

이것도 다 PERSIST 때문이었다.

 

JPA에서 하위 엔티티의 id 값이 이미 매핑된 상태이기 때문에 이미 존재하고 있는 데이터라고 지 맘대로 간주해서 조회하기 때문이다.

즉, id값이 매핑되었으니 ' 아! 이건 이미 저장된 데이터구나! 체크만 해봐야지 ' 라는 생각으로 조회를 하니까 ' 이거 데이터베이스에 없는데? ' 이런 소리를 하는거다.

 

여기서 포인트는 ' 하위 엔티티의 id 값이 이미 매핑된 상태 ' 다.

실제로 단일 엔티티 저장 시 id 타입이 String인건 전혀 문제가 안된다.

심지어 String 타입인 경우 영속성 컨텍스트에 해당 객체가 관리되고 있지 않다면 select는 시도하지도 않고 바로 insert를 수행한다.

 

연관관계에 있는 엔티티의 경우 JPA에서 자식 엔티티를 저장하기 전 부모 엔티티와 참조 관계를 데이터베이스와 동기화 하려고 시도하게 된다.

이때, 자식 엔티티의 id 값이 이미 존재한다면 ' 이건 데이터베이스에 저장된 데이터인데 나한테 없었구나! ' 라고 판단해서 일치 여부만 확인하는 select를 수행하게 되고, 당연히 없는 데이터니까 없다고 오류를 발생시킨다는 말이다.

 

PERSIST가 원인이라는 말이 이것 때문이다.

참조 관계를 데이터베이스와 동기화 하는 과정을 수행하는 이유가 PERSIST가 있기 때문인 것.

JPA는 단일 엔티티에 대해서는 영속성 컨텍스트에 새로 추가해 의도한대로 처리해주지만, 연관관계에서는 상태를 좀 더 엄격하게 관리하기 때문에 id 값이 설정된 경우 데이터베이스와의 검증을 필수로 수행하도록 설계 되어 있기 때문이다.

 

그럼 하위 엔티티의 IDENTIY 전략 안되고, String 타입 id도 안된다는 건데 SEQUENCE는 된다고 한다.

 

SEQUENCE의 경우 아직 테스트해보지 않아서 명확하게 정리하기는 어려운데 SEQUENCE가 정상적으로 처리되는 이유에 대해서만 정리를 한다.

 

SEQUENCE의 경우 MySQL의 auto_increment와 다르게 다음 생성될 값을 nextVal()로 가져오게 된다.

근데 이 Sequence 조회 시점이 하위 엔티티의 select 이전이기 때문에 가능하다고 한다.

즉, flush( sequence 조회 -> sequence값 auth에 할당 -> select Auth ) 이런 순서이기 때문에 정상적으로 처리된다는 것이다.

 

String 타입의 id를 사용해도 하위 엔티티 select 이전에 할당되는건데 그건 안되고 이건 왜?

포인트는 ' 어디에서 값이 할당 되었는가 ' 를 기준으로 한다고 한다.

String 타입의 id는 ' 애플리케이션 ' 에서 값이 할당된다. 테스트 코드에서 혹은 서비스 코드에서 직접 작성하게 되기 때문이다.

하지만 Sequence의 경우에는 ' 데이터베이스 ' 에서 값이 할당된다.

 

자, String 타입의 id를 할당한 경우 영속성 컨텍스트는 ' 동기화 ' 를 위한 조회를 시도하기 때문에 오류가 발생한다고 했다.

그럼 Sequence 전략으로 인해 이미 데이터베이스에서 id 값을 받았다면??

동기화를 굳이 하지 않아도 된다는 의미가 되는 것이다.

데이터베이스에서 넘겨준 값을 할당했으므로 동기화 작업이 필요없고 이후 처리를 수행할 수 있게 되는 것이다.

 

실제로 Oracle 환경 만들어두고 테스트하면 또 뭔가 새로운 문제가 튀어나올 수 있겠지만 우선은 된다고 하니

추후 테스트를 해보고 추가 작성하는 쪽으로..

 

 

MySQL에서 동시 저장을 사용하기 위해서는?

 

그럼 아직 시도해보진 않았지만 Oracle 기반에서는 SEQUENCE 전략을 사용하는 경우 동시저장을 처리할 수 있다고 하고.

그럼 MySQL에서는 어떤 방법이 있을까? 해서 알아봤다.

 

  1. Entity, Application을 철저하게 분석해서 사용할 수 있는 곳에서만 CascadeType.ALL을 사용한다.
  2. CascadeType.PERSIST, CascadeType.MERGE를 사용한다.
  3. EntityManager.persist()를 통해 처리한다.
  4. Hibernate의 Batch Insert를 사용한다.

 

1번 방법은 혼자서만 개발하는게 아니라면 좀 지양하는게 좋지 않을까..? 라는 생각이다. 아니면 정말 확실하게 패널티가 전혀 없고 REMOVE가 되더라도 상관없다고 보장되는 경우에만 사용하고.

 

2번 방법은 REMOVE의 문제점에서 자유로울수는 있지만, 다른 참조 엔티티의 save() 과정에서 불필요한 JOIN이 발생해야 하기 때문에 그 부분에 대해 감안할 수 있다면 사용할 수 있을 것이다.

 

이 두개는 이전 포스트에서 다 정리했으니 넘어가도록 하고.

 

다음은 EntityManager의 persist()를 사용하는 방버이다.

EntityManager의 persist()를 사용하더라도 EntityManager.flush()가 호출되어야만 요청이 전달되기 때문에 동일한 동시 저장을 처리할 수 있게 된다.

하지만 이 방법의 경우 단점아닌 단점이 존재한다.

편의성 측면에서 오는 단점이라고 볼 수 있는데 EntityManager를 사용하는 경우 트랜잭션 관리에 더 많은 신경을 써야 한다.

flush(), clear() 호출 시점을 잘 설계해야 한다.

또한, 테스트 코드 작성에서 Mocking에 대한 제약이 발생하게 된다.

아예 Mocking을 할 수 없는 것은 아니지만, Repository 기반으로 처리하는 것에 비해 좀 더 복잡해 질 수 있다.

이런 단점들만 잘 처리할 수 있다면 좋은 선택이긴 하나, Repository를 아예 사용하지 않는 것이 아니라면 보통 Repository 기반으로 환경을 통일해서 처리하는 것이 가장 좋은 선택이라고 한다.

 

 

다음은 Hibernate의 Batch Insert다.

이건 시도해보지도 않은 방법이긴 한데 설정은 엄청 간결하다.

spring.jpa.properties.hibernate.jdbc.batch_size=30
spring.jpa.properties.hibernate.order_inserts=true
spring.jpa.properties.hibernate.order_updates=true

 

이렇게 properties 또는 yml에 작성해주기만 하면 된다.

수행시점은 repository.save()가 호출되는 시점인데 Batch Insert라고 해서 요청을 쌓아뒀다가 처리하는 방식은 아니고,

save() 시점에 호출되긴 하지만 결과적으로 트랜잭션 단위로 동작한다고 보면 된다.

 

이 방법은 시도해보지도 않은 이유가 있는데 이것 역시 IDENTITY 전략에 대한 단점이 존재하기 때문이다.

IDENTITY 전략은 영속성 컨텍스트에서 관리하기 위해 처리 이후 id 값을 반환받는다고 했다.

그래서 이 Batch Insert에서도 마찬가지로 IDENTITY 전략을 사용하는 엔티티라면 insert 처리마다 id 값을 개별적으로 가져와야 하기 때문에 그에 따른 지연시간이 발생할 수 있다고 한다.

 


마무리

그래서 결과적으로 MySQL을 사용하고, IDENTITY 전략을 많이 활용하고 있다면!

확실한 검증과 설계로 ALL 또는 PERSIST, MERGE를 사용하거나, 정말 성능 최적화를 위해 편의성을 조금 포기하고 EntityManager.persist()를 사용하거나, 아니면 동시 저장을 포기하고 각 Repository를 통한 save()를 처리하는 것이 현재까지 나온 결론이다!

 

결과적으로 프로젝트에서 서로 매핑되기만 하고 다른 엔티티들은 참조하지 않는 소수의 엔티티들에 대해서만 PERSIST, MERGE를 통한 동시저장을 할 수 있도록 처리했고, 그렇지 않은 엔티티들에 대해서는 동시저장을 포기하고 각자 save()를 처리하도록 수정했다.

 

벌써 몇번이고 최적화를 해보려고 프로젝트를 리팩토링해왔는데 처음으로 JMeter 테스트를 통해 새로운 문제점들을 많이 발견했다.

브라우저 또는 테스트 코드를 통한 일회성 요청이 정상적으로 처리되고 수행 시간이 만족스럽다면 꼭 JMeter 또는 자바의 CompletableFuture와 같은 비동기 병렬 처리를 통해 동시 요청을 보내 추가적인 문제점을 확인해야 겠다는 생각이 들었다.

 

일회성 요청에서 빠르더라도 요청 몇개 겹치기 시작하면 성능 저하 발생하는거 너무 쉬운 문제더라.................

 

 

1. 연관관계 엔티티 동시 저장 및 주의사항

2. 동시저장에서 발생하는 이슈에 대한 대응책

 


연관관계에 있는 엔티티를 동시 저장하기

 

JPA에서는 연관관계에 있는 엔티티를 한번의 save() 를 통해 같이 저장할 수 있는 방법이 존재한다.

그리고 그 방법을 아주 잘 활용하고 있었다.

 

@Entity
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Table(name = "member")
public class Member {
    @Id
    private String userId;
    
    private String userPw;
    
    private String userName;
    
    private String nickname;
    
    @OneToMany(mappedBy = "member", cascadeType = CascadeType.ALL)
    private final List<Auth> auths = new ArrayList<>();
    
    public void addMemberAuth(Auth auth) {
        auths.add(auth);
        auth.setMember(this);
    }
}


@Entity
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Table(name = "auth")
public class Auth {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    private String auth;
    
    @ManyToOne
    @JoinColumn(name = "userId")
    private Member member;
    
    public Auth(String auth) {
        this.auth = auth;
    }
    
    public void setMember(Member member) {
        this.member = member;
    }
}

 

이렇게 연관관계에 있는 두 테이블이 존재한다고 할 때, MemberRepository.save(member); 를 통해 Member 엔티티와 Auth 엔티티 리스트를 같이 저장할 수 있게 된다.

여기서 포인트는 두 엔티티가 양방향 매핑 구조로 되어있다는 점과 Member 엔티티의 OneToMany 중 cascadeType이다.

 

만약 이렇게 정의하지 않는다면 MemberRepository.save(member), AuthRepository.saveAll(auths)  이렇게 두번의 요청으로 저장해야 한다.

 

그럼 여기서 단순하게 보더라도 MemberRepository를 통해 한번에 저장하는 것과 각 Repository를 통해 저장하는 것에는 차이가 있어 보인다.

처음 이 동시 저장에 대해 알게 되었을때는 'MemberRepository 한번으로 저장하도록 하더라도 어차피 2개 테이블에 저장해야 한다는건데 데이터베이스 접근은 동일한거 아닌가?' 라는 생각을 했었지만 그렇지 않았다.

 

JPA에서는 이 한번의 요청을 통해 Member에 대한 insert와 Auth에 대한 insert를 같이 전달하기 때문에 1번의 데이터베이스 접근을 통해 해결할 수 있게 된다.

 

그럼 이게 단순하게 브라우저, 테스트코드, Postman 같은 방법을 통해 테스트하게 되는 경우 보통 1번의 요청만 처리하기 때문에 큰 차이를 못느낄 수 있지만, JMeter나 자바의 CompletableFuture 를 통한 동시 요청을 발생시키는 경우에는 차이가 발생하기 시작한다.

 

처음 이 차이에 대해 느끼게 된 것은 더미데이터 생성시에 느끼게 되었는데,

당시 테스트 코드를 통해 더미데이터를 만드는 과정에서 각 Repository를 통한 요청보다 최상위 엔티티의 Repository를 통한 save() 한번으로 처리하는 것이 훨씬 빠르게 처리된다는 점을 확인할 수 있었다.

그리고 예시는 2개 테이블이지만 4개씩 되는 경우에는 차이가 더 심해지는 것을 확인할 수 있었다.

 

 

 

CascadeType.ALL이 동시 저장을 처리해 줄 수 있는 이유

양방향 매핑이 되어야 한다는 점, CascadeType.ALL을 정의해야 한다는 점이 동시 저장처리의 포인트라고 했는데 그럼 이 CascadeType.ALL은 어떻게 동시 저장을 처리할 수 있을까?

 

처음에는 이 CascadeType에 대해 데이터베이스의 cascade와 동일한거 아닐까? 라는 생각을 했었다.

하지만 CascadeType은 데이터베이스의 cascade 제약조건과는 전혀 별개로 동작하게 된다.

단지 JPA에서 작업에 대한 제어를 하기 위한 옵션이기 때문이다.

 

이 CascadeType에는 6가지의 옵션이 존재한다.

 

  1. PERSIST
    • PERSIST 의미 그대로 영속화를 위한 옵션이다. '저장' 에 대한 작업에 연관된 설정이다.
    • 부모 엔티티가 저장될 때 자식 엔티티를 같이 저장하게 된다.
    • EntityManager.persist()를 호출하지 않고 repository.save()만으로도 함께 처리될 수 있다.
    • 단, '새로운' 엔티티들을 저장하는 경우에만 동작한다.
  2. MERGE
    • 부모 엔티티가 병합될 때 자식 엔티티를 같이 병합한다.
    • 부모 엔티티가 갱신되거나 수정되었을 때 자식 엔티티를 병합해야 하는 경우 동작한다.
  3. REMOVE
    • 부모 엔티티가 삭제될 때 연관된 자식 엔티티도 같이 삭제된다.
    • 데이터베이스의 ON DELETE CASCADE와 비슷하다.
    • 차이가 있다면 1개의 부모 엔티티에 대한 연관된 자식 엔티티 데이터 개수가 10개라면, 10번의 DELETE 쿼리를 날리기 때문에 그로 인한 성능 저하가 발생할 수 있다.
  4. REFRESH
    • 부모 엔티티가 새로고침 되는 시점에 자식 엔티티도 같이 새로고침 된다.
    • 데이터베이스 상태와 동기화 되도록 자식 엔티티를 업데이트 해준다.
  5. DETACH
    • 부모 엔티티가 영속성 컨텍스트로부터 분리될 때, 자식 엔티티도 분리된다.
    • '분리' 의 의미는 영속성 컨텍스트에서 관리되고 있는 상황에서 준영속 상태로 분리해 엔티티가 더 이상 영속성 컨텍스트에서 관리되지 않는 상태를 말한다.
    • 분리된 엔티티는 더이상 관리되지 않기 떄문에 변경사항이 데이터베이스에 반영되지 않는다.
    • 부모 엔티티를 준영속상태로 분리하는 경우 자식 엔티티도 같이 분리하므로 자식 엔티티의 변경사항 역시 반영되지 않는다는 의미가 된다.
  6. ALL
    • 위 모든 옵션을 사용한다.

 

이 옵션들 중 동시 저장에 영향을 끼치는 것은 PERSIST와 MERGE다.

PERSIST에는 또 다른 문제점이 하나 존재하는데 이건 다음 2번 포스팅에서 정리한다.

 

단일 엔티티 저장을 수행하는 경우 JPA는 SELECT 쿼리를 먼저 날려 이 데이터가 존재하는 데이터인지 아닌지를 먼저 확인한 뒤 새로운 데이터라면 INSERT를 날려 flush를 수행하게 된다.

기본적인 JPA 동작 방식인건데 양방향 매핑 구조에서 CascadeType.ALL을 정의하면 여기에 하위 엔티티 처리 과정이 포함되어야 한다.

 

예시 엔티티인 Member와 Auth를 기준으로 과정을 살펴보자면, 

  1. JPA는 Member 엔티티의 데이터가 존재하는 데이터인지 확인하기 위해 SELECT 쿼리를 날려본다.
  2. 새로운 데이터라는 것을 확인했기 때문에 INSERT를 처리한다.
  3. flush가 수행되는 시점에 PERSIST 옵션에 의해 하위 엔티티를 같이 처리하려고 시도한다.
  4. 하위 엔티티 데이터를 영속성 컨텍스트에서 관리하려고 시도했으나 이 데이터 역시 새로운 데이터이기 때문에 insert에 대한 처리를 MERGE 옵션을 통해 상위 엔티티인 Member의 처리와 병합해준다.
  5. 모든 Auth를 영속화했다면 flush를 마저 수행해 Member와 List<Auth>의 INSERT를 처리해 데이터베이스에 적용한다.

 

말을 다 줄이고 간단하게 보면 save(member) -> select Member -> insert Member -> flush ( PERSIST 감지 -> select Auth -> insert Auth -> MERGE ) -> 데이터 베이스 적용

이렇게 처리된다.

 

그럼 결과적으로 한번의 요청을 통해 INSERT INTO member ..., INSERT INTO auth .... 가 같이 데이터베이스에 전달되고 한번의 요청만으로 동시 저장을 처리하게 되며 MERGE로 인해 이때 처리되는 모든 엔티티를 영속성 컨텍스트에서 관리할 수 있게 된다.

 

 

CascadeType.ALL의 단점 및 주의사항

CascadeType.ALL은 편의성 측면에서 분명 좋은 선택지이다.

지금 예시처럼 단순 2개 테이블에 대한 양방향 매핑의 경우 생각하기에 따라 다를 수 있지만,

만약 상품 테이블 하위로 옵션, 썸네일 등등 여러 엔티티들이 연관관계를 맺고 있다면?

그리고 이 상품 데이터를 저장할 때 이 연관관계 엔티티들의 데이터가 항상 같이 처리되어야 한다면 한번의 요청으로 처리할 수 있게 된다는 점은 분명 좋은 이점이 된다.

 

하지만 그만큼 단점 및 주의사항이 존재한다.

대표적으로 REMOVE에 대한 것이 가장 많이 언급되는 것 같고, 

테스트를 통해 확인한 문제로는 save() 시 JOIN 문제가 있었다.

 

 

CascadeType.REMOVE 주의사항

 

 

REMOVE의 경우 잘 사용한다면 좋을 수 있지만, 그렇지 않다면 오히려 위험한 옵션으로 볼 수 있다.

CascadeType에 대해 검색해보면 주로 동시저장이 많이 나오고 주의사항으로 ALL 설정으로 인한 REMOVE의 위험성에 대해 많이 언급되는 것을 볼 수 있다.

 

REMOVE 옵션의 경우 '부모 엔티티를 삭제하는 경우 자식 엔티티를 같이 제거한다' 라는 특징을 갖고 있다.

그럼 어차피 ON DELETE CASCADE 제약조건을 정의해둔 엔티티라면 문제 없겠네? 라고 생각할 수 있다.

 

맞는말이긴 한데 주의사항이 있다.

우선 REMOVE의 경우 각 데이터들에 대한 DELETE 쿼리를 개별적으로 날리기 떄문에 성능적으로 좋지 않다.

Member 엔티티에 userId가 tester1 이라는 사용자가 있다고 가정하고 이 데이터에 대한 Auth 엔티티는 1, 2, 3, 4, 5라는 5개의 데이터가 존재한다고 가정해보자.

 

그럼 tester1에 대한 제거를 수행했을 때 아래와 같이 많은 개수의 쿼리가 발생하게 된다.

DELETE FROM member WHERE userId = 'tester1';

DELETE FROM auth WHERE id = 1;
DELETE FROM auth WHERE id = 2;
DELETE FROM auth WHERE id = 3;
DELETE FROM auth WHERE id = 4;
DELETE FROM auth WHERE id = 5;

 

그럼 tester1 데이터 하나를 지우려고 6번의 데이터베이스 커넥션이 발생하게 되는 것이다.

이로 인한 성능 저하는 요청이 쌓일수록 더 눈에 띄게 발생하게 될 것이다.

 

두번째 문제는 제약조건의 무시다.

Auth 테이블에 ON DELETE SET NULL로 제약조건을 설정해뒀다면, tester1 데이터가 삭제되더라도 Auth 데이터는 삭제되지 않고 외래키값을 null로 수정하게 된다.

 

하지만 여기서 저장에 대한 편의성을 찾기 위해 ALL을 정의했다면?

그리고 tester1을 제거하면? 그에 해당하는 auth 데이터 역시 모두 제거된다.

 

이 문제는 주의사항과 같은 결이다.

서버에서 auth 데이터에 대해 DELETE 요청이 들어오는데 제약조건은 무시되고 삭제될 수 있다는 것이다.

 

그렇기 때문에 REMOVE 혹은 ALL을 사용할때는 이런 문제가 존재한다는 점을 확실하게 인지하고, 현재 상황에 맞게 잘 고려해야 한다.

 

동시 저장을 꼭 사용해야 하며 이 문제를 회피하기 위해서는 ALL을 선언하지 않고 PERSIST와 MERGE만 사용하는 방법도 있다.

 

@Entity
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
@Table(name = "member")
public class Member {
    @Id
    private String userId;
    
    private String userPw;
    
    private String userName;
    
    private String nickname;
    
    @OneToMany(mappedBy = "member", cascadeType = {CascadeType.PERSIST, CascadeType.MERGE})
    private final List<Auth> auths = new ArrayList<>();
    
    public void addMemberAuth(Auth auth) {
        auths.add(auth);
        auth.setMember(this);
    }
}

 

이런식으로 { } 안에 여러 옵션을 작성하는게 가능하기 때문에 ALL을 사용하는데 무리가 있다면 필요한 옵션만 골라서 정의하면 된다.

 

 

save() 요청에서의 문제.

 

save() 요청에서의 문제라고 소제목을 정하긴 했는데 말로 표현하기가 뭔가 좀 애매해서 바로 정리한다.

우선 문제의 포인트는 단방향 매핑을 하고 있는 테이블에서 CascadeType.ALL를 정의한 엔티티를 참조하고 있다면 save() 요청 시 select 과정에서 불필요한 JOIN이 발생하는 문제다.

 

문제가 발생한 부분의 엔티티들은 아래와 같다.

 

@Entity
@Getter
//...
public class ProductQnA {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    @ManyToOne
    @JoinColumn(name = "userId")
    private Member member;
    
    @ManyToOne
    @JoinColumn(name = "productId")
    private Product product;
    
    //...
}

@Entity
@Getter
//...
public class Product {
    
    @Id
    private String id;
    
    //...
    
    @OneToMany(mappedBy = "product", cascade = CascadeType.ALL)
    private final List<ProductOption> options = new ArrayList<>();
    
    @OneToMany(mappedBy = "product", cascade = CascadeType.ALL)
    private final List<ProductThumbnail> thumbnails = new ArrayList<>();
    
    @OneToMany(mappedBy = "product", cascade = CascadeType.ALL)
    private final List<ProductInfoImage> infoImages = new ArrayList<>();
    
    //...
}

 

필요한 코드만 두고 나머지는 좀 생략했다.

 

지금 상황을 보면 ProductQnA라는 엔티티가 존재하는데 여기에서는 Member, Product 엔티티를 참조하고 있다.

Member의 경우 최초 예시처럼 Auth와 양방향 매핑이 되어 있는 상태고, 

Product의 경우 ProductOption, ProductThumbnail, ProductInfoImage 라는 엔티티들과 양방향 매핑 관계를 갖고 있다.

 

그리고 ProductQnA는 Member와 Product에 대해 단방향 매핑 관계다.

 

 

문제는 JMeter 테스트 과정에서 알게 됐다.

이상하게 ProductQnA의 POST 요청에서 지연시간이 길게 발생하고 있었다.

복잡한 비즈니스 로직도 없는데 왜 그런가 하고 로그를 확인했을 때는 예상치도 못했던 문제가 발생하고 있었다.

 

 

기본적인 JPA의 동작 방식을 생각해보면

참조하고 있는 엔티티인 member와 product에 대한 select 이후 productQnA에 대한 select 처리를 통해 새로운 데이터라고 판단해 insert를 수행하게 될 것이다.

 

하지만 로그는 그렇지 않았다.

로그에서는 Member를 조회하면서 auth를 join해서 쿼리를 날리고 있었다.

그리고 product를 조회하면서는 productOption, productThumbnail, productInfoImage를 join해서 쿼리를 날렸다.

즉, SELECT .... FROM member JOIN auth ...  ,  SELECT .... FROM product JOIN productOption ... JOIN productThumbnail ... JOIN productInfoImage

이렇게 조회 쿼리가 발생하게 되는 것이다.

 

ProductQnA 엔티티 코드를 보면 나는 지금 전혀 Auth, ProductOption, ProductThumbnail, ProductInfoImage가 필요 없는데..? 왜 조회하지?

 

이 문제의 원인은 CascadeType 옵션들 중 MERGE에 있다.

 

MERGE는 ' 병합 '을 해주는 옵션이라고 했다.

그럼 ProductQnA를 저장하는 과정에서 이 엔티티가 Member와 Product를 참조하고 있고 참조하고 있는 엔티티를 '병합' 해야 하기 때문에 그 자식 엔티티들까지 모두 같이 병합하는 것이다.

 

Member를 병합해야 하니 Auth를 같이 JOIN 해서 조회해야 하고, 

Product를 병합해야 하니 ProductOption, ProductThumbnail, ProductInfoImage를 JOIN해서 조회해야 한다.

즉, 이 JOIN은 JPA의 FETCH JOIN이 수행된 경우라고 볼 수 있다.

 

Member는 그래도 Auth만 같이 조회하고 Auth 특성 상 그래봤자 대부분 1 : 1 매핑이 더 많은 구조니까 그러려니 할 수 있었지만, Product는 달랐다.

Option의 제한도 없고, Thumbnail, InfoImage에 대한 제한도 없기 때문에 당장 더미데이터는 괜찮을지 몰라도 더 커지면 얼마나 문제가 생길지 알 수 없었다.

 

심지어 더미데이터는 Option 최대치가 10이고 Thumbnail은 7이었다. InfoImage도 5개.

그런데도 JMeter 테스트 과정에서 지연시간이 크게 발생하는데 만약 이걸 넘어서는 데이터들이 존재하고 데이터 크기도 커진다면 큰 문제가 되는 것이라고 생각했다.

 

그리고 가장 맘에 들지 않았던 것은 쓰지도 않을 엔티티를 조회한다는 것이 가장 맘에 들지 않았다.

 

동시 저장을 고집한다면 PERSIST, MERGE를 사용할 수 있겠지만, 이 문제의 원인이 MERGE라는 점 때문에 해결 방안으로 동시저장을 포기하고 각 Repository를 통한 저장으로 수정해 문제를 해결했다.

PERSIST만 사용한다는 선택지도 있지만, 그걸 사용하지 않은 이유는 다음 포스팅에서 정리한다.

 

 

마무리

이번 테스트를 통해 CascadeType과 JPA의 동작 방식에 대해 좀 더 고민하고 깊게 알아볼 수 있는 기회가 되었는데 이번 기회를 통해 동시 저장에 대해 고려해야 할 사항이 뭐가 있는지, JPA 사용에 대한 적합성을 좀 더 고민해볼 수 있는 좋은 기회였다.

 

개인 프로젝트가 아닌 운영 서버를 개발하는 과정이었다면 ALL 사용은 굉장히 민감한 문제가 될 수 있는 부분이라는 점을 알 수 있었고, 그로 인해 ALL을 사용하지 않고 동시저장을 처리하는 대안에 대해 고민해보게 되었다.

 

다음 포스팅은 그 고민에 대한 정리와 대안, 프로젝트 환경에서 발생한 문제점들에 대해 정리할 것이다.

Redis 정의

Redis(Remote Dictionary Server)는 Key-Value 구조의 비정형 데이터를 저장하고 관리하기 위한 오픈소스 기반의 비관계형 데이터베이스 관리 시스템(DBMS)이지만 DBMS보다는 빠른 캐시의 성격으로 대표되며, NoSQL 중 하나에 속한다.

데이터베이스, 캐시, 메시지 브로커로 사용되며 인메모리 데이터 구조를 가진 저장소이다.

Redis는 기본적으로 영속성을 위한 데이터베이스는 아니지만, 백업을 통한 영속성을 지원한다.

Redis 활용처로는 Session Store, Cache, 분당 호출 수를 제한하는 등의 Limit Rater, Job Queue 등이 있다.

 

Key-Value Store

Redis는 Key-value 구조로 자바의 Map과 같은 구조이다.

Key-Value Store의 장단점은 아래와 같다.

장점 단점
- 단순성에서 오는 쉬운 구현과 사용성
- Hash를 이용해 값을 바로 읽으므로 빠른 속도를 보장한다.
- Hash를 이용하기 때문에 추가 연산이 필요없다.
- 분산 환경에서의 수평적 확장성이 보장된다.
- Key를 통해서만 값을 읽을 수 있다.
- 범위 검색등의 복잡한 질의가 불가능하다.

 

 

NoSQL의 종류

NoSQL의 데이터 모델은 Key-Value, Document, Wide-column, Graph로 분류할 수 있다.

 

  • Key-Value
    • Redis, Memcached, Riak, DynamoDB
  • Document
    • MongoDB, CouchDB
  • Wide-column
    • Cassandra, HBase, Google Big Table
  • Graph
    • Neo4j, OrientDB, AgensGraph

 

Data Type

Redis의 value는 여러 데이터 타입이 존재한다.

  1. Strings
  2. Lists
  3. Sets
  4. Hashes
  5. SortedSets
  6. Bitmaps
  7. HyperLogLog

 

Strings

가장 기본적인 데이터 타입이며 바이트 배열을 저장하는데 binary-safe로 처리된다.

binary-safe는 모든 문자를 표현할 수 있다는 의미로 제외되는 문자 없이 모든 문자를 저장할 수 있다.

C 언어의 경우 변수에 문자열을 넣을 때 마지막에 null 문자를 넣는다. null 문자열 자체는 문자 코드가 존재하는데 이 문자코드는 문자열의 끝을 의미하기 때문에 문자열로 표현하지 않는다.

그러나 binary-safe는 이런 제외되는 문자열 없이 모든 문자를 저장할 수 있는 것이다.

 

주요 명령어로는 set, get, incr, decr, mset, mget이 있다.

 

 

 

set은 특정 키에 문자열 값을 저장하는 명령어다. set [keyName] [value] 구조로 저장할 수 있다.

get은 특정 키에 저장된 문자열 값을 가져오는 명령어다. get [keyName] 구조로 되어있다.

 

Redis에는 MySQL의 auto_increment나 Oracle의 Sequence같이 자동 증감을 처리할 수 있다.

위 기능들은 간단한 이해를 위한 예시일 뿐이고 차이가 있는데 MySQL과 Oracle은 데이터가 삽입되는 시점에 특정 컬럼 값을 증가시켜주기 위함이지만 Redis에서는 특정 Key의 value를 Integer로 취급하고 1씩 증감한다는 점이다.

INCR [keyName] 혹은 DECR [keyName] 구조로 사용한다.

INCR은 1 증가, DECR은 1 감소시킨다.

이 두 명령어는 원자성이 보장되기 때문에 여러 요청이 몰리더라도 중복되거나 누락되는일 없이 정상 처리된다.

 

mset, mget은 한번에 여러 키에 대한 값을 저장하거나 가져오는 명령어다.

mset keyName1 value1 keyName2 value2 ... 와 같은 구조다.

mget keyName1 keyName2 ... 와 같은 구조로 여러 데이터를 가져올 수 있다.

mset firstKey firstValue secondKey secondValue 이 명령어를 수행한 뒤

keys * 명령어를 통해 모든 key를 출력해보면 firstKey, secondKey가 출력되는 것을 확인할 수 있으며

mget firstKey secondKey 이 명령어를 실행하게 되면 firstValue, secondValue가 출력되는 것을 볼 수 있다.

한번에 여러 Key와 그 값을 저장할 수 있다라는 개념이지 배열 형태로 저장하는 것이 아니다.

 

 

Lists

Linked-List 형태의 자료구조로 인덱스 접근은 느리지만, 데이터의 추가 및 삭제가 빠르다는 장점이 있다.

Lists는 Queue와 Stack의 역할로도 사용할 수 있다.

 

주요 명령어로는 lpush, rpush, llen, lrange, lpop, rpop이 있다.

 

lpush와 rpush는 리스트의 왼쪽(head) 혹은 오른쪽(tail)에 새로운 값을 추가하는 명령어다.

lpush listName value

rpush listName value

 

llen은 리스트에 들어있는 아이템 개수를 반환한다.

llen listName

 

lrange는 리스트의 특정 범위를 반환한다.

lrange listName 시작 인덱스 끝 인덱스

인덱스 범위를 0 -1로 설정하게 되면 0번 인덱스부터 가장 끝 요소까지 모두를 출력한다.

이때 인덱스의 경우 zero index 방식이다.

 

lpop은 리스트의 왼쪽(head)에서 값을 삭제하고 반환한다.

lpop listName

즉, Queue와 같이 가장 먼저 들어간 head 값을 추출하고 반환하게 된다.

 

rpop은 리스트의 오른쪽(tail)에서 값을 삭제하고 반환한다.

rpop listName

lpop과 반대로 Stack처럼 가장 나중에 들어간 값을 추출하고 반환한다.

 

 

Sets

순서가 없는 유니크한 값의 집합이다. 유니크한 집합인 만큼 중복이 존재할 수 없다.

검색이 빠르고 개별 접근을 위한 인덱스는 존재하지 않는다.

sets 타입을 통한 교집합, 합집합 등의 집합 연산이 가능하다.

 

주요 명령어로는 sadd, srem, scard, smembers, sismember가 있다.

 

sadd는 Set에 데이터를 추가하는 명령어다.

sadd setName value

 

srem은 set에서 데이터를 삭제하는 명령어다.

srem setName value

 

scard는 set에 저장된 아이템의 개수를 반환한다.

scard setName

 

smembers는 set에 저장된 아이템들을 반환한다.

smembers setName

 

sismember는 특정 값이 set에 들어있는지를 반환한다.

sismember setName value

 

위 명령어들 중 scard, smembers를 제외한 나머지 명령어들은 결과가 0, 1로 반환된다.

0은 실패, 1은 성공이다.

이미 들어가있는 데이터를 또 넣으려고 한다거나, 존재하지 않는 데이터를 삭제하고자 하는 경우에도 오류가 발생하는 것이 아닌 0이 출력된다.

 

 

Hashes

Hashes는 하나의 Key 하위에 여러개의 field-value 쌍을 저장하는 구조다.

여러필드를 가진 객체를 저장하는 것으로 HINCRBY 명령어를 통해 카운터로 활용이 가능하다.

 

하나의 Key 하위에 여러개의 field-value 쌍을 가지는 구조라는 것은 마치 아래 JSON과 같은 구조라고 볼 수 있다.

{
    "user1": {
        "name": "coco",
        "age": 9
    }
}

 

Redis는 JSON을 그대로 value에 넣을 수 있는데 Hashes와 JSON Value의 차이점은 단일 데이터 조회에서 발생한다.

위와 같은 구조의 데이터를 저장한뒤 name만 조회해야 하는 상황이라고 가정했을 때, JSON이 그대로 value에 담기는 경우 user1의 모든 데이터를 조회해 가져온 뒤 name만 따로 뺄 수 있도록 파싱해야 한다.

하지만, Hashes로 저장하게 되면 조회 시점에서부터 name만 조회해 파싱하는 과정이 사라지게 된다.

 

항상 해당 데이터가 전부 필요하다고 보장된다면 JSON을 그대로 담는 방법도 좋을 수 있겠으나, 그렇지 않은 경우에는 Hashes가 더 유용할 수 있다.

또한, 하나의 value에 대해 increase, decrease도 사용할 수 있기 때문에 이런 기능이 필요하다면 Hashes를 선택하는 것이 유리하다.

 

주요 명령어로는 hset, hget, hmget, hincrby, hdel이 있다.

 

hset은 한개 또는 다수의 필드에 값을 저장하는 명령어다.

hset key field value field value
위 코드를 예시로 user1은 key가 된다. 그리고 name은 field 또는 sub-key라고 불리며, 그 값은 value이다.

그럼 예시대로 저장하기 위한 명령어는 hset user1 name bear age 10  이런 명령어가 된다.

 

hget은 특정 필드의 값을 반환하는 명령어다.

hget key field

hget user1 name 명령어를 입력하면 user1의 name 필드 값인 coco가 반환된다.

 

hmget은 한개 이상의 필드값을 반환하는 명령어다.

hmget key field field

# 명령어 결과
hmget user1 name age

1) "coco"
2) "9"

 

 

hincrby는 특정 필드의 값을 Integer로 취급하고 지정한 수 만큼 증가시킨다.

hincrby key field 증가값

# 명령어 결과
hincrby user1 name 1

(error) ERR hash value is not an integer


hincrby user1 age 1

(integer) 10


hmget user1 name age

1) "coco"
2) "10"

위와 같이 Integer로 변환이 불가능한 경우에는 오류가 발생하며, 변환할 수 있는 값인 경우에만 증가된다.

Hashes에서는 hincrby를 통해 값을 증가시키는 것은 가능하지만 감소시키는 decrease는 수행할 수 없다.

 

 

hdel은 한개 이상의 필드를 삭제하는 명령어다.

hdel key field field

삭제된 필드를 조회하면 (nil)이 출력된다. 애초에 저장하지 않았던 필드를 조회하는 경우에도 (nil)이 출력되기 때문에 존재하지 않는 필드를 조회하는 경우에는 (nil)이 출력된다고 보면 된다.

그렇기 때문에 name, age를 모두 제거하고 hmget으로 조회하더라도 둘다 (nil)이 나오게 된다.

그럼 user1이라는 key에 존재하는 모든 필드가 제거되었는데 이 key는 남는건가? 싶을 수 있는데 keys * 명령어를 통해 조회해보면 해당 key 역시 삭제되어 존재하지 않는 것을 확인할 수 있다.

 

그리고 hdel user1 name을 통해 name 필드만 제거한 뒤 다시 hset user1 name coco 명령어를 통해 필드를 저장하면 동일하게 user1 key안에 name 필드가 저장된다.

 

 

SortedSet

Set과 유사한 유니크한 값의 집합이지만, 각 값은 연관된 정수형의 score를 갖고 정렬되어 있다는 점이 차이점이다.

SortedSet은 정렬된 상태이기 때문에 빠르게 최소, 최대값을 구할 수 있어 순위계산이나 리더보드 구현등에 활용이 된다.

 

주요 명령어는 zadd, zrange, zrank, zrevrank, zrem, zincrby가 있다.

 

 

zadd는 한개 또는 다수의 값을 추가 또는 업데이트 하는 명령어다.

zadd key score value score value ... 와 같은 구조다.

 

zrange는 특정 범위의 값을 오름차순 정렬로 반환한다.

zrange key 시작인덱스 끝인덱스 [withscores]

Lists와 마찬가지로 zero index 방식이다.

withscores 옵션을 같이 입력하게 되면 score와 함께 출력한다.

# 값 추가
zadd rank 10 coco 20 mozzi

# 조회
zrange rank 0 -1

1) "coco"
2) "mozzi"


zrange rank 0 -1 withscores

1) "coco"
2) "10"
3) "mozzi"
4) "20"

 

그럼 여기까지 잠깐 정리하면 score는 정렬되는 기준이 된다.

그렇기 떄문에 삭제 명령어를 통해 coco를 제거한 뒤 다시 10의 score로 추가하게 되면 동일하게 coco가 먼저 출력되게 된다.

 

주의사항으로는 score는 중복될 수 있으며, 같은 값인 경우에는 value 기준 사전순으로 정렬된다.

zadd rank 10 coco
zadd rank 10 mozzi
zadd rank 10 youn
zadd rank 10 jung

위와 같이 값을 추가하는 경우 coco -> jung -> mozzi -> youn 순서로 정렬된다.

 

 

zrank는 특정 값의 위치(순위)를 반환하는데 이때 오름차순 기준으로 위치를 반환한다.

zrank key value

이때도 zero index로 가장 첫 값의 위치는 0이다.

 

zrevrank는 zrank와 마찬가지로 특정 값의 위치를 반환하는데 내림차순 기준으로 위치를 반환한다.

zrevrank key value

그래서 가장 첫 값을 조회하게 되면 0이 아닌 총 개수 - 1개의 결과를 반환한다.

 

 

zrem은 한개 이상의 값을 삭제하는 명령어다.

zrem key value

 

 

zincrby는 특정 값의 순위를 지정한 값만큼 증가시키는 명령어다.

zincrby key increase-score value

위 명령어 예시에서 increase-score에 작성한 값만큼 score 값이 증가하게 되는데 이때도 증가한 값이 중복되는 값이라면 value 기준 사전순으로 정렬된다.

zadd rank 10 coco 20 mozzi 30 jung 30 youn

zrange rank 0 -1

1) "coco"
2) "mozzi"
3) "jung"
4) "youn"


zincrby rank 10 mozzi

1) "coco"
2) "jung"
3) "mozzi"
4) "youn"

 

 

 

Bitmaps

비트 벡터를 사용해 N개의 Set을 공간 효율적으로 저장한다.

하나의 비트맵이 가지는 공간은 4,294,967,295(2^32 - 1)인데 0과 1로만 이루어진 비트 벡터를 사용함으로써 간결하게 표현할 수 있는 데이터들의 저장 공간을 효율적으로 사용하는 것이다.

예시로, 방문 여부를 확인해야 할 때, visit이라는 key값의 bitmap을 활용하고 비트맵의 인덱스를 사용자 번호로 사용한다면 0은 미방문, 1은 방문으로 체크할 수 있게 된다.

그럼 하나의 비트맵만으로 42억명의 방문 이력을 처리할 수 있기 때문에 효율적이다.

 

주요 명령어로는 setbit, getbit, bitcount, bitop가 있다.

 

 

setbit는 비트맵의 특정 offset 값을 변경한다.

setbit key offset 변경값(0, 1)

 

getbit는 비트맵의 특정 offset 값을 반환한다.

getbit key offset

 

bitcount는 비트맵에서 set(1) 상태인 비트의 개수를 반환한다.

bitcount key

 

bitop는 비트맵들간의 비트 연산을 수행하고 결과를 비트맵에 저장한다.

bitop 수행할연산 결과비트맵key key1 key2

여기서 연산에는 and, or, xor, not을 사용할 수 있다.

 

# visit 이라는 key 값의 bitmap 생성 및 10번 인덱스의 값을 1로 변환
setbit visit 10 1
(integer) 0

setbit visit 9 1
(integer) 0


getbit visit 10
(integer) 1

getbit visit 11
(integer) 0


# 범위를 벗어난(43억번째 인덱스) 값 변경. getbit도 동일한 오류 발생
setbit 4300000000 1
(error) ERR bit offset is not an integer or out of range


# 새로운 visit2 bitmap 생성
setbit visit2 10 1
(integer) 0


# visit과 visit2를 비트연산하고 결과는 visitResult 라는 비트맵을 생성해 저장
bitop and visitResult visit visit2
(integer) 2


keys *
1) "visitResult"
2) "visit2"
3) "visit"


getbit visitResult 10
(integer) 1

bitcount visitResult
(integer) 1

 

결과에 대해 정리.

setbit에 대한 결과는 수정되기 전의 값이다.

즉, setbit visit 10 1을 최초 수행했을 때는 최초 설정값이 0이기 때문에 0이 출력되는거고 이후 setbit visit 10 1 또는 setbit visit 10 0을 수행하는 경우 이전 값인 1이 출력된다.

 

범위를 벗어난 값을 조회 또는 수정하려고 한다면 out of range 오류가 발생한다.

 

bitop 명령어의 결과는 생성된 비트맵의 크기를 나타내는데 byte 단위 결과다.

8bit = 1byte이기 때문에 10(index 11)까지만 값을 변경했으므로 2byte의 크기를 갖게 되므로 2를 반환하는 것이다.

그럼 여기서 알 수 있는것은 bitmaps는 크기가 최초 생성시부터 2^32 - 1인 512MB로 생성되는 것이 아니라는 것을 알 수 있다.

 

최대 크기가 512MB인거지 최초 생성시부터 512MB갖고 생성되지 않는다.

위 코드처럼 setbit visit 10 1을 통해 visit이라는 bitmap을 생성하게 되면 zero index 구조이기 때문에 11의 크기가 필요하다.

하지만 byte 단위로 생성되기 때문에 16bit은 2byte의 크기로 최초 생성되게 된다.

이후 setbit 명령어를 통해 더 큰 수의 index를 수정하는 경우 그때마다 byte 단위로 크기가 커지게 되는 것이다.

그리고 그 최대치는 512MB인 2^31 - 1까지 허용이 된다.

 

 

HyperLogLog

HyperLogLog는 유니크한 값의 개수를 효율적으로 얻을 수 있다.

확률적 자료구조이기 때문에 오차가 있으며, 매우 큰 데이터를 다룰 때 사용이 된다.

18,446,744,073,709,551,616(2^64, 1844경)개의 유니크 값을 계산하는 것이 가능하며 12KB의 메모리를 사용해 0.81%의 오차율을 허용한다.

보통 값들을 넣은 뒤 그 값들이 유니크한 값으로 나눴을 때 총 몇개인지를 알아내는 용도로 사용되고, bitmap의 bitcount와 비슷한 용도이지만 효율성이 더 높다.

 

만약 HyperLogLog가 아닌 Set을 사용한다면 데이터가 수백만개만 되더라도 몇 MB의 메모리를 사용하게 된다.

하지만 HyperLogLog를 사용하면 약 1800경 개의 데이터를 담더라도 고작 12KB만 사용하게 된다.

너무 대용량의 데이터이기 때문에 확률적으로 어느정도 오차율을 허용하고 성능을 높인다.

너무 많은 양의 데이터를 사용하는 경우는 99% 이상의 정확도라면 로직을 처리하기에 충분한 경우가 많다고 한다.

HyperLogLog는 0.81%의 오차율을 허용하기 때문에 이에 적합하고 많이 사용된다고 한다.

 

용도는 아무래도 count에 관련되다보니 주로 add, count 두가지 명령어를 사용한다.

특징으로는 값을 넣을 때 내부에 데이터를 저장하지 않는다는 점이 있다.

HyperLogLog라는 나름대로의 확률적 자료구조를 내부구현 형태로 갖고 있기 때문에 add로 저장한다고 해서 실제로 내부에 저장되지는 않는다.

 

 

주요 명령어로는 pfadd, pfcount, pfmerge가 있다.

 

pfadd는 HyperLogLog에 값을 추가하는 명령어다.

pfadd key value value value ...

 

pfcount는 HyperLogLog에 입력된 값들의 cardinality를 반환한다.

pfcount key

 

pfmerge는 다수의 HyperLogLog를 반환한다.

pfmerge 결과key key1 key2

 

# visit이라는 HyperLogLog 생성
pfadd visit coco mozzi youn jung
(integer) 1

pfcount visit
(integer) 4

# visit2라는 HyperLogLog 생성. youn만 visit과 동일하게.
pfadd visit2 coco2 mozzi2 youn jung2
(integer) 1

pfcount visit2
(integer) 4


# visit과 visit2를 합집합으로 합쳐서 visitResult라는 HyperLogLog 생성
pfmerge visitResult visit visit2
OK

# 중복은 제거되기 때문에 중복된 youn 하나가 제거되고 7개만 남는다.
pfcount visitResult
(integer) 7

 

HyperLogLog의 설명처럼 대용량의 데이터를 넣어서 테스트 해볼수가 없어서 간단하게 명령어 테스트만 수행해봤다.

유니크한 값의 개수를 효율적으로 얻기 위한 방법이기 때문에 set과 마찬가지로 중복은 허용하지 않으며, 중복되는 값을 의도적으로 넣더라도 오류가 발생하지 않는다.

 

 

 

Reference

 

[Redis] 레디스 데이터 타입 정리

이 포스팅은 레디스 공식 문서를 보고 지식을 정리하기 위해 쓴 글입니다. Strings Redis String 유형은 Redis 키와 연결할 수 있는 가장 간단한 유형의 값이다. Memcached에서의 유일한 데이터 타입이자, R

yeongunheo.tistory.com

 

프로젝트 리펙토링을 진행하면서 발생한 문제.

다중 insert를 처리해야 하는데 서버에서 반복문을 통해 호출하도록 하지 않고 처리하고자 했으나 Sequence가 정상동작하지 않아 발생한 문제다.

 

프로젝트 환경

Spring MVC

MyBatis

Oracle 18c

 

발생한 문제

기존에 진행했던 프로젝트를 Spring Boot 환경으로 재구성한 프로젝트와 동일하게 리펙토링 하기 위해 테이블 구조를 해당 MySQL 환경의 구조와 동일한 설계의 데이터베이스 환경으로 수정.

수정하게 되면서 많은 테이블의 Primary Key가 NUMBER 타입으로 수정되었고 IDENTITY 설정을 통해 Sequence를 통한 처리를 하도록 수정했다.

그러나 다중 insert 처리 시 SQLIntegrityConstraintViolationException: ORA-00001: unique constraint ... 오류가 발생.

Unique가 보장되어야 하는 Primary key가 중복되는 요청으로 인해 오류가 발생했다.

 

 

원인 파악

여러 테스트를 해보고 검색도 해보니 Oracle에서 다중 insert 즉, INSERT ALL 처리 시 Sequence는 매번 호출되는 것이 아니라 최초 호출된 값을 재사용한다.

INSERT ALL
INTO testTable (title, content, userId)
VALUES(title, content, userId)
INTO testTable (title, content, userId)
VALUES(title2, content2, userId2)
SELECT * FROM DUAL;

이러한 쿼리를 실행하면서 IDENTITY가 설정된 id의 값이 1, 2 로 처리되어야 하지만 최초 호출된 1이 계속해서 재사용되기 때문에 1, 1로 처리되는 것이 문제였다.

이건 Oracle의 메커니즘이 그렇기 때문에 어떻게 할 수 있는 방법이 없었다.

 

 

문제 해결 방법

Sequence를 계속해서 호출하도록 수정할 수 없었기 때문에 다른 문제 해결 방법을 찾아야 했다.

그래서 찾은 방법은 3가지가 있었다.

  1. 서버에서 반복문을 통해 mapper를 여러번 호출한다.
  2. xml에서 PL/SQL을 직접 작성해 처리한다.
  3. 데이터베이스에 Procedure를 정의하고 xml에서 Procedure를 호출하도록 해 처리한다.

여기서 1번 방법은 동적 처리가 아닌 매번 호출하는 방법이다.

적은 반복 횟수가 보장된다면 괜찮은 방법이지만 반복 횟수를 보장할 수 없다면 큰 문제가 발생할 수 있다.

가장 큰 단점으로는 반복문을 통해 매번 mapper를 호출하기 때문에 매 요청마다 Connection 요청으로 인해 Connection Overhead가 증가하게 된다. 그렇기에 횟수에 제한을 두지 않은 경우 선택하기에는 어려운 방법이다.

 

2번의 PL/SQL을 직접 작성해서 처리하는 방식의 경우 Java 코드와 더 가까운 형태로 작성할 수 있기 때문에 Java 코드의 흐름을 유지하면서 데이터베이스 작업을 처리하는데 유리하다.

또한, 배포 역시 코드가 애플리케이션에 포함되어있어 데이터베이스의 업데이트를 따로 수행하지 않아도 된다는 장점이 있다.

그러나 데이터베이스에 대한 각 호출은 Network Overhead를 초래할 수 있고, 많은 데이터를 처리할 때 성능 저하가 발생할 수 있다.

PL/SQL 코드가 여러번 호출되는 경우 더 느려질 가능성도 존재한다.

 

3번의 Procedure 정의 후 호출하는 방식의 경우 데이터베이스에서 Procedure가 직접 실행되기 때문에 성능이 뛰어나다.

그만큼 Network Overhead 역시 줄일 수 있으며 Procedure 정의 시 조건문, 반복문을 통해 복잡한 로직을 처리하기에 더 적합하다.

하지만, 스키마가 변경되는 경우 프로시저 역시 같이 수정해야 하는 경우가 발생할 수 있기 때문에 가독성이 떨어지고 유지보수 측면에서 불리할 수 있다.

그리고 Java 디버깅 도구를 사용할 수 없기 때문에 문제발생 시 원인을 찾기가 다른 방법들에 비해 더 어려울 수 있다.

배포 역시 데이터베이스에 직접 배포해야 하기 때문에 배포 처리 과정이 복잡해 질 수 있다는 단점이 존재한다.

 

각 방법에 대해 장단점이 존재하지만 처리하는 기능은 적은 횟수만 존재할 것이라고 보장할 수 없기 때문에 1번은 택하지 않게 되었다.

2, 3번 중 고민했는데 좀 더 성능적으로 유리한 3번 방법으로 문제를 해결했다.

 

 

각 해결 방법에 대한 정리

해결 방법 정리를 하며 예제 코드를 정리하기 위해 Entity 클래스와 Table 구조를 먼저 정리.

public class Board {
    private Long id;
    private String title;
    private String content;
    private String userId;
}

 

CREATE TABLE board (
    id NUMBER GENERATED BY DEFAULT AS IDENTITY
                START WITH 1
                INCREMENT BY 1
                NOCACHE
                NOCYCLE
                NOMAXVALUE
                PRIMARY KEY,
    title VARCHAR2(100),
    content CLOB,
    userId VARCHAR2(50)
);

 

우선 IDENTITY에 대해 조금 정리.

IDNETITY는 MySQL에서의 auto_increment와 유사하게 동작한다.

IDENTITY는 12c 부터 도입된 기능이다. 기존에는 Sequence를 따로 정의하고 SQL에서 SEQ.nextval을 명시해야 사용할 수 있었던 반면, MySQL의 auto_increment처럼 명시하지 않아도 자동 증가를 처리할 수 있다.

기본적으로는 Sequence가 생성되고 Oracle에서 해당 Sequence를 통해 자동으로 처리해주는 개념이기 때문에 IDENTITY가 Sequence와 완전 다른 개념이라고 보기에는 어렵다고 생각한다. 편의성정도..?

IDENTITY의 옵션 설정에 대해서는 Sequence와 동일한 옵션을 갖는다.

차이로는 AS IDENTITY 이전에 DEFAULT, DEFAULT ON NULL, ALWAYS라는 옵션을 설정할 수 있다.

크게 나누면 DEFAULT, ALWAYS로 나눠볼 수 있는데 ALWAYS는 SQL에 값을 명시하더라도 Sequence의 값을 따른다.

그러나 DEFAULT는 값을 SQL에 직접 명시한다면 Sequence의 값이 아닌 해당 값을 따른다.

즉, ALWAYS 설정에서 id에 2를 넣도록 SQL을 작성하더라도 Sequence의 값이 1이라면 1이 들어가게 되고 DEFAULT는 2가 들어가게 된다.

DEFAULT ON NULL의 경우는 NULL로 명시하는 경우 Sequence의 값으로 처리되는 방법이다.

그래서 DEFAULT로 설정하는 경우 NULL로 명시했을 때 NULL을 그대로 저장해주지만 DEFAULT ON NULL을 설정하게 되면 NULL을 넣고 싶어도 Sequence 값이 들어가기 때문에 담을 수 없게 된다.

 

IDENTITY에 대한 주의 사항으로는 생성 이후 수정이 불가능하다는 점이다.

이것 때문에 발생한 문제도 있었는데 기존 MySQL에 담아두었던 더미데이터를 옮기는 과정에서 문제가 있었다.

연관관계 설정이 되어있기 때문에 IDENTITY가 동작하지 않고 그대로 데이터를 담도록 처리했다.

하지만, IDENTITY의 START WITH 값을 그대로 1로 두었고 이걸 수정할 수 없다보니 이후 처리에 대해서 계속 1만 가져오니 오류가 발생했다.

그래서 더미데이터를 최초 삽입하는 경우 IDENTITY를 통해 처리하도록 하면 간단하지만, 기존 더미데이터를 옮기는 경우에는 IDENTITY의 START WITH값을 더미데이터 이후의 값으로 설정해야 한다.

 

 

1. 서버에서 반복문을 통해 mapper 호출

 

서버에서 반복문을 통해 mapper를 호출하는 방법은 간단하기 때문에 설명없이 코드만 정리한다.

@Override
public void insertBoard(List<Board> boardList) {
    for(Board board : boardList)
    	mapper.saveEntity(board);
}
<insert id="saveEntity" parameterType="Board">
    INSERT INTO board(title, content, userId)
    VALUES (#{title}, #{content}, #{userId})
</insert>

 

 

2. PL/SQL을 xml에 직접 작성해서 처리

@Override
public void insertBoard(List<Board> boardList) {
    mapper.saveEntity(boardList);
}
<insert id="saveEntity" parameterType="java.util.List">
    BEGIN
    <foreach collection="list" item="board" seperator=";">
        INSERT INTO board(titie, content, userId)
        VALUES (#{board.title}, #{board.content}, #{board.userId})
    </foreach>
    END;
</insert>

 

PL/SQL을 직접 작성하는 경우 BEGIN 과 END; 사이에 foreach를 통해 list를 반복하게 처리해주면 된다.

 

3. Procedure 정의 후 서버에서 Procedure 호출

 

이 방법은 위 두 방법에 비해서 조금 더 설정해야 하는 부분들이 존재한다.

우선 데이터베이스에서 Procedure를 정의해야 한다. 그리고 처리하는 방법에 따라 Type을 정의해야 하기도 한다.

이때 Type은 Oracle 사용자 정의 타입(User-Defined Type, UDT)을 말한다.

좀 더 가볍게 정리하면 Oracle의 Type은 Java에서 Class와 비슷한 역할을 한다고 생각하면 편하다.

그래서 조금 여러 방식의 요청을 정리한다.

 

1. 클래스 리스트를 매개변수로 받아 처리하는 방법

2. 클래스 리스트와 단일 객체를 받아 처리하는 방법

3. 클래스가 아닌 객체 리스트를 받아 처리하는 방법

 

모두 비슷하지만 정의할 때 타입에 대한 처리와 같은 부분에서 차이가 조금씩 발생한다.

 

3-1. 클래스 리스트를 매개변수로 받아 처리하는 방법

매개변수인 클래스 리스트는 Entity List라고 가정하고 정리한다.

우선적으로 데이터베이스에서 Procedure를 정의해야 하는데 클래스를 매개변수로 처리하는 경우 Type을 먼저 정의한 후에 Procedure를 정의하면 된다.

CREATE OR REPLACE TYPE BOARD_OBJ AS OBJECT(
    title VARCHAR2(100),
    content CLOB,
    userId VARCHAR2(50)
);

CREATE OR REPLACE TYPE BOARD_OBJ_LIST AS TABLE OF BOARD_OBJ;

CREATE OR REPLACE PROCEDURE insert_board(
    board_data IN BOARD_OBJ_LIST
) IS
BEGIN
    FOR i IN 1..board_data.COUNT LOOP
        INSERT INTO board(title, content, userId)
        VALUES(board_data(i).title
            , board_data(i).content
            , board_data(i).userId
        )
    END LOOP;
END insert_board;

 

가장 먼저 정의된 BOARD_OBJ는 매개변수로 받을 클래스의 필드들을 작성해주면 된다.

그리고 이 BOARD_OBJ라는 Type은 자바에서 Class를 정의한 것과 유사하다고 했다.

BOARD_OBJ_LIST는 BOARD_OBJ들을 담고 있는 배열이다.

자바 코드로 정리해보면 아래와 같은 개념이다.

public class Board_obj {
    private String title;
    private String content;
    private String userId;
}

Board_obj[] board_obj_list;

 

그리고 이 Type들을 통해 서버에서 Oracle에서 이해할 수 있도록 변환해 Procedure 매개변수에 매핑을 하게 된다.

그래서 Procedure 코드를 보면 BOARD_OBJ_LIST 타입으로 매개변수가 선언되어있다.

서버에서 List<Board> 를 BOARD_OBJ_LIST로 변환해서 요청하게 될 것이고 Procedure에서는 해당 타입의 데이터를 받아 board_data라는 변수를 통해 받게 된다.

그리고 이후 LOOP를 통해 insert 를 여러번 처리하게 되는 것이다.

그럼 Sequence가 정상적으로 동작한다.

왜냐하면 INSERT ALL에서 Sequence가 정상적으로 증가되지 않는 것이 문제인데 여기에서는 LOOP를 통해 개별적인 INSERT 요청을 처리하는 것이 되기 때문이다.

 

여기서 깊게 생각 안하고 겉으로만 본다면 Procedure에서도 LOOP를 통해 insert를 여러번 반복하네? 라고 볼 수도 있지만,

초반에 정리했듯이 mapper를 여러번 호출하면 connection을 계속 요청해야 한다는 문제가 있지만, 이건 한번의 connection으로 Procedure를 호출하고 procedure 내부에서 여러번 반복을 처리하는 경우다. 또한, Procedure 동작이 데이터베이스에서 이루어지기 때문에 더 효율적으로 처리할 수 있게 된다.

 

데이터베이스에서는 여기까지 정의하면 끝이다.

 

이후는 서버에서 처리하는 방법에 대해 정리한다.

우선 Java Class 리스트를 Oracle에서 그대로 받을 수 없기 떄문에 Oracle에서 받을 수 있는 타입으로 변환이 필요하다.

그래서 TypeHandler를 정의해야 한다.

MyBatis에서는 일반적인 SQL 타입인 VARCHAR, INTEGER 등의 단일 객체 타입은 자동으로 변환해서 Oracle에 전달하지만 Class 객체나 리스트같은 경우는 알아서 변환하지 못하기 때문에 데이터베이스에서도 Type을 정의해야 하고 서버에서는 TypeHandler를 통해 변환한 뒤 넘겨줘야 한다.

 

import oracle.jdbc.OracleArray;
import oracle.jdbc.OracleConnection;
import oracle.jdbc.OracleStruct;
import org.apache.ibatis.type.JdbcType;
import org.apache.ibatis.type.TypeHandler;
import com.example.domain.entity.Board;

import java.sql.*;
import java.util.List;
public class BoardTypeHandler implements TypeHandler<List<Board>> {
    @Override
    public void setParameter(PreparedStatement ps
                                        , int i
                                        , List<Board> parameter
                                        , JdbcType jdbcType) throws SQLException {
        if(parameter != null && !parameter.isEmpty()) {
            OracleConnection oracleConn = ps.getConnection()
                                            .unwrap(OracleConnection.class);
            OracleStruct[] structArray = new OracleStruct(parameter.size());
			
            for(int idx = 0; idx < parameter.size(); idx++) {
                Board board = parameter.get(idx);
				
                Object[] boardAttributes = {
                    board.getTitle(),
                    board.getContent(),
                    board.getUserId()
                };
				
                OracleStruct struct = (OracleStruct) oracleConn
                                           .createStruct("BOARD_OBJ", boardAttributes);
                structArray[idx] = struct;
            }
			
            OracleArray array = (OracleArray) oracleConn
                                     .createOracleArray("BOARD_OBJ_LIST", structArray);
            ps.setArray(i, array);
        }else
            ps.setNull(i, Types.ARRAY);
    }
    
    @Override
    public List<Board> getResult(ResultSet rs, String columnName) throws SQLException {
        return null;
    }
    
    @Override
    public List<Board> getResult(ResultSet rs, int columnIdx) throws SQLException {
        return null;
    }
    
    @Override
    public List<Board> getResult(CallableStatement cs, int columnIdx) throws SQLException {
        return null;
    }
}

 

TypeHandler에 대한 코드를 조금 정리하고 넘어간다.

  • setParameter()
    • 매개변수 i는 parameter의 인덱스를 의미한다.
    • jdbcType은 여기서 사용되지 않았으나 JDBC 타입 정보다.
    • OracleConnection 객체는 Oracle의 고유한 타입인 Struct, Array를 다루기 위해 생성한다.
    • 반복문 내에서는 Board 리스트의 각 요소들을 OracleStruct 객체로 변환한 뒤 배열에 담아준다.
    • 반복문 종료 이후 반복문 내에서 생성한 배열로 OracleArray를 생성한다.
    • 생성된 OracleArray를 PreparedStatement의 파라미터로 생성한다.
  • getResult()
    • SQL 쿼리의 결과를 Java 객체로 변환하는 역할을 한다.
    • 예제에서는 return null만 처리하기 때문에 구현되지 않은 상태다.
    • String columnName을 받는 메소드는 컬럼 이름을 통해 ResultSet에서 값을 추출한다.
    • int columnIndex를 받는 메소드는 인덱스 값을 통해 ResultSet에서 값을 추출한다.
    • CallableStatement를 받는 메소드는 CallableStatement의 출력 파라미터에서 값을 추출한다.

setParameter 메소드 반복문 내부에 대해 조금 더 정리하면 oracleConn.createStruct()를 통해 데이터베이스에서 정의한 BOARD_OBJ라는 이름을 가진 Struct타입으로 boardAttributes를 변환하는 것이다.

이후 createOracleArray()에서도 역시 BOARD_OBJ_LIST라는 이름을 가진 OracleArray를 생성하는데 그 안에 Struct 배열을 담도록 처리하는 과정이다.

이렇게 변환을 거쳐 Oracle에 정의한 Procedure에서 정상적으로 매개변수를 받을 수 있게 된다.

 

getResult()의 경우 아무것도 작성하지 않았는데 프로젝트 진행중에서도 필요하지 않은 처리였고 해서 사용해보진 않았다.

다중 insert를 위해 사용하기 때문에 따로 결과값을 반환 받을 것도 없었다.

이 getResult 메소드에 대해 알아봤을 때 JDBCTemplate에서 ResultSet을 통해 결과를 받아보듯이 사용하는 것을 확인할 수 있었다.

 

그럼 이제 서비스에서 Mapper를 호출할 때 데이터를 어떻게 담아야 하고, xml에서는 어떻게 데이터를 넘기는 프로시저 호출을 작성해야 하는지 정리.

환경은 serviceImpl - MapperInterface - mapper.xml 구조다.

 

@Service
@RequiredArgsConstructor
public class BoardServiceImpl implements BoardService {
    private final BoardMapper mapper;
    
    @Override
    public void insertBoard(List<Board> boardList) {
        Map<String, Object> params = new HashMap<>();
        params.put("boards", boardList);
        mapper.saveAll(params);
    }
}

public interface BoardMapper {
    void saveAll(Map<String, Object> boards);
}
<insert id="saveAll" parameterType="map">
    {CALL insert_board(
        #{boards, jdbcType=ARRAY, jdbcTypeName="BOARD_OBJ_LIST",
                typeHandler=com.example.mapper.typeHandler.BoardTypeHandler}
    )}
</insert>

 

서비스단에서는 해당 List를 Map<String, Object> 타입의 Map에 담아 Mapper를 호출한다.

그리고 xml에서는 받은 매개변수명, jdbcType, jdbcTypeName, typeHandler를 작성한다.

jdbcType은 현재 배열을 넘기는 것이기 때문에 ARRAY로 정의한다.

jdbcTypeName은 데이터베이스에서 정의한 컬렉션 Type의 이름을 정의한다.

typeHandler는 해당 데이터를 변환할 TypeHandler 경로를 작성해주면 된다.

그럼 TypeHandler를 통해 데이터를 변환한 뒤에 Procedure를 호출해 정상적으로 처리할 수 있게 된다.

 

3-2. 클래스 리스트와 단일 객체를 받아 처리하는 방법

다음은 클래스 리스트와 단일 객체를 넘기는 방법이다.

그래서 title, content만 필드로 갖는 BoardDTO라는 클래스가 존재한다고 가정하고, userId는 String으로 넘겨 insert 처리 시 모든 데이터가 동일한 userId를 갖도록 한다.

 

이 경우는 1번의 클래스 리스트를 넘기는 방법과 크게 다르지 않다.

Type 정의 시 userId는 단일 객체로 받을 것이기 때문에 포함하지 않는다.

그리고 Procedure의 매개변수에 userId를 받는 매개변수를 추가해주면 된다.

 

CREATE OR REPLACE TYPE BOARD_OBJ AS OBJECT(
    title VARCHAR2(100),
    content CLOB
);

CREATE OR REPLACE TYPE BOARD_OBJ_LIST AS TABLE OF BOARD_OBJ;

CREATE OR REPLACE PROCEDURE insert_board(
    user_id IN VARCHAR2,
    board_data IN BOARD_OBJ_LIST
) IS
BEGIN
    FOR i IN 1..board_data.COUNT LOOP
        INSERT INTO board(title, content, userId)
        VALUES(board_data(i).title
            , board_data(i).content
            , user_id
        )
    END LOOP;
END insert_board;

 

이정도만 간단하게 수정된다.

Type 정의에서 userId를 제외하고, Procedure 에서는 userId를 VARCHAR2 타입으로 받도록 해 문자열을 받을 수 있도록 한다.

이때 만약 userId가 Integer, Long 과 같은 타입이라면 NUMBER 타입으로 받도록 처리하면 된다.

 

그럼 TypeHandler에서 역시 boardAttributes 정의 시 userId를 제외하도록 하기만 하면 나머지는 동일하다.

import oracle.jdbc.OracleArray;
import oracle.jdbc.OracleConnection;
import oracle.jdbc.OracleStruct;
import org.apache.ibatis.type.JdbcType;
import org.apache.ibatis.type.TypeHandler;
import com.example.domain.entity.Board;

import java.sql.*;
import java.util.List;
public class BoardDTOTypeHandler implements TypeHandler<List<BoardDTO>> {
    @Override
    public void setParameter(PreparedStatement ps
                                        , int i
                                        , List<BoardDTO> parameter
                                        , JdbcType jdbcType) throws SQLException {
        if(parameter != null && !parameter.isEmpty()) {
            OracleConnection oracleConn = ps.getConnection()
                                            .unwrap(OracleConnection.class);
            OracleStruct[] structArray = new OracleStruct(parameter.size());
			
            for(int idx = 0; idx < parameter.size(); idx++) {
                BoardDTO board = parameter.get(idx);
				
                Object[] boardAttributes = {
                    board.getTitle(),
                    board.getContent()
                };
				
                OracleStruct struct = (OracleStruct) oracleConn
                                           .createStruct("BOARD_OBJ", boardAttributes);
                structArray[idx] = struct;
            }
			
            OracleArray array = (OracleArray) oracleConn
                                     .createOracleArray("BOARD_OBJ_LIST", structArray);
            ps.setArray(i, array);
        }else
            ps.setNull(i, Types.ARRAY);
    }
    
    @Override
    public List<BoardDTO> getResult(ResultSet rs, String columnName) throws SQLException {
        return null;
    }
    
    @Override
    public List<BoardDTO> getResult(ResultSet rs, int columnIdx) throws SQLException {
        return null;
    }
    
    @Override
    public List<BoardDTO> getResult(CallableStatement cs, int columnIdx) throws SQLException {
        return null;
    }
}

 

서비스와 xml 에서의 처리는 아래와 같이 하면 된다.

@Service
@RequiredArgsConstructor
public class BoardServiceImpl implements BoardService {
    private final BoardMapper mapper;
    
    @Override
    public void insertBoard(List<BoardDTO> boardList, String userId) {
        Map<String, Object> params = new HashMap<>();
        params.put("boards", boardList);
        params.put("userId", userId);
        mapper.saveAll(params);
    }
}

public interface BoardMapper {
    void saveAll(Map<String, Object> boards);
}
<insert id="saveAll" parameterType="map">
    {CALL insert_board(
        #{userId, jdbcType=VARCHAR},
        #{boards, jdbcType=ARRAY, jdbcTypeName="BOARD_OBJ_LIST"
                , typeHandler=com.example.mapper.typeHandler.BoardDTOTypeHandler}
    )}
</insert>

 

처리는 의외로 간단하다.

서비스에서는 Map에 userId를 추가로 담아주면 된다.

xml에서는 userId의 jdbcType을 VARCHAR로 처리해주기만 하면 된다.

여기서 만약 userId가 Integer, Long 타입인 경우에는 NUMERIC으로 명시해주면 된다.

 

3-3. 클래스가 아닌 객체 리스트를 받아 처리하는 방법

이번에는 클래스를 전혀 넘기지 않고 각 객체들을 넘기는 방법이다.

조금 여러 케이스를 정리하기 위해 Board 필드 객체 타입에 살짝 억지를 부려서

List<Integer> title, List<String> content, List<Long> userId 라는 값들을 받는다고 가정한다.

 

그럼 여기서는 Procedure에 매개변수로 클래스를 넘기지 않는다.

그렇기 때문에 Type 정의를 해줄 필요가 없게 된다.

Procedure만 정의해주면 되는데 아래와 같이 정의한다.

CREATE OR REPLACE PROCEDURE insert_board(
    board_title IN SYS.ODCINUMBERLIST,
    board_content IN SYS.ODCIVARCHAR2LIST,
    user_id IN SYS.ODCINUMBERLIST
) IS
BEGIN
    FOR i IN 1..board_title.COUNT LOOP
        INSERT INTO board(title, content, userId)
        VALUES(board_title(i), board_content(i), user_id(i))
    END LOOP;
END insert_board;

 

굳이 매개변수 타입을 조금 억지 부린 이유가 여기있다.

Integer, Long 타입인 title, userId의 경우 SYS.ODCINUMBERLIST 타입으로 선언하게 된다.

Java에서는 Integer, Long으로 나눠서 처리하지만 Oracle에서는 동일하게 NUMBER 타입이기 때문이다.

그리고 String의 경우 SYS.ODCIVARCHAR2LIST 타입으로 받아주게 된다.

이 타입들의 경우 Oracle의 내장컬렉션이기 때문에 Type 정의를 해줄 필요가 없다.

 

그리고 이렇게 클래스가 아닌 객체 리스트 역시 변환이 필요하다.

최초 Entity를 넘겨 처리하는 방법에서 정리했다시피 MyBatis에서는 List 역시 제대로 변환하지 못하기 때문에 TypeHandler를 정의해야 할 필요가 있다.

TypeHandler는 타입만 조금 바꿔주면 되는 정도로 유사하기 때문에 Integer 타입에 대한 Handler만 정리한다.

import oracle.jdbc.OracleConnection;
import org.apache.ibatis.type.JdbcType;
import org.apache.ibatis.type.TypeHandler;

import java.sql.*;
import java.util.List;

public class IntegerArrayTypeHandler implements TypeHandler<List<Integer>> {
    
    @Override
    public void setParameter(PreparedStatements ps
                            , int i
                            , List<Integer> parameter
                            , JdbcType jdbcType) throws SQLException {
        if(parameter != null && !parameter.isEmpty()) {
            OracleConnection oracleConn = ps.getConnection()
                                            .unwrap(OracleConnection.class);
            Integer[] arrayData = parameter.toArray(new Inteter[0]);
            Array oracleArray = oracleConn
                                .createOracleArray("SYS.ODCINUMBERLIST", arrayData);
            ps.setArray(i, oracleArray);
        }else
            ps.setNull(i, Types.ARRAY);
    }
    
    @Override
    public List<Integer> getResult(ResultSet rs
                                  , String columnName) throws SQLException {
        return null;
    }
    
    @Override
    public List<Integer> getResult(ResultSet rs
                                  , int columnIdx) throws SQLException {
        return null;
    }
    
    @Override
    public List<Integer> getResult(CallableStatement cs
                                  , int columnIdx) throws SQLException {
        return null;
    }
}

 

attributes 객체를 따로 생성해줄 필요가 없기 때문에 코드가 좀 더 간결하다.

다른 타입들의 TypeHandler와의 차이점으로는 타입 선언부분에서의 차이, createOracleArray에서 타입명의 차이 정도가 있다.

Long 타입의 경우 동일하게 SYS.ODCINUMBERLIST로 처리되지만 String의 경우 SYS.ODCIVARCHAR2LIST로 수정해주면 된다.

 

서비스와 Mapper는 아래와 같다.

@Service
@RequiredArgsConstructor
public class BoardServiceImpl implements BoardService {
    private final BoardMapper boardMapper;
    
    @Override
    public void insertBoard(List<Integer> titleList
                          , List<String> contentList
                          , List<Long> userIdList) {
        Map<String, Object> params = new HashMap<>();
        params.put("titleList", titleList);
        params.put("contentList", contentList);
        params.put("userIdList", userIdList);
        
        mapper.saveAll(params);
    }
}

public interface BoardMapper {
    void saveAll(Map<String, Object> boards);
}
<insert id="saveAll" parameterType="map">
    {CALL insert_board(
        #{titleList, jdbcType=ARRAY, jdbcTypeName="SYS.ODCINUMBERLIST",
                typeHandler=com.example.mapper.typeHandler.IntegerArrayTypeHandler},
        #{contentList, jdbcType=ARRAY, jdbcTypeName="SYS.ODCIVARCHAR2LIST",
                typeHandler=com.example.mapper.typeHandler.StringArrayTypeHandler},
        #{userIdList, jdbcType=ARRAY, jdbcTypeName="SYS.ODCINUMBERLIST",
                typeHandler=com.example.mapper.typeHandler.LongArrayTypeHandler}
    )}
</insert>

 

Procedure를 호출해 사용하는 방법들은 이렇게 여러가지 방법이 있다.

사실 여러가지 방법이라기 보다도 User-Defined Type을 어떤 경우 정의해야 하며, 어떻게 정의해야 하고 Procedure에서 매개변수 타입에 대한 설정을 어떻게 처리할지, TypeHandler는 언제 작성해야 하고 어떻게 처리해야 할지, MyBatis에서 호출할때는 어떻게 해야할지만 이해한다면 조합해서 사용하는 정도이다.

 

이번 문제해결을 하며 느낀점.

결과적으로 이 문제가 발생한 이유는 Sequence가 INSERT ALL에서는 정상적으로 동작하지 않는다는 데에서 시작한 것이다.

최초 프로젝트 수행시에는 11g를 사용했고 SEQ.nextval을 직접 작성해가며 처리했었다.

이번에 리펙토링하면서 18c로 업그레이드를 해줬고 IDENTITY가 자동 증가처리를 해준다길래  MySQL 설계와 동일하게 처리했더니 완전 폭탄 맞았다.

보통 id를 정수형으로 사용하는 이유는 검색 속도 때문이라고 알고 있다.

아무래도 문자열을 index로 갖고 검색하는 것 보다는 정수형이 정렬도 빠르기 때문이다.

 

그래서 이번에 리펙토링을 하면서 Oracle에서도 그렇게 써봐야지 했던건데 아무래도 일이 커졌다..

만약 성능에 영향이 덜하다면 굳이 정수형을 사용해서 IDENTITY를 설정하고 다중 insert를 이렇게 처리할 필요는 없지 않을까? 라는 생각이 들었다.

아무래도 Procedure를 사용하거나 PL/SQL을 직접 작성해 처리하도록 하면 복잡도도 늘어나고 관리해야 할 포인트들이 늘어난다.

그렇다고 서버에서 반복문을 통해 처리하자니 대량의 데이터를 처리해야 한다면 그건 그거대로 또 문제가 될 수 있다.

Oracle 환경에서는 성능에 큰 영향이 없고 앞으로의 운영 또는 확장에 대해서도 성능이 보장되는 경우에는 서버에서 직접 기본키를 생성하고 넘기는 형태가 더 유리하지 않을까 라는 생각이 들었다.

물론, 성능 측면에서는 관리 포인트가 늘어나더라도 조금이라도 더 좋은 성능을 낼 수 있는 방법을 택하는 것이 우선적이겠지만.

이번 문제 해결을 통해 데이터베이스 설계시 Sequence 역시 고려해야 할 포인트가 MySQL에 비해 더 많다는 것을 느낄 수 있었다.

 

Kotlin 프로젝트에서 build.gradle.kts를 열었을 때 모든 라인이 빨간 밑줄이 생긴 것에 대한 문제 해결입니다.

 

문제해결이 다 된 상태라 캡쳐를 하지 못한점은 아쉽지만 문제 해결과정에서 찾지 못한 방법이었고 어이없게 해결할 수 있었던 방법이기 때문에 뭘 해도 안된다 하시는 분들은 한번 확인 해보시는 것도 좋을 것 같습니다.

 

Unresolved reference: plugins

Cannot access script base class 'org.gradle.kotlin.dsl.KotlinBuildScript'. Check your module classpath for missing or conflicting dependencies

 

이런 오류가 발생.

IDE는 IntelliJ를 사용하고 있습니다.

이 오류를 해결하는 방법으로 대부분의 블로그에서는 File -> Invalidate caches를 눌러 Clear VCS Log caches and indexes와 Mark downloded shared indexes as exluded를 선택하고 INVALIDATE AND RESTART 버튼을 눌러 캐시 삭제와 재부팅을 수행하도록 권장하고 있습니다.

실제로 그렇게 해결하신 분들도 많은 것으로 보입니다.

 

하지만 이 방법으로 문제가 해결되지 않았고 직접 맥북의 /User/{username}/.gradle/caches를 직접 삭제하는 방법까지 수행해봤으나 아무런 변화도 없었습니다.

 

그래서 이리저리 방법을 알아보고 chatGPT에도 물어봤으나 이미 확인했던 비슷한 답변만 존재해서 답답하던 와중.

해당 프로젝트의 Application.kt 파일을 무심코 열어봤는데 상단에 오류가 떠있었습니다.

 

Kotlin runtime library and 4 other jars have an unsupported binary format

코틀린 런타임 라이브러리 및 4개 jar에 지원되지 않는 바이너리 형식이 있다는 내용.

 

그리고 옆에는 update plugin 버튼이 있었고 버튼을 눌러 update 해줬습니다.

여기서 kotlin Plugin을 설치하지 않은 상태도 아니었고 chatGPT가 내놓은 해답 중 하나가 kotlin plugin 설치를 확인하라는 것이었기 때문에 설치를 확인까지 했으나 업데이트를 확인하지 않은것이 문제였던 것 같습니다.

 

업데이트가 종료되고 나면 재부팅 안내 창이 뜰텐데 재부팅 해주고 다시 build.gradle.kts를 열어보면 그대로 빨간 밑줄이 있습니다.

다시 File -> Invalidate caches를 눌러 캐시 삭제 후 재부팅을 해주고 나니 빨간 밑줄이 사라지는 것을 확인할 수 있었습니다.

 

build.gradle.kts에 의존성을 추가하고 build 했을 때 build 탭에는 SUCCESS로 나오긴 하지만 

 

> Task :prepareKotlinBuildScriptModel UP-TO-DATE

Deprecated Gradle features were used in this build, making it incompatible with Gradle 9.0.

You can use '--warning-mode all' to show the individual deprecation warnings and determine if they come from your own scripts or plugins.

For more on this, please refer to https://docs.gradle.org/8.8/userguide/command_line_interface.html#sec:command_line_warnings in the Gradle documentation.

BUILD SUCCESSFUL in 1s

 

이렇게 gradle 버전이 문제라는 말만 나오지 플러그인에 대한 내용이 전혀 없어서 버전 문제 혹은 설정 문제일 것이라고 생각했고 실제 문제 해결에 대한 방법을 찾았을 때도 캐시 또는 버전 문제였는데 설마 Kotlin 플러그인 업데이트 문제일 것이라고는 생각도 못했습니다.

이번 문제는 운빨로 정말 우연히 해결하게 되었는데 눈에 보이는 코드만이 아니라 좀 더 폭 넓게 확인하는 습관을 들여야겠습니다 ㅠㅠ

정리 목적

이번에 AWS에 배포 테스트를 하며 UI가 변경된 부분도 좀 있었고 이전에는 도메인 구매 후 접근 테스트만 해본 반면 이번에는 여러 환경을 설정하고 처리했기 때문에 이전 정리 내용을 지우고 새로 다시 정리.

 

처리 환경 정리

Project

  • BackEnd - Spring Boot 3
  • FrontEnd - React
  • 빌드 방식 - 통합빌드

AWS

  • EC2 t2.micro
    • ubuntu 22.0.4
  • Application Load Balancer
  • AWS Certificate Manager(ACM)
  • Route53
  • S3
  • RDS(MySQL)
  • ElastiCache(Redis)

CI / CD

  • Jenkins
  • Git Webhook

WebServer

  • Nginx

Domain Register

  • Gabia

 

정리 순서

  1. S3 Bucket 생성 후 Local에서 테스트
  2. RDS MySQL Database Instance 생성 후 Local Workbench와 연결 테스트 및 프로젝트 연결 테스트
  3. EC2 Instance 생성 후 설정
    1. swap 메모리 설정
    2. JDK 17 설치
    3. nvm 설치 및 node 21.7.1 설치
    4. Nginx 설치 및 설정
    5. Jenkins 설치 및 빌드 Job 설정을 제외한 나머지 처리
    6. ElasiCache Redis OSS Cache 생성 및 Redis 설치
  4. Gabia 도메인 구매 후 Route53, ACM 처리
  5. Load Balancer 대상 그룹 생성 및 ALB 생성 후 대상 그룹 설정
  6. A레코드 생성 및 도메인 접근 테스트
  7. git clone을 통해 사전 테스트 이후 Jenkins Job 설정과 Build 테스트
  8. Git Webhook 연동 및 테스트

Git webhook 연동

이제 배포 테스트 과정 중 마지막이다.

webhook을 연동해두면 로컬에서 git에 push 했을 때 이 push 이벤트를 Jenkins에 전달하고 이벤트를 전달받은 Jenkins가 자동으로 해당 Job 설정대로 빌드와 배포를 처리해준다.

 

이 연동을 위해 github에서 personal access token을 발급 받아야 한다.

github 페이지에서 오른쪽 상단에 있는 자신의 프로필을 눌러 Settings로 이동한다.

 

그리고 최하단의 Developer settings로 이동

 

Personal access tokens 하위의 Tokens(classic)을 클릭

 

Generate new token을 눌러 새 토큰을 생성한다.

 

Note에는 토큰명을 입력해주면 되고 아래 이미지와 같이 체크해준다.

webhook에 필요한 포인트는 가장 위의 repo 하위 선택지와 admin:repo_hook의 하위 선택지이다.

다 체크했다면 토큰 생성!

토큰을 생성하게 되면 토큰 값이 바로 나오는데 이건 이때 아니면 확인할 수 없기 때문에 바로 복사해서 어딘가에 저장해둬야 한다.

 

webhook을 연동하기 위해 연동할 프로젝트 repository 페이지로 이동해서 상단의 Settings로 이동한다.

 

그리고 왼쪽 탭에서 webhooks로 이동

 

Add webhook 버튼을 클릭해준다.

 

Payload URL에는 자신의 jenkins접속 IP와 포트 번호 /github-webhook/을 입력해준다.

이때 꼭 끝에 / 가 붙어있어야 한다.

Content type은 application/json으로 변경하고 Add webhook 버튼을 클릭한다.

 

그럼 이제 jenkins 페이지로 이동한다.

jenkins 관리 탭에서 Security 하위의 Credentials로 이동한다.

 

Stores scoped to Jenkins에서 system의 Domains인 global을 클릭한다.

Add Credentials를 클릭

Kind에는 Username with password를 선택.

Username은 git username을 적어주고 password에는 생성한 personal access token을 입력해준다.

그리고 create!

 

다시 Jenkins로 돌아와 system으로 이동한다.

 

GitHub 탭에서 Add GitHub Server를 눌러 추가해준다.

Name은 임의로 입력해도 되고, Credentials는 방금 생성한 Credential을 선택해주면 된다.

Manage hook를 체크해주고 Apply 이후 저장하면 된다.

저장 이전 Test connection으로 연결 상태를 체크할 수 있다.

 

Jenkins Job에서 설정해야 하는 것은 이전 포스팅에서 끝났기 때문에 모든 설정이 끝났다.

이제 프로젝트에서 README 같은것을 조금 수정하고 push 했을 때 Jenkins 페이지에서 Build History에 빌드가 추가된다면 연동은 성공이다!

빌드 시작까지 확인했다면 처리 이후 브라우저에서 도메인 접근까지 체크한다면 모든 배포 테스트 끝!!

정리 목적

이번에 AWS에 배포 테스트를 하며 UI가 변경된 부분도 좀 있었고 이전에는 도메인 구매 후 접근 테스트만 해본 반면 이번에는 여러 환경을 설정하고 처리했기 때문에 이전 정리 내용을 지우고 새로 다시 정리.

 

처리 환경 정리

Project

  • BackEnd - Spring Boot 3
  • FrontEnd - React
  • 빌드 방식 - 통합빌드

AWS

  • EC2 t2.micro
    • ubuntu 22.0.4
  • Application Load Balancer
  • AWS Certificate Manager(ACM)
  • Route53
  • S3
  • RDS(MySQL)
  • ElastiCache(Redis)

CI / CD

  • Jenkins
  • Git Webhook

WebServer

  • Nginx

Domain Register

  • Gabia

 

정리 순서

  1. S3 Bucket 생성 후 Local에서 테스트
  2. RDS MySQL Database Instance 생성 후 Local Workbench와 연결 테스트 및 프로젝트 연결 테스트
  3. EC2 Instance 생성 후 설정
    1. swap 메모리 설정
    2. JDK 17 설치
    3. nvm 설치 및 node 21.7.1 설치
    4. Nginx 설치 및 설정
    5. Jenkins 설치 및 빌드 Job 설정을 제외한 나머지 처리
    6. ElasiCache Redis OSS Cache 생성 및 Redis 설치
  4. Gabia 도메인 구매 후 Route53, ACM 처리
  5. Load Balancer 대상 그룹 생성 및 ALB 생성 후 대상 그룹 설정
  6. A레코드 생성 및 도메인 접근 테스트
  7. git clone을 통해 사전 테스트 이후 Jenkins Job 설정과 Build 테스트
  8. Git Webhook 연동 및 테스트

git clone 후 서버 실행 및 테스트

Jenkins를 통해 CI / CD 를 수행하기 전에 프로젝트가 정상적으로 배포되는지 확인하기 위해 테스트했다.

 

EC2에서 프로젝트를 clone 받았다.

# root 위치에서 디렉토리 생성
sudo mkdir testBuild

# 이동
cd testBuild

# git clone
git clone https://github.com/~

 

그리고 바로 프로젝트 빌드 후 실행했다.

# 프로젝트 경로에서 명령어 실행
./gradlew build

# jar 파일 실행
java -jar build/libs/project.jar

 

이렇게 실행 후 도메인으로 접근했을 때 정상적인 결과를 얻을 수 있었다.

 

Jenkins Provide Configuration files 사용을 위한 처리

이걸 뭐라고 불러야 할지 몰라서 부제를 이렇게 정하긴 했다.

이후 Jenkins Job 설정을 하면서 Provide Configuration files라는 기능을 체크해 사용할 것이다.

이 기능은 빌드 환경에서 선택할 수 있는데 Spring을 기준으로 application.yml 같은 설정 파일은 공개가 되면 안되는 내용들이 작성되기도 하므로 git에 올라가지 않도록 ignore에 걸어둔다.

그럼 단순하게 application.yml 하나만 ignore에 걸어두었다고 가정하고 이걸 처리하기 위해서는 두가지 방법이 있었다.

첫번째 방법은 EC2에 shell script를 작성해두고 Jenkins Job 설정 시 빌드 과정에 해당 스크립트를 실행하도록 해 application.yml 파일을 추가하는 방법.

두번째 방법은 Provide Configuration files를 체크해 Jenkins에서 작성해둔 application.yml 파일을 생성하도록 하는 방법.

 

두 방법 모두 사용해봤는데 개인적으로는 Provide Configuration files를 통해 처리하는게 더 쉬웠던 것 같다.

하지만 이것도 Job 설정 시 어떻게 처리하느냐에 따라 장단점이 존재할 것 같다.

 

일단 shell script 를 통해 처리하는 방법.

echo "application.yml 내용" > /var/lib/jenkins/project/src/main/resources/application.yml

 

이런식의 내용을 EC2 내부에서 000.sh 이런 식의 파일을 생성해준다.

그리고 Job 설정 중 Build Step에서 빌드 과정에 대한 스크립트를 작성하면서 해당 스크립트를 실행하는 명령어를 사용해주면 된다.

 

Provide Configuration Files를 처리하는 방법.

이 방법을 사용하기 위해서는 어떤 내용의 파일을 생성할 것인지 Jenkins에 설정해 둬야 한다.

 

Jenkins 관리 탭으로 이동해 System Configuration 하위의 Managed files를 클릭한다.

 

그럼 아래와 같은 페이지로 접근하게 되는데 Add a new Config를 눌러 추가해준다.

아래 이미지는 이미 추가해둔 파일들이고 아마 최초 접근 시에는 아무것도 없을 것이다.

 

여기서 생성할 타입을 선택하고 next로 넘어간다.

yml 또는 properties는 Properties file을 선택하면 되고 React의 .env같은 파일은 Custom file을 선택하면 된다.

ID는 자동 생성이기 때문에 그냥 Next!

 

여기에서는 Name과 Comment, Content를 작성한다.

Name은 파일의 이름을 작성해주면 된다. 이 Name이 그대로 파일명이 되는 것은 아니다.

Comment는 설명 정도.

Content에는 파일의 내용을 작성해주면 된다.

 

이렇게 필요한 Configuration file 들을 작성해 생성해주면 된다.

이걸 적용하는건 아래 Job 설정에서 마저 정리한다.

Jenkins Job 설정 및 Build 테스트

git clone을 통한 사전 배포 테스트가 정상적으로 수행되었기 때문에 Jenkins를 마저 설정하고 build 테스트까지 정리한다.

Jenkins 페이지에서 Job 을 새로 작성하기 위해 새로운 Item 버튼을 눌러 생성한다.

 

그럼 아래와 같은 페이지가 나온다.

Item name을 원하는 이름으로 작성해주고 Freestyle project를 선택한 뒤 OK 버튼을 클릭.

이때 Item name은 EC2 내에서 프로젝트 경로명이 되므로 너무 복잡하게 설정하면 나중에 피곤할 것 같다.

 

소스 코드 관리 탭에서는 Git에 올라가있는 프로젝트를 대상으로 할 것이기 때문에 GIt을 선택해준다.

Repository URL은 해당 프로젝트의 git url을 적어준다.

그리고 Credentials는 git 계정에 대한 정보인데 처음 생성하면 없을 것이므로 + Add 버튼을 눌러 생성해준다.

 

Add 버튼을 눌렀을 때 아래와 같은 창이 뜰 것이다.

git username과 비밀번호를 입력하고 Add 버튼을 눌러준다.

git에서는 personal Token을 사용하고 있어 해당 토큰 값을 비밀번호로 넣어줬다.

그러고보니 웹에서 로그인할 때 사용하는 비밀번호로는 테스트를 안해봤는데..

이후 webhook 연동할 때도 personal Token을 사용하기 때문에 없다면 다음 포스팅에서 git personal token 생성하는 부분을 먼저 보고 생성한 뒤 사용하시는 것이 좋을 것 같습니다.

 

Add 버튼을 눌러 Credentials를 생성하고 나면 생성된 Credentials를 선택할 수 있으니 선택해준다.

 

빌드 유발탭에서는 GitHub hook trigger for GITScm polling을 선택해준다.

다음 포스팅에서 git webhook과 연동할때 사용되는 건데 그때 수정하지 않기 위해 미리 체크한다.

git webhook 연동까지 처리하지 않을 것이라면 체크하지 않아도 된다.

 

빌드 환경 탭에서는 Provide Configuration files와 Provide Node & npm bin/folder to PATH를 체크한다.

Provide Node & npm bin/ folder to PATH의 경우 NodeJS가 필요하다면 체크하는 것이므로 자신의 환경에 NodeJS가 사용되지 않는다면 체크할 필요가 없다.

체크한다면 초반 Jenkins plugin 설치 이후 Tools의 NodeJS Installation에서 설정해뒀던 정보가 알아서 설정되어있기 때문에 따로 건드릴건 없다.

 

Configuration file에 대한 설정을 해두었다면 체크했을 때 아래와 같은 탭이 생성될 것이고 File에서는 생성한 파일들의 목록이 나올 것이다.

Target에는 경로를 설정해준다.

기본 시작 경로는 프로젝트 루트 경로이다.

그렇기 때문에 프로젝트 내부에서 src/main/resources 안에 위치하는 application.yml은 아래 이미지 처럼 작성해준다.

이때 파일명까지 작성해줘야 한다.

이 처리를 필요한 파일의 개수만큼 처리해주면 된다.

 

 

다음으로는 BuildSteps다.

Add build step 버튼을 누르면 여러 설정 중 선택해 처리할 수 있다.

여기에서도 두가지 선택지로 간단하게 처리할 수 있다.

가장 먼저 매우 간단한 Invoke Gradle script다.

빌드 과정에서 별다른 스크립트가 필요하지 않고 빌드만 하는 것으로 처리가 된다면 Invode Gradle script를 선택하는 것이 편하다.

 

아래와 같은 탭이 생길텐데 Use Gradle Wrapper를 선택하고 Make gradlew executable을 체크.

Wrapper location 에는 ${workspace}라고 작성하면 알아서 프로젝트 경로를 잡아준다.

Tasks에는 build 라고 작성해주는 것만으로 ./gradlew build 까지 수행하게 된다.

 

하지만 스크립트를 작성해 처리해야 할 것이 필요하다면 Execute shell을 선택해 처리한다.

이전에는 Spring, thymeleaf로 작성된 프로젝트를 배포해 Invoke Gradle script를 통해 간단히 처리할 수 있었으나 오류가 발생했기 때문에 Execute shell을 통해 처리했다.

 

프로젝트는 Spring Boot와 React의 통합 빌드를 처리하기 위해 build.gradle에 스크립트가 작성되어있다.

그래서 Invoke Gradle script 설정으로 간단하게 처리될 줄 알았는데 오산이었다.

install React FAILED 오류가 대체적으로 발생하였고 이 오류에 대한 해결방안으로 npm cache 초기화와 node_modules를 삭제하는 방법이 있었다.

 

그래서 빌드 수행 이전 해당 처리를 위해 아래 이미지처럼 스크립트를 작성했고 빌드를 수행하도록 처리했다.

 

추가적으로 통합 빌드 기준 이렇게 수행하더라도 Jenkins에서 React 경고를 오류로 인식해 빌드에 실패하는 경우가 있다.

그런 경우 npm run build 앞에 CI=false를 추가해 주면 되는데 이 문제에 대해서는 execute shell에 작성하는 것이 아닌 build.gradle에 작성해 문제를 해결했다.

 

마지막 Job 설정으로는 빌드 후 조치에 대한 설정을 해주면 끝난다.

빌드 후 조치로는 Post build task를 선택해준다.

빌드가 성공했다면 BUILD SUCCESSFUL이 찍히기 때문에 해당 로그가 찍혔을 때 스크립트가 동작하게 된다.

그래서 스크립트에는 jar 파일을 실행하도록 작성해둔다면 빌드에 성공했을 때 jar 파일을 실행해 서버가 배포된다.

 

여기까지 체크가 끝났다면 Apply 이후 저장!

 

그럼 Jenkins 페이지에서 해당 job으로 이동하고 지금 빌드 버튼을 눌렀을 때 Build History에 추가가 될 것이다.

성공한 경우 초록색 체크 표시가 뜨는데 jar 파일 실행까지 모두 작성한 경우에는 종료되지 않고 계속 돌아가고 있기 때문에 상태바가 거의 다 채워진 것 처럼 보일 때 도메인으로 접근해 서버가 동작하고 있는지 확인할 수 있다.

중단하기 위해서는 해당 히스토리의 오른쪽 상단 x 버튼을 누르면 서버가 중단된다.

+ Recent posts