카테고리 없음

[Spring Boot] 페이징 기법에 대한 이해

shootingstar-1117 2025. 12. 21. 00:33

1. 페이징 기법, 왜 필요할까?

  • 페이징(Paging): 많은 양의 데이터한 번에 보여주는 대신, 정해진 갯수만큼페이지(Page) 단위로 나누어 보여주는 기법
  • "그냥 데이터가 많으면 전부 다 보내주고, 클라이언트(브라우저)에서 알아서 나눠서 보여주면 안되나?" 라고 생각할 수도 있습니다. 하지만 그렇게 하면, 대부분의 서비스에서 다음과 같은 재앙이 발생됩니다.
    1. 데이터베이스비명을 지릅니다.
      • 예를 들어 데이터베이스에 'post' 라는 테이블에 1억 건의 레코드가 있다고 해봅시다. "SELECT * FROM post;" 같은 쿼리는 1억 개의 데이터를 모두 디스크에서 읽어와야 합니다. 이 작업 하나만으로도 데이터베이스는 엄청난 부하를 받게 되며, 데이터베이스 서버CPU메모리 자원크게 소진시킵니다.
      • 결과적으로 쿼리 하나 때문에, 다른 모든 API 요청들까지 느려지거나 응답 불가능 상태에 빠질 수 있게됩니다.
    2. 네트워크가 막혀버립니다. (네트워크 과부화)
      • 서버가 1억 건의 게시글 데이터JSON 형태로 만들어 클라이언트에게 보내는 데는 엄청난 시간이 걸립니다. 그리고 수십 mb, 수백 mb에 달하는 데이터인터넷을 통해 전송하는 것매우매우!!! 비효율적입니다.
      • 결과적으로 사용자는 하얀 화면만 보면서 하염없이 기다려야 합니다.. 모바일 환경에서는 사용자의 소중한 데이터를 순식간에 전부 소진시켜 버릴 수도 있습니다.
    3. 사용자의 기기(브라우저, 앱)멈춰버립니다. (클라이언트 성능 저하)
      • 어찌저찌 수백 mb의 데이터를 모두 받은 브라우저은 이제 그 데이터를 화면에 그려내야 합니다. 1억 개의 데이터에 대한 UI 컴포넌트를 한 번에 렌더링하려고 시도하면, 기기의 메모리를 초과하여 앱이 느려지거나, 멈추거나, 강제 종료될 수 있습니다.
      • 결과적으로 사용자 경험최악으로 치닫고, 다시는 그 서비스를 이용하지 않을 겁니다..

뿔난 사용자

