본문 바로가기

Programming/TroubleShooting

[SpringBoot/JPA] List<Integer> 를 서버로 전송하기 (@RequestBody)

 


List<Integer> 를 서버로 전송하기 (@RequestBody)


 

 

1. 문제 개요 (문제 발생 환경)


1.1 애플리케이션 유형

- Full-stack (Spring Boot + React)

 

1.2 백엔드 기술 스택

- Spring Boot 3.4.2

- JPA (MySQL 8.0)

- Gradle

 

1.3 프론트엔드 기술 스택

- React 18.2.0

 

1.4 배포 환경

- 로컬 개발 환경

 

1.5 OS

- window 11

 

1.6 IDE

- IntelliJ IDEA 2024.3, VS Code 1.98.2

 

1.7 JDK 버전

- OpenJDK 17

 

1.8 Node.js 버전

- 20.18.1

 

1.9 문제 발생 시점

- 프론트에서 musicList(List<Integer>) 에 대한 배열 정보를 param 으로 보내자

서버에서 Required request parameter 'musicIdList' for method parameter type List is not present] 오류 발생

 

 

 

2. 문제 상세 설명


2.1 문제 발생 과정

/* 개별곡 구매를 위한 장바구니 담기 */
async function insertCart(musicId){
    if(!loginUser){
        alert('로그인이 필요한 서비스입니다');
        navigate('/login');
    }else{
        addUniqueMusicId(musicId)
        try{
            const response = await jaxios.post('/api/cart/insertCart', null, {
            	params: {memberId: loginUser.memberId, musicIdList}
            });
        }catch(error){
            console.error('장바구니 담기 실패', error)
        }
    }
}

 

const response = await jaxios.post('/api/cart/insertCart', null, {params: {memberId: loginUser.memberId, musicIdList}});

 

프론트엔드에서 서버로 router 로 저장하도록 만든 로그인 유저 정보 중 아이디와 musicIdList 를 전달하는 과정에 발생했다. 두 파라미터를 param 으로 전달하려고 했다.

 

서버에서는 param 으로 전달했으니 @ReqestParam 으로 데이터를 받았다.

 

 

2.2 예상 동작

List 가 서버로 무사히 전달되면, List 속 musicId 값을 기반으로 music 정보를 추출하고, 해당 정보를 cart 엔티티에 담는 것을 기대했다.

 

 

2.3 실제 동작

그러나 실행은 커녕 서버에서 오류를 띄우며 프로그램이 동작을 멈추었다.

 

 

2.4 에러 메시지 및 로그

Required request parameter 'musicIdList' for method parameter type List is not present

 

해당 오류는 클라이언트에서 memberIdList 파라미터가 올바르게 전달되지 않았음을 뜻했다.

 

 

3. 원인 분석


3.1 관련 로그 및 분석 (해당 문제가 발생한 코드 또는 시스템 로그 분석 내용)

해당 오류는 보통 Spring 에서 @ReqeustParam 을 사용하면서 배열이나 리스트를 받을 때 발생하는 문제라고 한다.

 

/api/cart/insertCart?memberId=1&musicIdList=1&musicIdList=2&musicIdList=3

 

@RequestParam 으로 List 를 받으려면 위와 같은 쿼리스트링 형식으로 데이터를 하나하나 받아야 하는데

state 변수로 선언한 musicIdList 는 초기 값을 [] 로 선언해둔, musicIdList: [1,2,3] 형태였다.

 

따라서 변수와 데이터를 1:1 이 아닌 1:n 으로 전달하려고 했기 때문에 형식이 맞지 않아 서버에서 잘못된 값으로 판단한 것이다.

 

 

 

4. 해결 방안


4.1 적용된 해결 방법 (수정한 코드, 설정 변경, 패치 적용 등)

4.1.1 쿼리 파라미터

import qs from 'qs';

