Project/땅땅땅

[땅땅땅] ObjectMapper 빼고 코드 최적화해보기 - 리팩토링

S_N_Y 2024. 3. 30. 11:35

ObjectMapper의 사용, 이게 최선일까?🧐

일단 ObjectMapper는 많은 양의 데이터로 작업하거나 트래픽이 많은 환경에서 작업할 경우 성능 이슈에 유의해야 한다.

왜냐하면, ObjectMapper가 런타임 시점에 ObjectMapper 내부에 구현되어 있는 Java Reflection(객체를 통해 클래스의 정보를 분석해 내는 프로그램 기법)을 사용해서 JSON 데이터를 JAVA 객체에 매핑하기 때문이다.

그리고 생성 비용도 비싸다.(bean이나 static으로 처리하는 편)

=> Java Reflection은 서버의 리소스를 과도하게 사용한다는 문제점 ✅

그리고 ObjectMapper는 역직렬화할 때마다 새 인스턴스를 생성하기 때문에 많아지면 많아질수록(=많은 객체를 역직렬화하는 경우) 상당한 오버헤드가 발생할 수 있다.

.

.

.

neighbor_id_list가 String 타입(TEXT)으로 들어가있는 모습

땅땅땅 프로젝트 중에 근방에 있는 이웃의 리스트를 위와 같이 하나의 String값으로 묶여있는 상황에서

다른 팀원 분의 코드인데 ObjectMapper를 아래와 같이 쓰고 있었다.

public Page<AuctionListResponseDto> getAuctions(Long userId, Pageable pageable) {
        User user = userService.findUserOrElseThrow(userId);
        String townList = user.getTown().getNeighborIdList();

        ObjectMapper mapper = new ObjectMapper();
        List<Long> neighbor;
        try {
            neighbor = mapper.readValue(townList, new TypeReference<List<Long>>() {
            });
        } catch (JsonProcessingException e) {
            throw new IllegalArgumentException("JsonProcessingException exception");
        }

        List<Auction> response = new ArrayList<>();
        for (Long townId : neighbor) {
            Page<Auction> auctionList = auctionRepository.findAllByTownIdAndOnSale(townId,
                pageable, pageLimit(pageable));
            response.addAll(auctionList.getContent());
        }
        Page<Auction> allAuctions = new PageImpl<>(response, pageable, response.size());
        return allAuctions.map(
            auction -> new AuctionListResponseDto(auction.getId(), auction.getTitle(),
                auction.getStatusEnum(), auction.getFinishedAt()));
    }

팀원 분의 기존 코드가 굉장히 복잡하고 가장 자주 사용하는 API 비즈니스 로직임에도 그 안에 ObjectMapper가 새롭게 만들어진다는 것 자체가 다분히 위험성을 크게 가지고 있는 상황이다.

대한민국 위치 정보의 엑셀 파일을 DB화하는 과정에서 특정 동네에서의 인근 동네를 넣어주려고 하는 것인데

이 방법이 과연 최선일지 고민해봐야한다.

 

문제점 인식 🔍 : 가장 메인 로직인 getAuctions의 성능이 좋지 않다

메인 비즈니스 로직을 열어보니 추정되는 점이 한 눈에 보였다.

GET요청을 보낼 때마다, 데이터베이스에 String값으로 들어가있으니 이를 변환해주는 과정에서도 상당한 오버헤드가 발생하고 요청 속도가 상대적으로 느린 것을 확인할 수 있었다.

...

그럼 해결할 수 있는 문제점을 발견했으니 바꿔보자

 

문제점 해결해보기🤸

1. 먼저 해당 컬럼의 데이터 타입을 TEXT ->JSON으로 바꿔주었다.

Database 클릭 후, Ctrl + F6

결과 1 :



2. DB화 하는 서비스로직에 직렬화 과정 제거 및 제거에 따른 Exception 삭제

팀원의 기존 코드 ⬇️

@RequiredArgsConstructor
@Transactional(readOnly = true)
@Service
public class TownService {

    private final Double DEFAULT_X = 111.35;
    private final Double DEFAULT_Y = 88.80;

    //기준 km
    private final Double DEFAULT_DISTANCE = 5.0;

    private final TownRepository townRepository;
    private final TownListRepository townListRepository;

    private final ObjectMapper objectMapper;