2. Spring Boot 에서 페이징 기법 사용을 위한 유용한 도구들

  • 위에서 얘기했던 문제들처럼 우리는 페이징 없이 대용량의 데이터를 처리하는 서비스를 만들기가 거의 불가능하다는 것을 확인했습니다! 그렇다면 복잡한 페이징 로직(LIMIT, OFFSET, COUNT, WHERE 쿼리 등)을 개발자가 매번 직접 구현해야 할까요?
  • 다행히도 스프링의 JPA는 이 모든 과정을 거의 자동화해주는 강력한 "페이징 삼총사"를 제공합니다!
    1. Pageable 인터페이스
      • "어떻게 데이터를 가져올지"에 대한 요청 규격(인터페이스)입니다.
      • 페이징을 요청하려면 "페이지 번호", "페이지 크기", "정렬 방식" 정보가 필요하다는 약속입니다.
      • 다음은 가장 기본적이고 널리 쓰이는 표준 방식 예시 코드입니다. (후술할 커스텀 validator 어노테이션 사용 방법도 있음)
      @GetMapping("/api/posts")
      public ApiResponse<...> getPosts(Pageable pageable){ // Pageable 객체를 직접 받음
        // 여기에서 서비스로 Pageable 객체를 넘겨서 로직 처리
        // pageable.getPageNumber(); -> 인덱스처럼 처음이 0인 0-based 페이지 번호
        // pageable.getPageSize(); -> 페이지의 크기 (기본값 20)
        // pageable.getSort(); -> 정렬 정보
        return ...;
      }
      • 위 코드처럼 Pageable 인터페이스를 선언해두면, 스프링url의 쿼리 파라미터(?page=...&size=...&sort=...)를 보고 알아서 해당 규격에 맞는 객체(pageable)를 만들어줍니다.
    2. PageRequest 객체
      • Pageable 이라는 '양식'에 구체적인 내용을 채워넣은 실제 요청서(클래스) 입니다.
      • 예를 들어 "3번 페이지의 데이터 10개를 최신순으로 정렬해서 주세요~" 라는 구체적인 요청 내용을 담고 있는 객체입니다. 주로 PageRequest.of(...) 메서드를 통해 생성하며, 서비스 계층에서 데이터베이스요청을 보내기 직전에 사용됩니다.
      • 다음은 실제 사용되는 객체 사용 예시입니다.
      // 3번 페이지의 데이터 10개를 최신순으로 정렬
      // PageRequest.of(페이지, 갯수(limit 절과 동일), 정렬 방식), 페이지는 0부터 시작
      Pageable pageRequest = PageRequest.of(2, 10, Sort.by("createdAt").descending());
      • 위 코드처럼 작성하여 페이징을 할 조건을 지정하면 됩니다! 반환되는 타입PageRequest 이나, 보통 JpaRepository는 더 넓은 범위인, Pageable 인터페이스 타입을 원합니다.(다형성)
    3. Page 인터페이스
      • 페이징 쿼리결과를 담는 그릇(인터페이스)입니다. 단순한 데이터 목록(List)이 절대 아닙니다!
      • 작성된 요청서(PageRequest)에 따라 데이터베이스의 조회를 마친 후, 실제 데이터와 함께 온갖 유용한 통계 정보들을 함께 담아주는 "종합 보고서" 역할을 합니다. 다음은 그 보고서의 내용물(주요 메서드)을 소개하겠습니다. 소개하려는 메서드 외에도 더욱 많습니다!
        1. getContent(): 이번 페이지에 해당하는 실제 데이터 목록 (List)
        2. getTotalElements(): 필터링된 전체 데이터총 갯수 (Integer)
        3. getTotalPages(): 전체 페이지 수 (Integer)
        4. isFirst(), isLast(): 첫 페이지인지, 마지막 페이지인지 여부 (true/false)
        5. getSize(), getNumber(), hasNext()... 등등이 있습니다..!!

