UMC_Back

여행기 - 도시, 국가 검색 정리 (전반적인 스프링 코드 이해)

oyatplum 2024. 8. 18. 00:07

트립피스 플젝을 진행하며...

다른 팀원들이 쓴 코드를 보면서 dto라는 것을 처음 알았다..

DTO란?

  • DTO(data transfer object, 데이터 전송 객체)
    • 클라이언트와 서버가 데이터를 주고받을 때 사용하는 객체

    • RequestDTO
      • 클라이언트로부터 데이터를 받을 때 사용
    • ResponseDTO
      • 서버에서 클라이언트로 데이터를 보낼 때 사용
  • https://blog.scottlogic.com/2020/01/03/rethinking-the-java-dto.html

public class CityRequestDto {
    @Getter
    @NoArgsConstructor
    @AllArgsConstructor
    public static class searchDto{
        @NotNull(message = "Keyword cannot be null.")
        private String keyword;
    }
}
public class CityResponseDto {
    @Getter
    @AllArgsConstructor
    public static class searchDto{
        private String cityName;
        private String countryName;
        private String cityDescription;
        private String countryImage;
        private Long logCount;
    }
}
  • @NoArgsConstructor @AllArgsConstructor @RequiredArgsConstructor
    • 모두 생성자를 자동으로 생성해주는 어노테이션
    • @NoArgsConstructor : 파라미터가 없는 디폴트 생성자 자동 생성
    • @AllArgsConstructor : 클래스의 모든 필드 값을 파라미터로 받는 생성자 자동 생성
    • @RequiredArgsConstructor : final이나 @Nonnull로 선언된 필드 값을 파라미터로 받는 생성자 자동 생성
    • https://gong-story.tistory.com/15

영속성 컨텍스트

영속성 컨텍스트란?

  • 엔티티를 영구 저장하는 환경 (애플리케이션과 db 사이에서 객체를 보관하는 가상의 db 역할)
  • 엔티티 매니저를 통해 저장, 조회
  • 엔티티 생명주기
    • 비영속 : 영속성 컨텍스트와 관계가 없는 상태 (객체만 생성)
    • 영속 : 영속성 컨텍스트에 저장된 상태 (em.persist())
    • 준영속 : 영속성 컨텍스트에 저장되었다가 분리된 상태 (em.detach())
    • 삭제 : 영속성 컨텍스트에서 삭제된 상태 (em.remove())
    • https://velog.io/@neptunes032/JPA-영속성-컨텍스트란 (1차 캐시 등 자세한 정보는 링크 참고)

City, Country Domain

@Entity
@Getter
public class City {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "city_id")
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "country_id")
    private Country country;

    private String name;
    private String comment;
    @Setter
    private Long logCount;

    @OneToMany(mappedBy = "city")
    private List<Travel> travels = new ArrayList<>();
}
@Entity
@Getter
public class Country {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "country_id")
    private Long id;

    private String name;
    private String code;
    private String countryImage;

    @OneToMany(mappedBy = "country")
    private List<City> cities = new ArrayList<>();
}

영속성 컨텍스트에 데이터를 조회하고 저장하는 모든 기준은 기본키(PK)이다.

기본키 매핑

  • @Id : 직접 할당 (pk가 무엇인지 알려줌)
  • @GeneratedValue : 자동 할당 (Id 값의 생성 전략을 지정)
    • IDENTITY : MYSQL과 같은 데이터베이스에 위임 (auto_increment : 순차적으로 id 값 증가)
    • 나머지 타입은 나중에..

즉시 로딩(EAGER) vs 지연 로딩(LAZY)

@xxToOne의 경우, (fetch = FetchType.LAZY)를 하라고 배웠다.

  • 즉시 로딩 : 데이터를 조회할 때, 연관된 데이터까지 한 번에 불러옴
  • 지연 로딩 : 필요한 시점에 연관된 데이터를 불러옴

저 코드에서 country 와 city 엔티티는 1:N의 관계이다.

이때, fetch = fetchType.EAGER) 로 즉시 로딩을 사용한다면 city를 조회하는 시점에 country까지 한 번에 조회된다.

하지만 위의 코드처럼 지연 로딩을 사용하면 city를 조회할 때, city를 조회하는 쿼리만 나간다고 한다.

우선 복잡하기 때문에 단순하게 지연 로딩을 사용하는 것이 낫다고만 이해를 하고

fetch의 디폴트 값은 @xxToOne에서는 EAGER, @xxToMany에서는 LAZY이므로

@xxToOne에서 지연 로딩을 설정해주면 된다.


CityRepository, CountryRepository

public interface CityRepository extends JpaRepository<City, Long> {
    List<City> findByNameContainingIgnoreCase(String name);
    List<City> findByCountryId(Long countryId);
}
public interface CountryRepository extends JpaRepository<Country, Long> {
    List<Country> findByNameContainingIgnoreCase(String name);
}