const response = await jaxios.post(
  '/api/cart/insertCart',
  null,
  {
    params: {
      memberId: loginUser.memberId,
      musicIdList: musicIdList,
    },
    paramsSerializer: (params) => qs.stringify(params, { arrayFormat: 'repeat' }),
  }
);

 

쿼리 파라미터로 데이터를 전달하기 위해서는 데이터 하나하나를 추출해 전달하는 paramSerializer 를 사용하는 방식이 있었다.

 

import qs from 'qs';

const response = await jaxios.post(
  '/api/cart/insertCart',
  null,
  {
    params: {
      memberId: loginUser.memberId,
      musicIdList: musicIdList,
    },
    paramsSerializer: (params) => qs.stringify(params, { arrayFormat: 'repeat' }),
  }
);

대충 이런 방식이라고 한다

 

그러나 해당 코드는 데이터를 2차 가공하는 방식이라 가독성도 좋지 않아 보이고,

이미 List 안에 데이터를 담는 작업을 진행한 상태며,

List 안에 담긴 데이터의 개수가 많아지면 쓸데 없는 연산 속도 증가 문제가 생길 것을 우려했다.

 

 

 4.1.2 DTO 이용해 @RequestBody 로 한 번에 받기💡

따라서 @RequestParam 방식을 포기하고, List 를 한 번에 전달받을 수 있는 @ReuqestBody 방식을 이용했다.

 

const response = await jaxios.post('/api/cart/insertCart', {memberId: loginUser.memberId, musicIdList});

 

프론트에서 데이터를 보내주는 방식을 param 에서 body 형태로 바꾸었다.

memberId 와 musicIdList 를 Body 에 담아 보내준다.

 

// DTO
@Getter
@Setter
public class CartRequestDto {
    private String memberId;
    private List<Integer> musicIdList;
}

 

우선 Cart 에 담기 위해 받아야 하는 정보인 memberId 와 musicIdList 배열을 저장할 수 있는 CartRequestDto 를 만들었다.

 

// Controller.java
/* 장바구니에 구매할 곡 넣기 */
@PostMapping("/insertCart")
public HashMap<String, Object> insertCart(
        @RequestBody CartRequestDto requestDto) {
    HashMap<String, Object> result = new HashMap<>();
    cs.insertCart(requestDto);
    return result;
}

 

Controller 에 @RequestParam 으로 memberId 와 musicIdList 데이터를 각각 받던 부분을 @RequestBody CartRequestDto 형태로 변경했다.

 

// Service.java
/* 장바구니에 구매할 곡 넣기 */
public void insertCart(CartRequestDto requestDto) {
    Member member = memberRepo.findByMemberId(requestDto.getMemberId());
    List<Integer> musicIdList = requestDto.getMusicIdList();

    for (Integer musicId : musicIdList) {
        Music music = musicRepo.findByMusicId(musicId);
        Optional<Cart> checkCart = cartRepo.findByMemberAndMusic(member, music);
        if (checkCart.isEmpty()) {
            Cart newCart = new Cart(); // Cart 객체를 반복문 안에서 새로 생성
            newCart.setMember(member);
            newCart.setMusic(music);
            cartRepo.save(newCart);
        }
    }
}

 

Service 계층에서는 Controller 에서 전달 받은 Dto 를 그대로 전달받아 데이터를 가공해주었다.

 

 

4.2 해결 후 확인 사항 (문제 해결 후 정상 동작 여부 확인)

4.2.1 new 객체 생성 위치에 따른 오류

/* 장바구니에 구매할 곡 넣기 */
public void insertCart(CartRequestDto requestDto) {
    Member member = memberRepo.findByMemberId(requestDto.getMemberId());
    Cart newCart = new Cart(); // newCart 객체 생성
    newCart.setMember(member);
    List<Integer> musicIdList = requestDto.getMusicIdList();

    for (Integer musicId : musicIdList) {
        Music music = musicRepo.findByMusicId(musicId);
        Optional<Cart> checkCart = cartRepo.findByMemberAndMusic(member, music);
        if (checkCart.isEmpty()) {
            newCart.setMusic(music); // 동일한 인스턴스 재사용
            cartRepo.save(newCart);
        }
    }
}

 