3. 페이징 삼총사들을 어떻게 써먹을 것인가? (표준 방식)

  • 위에서 Page, PageRequest, Pageable 라는 JPA에서 제공하는 페이징 삼총사들의 개념을 살펴보았습니다. 이제 이 삼총사가 실제 프로젝트의 각 계층(Controller, Service, Repository)에서 어떻게 유기적으로 협력하여 페이징 API를 완성하는지, '가게 리뷰 목록 조회' 기능을 예시로 단계별로 설명해 보겠습니다.
      • 가장 먼저, 데이터베이스와 통신하는 Repository 계층에 페이징을 위한 '규격'을 정의합니다.
    @Repository
    public interface ReviewRepository extends JpaRepository<Review, Long> {
        // storeId(기본키)로 리뷰를 찾는데, Pageable 정보에 맞춰서 페이징 해달라고 약속
        Page<Review> findByStoreId(Long storeId, Pageable pageable);
    }
      • 다음으로, Service 계층에서 위의 Repository를 호출하여 비즈니스 로직을 수행합니다. 여기서는 스프링의 표준 방식을 최대한 활용하여, Pageable 객체를 그대로 전달하는 간결한 형태로 구현해 보겠습니다. 먼저 인터페이스를 통해 메서드를 정의합니다.
    public interface ReviewService {
    	// Pageable 객체를 그대로 받는 메서드를 인터페이스에서 정의
        Page<Review> getReviewByStoreId(Long storeId, Pageable pageable);
    }
      • 그 후, 해당 인터페이스구현체를 구현합니다. Service의 역할은 Controller로부터 페이징 요청을 받아 Repository에 그대로 '전달'하는 것입니다. 이 방식의 장점은 Service가 페이징의 세부 구현(페이지 크기, 정렬 방식 등)에 얽매이지 않고, 오직 비즈니스 로직에만 집중할 수 있다는 점입니다.
    @Repository
    @RequiredArgsConstructor
    public class ReviewServiceImpl implements ReviewService {
        private final JPAQueryFactory queryFactory;
        private final ReviewRepository reviewRepository;
        private final StoreRepository storeRepository;
        
        @Transaction(readOnly = true)
        @Override
        public Page<Review> getReviewByStoreId(Long storeId, Pageable pageable) {
            // 가게 존재 여부 확인
            storeRepository.findById(storeId)
                    .orElseThrow(() -> new StoreHandler(StoreErrorCode.NOT_FOUND));
            
            // Controller로부터 받은 Pageable 객체를 Repository에 그대로 전달
            return reviewRepository.findByStoreId(storeId, pageable);
        }
    }
     
        • Service로부터 받은 Page<Review> 객체클라이언트가 보기 좋게끔 최종 DTO로 변환합니다. Page 객체단순한 리스트가 아니라, 페이징에 필요한 모든 정보를 담고 있습니다.
        • Converter에서는 이 객체의 실제 내용물(getContent())과 페이지의 통계 정보(getTotalPages(), getTotalElements(), isLast() ... 등)를 꺼내서, 설계한 최종 응답 DTO에 맞게 채워넣는 역할을 합니다!
    public class ReviewConverter {
    	public static ReviewResDTO.ReviewPreviewListDTO toReviewPreviewListDTO(Page<Review> reviewPage) {
        	List<ReviewResDTO.ReviewPreviewDTO> reviewList = reviewPage.getContent().
            	.stream().map(ReviewConverter::toReviewDTO).toList();
                
            return ReviewResDTO.ReviewPreviewListDTO.builder()
                     .isLast(reviewPage.isLast())
                     .isFirst(reviewPage.isFirst())
                     .totalPage(reviewPage.getTotalPages())
                     .totalElements(reviewPage.getTotalElements())
                     .listSize(reviewList.size())
                     .reviewList(reviewList)
                      .build();
        }
        
        public static toReviewDTO(Review review) {
        	return ...
        }
    }
     
      • 이제 클라이언트의 http 요청을 받는 Controller를 구현합니다. 파라미터Pageable을 선언하면, 스프링 MVC?page=1&size=10&sort=createdAt,desc 와 같은 쿼리 파라미터를 자동으로 해석하여 Pageable 객체를 만들어줍니다.
      • Controller에서는 편리하게 만들어진 이 객체를 받아 Service에 전달하고, Service로부터 받은 Page 라는 "결과 보고서"를 Converter를 통해 최종 응답 형태로 가공해서 return 하면 됩니다!
    @RestController
    @RequiredArgsConstructor
    @RequestMapping("/api")
    public class ReviewController {
    	private final ReviewQueryService reviewQueryService;
        private final ReviewConverter reviewConverter;
        
        @GetMapping("/stores/{storeId}/reviews")
        public ApiResponse<ReviewResDTO.ReviewPreviewListDTO> getStoreReviewList(
        	@PathVariable Long storeId,
            Pageable pageable // Pageable을 파라미터로 선언!!
        ) {
        	Page<Review> reviewPage = reviewQueryService.getStoreReviewList(storeId, pageable);
            
            return ApiResponse.onSuccess(
            	reviewConverter.toReviewPreviewListDTO(reviewPage);
            )
        }
    }

