본문 바로가기
개발 일기

JPA를 사용하여 여러 테이블에서 데이터를 가져오는 방법에 대한 고찰(1)

by 팡펑퐁 2023. 7. 26.
728x90

요구사항 예시

💡아래 예시는 회사 프로젝트 작업 중에 생긴 문제에 대한 해결 과정을 각색, 재구성한 것이다.
 
데이터베이스에는 사용자가 자가 입력한 데이터를 저장하는 7개의 테이블이 있다.

각 테이블에는 사용자가 입력한 자가 입력 정보가 들어있다.
자가 입력하는 내용은 사용자가 하루 동안 섭취한 영양소와 체중에 관한 데이터이다.
예를 들어 단백질 테이블에는 총 4 개의 컬럼( id, user_id, protein, update_time)이 있다.
탄수화물 테이블에는 5개의 컬럼(id, user_id, carbohydrate, update_time)이 있다.
체중 테이블에는 4개의 컬럼이 있다.(id, user_id, morning_weight, night_weight, update_time)이 있다.

이런 식으로 7개의 테이블에는 각각 사용자가 입력한 정보가 적절하게 분류되어 저장되어 있다.

지금까지는 사용자의 스마트폰에만 데이터가 저장되어 있어 앱을 지울 경우 그동안 자가 입력한 정보가 모두 사라졌다.
따라서 재설치하게 되면 자가 입력 정보를 처음부터 다시 기록을 해야 했다.

이를 해결하기 위해 사용자의 스마트폰에 저장되어 있는 데이터를 서버와 동기화하여 서버에서도 사용자의 데이터를 가지고 있다가 사용자가 앱을 재설치할 경우 서버에 저장되어 있는 사용자의 데이터를 불러와 사용자의 스마트폰에 저장하는 방식으로 변경할 예정이다.

새롭게 추가할 기능은 앱을 재설치하게 되면 서버에 저장되어 있는 데이터를 가져다 사용자의 스마트폰에 불러와 저장하는 로직을 수행해야 한다.
이를 위헤 서버에서 데이터베이스에 접근해 데이터를 불러와 클라이언트에 보내는 API를 만들 것이다.

단, 요청 파라미터로 받은 user의 id를 통해 데이터베이스를 조회해서 정해진 응답값으로 반환해야 한다.

 

"data" : [
    {
        "id" :"1", 
        "name" : "protein",
        "value" : "20g",
        "updateTime" : "2023-07-25 16:25:49"
    },
    {
        "id" :"1", 
        "name" : "carbohydrate",
        "value" : "60g",
        "updateTime" : "2023-07-25 16:25:49"
    }
    ...
]
응답값은 위와 같은 형식으로 보내야 한다.

 

{
	"id" :"10", 
    "name" : "morning_weight",
    "value" : "70kg",
    "updateTime" : "2023-07-25 18:44:20"
},
{
	"id" :"10", 
    "name" : "night_weight",
    "value" : "70kg",
    "updateTime" : "2023-07-25 18:44:20"
}
만약 한 테이블에 id, update_time을 제외한 컬럼의 개수가 2개 이상이라면 그 개수만큼 응답값이 중복으로 만들어져야 한다.

예를 들어 체중 테이블의 경우에는 morning_weight, night_weight 컬럼을 가지고 있으므로 두 개의 응답 데이터가 만들어져야 한다.

이렇게 응답 데이터를 만드는 이유는 관계형 데이터베이스의 일반적인 형태로 저장하는 것이 아닌 EAV 모델로 데이터를 저장하기 때문이다.

EAV 모델로 저장하는 이유를 간단하게 설명하면 클라이언트 측에서는 데이터의 검색을 필요로 하지 않고 그저 전체 데이터를 불러오기만 하면 때문이다.

또한, 나중에 자가 입력 데이터의 종류가 추가될 경우 과거 데이터는 없었던 상태 그대로 유지하면서 새로운 데이터만 추가되는 것이기 때문에 새로운 테이블이나 컬럼이 추가되었다고 해서 쓸데없이 빈 컬럼을 채울 필요가 없어 효율적이다.

EAV 모델에 대한 자세한 내용은 블로깅 해두었으니 이 링크를 통해 참고 가능하다.

 

문제 해결 과정

 

나의 경우에는 크게 두 가지의 방법이 생각이 났다.

1. 각 엔티티의 레파지토리에서 해당 데이터를 가져와 dto로 매핑 후에 응답 리스트에 모두 넣고 클라이언트에 보낸다.

2. 여러 테이블에서 한 번에 데이터를 가져와 응답 리스트에 넣고 클라이언트에 보낸다.

 

우선 첫 번째 방법부터 생각해 보자.

public interface ProteinRepository extends JpaRepository<Protein, Long> {
 	// 필요한 메서드
 	...
 
 	List<Protein> findAllByAvatarId(String userId);
}
  • 단순하게 JPA를 이용해 데이터베이스에 데이터를 다루는 Repository 클래스이다.
  • 위의 예시 코드와 같이 특정 Entity에 대한 Repository가 있다.

 

...
@Entity
public class Protein {

	@Id
    @GeneratedValue(strategy = ...)
    @Column(...)
    private Long id;
    
    @Column(...)
    private String userId;
    
    @Column(...)
    private String protein;
    
    @Column(...)
    private Date updateTime;

}
  • Protein 테이블에 해당하는 Entity이다.

 

public class ResponseDto {
    private Long id;
    private String name;
    private String value;
    private String updateTime;
}
  • 응답값으로 내보낼 Dto이다.

 

여기서 구현하는 방법은 여러 가지가 있을 것이다.

