int vs Integer 어떻게 사용할까?

나민혁·2024년 9월 16일

들어가며

Product 엔티티에 stock 필드인 재고를 추가하면서 자료형을 int로 주었다. 그랬더니 테스트를 통과하지 못했다 !

컨트롤러 테스트를 통과하지 못했다. 도대체 왜그럴까 ?

문제 상황

문제가 발생한 테스트 지점

왜 필수값인 곳에서만 테스트가 깨졌을까? 나는 분명히 경계값 테스트를 진행했기 때문에, 재고가 음수일때 예외를 던지게하고, 0일때 테스트가 통과되게 만들었다. 하지만 재고에 값을 넣지 않았을 때만 테스트가 깨졌다.

먼저 테스트코드를 살펴보자

@DisplayName("신규 상품을 등록 할 때 재고는 필수값이다.")
@Test
void createProductWithoutStock() throws Exception {
    ProductCreateRequest request = ProductCreateRequest.builder()
        .name("스타벅스 원두")
        .category("원두")
        .description("에티오피아산")
        .price(50000L)
        .build();

    mockMvc.perform(
            post("/api/v1/products")
                .content(objectMapper.writeValueAsString(request))
                .contentType(MediaType.APPLICATION_JSON)
        )
        .andDo(print())
        .andExpect(status().isBadRequest())
        .andExpect(jsonPath("$.code").value("400"))
        .andExpect(jsonPath("$.status").value("BAD_REQUEST"))
        .andExpect(jsonPath("$.message").value("재고는 필수입니다."))
        .andExpect(jsonPath("$.data").isEmpty());
}

나는 400과 함께 BAD_REQUEST 예외가 발생하기를 기대하고 있다.

이 때 테스트 결과를 한번 살펴보자

나는 400을 기대했으나 사실은 200이 온 것이다.
(이 때 Response는 201이나 응답은 200이다 이것은 내 공통응답 때문이다. 이것에 대한 내용은 공통응답 객체 vs ResponseEntity 포스팅에 담아두었다. )

그럼 생성하는 것 말고 수정하는 것도 한번 살펴보자

@DisplayName("상품 ID를 통해 상품정보를 변경 할 때 재고는 필수값이다.")
@Test
void updateProductWithoutStock() throws Exception {
    Long productId = 1L;

    ProductUpdateRequest request = ProductUpdateRequest.builder()
        .name("이디야 커피")
        .category("커피")
        .price(40000L)
        .description("국산")
        .build();

    mockMvc.perform(
            put("/api/v1/products/{id}", productId)
                .content(objectMapper.writeValueAsString(request))
                .contentType(MediaType.APPLICATION_JSON)
        )
        .andDo(print())
        .andExpect(status().isBadRequest())
        .andExpect(jsonPath("$.code").value("400"))
        .andExpect(jsonPath("$.status").value("BAD_REQUEST"))
        .andExpect(jsonPath("$.message").value("재고는 필수입니다."))
        .andExpect(jsonPath("$.data").isEmpty());
}

똑같은 문제이다 400을 기대했으나 200이 나왔다.

그러면 도대체 왜그런 것일까 ? Request DTO를 확인해보자

Request DTO

앞서서 두개가 같은 문제를 보이고 있으니 이제는 생성할 때만 다루도록 하겠다.

@Getter
@NoArgsConstructor
public class ProductCreateRequest {

    @Size(max = 20, message = "상품명은 20자 이하여야 합니다.")
    @NotNull(message = "상품명은 필수입니다.")
    private String name;

    @Size(max = 50, message = "카테고리는 50자 이하여야 합니다.")
    @NotNull(message = "카테고리는 필수입니다.")
    private String category;

    @Positive(message = "가격은 양수이어야 합니다.")
    @NotNull(message = "가격은 필수입니다.")
    private Long price;

    @Size(max = 500, message = "상품 설명은 500자 이하여야 합니다.")
    @NotNull(message = "상품 설명은 필수입니다.")
    private String description;

    @Min(value = 0, message = "재고는 0 이상이어야 합니다.")
    @NotNull(message = "재고는 필수입니다.")
    private int stock;

    @Builder
    private ProductCreateRequest(String name, String category, Long price, String description, int stock) {
        this.name = name;
        this.category = category;
        this.price = price;
        this.description = description;
        this.stock = stock;
    }

    public ProductCreateServiceRequest toServiceRequest() {
        return ProductCreateServiceRequest.builder()
            .name(name)
            .category(category)
            .price(price)
            .description(description)
            .stock(stock)
            .build();
    }
}