4. 살짝 아쉬운 표준 방식..? (커스텀 어노테이션)

  • 앞서 우리는 Pageable 인터페이스를 이용해 얼마나 간편하게 페이징 API를 구현할 수 있는지 확인했습니다. Repository에 메서드 한 줄만 추가하면 페이징 쿼리가 완성이 됐었습니다!
  • 하지만 이 순정 옵션(?)을 실제 프로젝트에 그대로 적용시키기에는 몇 가지 아쉬운 점들이 존재합니다. 이번 챕터에서는 그 아쉬운 점들이 무엇인지 함께 살펴보겠습니다.
    1. "0-based 인덱스" - 개발자는 편할 수 있어도, 클라이언트는 헷갈릴 수 있다.
      • 스프링의 JPA의 Pageable은 기본적으로 페이지 번호를 0부터 시작(0-based index)하는 것으로 인식합니다. 즉, 첫 번째 페이지를 조회하려면 클라이언트는 ?page=0 으로 요청을 보내야 합니다.
      • 직관적으로 생각했을 때 당연히 첫 번째 페이지는 page=1 이라고 생각합니다. 하지만 이 작은 차이인 0부터 시작하는 것페이지가 하나씩 밀리는 혼란스러운 버그의 원인이 될 수도 있습니다.
    2.  유효하지 않은 입력값에 대한 방어 부재 (유효성 검증 관련)
      • 스프링의 기본 HandlerMethodArgumentResolver(인자 검사기 역할, 인터페이스)는 Pageable 파라미터의 유효성을 직접적으로 검증해주지 않습니다. 만약 클라이언트가 유효하지 않은 값을 보내면 어떻게 될까요? 그러면 클라이언트는 의미를 알 수 없는 500 Internal Server Error 응답을 받게 됩니다. 커스텀 예외 처리를 통해 어떤 것이 잘못인지 알려주는 것이 더 좋지 않을까요?!
      • 다음은 간단한 유효하지 않은 값에 대한 예시입니다. 
        1. 음수 값 요청 (?page=-1)
        2. 숫자가 아닌 값 요청 (?page=abc)
    3. 서비스 계층에서의 책임 증가코드 중복
        • 위의 두 가지 문제를 해결하기 위해, Service 계층에서 직접 유효성 검증하는 코드를 넣습니다. 하지만 이 방법은 또 다른 문제를 낳습니다. 다음은 나쁜(?) 해결책 코드 예시입니다.
        • 먼저, 페이지 번호 값만 받는다고 생각을 하고, Pageable 객체로 받는 것이 아닌, int형으로 받도록 하겠습니다. 
      public Page<Review> getStoreReviewList(Long storeId, int page) {
      	// 클라이언트가 1부터 요청한다고 가정하고, 서비스에서 검증 및 변환
          if(page < 1 || page == null) {
          	throw new MyException(...);
          }
          
          // page - 1에서, 한 페이지 당 5개씩 
      	Pageable pageRequest = PageRequest.of(page - 1, 5);
          
          // ...
      }
      • 서비스 계층본질적인 책임비즈니스 로직 처리입니다. 하지만 위 코드는 웹 파라미터 변환이라는, 웹 계층에 더 가까운 책임을 떠안게 되었습니다. 그리고 페이징이 필요한 메서드마다 저 if문page - 1 변환 코드가 반복적으로 사용됩니다.
      • 나중에 페이지 정책이 바뀌면(페이지 당 데이터 갯수를 5개에서 10개로 바뀌다던가..), 이 모든 메서드를 찾아서 수정해야 합니다. 당연히 유지보수도 힘들겠죠?!
  • 나만의 어노테이션으로 문제를 해결해보자!
      • 앞서 우리는 서비스 계층에서 검증 로직이 추가되면서 코드가 중복되고, 책임이 모호해지는 아쉬운 점들을 확인했습니다.
      • 이제 스프링 MVC강력한 확장 기능HandlerMethodArgumentResolver를 사용하여, 이 모든 문제를 해결하는 커스텀 어노테이션을 만들어 보겠습니다.
      • 어노테이션 이름은 @CheckPage 로 정해놓고 진행하겠습니다! 해당 어노테이션 자체로는 어떤 동작을 하진 않습니다. Controller 메서드의 파라미터에 붙이는 '트리거'를 붙였다고 생각하면 될 것 같습니다!
    @Target(ElementType.PARAMETER) // 메서드의 파라미터에만 붙일 수 있다는 조건
    @Retention(RetentionPolicy.RUNTIME) // 런타임에도 해당 어노테이션의 정보를 유지함을 알림
    public @interface CheckPage {
    }
      • 그 다음 @CheckPage 트리거가 붙은 주문을 실제로 처리할 메서드를 만듭니다.
      • HandlerMethodArgumentResolver 인터페이스를 구현한 클래스는 특정 조건의 파라미터에 들어갈 값을 직접 만들어서 반환하는 책임을 가집니다. 다음은 HandlerMethodArgumentResolver 인터페이스가 정의하고 있는 메서드와 코드입니다.
          1. supportsParameter(MethodParameter parameter): 특정 어노테이션이 붙었는지 확인하는 역할을 합니다.
          2. resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer,          NativeWebRequest webRequest, WebDataBinderFactory binderFactory): 실제 로직을 처리하는 역할을 합니다. 쿼리 스트링(Query string)이나 Path Variable, 또는 바디 영역에서 로직을 처리할 값을 꺼내고 원하는 값으로 반환할 수 있습니다.
        public class CheckPageArgumentResolver implements HandlerMethodArgumentResolver {
            @Override
            public boolean supportsParameter(MethodParameter parameter) {
                // 처리할 파라미터인지 검사(@CheckPage 어노테이션이 붙어있는지 확인)
                return parameter.hasParameterAnnotation(CheckPage.class);
            }
        
            @Override
            public Object resolveArgument(
                    MethodParameter parameter,
                    ModelAndViewContainer mavContainer,
                    NativeWebRequest webRequest,
                    WebDataBinderFactory binderFactory
            ) throws Exception {
            	// HttpServletRequest 객체를 얻음
                HttpServletRequest request = (HttpServletRequest) webRequest.getNativeRequest();
                // 요청에서 page 값을 얻어옴
                String pageString = request.getParameter("page");
        
                int page;
        
                try {
                    // 값이 없으면 기본값 1로 처리
                    page = (pageString == null) ? 1 : Integer.parseInt(pageString);
                } catch (NumberFormatException e) { // 숫자가 아닌 값이 들어왔을 때
                    throw new GeneralException(PageErrorCode.BAD_REQUEST);
                }
        
                // 1미만의 값이 들어왔을 때
                if (page < 1) {
                    throw new GeneralException(PageErrorCode.BAD_REQUEST);
                }
        		
                // 1부터 시작하기 위해 page - 1의 값을 최종 반환
                return page - 1;
            }
        }
      • 현재 @CheckPage 라는 어노테이션이 붙어있을 때, 페이지 정보 값CheckPageArgumentResolver가 가로채서 해당 쿼리 파라미터를 검증하고, 첫 번째 페이지가 1부터 시작할 수 있게끔 로직을 담당하고 있습니다.
      • 하지만 Spring MVC(Spring 컨테이너 위에서 동작하는 하나의 독립된 프레임워크 모듈)는 이 클래스의 존재 자체를 자동으로 알지 못합니다. 지금은 단순히 HandlerMethodArgumentResolver의 클래스를 구현했다 뿐이지 프레임워크가 알지 못하는 상황입니다.
      • 그래서! 우리가 만든 CheckPageArgumentResolver 클래스를 Spring MVC인식하고 사용하도록 등록시켜줘야 합니다!
      • 이는 Spring@RequestParam, @PathVariable, @RequestBody와 같은 표준 어노테이션들해석하는 방식과 같게끔, @CheckPage 어노테이션이 붙은 파라미터도 자동으로 인식하고 처리할 수 있도록 하는 과정입니다.
      • 등록은 WebMvcConfigurer 인터페이스를 구현한 설정 클래스에서 이루어집니다.
      • WebMvcConfigurer: Spring MVC자동 설정을 완전히 대체하는 것이 아니라, 기존 설정을 그대로 유지하면서 필요한 부분만 추가하거나 재정의할 수 있는 Hook을 제공합니다. WebMvcConfigurer의 주요 특징은 다음과 같습니다. 
        • Spring MVC내부 동작을 건드리지 않고, 약속된 메서드오버라이드하는 것만으로 설정을 변경할 수 있습니다.
        • Argument Resolver 등록 외에도 인터셉터 추가(addInterceptors), CORS 설정(addCorsMappings) 등 MVC 전반에 걸친 다양한 설정을 변경할 수 있는 메서드를 제공합니다.
        • 보통 @Configuration 어노테이션이 붙은 설정 클래스에서 WebConfigurer를 구현하여 사용합니다. 이를 통해 Spring 컨테이너서버 구동 시 해당 설정 정보를 읽어들이게끔 하여 MVC 설정에 반영합니다.
      • addArgumentResolvers(): WebMvcConfigurer가 제공하는 여러 메서드 중 하나로, HandlerMethodArgumentResolver구현체들을 등록하는 데 특화된 메서드입니다. 동작 방식 및 개념은 다음과 같습니다.
        • 호출 시점: Spring 애플리케이션이 시작될 때, Spring MVCWebMvcConfigurer구현한 모든 설정 클래스를 찾아 addArgumentResolvers 메서드를 호출합니다.
        • resolvers 파라미터: 메서드가 호출될 때, Spring은 기본적으로 내장된 모든 Argument Resolver들이 이미 들어있는 List 객체파라미터로 전달해 줍니다. 이 리스트에는 @RequestParam, @PathVariable, @RequestBody 등을 처리하는 기본 resolver들이 포함되어 있습니다.
        • 등록 과정: 이 resolvers 리스트에 우리가 직접 만든 CheckPageArgumentResolver의 인스턴스를 add 해주기만 하면 됩니다!
    @Configuration // 해당 클래스가 Spring의 설정 파일임을 알림
    public class WebConfig implements WebMvcConfigurer {
    	@Override
        public void addArgumentResolvers(
            List<HandlerMethodArgumentResolver> resolvers
        ) {
            // 직접 만든 CheckPageArgumentResolver를 Spring MVC의 Argument Resolver 목록에 등록
            resolvers.add(new CheckPageArgumentResolver());
        }
    }
  • 이제 실제로 테스트를 해보겠습니다. 테스트 요청 및 응답 확인은 기존에 제가 사용하던 Swaager UI로 확인을 해보겠습니다!
  • 저는 테스트 데이터를 MySQL 데이터베이스에 미리 삽입을 해놓았습니다!
  • 가게의 id1부터 시작하는 페이지 번호를 입력 후 요청을 아래 사진과 같이 날리게 되면..!!

 

  • 다음과 같이 결과가 나오는 것을 확인할 수 있습니다! 상세 응답 데이터는 코드 블럭으로 넣어두겠습니다.