단순히 생각해 봐도 생성자 메서드, setter, builder 등이 떠오른다.

 

고민 끝에 나는 static 메서드를 사용하기로 했다.

요구사항을 보면 응답 데이터를 만들 때 테이블의 컬럼 개수에 따라 중복적인 데이터셋 작업이 이루어질 수 밖에 없다.

key-value 형태의 EAV 모델 특성상 테이블의 컬럼 수에 따라 응답 데이터 개수가 늘어나기 때문이다.

 

  • 우선 생성자 메서드로 인스턴스를 생성 후에 setter로 값만 바꾸는 방법을 생각해 보았다.
  • 이 방법의 경우에는 setter를 이용하여 특정 값만 변경하는 부분(ex. morning_weight -> night_weight)으로 인해 코드가 지저분해지고 가독성을 해칠 것 같았다.
  • 생성자 오버로딩으로 값을 넣는 방법도 있지만 파라미터만 보고 구분해야하는 최악의 가독성을 보여줄 것 같아 패스했다.

 

public List<ResponseDto> get(String userId) {

	...

	weightList.forEach(weight -> {
        ResponseDto morningResponse =  ResponseDto.builder()
                .id(weight.getId())
                .name("morning_weight")
                .value(weight.getMorning())
                .updated(String.valueOf(weight.getUpdateTime()))
                .build();
        responseList.add(morningResponse);

         ResponseDto nightResponse =  ResponseDto.builder()
                .id(weight.getId())
                .name("nigh_weightt")
                .value(weight.getNight())
                .updated(String.valueOf(weight.getUpdateTime()))
                .build();
        responseList.add(nightResponse);
    });
    
    ...
}
  • builder 패턴의 경우에는 필수값만 넣어두고 나중에 null을 setter 등으로 값을 채우는 방식을 사용할 수 있지만 그렇다면 변경을 염두한다는 이야기이므로 builder 패턴을 쓸 이유가 없다. 선택적인 값(morning, night)으로 나눠 만든다고 하면 컬럼의 개수가 많아질수록 코드가 지저분하게 보이고, 중복 코드가 길어지는 느낌을 지울 수 없었다.

 

// Dto

@Getter
public class ResponseDto {
    private Long id;
    private String name;
    private String value;
    private String updateTime;

    private PromDto(Long id, String name, String value, String updateTime) {
        this.id = id;
        this.name = name;
        this.value = value;
        this.updateTime = updateTime;
    }

    public static ResponseDto proteinOf(Long id, String protein, String updateTime) {
        return new ResponseDto(id, "protein", protein, updateTime);
    }

    public static ResponseDto weightMorningOf(Long id, String morningWeight, String updateTime) {
        return new ResponseDto(id, "morning_weight", morningWeight, updateTime);
    }

    public static ResponseDto weightNightOf(Long id, String nightWeight, String updateTime) {
        return new ResponseDto(id, "night_weight", nightWeight, updateTime);
    }
    
    
// service 클래스
    
List<ResponseDto> responseList = new ArrayList<>();

weightList.forEach(weight -> {
            ResponseDto morningResponse = ResponseDto
                    .weightMorningOf(weight.getid(), weight.getMorningWeight(), String.valueOf(weight.getUpdateTime()));
            responseList.add(morningResponse);

            ResponseDto nightResponse = ResponseDto
                    .weightNightOf(weight.getid(), weight.getNightWeight(), String.valueOf(weight.getUpdateTime()));
            responseList.add(nightResponse);
        });
  • static 메서드의 경우에는 사용에 맞는 메서드를 만들면 위의 예시 코드와 같이 dto에 대한 생성자를 private으로 두어 다른 코드에서 사용하지 못하게 막으면서(향후 잘못된 사용 차단), 메서드 네이밍으로 메서드 이름만 보고 해당 메서드가 어떤 응답값을 만들어내는지 바로 알 수 있고(가독성 향상), return 으로 생성자만 따로 생성하면 되기 때문에(코드 지저분함 줄임) 어차피 중복적인 내용이 나올 수밖에 없는 상황에서 로직을 파악하기 쉽게 만드는 방법이라는 생각이 들었다.

 

두 번째 방법은 한 번에 여러 테이블에서 데이터를 조회하여 가공한 채로 가져오는 것이다.

 

public class ResponseDto {
    private Long id;
    private String name;
    private String value;
    private String updateTime;
}

 이렇게 생각한 이유는 여러 테이블에서 조회한다고 해도 결국 사용자의 자가입력 데이터 조회라는 목적성이 같아 테이블마다 구조가 조금씩 차이가 있더라도 결국에는 동일한 필드에 매핑되어 반환할 것이기 때문이다. 따라서 데이터베이스에서 데이터를 가공한 다음 가져온다면 불필요한 값(사용하지 않는 컬럼)을 가져올 필요가 없어 조금 더 효율적이지 않을까 생각했다.

 

 정리하면 첫 번째 방식은 Raw 한 데이터를 그대로 가져와서 비즈니스 로직을 수행하는 과정에서 데이터가 엔티티 -> Dto로 매핑이 되면서 가공처리가 된다. 두 번째 방식은 데이터베이스에서 SQL 문으로 내가 원하는 데이터만 처리하고 가져온 후에 그대로 Dto로 매핑하여 내보내는 것이다.

 

 구글링을 통해 두번째 방식에 대해 찾아본 결과 JPA DTO PROJECTION이라는 키워드를 찾을 수 있었다. 이 부분에 대해서 설명하면 너무 길어지기 때문에 다음 글에서 계속하겠다.

 

 

728x90