나는 stock에 @NotNull 어노테이션을 걸었다. 하지만 여기서 검증이 안되고 통과가 되고 있는 것 같다. 아마도 @Valid@NotNull을 잘못 이해해서 발생한 문제같다.

MockHttpServletRequest를 살펴보면 실제로 stock에 0이라는 값이 들어가고 있는 것을 확인 할 수 있다.

나는 분명히 @NotNull을 걸어두었는데 왜일까 ?

정수형은 그냥 @NotNull하면 되는거아니야 ?

NotBlank와 NotEmpty는 애초에 "", " "와 같이 정수가 들어갈 값이 아니다. 따라서 NotBlank와 NotEmpty 모두 받을 수 없다. 실제로 int 값에 String을 넣어서 postman으로 요청을 보내보자

String은 변환 될수 없다고 한다. 그리고 @NotEmpty의 검증에 걸리는 ""값과 @NotBlank의 검증에 걸리는 " " 모두 시도해보자

Validation이 전혀 안된채로 값이 들어가고 자동으로 stock의 값은 0이 되었다.

그러면 Integer로 하면 결과가 어떻게 될까 ?

모두 @NotNull 어노테이션의 결과이다. 제대로 검증이 이루어지고 있는 것을 볼 수 있다.

@Valid@NotNull,@NotEmpty,@NotBlank는 int 타입은 검증을 지원을 안한다. 하지만 지원을 안하는 근본적인 이유가 존재한다. 이유를 알아보기 전에 Validation에 대해서 어떨 때 사용해야 하는지 알아보자.

Validation @NotNull, @NotEmpty, @NotBlank

그렇다면 @Valid@NotNull, @NotEmpty, @NotBlank 는 각각 어떨 때 사용해야 할까 ? 한번 알아보자

@NotNull

NotNull은 값이 null이 아닌지 확인한다.