주제와는 다른 문제가 하나 발생했었는데, 전달한 List 의 마지막 music 데이터만 저장되는 에러가 있었다.

이는 new 객체의 생성 시기가 잘못되어 발생한 문제였다.

 

반복문 밖에서 newCart 객체를 생성하고, 반복문 안에서 해당 객체의 데이터만 바꿔서 저장하는 방식은

동일한 newCart 인스턴스로 인식되어 같은 인스턴스에 데이터만 덮어지다 마지막 music 만 저장되는 문제를 발생시켰다.

 

따라서 데이터를 레포지토리를 통해 저장할 때마다 새로운 Cart 객체를 생성했다.

 

 

4.2.2 성능 향상

cartRepo.save(newCart);

 

기존 코드는 List 속 데이터를 순회할 때마다 cartRepo.save() 호출을 했다.

매번 반복문마다 DB 와 통신하게 되니 성능 저하를 발생시켜 DB 통신을 한 번으로 단축하는 방법으로 진행했다.

 

// 한 번의 DB 호출로 일괄 저장
if (!cartsToSave.isEmpty()) {
    cartRepo.saveAll(cartsToSave);
}

New

 

List<Cart> 를 만들어 반복문으로 가공한 데이터를 저장하고, 추후 cartRepo.saveAll() 을 사용하여 일괄 저장했다.

 

/* 장바구니에 구매할 곡 넣기 */
public void insertCart(CartRequestDto requestDto) {
    Member member = memberRepo.findByMemberId(requestDto.getMemberId());
    List<Integer> musicIdList = requestDto.getMusicIdList();
    List<Cart> cartsToSave = new ArrayList<>();

    for (Integer musicId : musicIdList) {
        Music music = musicRepo.findByMusicId(musicId);
        Optional<Cart> checkCart = cartRepo.findByMemberAndMusic(member, music);
        if (checkCart.isEmpty()) {
            Cart newCart = new Cart();
            newCart.setMember(member);
            newCart.setMusic(music);
            cartsToSave.add(newCart);  // 저장할 Cart를 리스트에 추가
        }
    }

    // 한 번의 DB 호출로 일괄 저장
    if (!cartsToSave.isEmpty()) {
        cartRepo.saveAll(cartsToSave);
    }
}

 

long startTime1 = System.nanoTime();
...
long endTime1 = System.nanoTime();
long duration1 = endTime1 - startTime1;
System.out.println("firstMethod 실행 시간: " + duration1 + " 나노초");

해당 방법을 사용하여 야매 측정

 

서버 실행 후 최초 실행 시,

For문으로 매번 cartRepo.save() 를 실행했을 때는 108,735,100 나노초

saveAll() 방식으로 한 번에 저장했을 때는 47,255,400 나노초

 

약 56.54% 의 성능 향상을 기대할 수 있었다.

 

 

 

5. 결론 및 예방 조치


5.1 최종 결론 (문제 원인 및 해결 방법 요약)

리스트, 객체와 같은 데이터는 param 보다 body 방식이 더 적절하다.

param 으로 보내고자 한다면 paramSerializer 를 사용하여 쿼리 파라미터로 전달하도록 해야한다.

하지만 리스트, 객체 데이터가 오가는 경우는 get 요청보다 post 요청의 경우가 많을 것이다.

데이터 크기 제한이 없고, URL 에 노출되지 않아 보안상 안전하니 body 를 쓰자.

 

이때, 엔티티 데이터에 직접 접근하는 것은 본래 설정이 변경될 수 있으므로 DTO 를 사용하여 데이터를 관리해보자.

 

 

5.2 재발 방지 대책 (추가 모니터링, 코드 수정, 문서화 등)

HTTP 요청 메소드에 따른 데이터 전달 방식과, 메소드 별 특징을 더 잘 알아둘 필요가 있겠다.