{
  "isSuccess": true,
  "code": "REVIEW200_1",
  "message": "리뷰가 성공적으로 조회되었습니다.",
  "result": {
    "reviewList": [
      {
        "ownerNickname": "슈팅스타",
        "rating": 4.5,
        "body": "정말 맛있어요! 첫 번째 리뷰입니다.",
        "createdAt": "2025-12-20T14:48:27"
      },
      {
        "ownerNickname": "슈팅스타",
        "rating": 5,
        "body": "분위기가 너무 좋아요. 강추!",
        "createdAt": "2025-12-20T14:48:27"
      },
      {
        "ownerNickname": "슈팅스타",
        "rating": 3.5,
        "body": "가격이 조금 비싸지만 맛은 있네요.",
        "createdAt": "2025-12-20T14:48:27"
      },
      {
        "ownerNickname": "슈팅스타",
        "rating": 4,
        "body": "직원분들이 친절해서 좋았어요.",
        "createdAt": "2025-12-20T14:48:27"
      },
      {
        "ownerNickname": "슈팅스타",
        "rating": 2.5,
        "body": "기대보다는 별로였어요.",
        "createdAt": "2025-12-20T14:48:27"
      }
    ],
    "listSize": 5,
    "totalPage": 4,
    "totalElements": 20,
    "isFirst": true,
    "isLast": false
  }
}

 

  • 결과는 대성공! 총 20개의 데이터5개만 가져온 것을 확인할 수 있으며, 정상적으로 페이지 번호 1번이 첫 번째 페이지로 인식되고 있는 것을 확인할 수 있습니다.