JpaRepository란?

  • ORM(Object Relational Mapping)
    • 객체와 관계형 데이터 베이스를 매핑하기 위한 기술명
    • SQL query가 아닌 직관적인 코드로 데이터 조작 가능
  • JPA(Java Persistence API)
    • 자바 객체를 관계형 데이터 베이스에 영속적으로 저장하고 조회할 수 있는 ORM 기술에 대한 표준 명세 (자바 진영의 ORM API)
  • JpaRepository
    • JPA를 사용하여 데이터 베이스를 조작하기 위한 메서드 제공 (findById 등)

https://velog.io/@minju0426/JPARepository에-대해-알아보자사용법-Method

따라서 위의 CityRepository와 CountryRepository 모두 JpaRepository를 extends했다.

코드 설명

findByNameContainingInoreCase 는 이름(파라미터)의 대소문자를 무시하고 포함되어 있는지 확인하는 메서드이다.


CityService, CityConverter

@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class CityService {
    private final CityRepository cityRepository;
    private final CountryRepository countryRepository;

    public List<CityResponseDto.searchDto> searchCity(CityRequestDto.searchDto request){
        String keyword = request.getKeyword();

        if (keyword == null || keyword.trim().isEmpty()) {
            return Collections.emptyList(); // 빈 리스트를 반환
        }
        
        List<City> cities = cityRepository.findByNameContainingIgnoreCase(keyword);
        List<Country> countries = countryRepository.findByNameContainingIgnoreCase(keyword);
        List<CityResponseDto.searchDto> searched = new ArrayList<>();
        
        if (!cities.isEmpty()){
            searched.addAll(cities.stream().map(CityConverter::toSearchDto).toList());
        }
        if(!countries.isEmpty()){
            countries.forEach(country -> {
                List<City> citiesInCountry = cityRepository.findByCountryId(country.getId());
                searched.addAll(citiesInCountry.stream().map(CityConverter::toSearchDto).toList());
            });
        }
        return searched;
    }
    
public class CityConverter {
	public static CityResponseDto.searchDto toSearchDto(City city){
       return new CityResponseDto.searchDto(
                city.getName(),
                city.getCountry().getName(),
                city.getComment(),
                city.getCountry().getCountryImage(),
                city.getLogCount()
        );
    }
}
}
  • @Transactional
    • https://hungseong.tistory.com/74
    • 트랜젝션은 쉽게 말하면 특정 실행 단위에서 오류가 발생했을 때, 실행 내용을 롤백해주는 기능이다.
    • @Transactional 어노테이션으로 service layer에 트랙젝션을 걸 수 있다.
    • 조회용 메서드에는 @Transactional(readOnly = true)을 통해 성능상 이점을 얻을 수 있다.
      • 변경 감지(Dirty Checking)
        • 영속성 컨텍스트는 엔티티 조회 시 초기 상태에 대한 snapshot을 저장
        • 트랜젝션이 커밀될 때, snapshot과 변경 사항을 비교하여 update query를 생성해 쓰기 지연 저장소에 저장
        • 저장된 query를 flush하고 데이터베이스의 트랜젝션을 커밋함으로써 엔티티의 수정이 이루어짐
      • readOnly = true 설정하면?
        • JPA의 flush mode를 MANUAL로 설정
        • MANUAL mode : 사용자가 수동으로 flush 하지 않으면 자동으로 수행되지 않음
        • 즉, 수정 내역이 db에 적용되지 않음
        • 따라서 조회용으로 가져온 엔티티의 예상치 못한 수정 방지 가능

따라서, 지금 구현하는 부분은 조회용이므로 @Transactional(readOnly = true)를 해주었고

그리고 위에서 @RequiredArgsConstructor에 대해서도 잠깐 언급했었는데,

final이나 @Nonnull로 선언된 필드 값을 파라미터로 받는 생성자를 자동 생성한다고 했다.

이를 더 자세하게 살펴보면 의존성 주입과 관련이 있다.

의존성, 의존성 주입

  • 의존성
    • 객체 지향 프로그래밍에서 클래스나 모듈 간의 관계
    • 한 클래스가 다른 클래스를 의존한다는 것은 다른 클래스의 인스턴스나 메서드를 사용한다는 것
  • 의존성 주입
    • 의존하는 객체를 생성하지 않고 외부에서 주입받는 것

이러한 의존성을 주입하는 방법에는 3종류가 있다.

https://velog.io/@developerjun0615/Spring-RequiredArgsConstructor-어노테이션을-사용한-생성자-주입

https://velog.io/@merci/스프링-생성자-주입

  1. Construtor(생성자) 주입
  2. Setter 주입
  3. Field 주입

자세한 예시 코드는 포스팅을 참고하자.

Setter 주입은 불변성이 보장되지 않고 @Autowired는 final로 선언할 수 없기 때문에 역시 불변성이 보장되지 않는다.

따라서 생성자 주입 방식 중 @RequiredArgsConstructor를 사용하여 final로 선언하는 것이 가장 이상적인 방법이라고 한다.

코드 설명

파라미터로 만들어둔 CityRequestDto 타입의 request를 받고 반환값으로는 CityResponseDto 타입의 리스트를 반환하는 searchCity 메서드를 작성했다.

request.getKeyword(); (dto에 getter가 있으니까)로 해당 키워드를 가져오고, 키워드가 없는 경우에는 빈 리스트를 반환하도록 처리했다.