따라서, """ "을 허용하게 된다.

적용 가능한 타입으로는 모든 객체 타입 (예: String, Integer, List, Map, 사용자 정의 객체 등)이 가능하지만, 기본 자료형에는 적용할 수 없다. (int, long 등은 애초에 null 값을 가질 수 없기 때문)

@NotEmpty

NotEmpty는 null이 아니고, 길이가 0이 아닌지 확인한다. 즉, 빈 값("")은 허용되지 않는다.

따라서 " "에 대한 값만 허용한다.

적용 가능한 타입으로는 String, Collection (예: List, Set, Map 등), Array (배열) 에 사용 할 수 있다.

@NotBlank

NotBlank는 null이 아니고, 공백만 있는 값이 아닌지 확인합니다. 즉, null, 빈 값(""), 공백 문자열(" ")은 허용되지 않는다.
적용 가능한 타입으로는 오직 String만 가능하다.

int vs Integer

@NotNull의 설명에서 int값은 애초에 null값을 가질 수 없다고 했다. 그렇다면 int와 Integer는 어떤 차이를 가지고 있을까 ?

int

int는 우리가 흔히 쓰는 자료형이다.

int num = 10;

이런식으로 프로그래밍 기초때부터 배워왔을 것이다. java에서는 int를 Primitive Type 즉 원시타입이라고 정의한다.

int의 특징
- 산술 연산이 가능하다.

  • null로 초기화가 불가능하다.
  • 정수 값을 저장하는 데 4바이트가 필요하다.

Integer

integer에 대한 설명 wrapper 클래스 어쩌고

Integer는 Collection을 사용할 떄 많이 사용해왔을 것이다.

List<Integer> nums = new ArrayList<>();

Integer와 같은 타입을 Reference Type 참조 타입이라고 한다. 그리고 int형의 Wrapper Class이다.

Integer의 특징

  • 언박싱하지 않을 시 산술 연산이 불가하다.
  • null로 초기화가 가능하다.
  • Integer 클래스의 toBinaryString, toOctalString, toHexString 함수를 사용하면 각각 2진수,8진수 16진수로 변환할 수 있음(Integer에 저장된 정수 값을 직접 변환할 수 있음)

Primitive Type vs Reference Type

앞서 얘기한 원시타입과 참조타입은 무슨 말일까 ?

Primitive Type

원시 타입 은 정수, 실수, 문자, 논리 리터럴 등의 실제 데이터 값을 저장하는 타입이고,

int, long, double, float, boolean, byte, short, char 8개의 자료형을 말한다.

비객체 타입이다. 따라서 null 값을 가질 수 없습니다. 만약 Primitive type에 Null을 넣고 싶다면 Wrapper Class를 활용해야한다.

Java에서 기본 자료형은 반드시 사용하기 전에 선언(Declared) 되어야하며, 자료형의 길이는 운영체제에 독립적이며 변하지 않는다.

스택(stack) 메모리 에 저장된다.

Reference Type

원시 타입을 제외한 타입들을 말한다. 문자열, 배열, 열거형, 클래스 등 모두 참조타입이다.

빈 객체를 의미하는 Null이 존재한다.

즉, 참조 타입(Reference type 은 Java에서 최상위 클래스인 java.lang.Object 클래스를 상속하는 모든 클래스들을 말한다.

Java에서 실제 객체 는 힙(heap) 메모리에 저장되며 참조 타입 변수 는 스택 메모리 에 실제 객체들의 주소를 저장하여, 객체를 사용할때 마다 참조 변수에 저장된 객체의 주소를 불러와 사용하는 방식이다.

이후 Garbage Collector가 돌면서 메모리를 해제한다.

Heap 메모리에 생성된 인스턴스는 메소드나 각종 인터페이스에서 접근하기 위해 JVM의 Stack 영역에 존재하는 Frame에 참조값을 가지고 있어 이를 통해 인스턴스를 핸들링합니다.

Wrapper Class

그렇다면 Wrapper Class는 뭘까?

Wrapper Class는 참조타입의 일부이다. 단순하게 생각하면 말 그대로 원시 타입을 참조 타입으로 이용하기 위해 감싼 것이다. 그래서 int -> Integer, long -> Long과 같이 사용 할 수 있다.

원시타입을 제네릭으로 사용하기 위해서는 Wrapper Class를 이용해야한다.
아래와 같이 말이다.

List<int> nums = new ArrayList<>(); // X
List<Integer> nums = new ArrayList<>(); // O

jdk 1.5 버전부터 자바 컴파일러가 알아서 boxing과 unboxing을 해주고 있기 때문에 혼용해서 사용해도 문제가 없다. 하지만 어떨때 사용하는게 좋을 지 알아보는게 좋겠다.

언제 써야할까 ?

사실 설명만 보면 int값을 사용 할 필요가 없어보인다. Integer를 사용하면 null값을 저장 할 수 있고, 제네릭도 사용 할 수 있다. 그리고 어차피 jdk에서 auto boxing과 auto unboxing을 지원한다. 그렇다면 int를 써야 할 이유가 뭘까 ?

원시 타입은 스택 메모리에 존재하고, 참조 타입은 스택 메모리에는 참조 값만 있고, 실제 값은 힙 영역에 존재한다.

참조 타입은 최소 2번 메모리 접근을 해야하며, 일부 타입의 언박싱 과정을 거쳐야 하므로 원시 타입과 비교해 접근 속도가 느리다.

메모리의 양도 차이가 크다 단순히 int 는 4byte를 차지할 뿐이다.

결론

결과

나의 경우에는 Request에서만 Integer로 변경하고 다른 곳에서는 int로 사용하기로 했다.
이유는 Integer를 사용 할 때는 wrapper class 이므로 @NotNull과 같은 어노테이션을 통해 null check를 할 수 있다.
하지만 모든 곳에 Integer를 사용하면 메모리 저장공간을 많이 사용하게 된다. 그렇기 때문에 처음 입력이 들어오는 곳에서 null check이후에는 int값을 사용하여 메모리 이용을 최소화 하였다.

그리고 나서 테스트를 돌리니 당연하게도 모두 통과했다 !

느낀점

Java를 처음 배울 때 배웠던 내용이지만 어렴풋이는 알고 있었고, Integer로 바꾸면 될거같은 느낌적인 느낌이 있었다. 하지만 정확하게 알아보고 싶었다. @Valid와 함께 기초를 복습했다해야될까 ? 기초가 중요하다는 말이 정말정말 와닿았던 일이었다.

그리고 애초에 성능을 생각했으면 Java를 이용안했다 라는 말도 있지만, 개선할 수 있는 부분은 개선하는게 좋다라고 생각한다.

참고

[Java]int 와 Integer 의 차이

원시타입, 참조타입(Primitive Type, Reference Type)

Primitive type(원시타입) vs. Reference type(참조타입)

Primitive type & Reference type

[Java] int와 Integer는 뭐가 다를까?

[Spring] @NotNull, @NotEmpty, @NotBlank 에 대해

0개의 댓글