5. 마치며...

  • 위에서 진행했던 내용들을 요약하자면 다음과 같습니다.
    • Q. 페이징 처리를 왜 하는가?
    • A. 안하면 많은 데이터를 한꺼번에 요청으로 보낼 때, 별의별 큰 문제들이 생기기 때문입니다.
    • Q. Spring Boot에서 페이징 기법을 무엇으로 구현하면 되겠는가?
    • A. SQL 구문으로 직접적으로 페이징 처리를 할 수 있지만 Spring BootJPAPageable, PageRequest, Page 와 같은 도구가 있어, 더욱 간단하게 구현이 가능합니다. 하지만 실제로 SQL 구문으로 직접 OFFSET 페이징이나, CURSOR 페이징을 구현해보는 것도 좋을 것 같습니다! 참고로, 페이징 객체들로 구현한 방식은 내부적으로 OFFSET 페이징 방식으로 동작한다는 것을 알아두셨으면 좋겠습니다!
    • Q. 왜 굳이 복잡하게 커스텀 어노테이션을 직접 만들어서 사용하는가?
    • A. 페이지 번호의 유효성 검증 및 page -1 로직 같은 것을 Service 계층에서 처리하는 것은 코드의 중복이 많아져 비효율적이고, 유지보수성이 저하됩니다. 그래서 중앙화된 관리를 위해 어노테이션 방식으로 진행을 했습니다.

 

부족하고, 긴 글이지만 읽어주셔서 감사합니다! 피드백은 언제나 환영입니다! ☺️