cityRepository와 countryRepository를 사용하여 키워드에 해당하는 도시나 국가를 리스트 형태로 받았고, 만약 해당 리스트들이 비어있지 않다면,

CityResponseDto 타입의 비어있는 리스트 searched를 만들어서(반환 값의 타입이 CityResponseDto니까) cities의 경우, stream().map으로 돌면서 toList()로 반환했는데, map에서 CityConverter::toSearchDto를 해줬다. 이후 searched.addAll 을 통해 리스트를 searched 리스트에 넣어줌.

이는 cities는 City 엔티티이므로 여기서는 해당 엔티티의 모든 필드값을 dto로 전달해주었지만, 아닌 경우가 있을 수 있기 때문에 (구조가 다를 수 있어서) 불필요한 데이터를 전송하지 않도록 중간에 바꿔준다.

키워드가 나라인 경우를 고려하여 cityRepository.findByCountryId(country.getId())를 통해 나라 Id로 해당 city를 찾도록 한 뒤, 이후 코드는 위와 cities와 동일하다.


CityController

@RestController
@RequiredArgsConstructor
public class CityController {
    private final CityService cityService;

    @PostMapping("search/cities")
    @Operation(
            summary = "도시, 국가 검색 API",
            description = "도시, 국가 검색"

    )
    public ResponseEntity<ApiResponse<List<CityResponseDto.searchDto>>> searchCities(@RequestBody @Valid CityRequestDto.searchDto request){
        List<CityResponseDto.searchDto> result = cityService.searchCity(request);

        if (result.isEmpty()) {
            return new ResponseEntity<>(ApiResponse.onFailure("400", "No matching cities or countries found.", null), HttpStatus.BAD_REQUEST);
        }
        else {
            return new ResponseEntity<>(ApiResponse.onSuccess(result), HttpStatus.OK);
        }
    }
}

@RestController? @Controller?

  • @Controller는 주로 view를 반환하기 위해 사용한다. data를 반환해야 하는 경우에 @ResponseBody 어노테이션을 함께 써줘야 하고 이렇게 사용하면 json 형태로 데이터를 반환할 수 있다.
  • @RestController는 @Controller에 @ResponseBody를 합쳤다고 생각하면 된다. 따라서, 바로 json 형태로 데이터를 반환할 수 있다.

@RequiredArgsConstructor는 역시 위에서 설명한 바와 동일하고 cityService에서 작성해준 searchCities 메서드를 사용해야 하기 때문에 final로 선언했다.

스웨거 ui 작성을 위해 @Operation 어노테이션을 사용해 상세 설명을 작성해주었고, 위의 코드를 보면 ResponseEntity가 있지만 사실 이전에는 ApiResponse로 시작했다. ResponseEntity가 생긴 이유는 아래와 같다.

 

 public ApiResponse<List<CityResponseDto.searchDto>> searchCities(@RequestBody CityRequestDto.searchDto request){
        List<CityResponseDto.searchDto> result = cityService.searchCity(request);
        return ApiResponse.onSuccess(result);
    }

이전에는 위와 같이 코드를 작성했기 때문에 스웨거에서 keyword에 아무것도 입력하지 않으면 모든 db가 총출동(?)했고 잘못된 스트링이 들어가면 400에러가 뜨지 않고 빈 배열이 나왔다.(당연하다.. 내가 코드를 그렇게 짰으니까..)

ApiResponse, ResponseEntity

  • ApiResponse
    • 일반적으로 API의 표준 응답을 정의하는 커스텀 클래스이다.
  • ResponseEntity
    • 스프링에서 제공하는 Rest API를 제작할 때, HTTP 응답을 나타내는 클래스이다.
    • body, status, header 등 추가 정보 전달 가능

이 둘을 함께 이용하여 최종 코드와 같이 수정했다.

코드 설명

@RequestBody 어노테이션을 사용해서 CityRequestDto 타입의 request를 @Valid 어노테이션을 통해 dto 값들을 검증했다.

이후, cityService의 searchCity 메서드를 이용해서 request에 해당하는 반환값을 CityResponseDto 타입의 리스트로 반환했고, 해당 리스트가 비어있다면 ApiResponse에 작성한 onFailure 메서드와 HttpStatus.BAD_REQUEST로 400 에러를 반환했고

성공한 경우, ApiResponse의 onSuccess 메서드와 HttpStatus.OK로 200 성공을 반환했다.

이와 관련하여 자세한건 스웨거 작성법 포스팅하면서….. 알아보자

  •  

nse에 작성한 onFailure 메서드와 HttpStatus.BAD_REQUEST로 400 에러를 반환했고

성공한 경우, ApiResponse의 onSuccess 메서드와 HttpStatus.OK로 200 성공을 반환했다.

 

 

이와 관련하여 자세한건 스웨거 작성법 포스팅하면서….. 알아보자

 

'UMC_Back' 카테고리의 다른 글

Week 3_(2)  (0) 2024.04.29
Week 3_(1)  (0) 2024.04.28
Week 2  (0) 2024.04.06
Week 1  (0) 2024.03.30