    @Transactional
    public void createTown() throws JsonProcessingException {
        List<TownList> townList = townListRepository.findAll();
        List<Long> idList = new ArrayList<>();

        for (TownList tl : townList) {
            String name = getTownName(tl);

            //neighbor
            for (TownList tl2 : townList) {
                double x = Math.pow((tl.getX() - tl2.getX()) * DEFAULT_X, 2.0);
                double y = Math.pow((tl.getY() - tl2.getY()) * DEFAULT_Y, 2.0);

                double distance = Math.sqrt(x + y);

                if (distance < DEFAULT_DISTANCE) {
                    idList.add(tl2.getId());
                }
            }

            String neighborIdList = objectMapper.writeValueAsString(idList);

            Town town = new Town(name, neighborIdList);
            townRepository.save(town);
            idList.clear();
        }
    }

 

바뀐 코드 ⬇️

@RequiredArgsConstructor
@Transactional(readOnly = true)
@Service
public class TownService {

    private final Double DEFAULT_X = 111.35;
    private final Double DEFAULT_Y = 88.80;

    //기준 km
    private final Double DEFAULT_DISTANCE = 5.0;

    private final TownRepository townRepository;
    private final TownListRepository townListRepository;

    @Transactional
    public void createTown() {
        List<TownList> townList = townListRepository.findAll();
        List<Long> idList = new ArrayList<>();

        for (TownList tl : townList) {
            String name = getTownName(tl);

            //neighbor
            for (TownList tl2 : townList) {
                double x = Math.pow((tl.getX() - tl2.getX()) * DEFAULT_X, 2.0);
                double y = Math.pow((tl.getY() - tl2.getY()) * DEFAULT_Y, 2.0);

                double distance = Math.sqrt(x + y);

                if (distance < DEFAULT_DISTANCE) {
                    idList.add(tl2.getId());
                }
            }

            Town town = new Town(name, idList);
            townRepository.save(town);
            idList.clear();
        }
    }

 

결과 2 

 

3. 기존의 List를 하나의 String으로 받아오는 대신에 @JdbcTypeCode를 사용하여 List 형태로 받아오기

먼저 코드를 살펴보자

 

기존 Entity ⬇️

@Entity
@Table(name = "towns")
public class Town {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private String name;

    @Column(columnDefinition = "TEXT")
    private String neighborIdList;

    public Town(String name, String idList) {
        this.name = name;
        this.neighborIdList = idList;
    }

}

 

바뀐 Entity ⬇️

@Entity
@Table(name = "towns")
public class Town {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private String name;


    @JdbcTypeCode(SqlTypes.JSON)
    @Column(columnDefinition = "json")
    private List<Long> neighborIdList;

    public Town(String name, List<Long> idList) {
        this.name = name;
        this.neighborIdList = idList;
    }

}

 

☑️ @JdbcTypeCode(SqlTypes.JSON) : neighborIdList 필드의 JDBC 타입을 명시, 즉 Hibernate의 JDBC 레벨에서 필드의 타입을 명확하게 정의하고 이 필드를 데이터베이스에서 JSON 타입으로 처리

(이렇게 처리해주면 특히나 지금 쓰고 있는 MySQL처럼 JSON 타입을 네이티브로 지원하는 데이터베이스에 유용하다.)

☑️ @Column(columnDefinition = "json") : 컬럼 타입을 JSON으로 명시적으로 설정해두기

 

이렇게 하면 neigborIdList에 [1, 2, 3]이라는 값을 저장하려고 할 때, 이 값이 JSON 문자열 "[1, 2, 3]"으로 데이터베이스에 저장되고 데이터를 다시 읽어올 때는 JSON 문자열이 List 타입으로 변환된다.

 

결과 3 :



최종 메인 로직 코드 변화🔍 :

 

+) 더 나아가기 🧗‍♂️ : ObjectMapper와 MapStruct, 그리고 사용해야 할 때는?

ObjectMapper뿐만 아니라 ModelMapper, Orika의 가장 큰 단점은 runtime 시점에 reflection을 통해 맵핑을 하기 떄문에 맵핑 객체의 사이즈가 커질수록 메모리 사용량이 선형적으로 증가하기 때문에 성능이 저하된다는 점을 기억해야 한다.

이에 비해서 MapStruct는 Lombok과 같이 annotation processor를 통해서 compile 시점에 객체간 맵핑이 이루어지기 떄문에 runtime 시점에 성능 저하가 없다.

=> 코드작성량은 줄어들면서 컴파일 언어의 자바의 장점은 제대로 누릴 수 있는 것이다.

 

사실 MapStruct 를 처음 접했을 때 가장 궁금했던 점은 ObjectMapper 를 대체하는가? 였다. 결론부터 말하면 그렇지 않고ObejctMapper 에서 객체매핑 기능을 지원하고 있기는 하지만 주 목적은 직렬화/역직렬화이고 MapStruct 주목적은 객체매핑이고 직렬화/역직렬화 기능은 제공하지 않는다.

=> 결국 직렬화/역직렬화는 ObjectMapper나 Gson을, 객체매핑은 MapStruct.