1️⃣ @DynamicInsert와 @DynamicUpdate가 무엇인지 ?

JPA의 구현체인 하이버네이트(Hibernate)에서 제공하는 어노테이션이다.

엔티티의 INSERT 및 UPDATE 쿼리를 생성하는 방식을 최적화하기 위해 사용된다.

하이버네이트가 INSERT 또는 UPDATE 쿼리를 고정된 형태가 아닌,

실제 엔티티의 상태에 따라 동적으로 생성하도록 하여 성능상 이점을 얻기 위해 사용하는 어노테이션 !

 

2️⃣ @DynamicInsert와 @DynamicUpdate가 어떻게 작동되는 지 ?

기존 방식

  1. JPA (정확히는 하이버네이트와 같은 JPA 구현체)는 기본적으로 엔티티의 영속성 상태 변화를 감지하여 자동으로 SQL 쿼리를 생성하고 실행한다.이 방식은 쿼리 생성 로직이 단순하다는 장점이 있지만, 불필요하게 많은 컬럼을 포함시키는 단점이 있다.
  2. 예시 
    1. INSERT: 기본적으로 모든 컬럼을 INSERT 문에 포함시킴
    2. UPDATE: 업데이트 가능한 모든 컬럼을 UPDATE 문의 SET 절에 포함시킴.

 

@DynamicInsert, @DynamicUpdate 적용

  1. 1. 엔티티 클래스 레벨에 @DynamicInsert 를 붙이면 적용된다.
  2. 예시
    1. INSERT: null이 아닌 필드들만 INSERT 문에 포함시킴
    2. UPDATE: 엔티티가 컨텍스트에 로딩된 후 실제로 값이 변경된 필드들만 SET절에 포함시킴

 

 

3️⃣ 적용 시 장단점

구분 기존 @DynamicInsert/Update
장점 쿼리 생성 로직이 단순하여 하이버네이트 내부 처리 부하가 적음 - 쿼리 문자열이 짧아지기 때문에 네트워크 부하가 줄어듦
- 불필요한 컬럼의 삽입/갱신을 막아 데이터베이스 부하를 줄일 수 있음
- null 값 삽입시 DB의 DEFAULT값이 적용되도록 유도할 수 있음.
- 실제 변경된 컬럼만 갱신하여 동시성 충돌 가능성을 줄일 수 있음
단점 - 불필요하게 많은 컬럼을 쿼리에 포함시켜 쿼리 문자열이 길어지고 네트워크 부하가 증가할 수 있음
- 데이터베이스의 DEFAULT 값이 아닌 명시적인 NULL이 삽입될 수 있음 (@DynamicInsert 시 기대하는 동작과 다를 수 있음)
- 값이 변경되지 않은 컬럼도 다시 갱신하여 불필요한 쓰기 작업이 발생할 수 있음.
어떤 컬럼을 쿼리에 포함시킬지 결정하는 추가적인 내부처리가 필요하여 하이버네이트 내부 처리 부하가 소폭 증가할 수 있음

 

 

4️⃣ 언제 적용하면 좋을까 ?

  • 엔티티의 컬럼 수가 매우 많은 경우
    • 컬럼 수가 많을 수록 불필요하게 포함되는 컬럼이 많아져 쿼리 문자열이 길어지고 네트워크 및 데이터베이스 부하가 커진다.
  • INSERT 시 대부분의 컬럼이 null이거나 DB의 DEFAULT 값을 사용하고 싶은 경우
    • @DynamicInsert → 명시적으로 null을 삽입하는 대신 DB의 기본값을 사용하도록 유도
  • UPDATE 시 변경되는 컬럼의 수가 적은 경우
    • 전체 컬럼 중 실제로 몇 개만 변경되는 경우가 빈번하다면, 변경된 컬럼만 갱신함으로써 데이터베이스 쓰기 부하를 줄이고 쿼리 크기를 최적화할 수 있음.
  • 성능 최적화가 필요한 와이드(Wide) 테이블 (컬럼이 많은 테이블): 대규모 데이터 처리에서 쿼리 크기나 불필요한 쓰기 작업이 병목이 될 수 있다면 고려해볼 수 있음.

🚨 주의사항

  • @DynamicInsert와 @DynamicUpdate는 하이버네이트의 확장 기능이며 표준 JPA 기능은 아니다.
  • 성능 측정 없이는 명확한 효과를 단정하기 어려움. 때로는 기본 동작이 더 나은 성능을 보이기도 함.
  • 단순히 컬럼 수가 적고 변경이 잦은 테이블에는 큰 이점이 없을 수 있으며, 오히려 내부 처리 부하 때문에 미미하게나마 성능 저하가 있을 수 있음.

 

DriveMate 에서 음성 챗봇 대화를 끝낸 뒤 client에서 main server로 chat log를 보내주면,

chatlog도 저장하고, 대화의 한줄 요약과 대화 키워드들도 추출해서 저장한다.

추출된 요약문과 키워드는 다음 대화에서 더욱 더 개인화된 대화를 구현하기 위해 사용된다.

 

1. 프로젝트 설정

먼저, Open API를 이용하기 위해서는 OpenAI에 신용카드를 등록해야 한다.

이후 발급된 API Key를 헤더에 넣어서 request를 보내면 된다.

1.1 의존성 추가

SpringBoot 프로젝트에서 ChatGPT와 통신하기 위해 RestTemplate 또는 WebClient를 사용할 수 있다.

나는 RestTemplate를 사용하여 구현하였다. 

build.gradle 파일에 spring-boot-starter-web 의존성을 추가하였다.

implementation 'org.springframework.boot:spring-boot-starter-web'

 

 

1.2 OpenAI API Key 설정

application.properties에 openAI 정보를 설정한다.

openai.api.key={your-api-key-here}
openai.api.url=https://api.openai.com/v1/chat/completions

 

 

2. ChatGptService 클래스 구현

chatGPT API와의 통신을 처리하는 ChatGptService 클래스를 작성한다.

이 클래스는 대화 내용을 받아서 요약과 키워드를 추출하고, 이를 반환하는 역할을 한다.

본인이 GPT를 이용해서 어떤 명령을 처리하고, 어떤 출력을 받고 싶은 지 구현하면 된다.

@Service
public class ChatGptService {

    @Value("${openai.api.key}")
    private String apiKey;

    @Value("${openai.api.url}")
    private String apiUrl;

    private final RestTemplate restTemplate;
    private final ObjectMapper objectMapper;

    public ChatGptService(RestTemplate restTemplate, ObjectMapper objectMapper) {
        this.restTemplate = restTemplate;
        this.objectMapper = objectMapper;
    }

    public GptResponseDto generateSummaryAndKeywords(String conversation) {
        String prompt =
                "Please summarize the following conversation in one sentence and extract key keywords. Provide the summary and keywords **in Korean**. The summary must be only one sentence.\n" +
                "\n" +
                "Summary: {your summary}\n" +
                "Keywords: {keyword1, keyword2, keyword3}\n" +
                "\n" +
                "Conversation:\n" +
                chatLog;

        HttpHeaders headers = new HttpHeaders();
        headers.set("Authorization", "Bearer " + apiKey);
        headers.setContentType(MediaType.APPLICATION_JSON);
        
        Map<String, Object> requestBody = new HashMap<>();
        requestBody.put("model", "gpt-4");
        requestBody.put("max_tokens", 150);

        List<Map<String, String>> messages = new ArrayList<>();
        messages.add(createMessage("system", "You are a helpful assistant that summarizes conversations and extracts keywords."));
        messages.add(createMessage("user", prompt));

        requestBody.put("messages", messages);

        String body = "{"
                + "\"model\": \"text-davinci-003\","
                + "\"prompt\": \"" + prompt + "\","
                + "\"max_tokens\": 150"
                + "}";

        String jsonBody;
        try {
            jsonBody = objectMapper.writeValueAsString(requestBody);
        } catch (Exception e) {
            throw new GeneralException(ErrorStatus.AI_BODY_ERROR);
        }

        HttpEntity<String> request = new HttpEntity<>(jsonBody, headers);

        ResponseEntity<String> response = restTemplate.exchange(apiUrl, HttpMethod.POST, request, String.class);

        try {
            JsonNode responseJson = objectMapper.readTree(response.getBody());
            JsonNode firstChoice = responseJson.get("choices").get(0);
            JsonNode messageNode = firstChoice.get("message");

            if (messageNode == null) {
                throw new GeneralException(ErrorStatus.EXTERNAL_API_ERROR);
            }

            JsonNode contentNode = messageNode.get("content");
            if (contentNode == null) {
                throw new GeneralException(ErrorStatus.EXTERNAL_API_ERROR);
            }

            String getResponse = contentNode.asText().trim();

            String[] parts = getResponse.split("Keywords:", 2);
            String summary = parts[0].trim();
            String keywords = parts.length > 1 ? parts[1].trim() : "";

            if (summary.startsWith("Summary:")) {
                summary = summary.substring("Summary:".length()).trim();
            }

            return GptResponseDto.GptSummaryKeywordDto.builder()
                    .summary(summary)
                    .keywords(keywords)
                    .build();

        } catch (Exception e) {
            throw new GeneralException(ErrorStatus.CHATGPT_PARSING_ERROR);
        }

    }
}

 

위 코드에서 generateSummaryAndKeywords 메소드는 대화 내용을 ChatGPT에 전달하고, 응답에서 요약과 키워드를 추출하여 반환한다. 이때, 요청의 형식은 Summary Keywords {}로 감싸서 받도록 프롬프트를 설정했다.

 

2.2 Controller

대화 로그를 받아 ChatGPT API와 연결하고, 요약과 키워드를 반환하는 API를 작성하였다.

@PostMapping("/process")
    public ResponseEntity<GptResponseDto> processConversation(
    		@RequestBody String conversation,
            @AuthenticationPrincipal CustomUserPrincipal userPrincipal) {
            
        // GPT 서비스 호출하여 요약 및 키워드 추출
        GptResponseDto gptResponse = chatGptService.generateSummaryAndKeywords(conversation, userPrincipal);

        return ApiResponse.onSuccess(SuccessStatus._OK, response);
    }

 

포스트에는 GPT 연결과 실질적인 사용을 위한 코드를 올려두었고,

그 외에 DB에 저장하는 로직이나, 다른 처리들은 생략하였다.

 

+ 결제한 모델에 따라서 코드나 설정이 달라지므로, 주의하기 !

 


결과

 

'SpringBoot' 카테고리의 다른 글

@DynamicInsert, @DynamicUpdate  (0) 2025.05.19
서블릿 vs. Spring MVC 비교  (0) 2025.04.07
AOP(Aspect-Oriented Programming)  (0) 2025.04.07
[SpringBoot] 자동 삭제 기능 구현 Scheduled Job  (0) 2025.01.21
[SpringBoot] API & Paging  (0) 2024.11.22

전통적인 서블릿(Servlet) 기반 개발

  • HttpServlet : 서블릿은 HttpServlet 클래스를 상속받아 사용한다. doGet()과 doPost() 메서드를 오버라이드하여 HTTP GET 및 POST 요청을 처리하며, 요청을 받으면 직접 처리하고 응답을 작성하는 형태로 동작한다.

doGet() / doPost() 방식:

  • doGet(): 클라이언트가 GET 방식으로 서버에 요청할 때 호출되는 메서드. 주로 데이터를 조회하는 데 사용.
  • doPost(): 클라이언트가 POST 방식으로 서버에 요청할 때 호출되는 메서드. 데이터 전송 및 처리에 사용.

서블릿에서는 클라이언트의 요청을 하나하나 처리하며, 직접적으로 요청과 응답을 관리해야 하므로 코드가 길고, 관리가 어려움

또한 여러 요청을 처리할 때마다 조건문 등을 사용하여 각 요청에 맞는 처리 로직을 작성해야 하므로 코드가 복잡해질 수 있음.

 

 

Spring MVC

  • @Controller: Spring MVC에서 클라이언트 요청을 처리하는 클래스는 @Controller로 선언한다. 이는 서블릿에서 직접 처리하던 요청을 좀 더 구조적이고 간편하게 처리할 수 있도록 해준다.
  • @RequestMapping: @RequestMapping은 요청 URL과 메서드를 매핑하여 처리하는 데 사용된다. 이를 통해 각 요청에 대한 처리 로직을 명확히 분리할 수 있다.
  • DispatcherServlet: Spring MVC의 핵심 컴포넌트로, 모든 요청을 중앙에서 처리한다. 클라이언트 요청이 들어오면, DispatcherServlet이 해당 요청을 어떤 컨트롤러가 처리할지 결정하고, 실제 처리할 컨트롤러에게 요청을 위임한다.

Spring MVC는 클라이언트 요청을 분리된 처리 방식으로 구조화하여 코드의 유지보수성과 확장성을 높인다.

서블릿 기반 개발은 요청과 응답을 직접 처리하는 반면, Spring MVC는 컨트롤러, 서비스, 뷰 등의 역할을 명확하게 분리하여 개발자가 각 부분을 독립적으로 관리할 수 있도록 도와준다.

 

 

Spring MVC가 왜 서블릿보다 편리한지?

  • Spring MVC는 웹 애플리케이션 개발을 위한 표준화된 구조를 제공한다. 이를 통해 개발자는 직접 서블릿을 작성하고, 요청을 처리하는 복잡한 작업을 하지 않아도 된다.
  • 구조화된 개발 : Spring MVC는 MVC(Model-View-Controller) 패턴을 따르므로, 비즈니스 로직UI 로직컨트롤러를 분리하여 코드가 더 깔끔하고 이해하기 쉬운 구조로 나누어 진다.
  • 핸들러 매핑 : Spring MVC는 요청 URL을 특정 컨트롤러 메서드에 매핑하여, 개발자가 직접 URL을 처리할 필요 없이 쉽게 로직을 처리할 수 있다.
  • 자동화된 요청 처리: DispatcherServlet을 통해 모든 요청을 중앙에서 처리하고, 이를 적절한 컨트롤러와 매핑하는 방식으로 개발자는 복잡한 요청 처리 로직을 직접 작성할 필요가 없다.
  • 유연성: Spring MVC는 다양한 뷰 기술(예: JSP, Thymeleaf, FreeMarker 등)을 지원하며, 요청 처리 방식을 유연하게 확장할 수 있다.

 

 

DispatcherServlet이 내부적으로 요청을 처리하는 방식

단계별 요청 처리 과정

  1. 요청 수신
    • 클라이언트의 HTTP 요청이 DispatcherServlet에 전달된다. 이 때, 요청 URL, HTTP 메서드(GET, POST 등), 파라미터 등이 포함된 요청 정보가 전달된다.
  2. HandlerMapping
    • DispatcherServlet은 HandlerMapping을 사용하여 요청을 처리할 적절한 컨트롤러를 찾습니다.
    • HandlerMapping은 URL 패턴과 메서드 정보를 바탕으로 해당 요청을 처리할 컨트롤러와 메서드를 결정한다.
  3. HandlerAdapter
    • HandlerMapping이 반환한 컨트롤러 메서드에 대한 핸들러 어댑터를 사용하여 실제 요청을 처리할 메서드를 실행합니다.
    • 핸들러 어댑터는 다양한 종류의 컨트롤러를 지원할 수 있도록 돕는 역할을 합니다. 예를 들어, @Controller가 선언된 클래스나 @RestController 등을 처리할 수 있다.
  4. 어드바이스 실행 (Advice Execution)
    • Spring AOP(Aspect-Oriented Programming)와 결합된 경우, 요청 전후에 어드바이스가 실행될 수 있다. 예를 들어, 트랜잭션 시작/종료, 로깅 등의 작업이 이 단계에서 수행된다.
  5. 뷰 리졸버(ViewResolver):
    • 컨트롤러가 반환한 모델 데이터를 바탕으로 적절한 뷰를 결정하고, 이를 사용하여 응답을 생성한다. 이때, 뷰 템플릿(예: JSP, Thymeleaf)을 사용하여 최종적인 HTML을 생성한다.
  6. 응답 전송
    • 최종적으로 생성된 응답 HTML을 클라이언트에 전송한다.

AOP란?

AOP (Aspect-Oriented Programming, 관점 지향 프로그래밍)은 프로그램의 핵심 비즈니스 로직과는 별개로 공통 관심사(cross-cutting concerns)를 분리하여 모듈화하는 프로그래밍 패러다임이다.

주로 반복적으로 사용되는 기능(예: 로깅, 보안, 트랜잭션 관리 등)을 핵심 로직에서 분리하여, 코드의 재사용성을 높이고 유지보수를 용이하게 만든다.

 

 

왜 필요한가 ?

  • 코드 중복 제거: 공통적으로 발생하는 기능을 여러 클래스에서 매번 반복적으로 작성하는 것보다, 한 곳에 정의해 두고 필요한 곳에서 사용하는 방식으로 코드 중복을 줄일 수 있다.
  • 모듈화: 공통 기능을 핵심 비즈니스 로직과 분리하여 코드의 구조를 더욱 깔끔하고 유지보수하기 쉬운 형태로 만들 수 있다.
  • 유지보수 용이성: 특정 기능을 수정할 때, 코드 전반에 영향을 미치지 않고 한 곳에서만 수정하면 되므로, 수정 및 유지보수가 용이해진다.
  • 응집도 높은 코드: 핵심 비즈니스 로직은 핵심 비즈니스에만 집중하고, 공통 관심사는 AOP로 처리하므로 코드의 응집도가 높아진다.

 

OOP와 AOP의 차이점

OOP는 주로 클래스와 객체 간의 관계를 중시하며, 이를 통해 데이터와 행동을 캡슐화하고 상속, 다형성 등을 통해 기능을 확장한다.

반면, AOP는 공통 관심사를 Aspect라는 단위로 분리하여, 핵심 비즈니스 로직과 독립적으로 관리한다.

AOP는 OOP의 개념을 보완하는 방식으로, 반복적인 코드와 부가적인 관심사를 처리하는 데 사용된다.

 

 

 

AOP의 핵심 개념

  • Aspect (관점): 애플리케이션의 여러 부분에 걸쳐 나타나는 공통 관심사를 뜻함. 예를 들어, 로깅, 보안, 트랜잭션 관리 등이 Aspect로 다뤄진다.
  • Join Point (조인 포인트): Aspect가 적용될 수 있는 프로그램의 특정 지점. 예를 들어, 메서드 호출, 예외 처리, 필드 접근 등이 조인 포인트가 될 수 있다.
  • Advice (어드바이스): Join Point에서 실행할 구체적인 행동을 정의하는 부분. 예를 들어, 메서드 실행 전후에 수행될 작업을 정의한다.
  • Pointcut (포인트컷): Advice가 적용될 조인 포인트들을 정의하는 표현식. 특정 메서드나 클래스에 대해 AOP를 적용할지를 지정한다.
  • Weaving (위빙): Aspect와 관련된 로직을 실제 코드에 결합하는 과정이다. AOP는 이 과정을 통해 Aspect를 기존 코드에 삽입한다.

 

 

 

AOP가 적용되는 런타임 위빙 vs 컴파일 타임 위빙의 차이점

  • 런타임 위빙 (Runtime Weaving):
    • AOP 적용이 실행 시점에 이루어진다.
    • 프록시 객체를 생성하여, 메서드 호출 전에 advice를 실행하는 방식.
    • Spring AOP가 런타임 위빙을 사용하며, 프록시 패턴을 활용해 동작한다.
    • 코드 변경 없이도 실행 중에 AOP를 적용할 수 있기 때문에 유연성이 뛰어나다.
  • 컴파일 타임 위빙 (Compile-time Weaving):
    • AOP 적용이 컴파일 시점에 이루어진다.
    • AOP 관련 코드가 컴파일 시에 자동으로 추가된다.
    • AspectJ와 같은 도구가 컴파일 타임 위빙을 사용한다.
    • 컴파일 시점에 위빙이 이루어지므로 성능이 향상될 수 있지만, 유연성은 떨어질 수 있다.

 

 

스프링에서 AOP가 프록시 패턴을 활용하여 동작하는 원리

Spring AOP는 프록시 패턴을 사용하여 AOP 기능을 구현한다.

프록시 패턴을 사용하면, 실제 객체에 대한 접근을 프록시 객체가 대신 처리하고, 프록시 객체에서 advice를 실행한다.

프록시 패턴 동작 원리

  1. 클라이언트가 실제 객체를 호출하는 대신 프록시 객체 호출
  2. 프록시 객체는 실제 객체의 메서드를 호출하기 전에 또는 후에 advice를 실행함.
    • 예를 들어, 메서드 실행 전에 로그를 찍거나 트랜잭션을 시작하는 작업을 할 수 있음
  3. 어드바이스가 실행되고, 그 후 실제 객체의 메서드가 호출됨.

Spring AOP는 기본적으로 JDK 동적 프록시와 CGLIB를 사용하여 프록시 객체를 생성함.

JDK 동적 프록시는 인터페이스 기반의 프록시를 만들고, CGLIB는 클래스 기반의 프록시를 만든다.

 

 

프록시 패턴 사용 이유:

  • 실제 객체의 메서드 호출을 가로채서, 추가적인 기능(로깅, 트랜잭션 관리 등)을 처리할 수 있게 된다.
  • 실제 객체를 변경하지 않고, 단일 책임 원칙에 따라 공통 기능을 분리할 수 있다.

 

작년에 node.js로 프로젝트 할 때는 node-cron으로 자동 삭제 기능을 구현했던 기억이 있다.

보통 데이터는 바로 hard-delete하는 방식이 아니라

is_deleted 값을 true로 바꾸는 soft-delete 방식으로 구현되기 때문에

soft-delete된 아이들 중 특정 시간이 지난(1년, 30일 등등..) 아이들은 서버가 실행될 때 자동으로 삭제 되는 방식으로 구현한다.

 

이번 에디슨 프로젝트는 SpringBoot로 구현하고 있어서 스프링으로는 어떻게 하는 지 정리해두려고 한다.

 

구현 목표 : Spring의 @Scheduled을 활용하여 일정 간격으로 30일 지난 버블을 삭제

 

Config

@Configuration
@EnableScheduling
public class SchedulingConfig {
    // 스케줄링 활성화
}

 

BubbleServiceImpl

@Override
@Scheduled(cron = "0 0 0 * * ?") // 매일 새벽 0시에 실행
    public void deleteExpiredBubble() {
        LocalDateTime now = LocalDateTime.now();
        LocalDateTime expiryDate = now.minusDays(30);

        List<Bubble> expiredBubbles = bubbleRepository.findAllByUpdatedAtBeforeAndIsDeletedTrue(expiryDate);

        if (!expiredBubbles.isEmpty()) {
            bubbleRepository.deleteAll(expiredBubbles);
            log.info("Deleted {} expired bubbles", expiredBubbles.size());
        } else {
            log.info("No expired bubbles found for deletion");
        }

    }

 

BubbleRepository

@Query("SELECT b FROM Bubble b WHERE b.updatedAt < :expiryDate AND b.isDeleted = true")
List<Bubble> findAllByUpdatedAtBeforeAndIsDeletedTrue(@Param("expiryDate") LocalDateTime expiryDate);

 

 

  • @Scheduled을 사용하여 매일 특정 시간에 자동으로 30일 지난 삭제된 버블을 삭제한다.
  • updatedAt 기준으로 30일이 지난 isDeleted = true 상태의 버블 (휴지통에 있는 버블) 을 찾아 삭제한다.

 

결과

- 휴지통에 만료된 버블이 없을 때 

 

- 휴지통에 만료된 버블이 있을 때

 

 

+ 수정 : Error Shooting

위에서 한 것처럼 간단하게 구현하니.. 자동삭제 될 때, fk constraint 관련 에러가 발생했다.

삭제하기 전 관련 아이디를 참조하는 값을 null로 바꿔주는 로직을 추가해서 해결하였읍니다 :>

'SpringBoot' 카테고리의 다른 글

서블릿 vs. Spring MVC 비교  (0) 2025.04.07
AOP(Aspect-Oriented Programming)  (0) 2025.04.07
[SpringBoot] API & Paging  (0) 2024.11.22
[DriveMate] SpringBoot 프로젝트 생성  (1) 2024.11.15
Spring IoC 컨테이너  (1) 2024.10.10

목록 조회가 필요한 API는 어떻게 만들지, 그리고 홈 화면처럼 정보량이 많으면 어떻게 할지에 대해서 

API 구현을 처음부터 끝까지 해보려고 한다.

 

예시로 가게의 리뷰 목록 조회 API 를 구현해보자!

 

 📜 목록 조회 API를 만들기 위해 필요한 정보 알아보기!

  1. 닉네임
  2. 리뷰의 점수
  3. 리뷰가 작성된 날짜
  4. 리뷰의 상세 내용

API 만드는 순서

🧭 (👉 전제 조건은 API URL은 설계가 되었다는 전제입니다!)

  1. API 시그니처를 만든다.
  2. API 시그니처를 바탕으로 swagger에 명세를 해준다
  3. 데이터베이스와 연결하는 부분을 만든다
  4. 비즈니스 로직을 만든다
  5. 컨트롤러를 완성한다
  6. validation 처리를 한다 

 

1. API 시그니처 만들기

  • 응답과 요청 DTO 만들기
  • 컨트롤러에서 어떤 형태를 리턴하는 지, 어떤 파라미터가 필요한지, URI는 무엇인지, HTTP Method는 무엇인지만 정해두기
  • 컨버터 정의만 해두기

1.1 DTO 작성

나는 이 두 DTO를 dto 파일의 ReviewResponse 클래스 안에 넣어두었다.

@Builder
@Getter
@NoArgsConstructor
@AllArgsConstructor
public static class ReviewPreviewListDTO{
    List<ReviewPreviewListDTO> reviewList;
    Integer listSize;
    Integer totalPage;
    Long totalElements;
    Boolean isFirst;
    Boolean isLast; 
}

@Builder
@Getter
@NoArgsConstructor
@AllArgsConstructor
public static class ReviewPreviewDTO{
    String ownerNickname;
    Float rating;
    String content;
    LocalDateTime createdAt;
}

 

위 코드를 보면, static class로 DTO를 만들었다.

DTO 클래스 파일을 줄여서 파일 아키텍처 가독성을 높이기 위함이다.

자잘한 DTO를 전부 다 각각 하나의 클래스로 만들면,

필요한 클래스를 찾을 때 찾기가 힘들고 프로젝트 구조를 알아보기가 힘들어진다.

 

따라서 큰 단위의 DTO를 하나의 클래스로 두고 하위 자잘한 DTO들은 static class로 둔다.

 

리뷰 '목록'이기 때문에 리뷰의 정보들의 목록이 필요하다.

그래서 리뷰의 정보를 담은 DTO(ReviewPreviewDTO)를 List를 담은 또 다른 DTO를 만든다.

 

만약에 리뷰에서 나타내는 사용자의 정보가 닉네임 말고도 더 많았다면,

아래 코드 처럼 사용자의 정보 자체를 DTO로 구성하는 것이 좋다.

public static class ReviewPreviewDTO{
	MemberInfoDTO memberinfo;
    Float rating;
    String content;
    LocalDateTime createdAt;
}

 

 

1.2 Converter 정의만 작성 해두기 

컨트롤러는 아래와 같이 일단 return null로만 만들어둔다.

@RestController
@RequiredArgsConstructor
@RequestMapping("/store")
public class StoreRestController {

    @GetMapping("/{storeId}/reviews")
    public ApiResponse<ReviewResponse.ReviewPreviewListDTO> getReviewList(@ExistStore @PathVariable(name = "storeId") Long storeId) {
        return null;
    }
}

 

 

컨버터 코드

아래 코드를 ReviewConverter 클래스에 넣어두었다.

    public static ReviewResponse.ReviewPreviewDTO reviewPreviewDTO(Review review) {
        return null;
    }
    public static ReviewResponse.ReviewPreviewListDTO reviewPreviewListDTO(List<Review> reviewList) {
        return null;
    }

 

일단 return null로 해두고 서비스의 메서드를 만들면서 완성하거나 서비스 메서드 완성 후 세부 로직을 구현해도 된다.

 

 

1.3 Swagger를 이용한 API 명세 = 컨트롤러 메서드 정의만 해두기

스웨거에 API가 완성되지 않았음에도 명세를 해두는 이유는 프론트엔드 개발자와의 개발 과정에서 병목을 최대한 줄이기 위함 !!S

API 하나를 모두 완성한 후에 명세를 하게 되면 프엔 개발자는 해당 API가 완성이 될때까지

다른 API의 응답을 모르기 때문에 작업을 멈추게 된다.

이런 상황을 최대한 막기 위해 우선적으로 응답 Data의 형태를 알려주어 프엔 개발자도

미리 API 연결 부분을 작업 해둬 최대한 개발을 병렬적으로 할 수 있도록 한다.

 

따라서 되도록 많은 API 시그니처를 빠르게 만들 것을 추천 !

 

Controller 부분을 정의만 해두면서 동시에 스웨거 명세를 한다.

@RestController
@RequiredArgsConstructor
@RequestMapping("/store")
public class StoreRestController {

    private final StoreQueryService storeQueryService;

    @GetMapping("/{storeId}/reviews")
    @Operation(summary = "특정 가게의 리뷰 목록 조회 API",description = "특정 가게의 리뷰들의 목록을 조회하는 API이며, 페이징을 포함합니다. query String 으로 page 번호를 주세요")
    @ApiResponses({
            @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200",description = "OK, 성공"),
            @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "AUTH003", description = "access 토큰을 주세요!",content = @Content(schema = @Schema(implementation = ApiResponse.class))),
            @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "AUTH004", description = "acess 토큰 만료",content = @Content(schema = @Schema(implementation = ApiResponse.class))),
            @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "AUTH006", description = "acess 토큰 모양이 이상함",content = @Content(schema = @Schema(implementation = ApiResponse.class))),
    })
    @Parameters({
            @Parameter(name = "storeId", description = "가게의 아이디, path variable 입니다!")
    })
    public ApiResponse<ReviewResponse.ReviewPreviewListDTO> getReviewList(@ExistStore @PathVariable(name = "storeId") Long storeId, @RequestParam(name = "page") Integer page) {
        storeQueryService.getReviewList(storeId, page);
        return null;
    }
}
  • @Operation은 이 API에 대한 설명을 넣게 되며 summary, description으로 설명을 적음
  • @ApiResponses로 이 API의 응답을 담게 되며 내부적으로 @ApiResponse로 각각의 응답들을 담음
  • @Parameters 는 프론트엔드에서 넘겨줘야 할 정보를 담으며, 위의 코드에선 일단 path variable만 기재했고, API 완성 단계에서 query String도 추가할 것.

에러 상황에 대해서만 content = 를 통해 형태를 알려줬고,성공에 대해서는 content를 지정하지 않음

content가 없으면 -> ApiResponse<ReviewResponseDTO.ReviewPreViewListDTO>

여기서 ReviewResponseDTO.ReviewPreViewListDTO가 응답 형태로 보여지게 됩니다.

 

 

 

2. Service 메서드 로직 작성 + Repository 메서드 작성

가장 복잡한 과정ㅜㅜ

서비스 로직을 작성하다 보면 리포지토리의 메서드가 필요함을 알게 되고, 리포지토리의 메서드를 처음부터 만들기에는 어떤 비즈니스 로직에서 필요한지 모르기에 두 과정을 섞어가며 진행하게 된다.

외부 API를 호출 할 경우, Feign Client등과 같은 외부 API 호출 부분도 해당 과정에서 이뤄진다.

 

Review Repository

public interface ReviewRepository extends JpaRepository<Review, Long>, ReviewRepositoryCustom {
    Page<Review> findAllByStore(Store store, PageRequest pageRequest);
}

 

이 코드는 Spring Date JPA에서 메서드 이름만으로 SQL을 만들어주는 기능을 활용한 것

PageRequest는 페이징과 관련된 옵션이 포함된다.

 

ServiceImpl

@Service
@RequiredArgsConstructor
public class StoreQueryServiceImpl implements StoreQueryService{

    private final StoreRepository storeRepository;
    
    private final ReviewRepository reviewRepository;

    // ... 다른 코드들

    @Override
    public Page<Review> getReviewList(Long StoreId, Integer page) {

        Store store = storeRepository.findById(StoreId).get();

        Page<Review> StorePage = reviewRepository.findAllByStore(store, PageRequest.of(page, 10));
        return StorePage;
    }
}

 

converter

public class ReviewConverter {

    public static ReviewResponse.ReviewPreviewDTO reviewPreviewDTO(Review review) {
        return ReviewResponse.ReviewPreviewDTO.builder()
                .ownerNickname(review.getUser().getNickname())
                .rating(review.getRating())
                .createdAt(review.getCreatedAt())
                .content(review.getContent())
                .build();
    }
    public static ReviewResponse.ReviewPreviewListDTO reviewPreviewListDTO(List<Review> reviewList) {
        List<ReviewResponse.ReviewPreviewDTO> reviewPreviewDTOList = reviewList.stream()
                .map(ReviewConverter::reviewPreviewDTO).collect(Collectors.toList());

        return ReviewResponse.ReviewPreviewListDTO.builder()
                .isLast(reviewList.isLast())
                .isFirst(reviewList.isFirst())
                .totalPage(reviewList.getTotalPages())
                .totalElements(reviewList.getTotalElements())
                .listSize(reviewPreviewDTOList.size())
                .reviewList(reviewPreviewDTOList)
                .build();
    }
}

 

 

그리고 .ownerNickname(review.getMember().getName())

이 코드를 통해 review에 @MantyToOne으로 지정해둔 Member를 통해 아주 편하게 데이터를 가져오는 것을 확인 할 수 있다.

이는 객체 그래프 탐색 이라는 Spring Data JPA에서 사용 가능한 아주 강력한 기능이다

 

이제 컨트롤러를 컨버터에 맞게 바꿔준다

@RestController
@RequiredArgsConstructor
@Validated
@RequestMapping("/stores")
public class StoreRestController {

    private final StoreQueryService storeQueryService;

    @GetMapping("/{storeId}/reviews")
    @Operation(summary = "특정 가게의 리뷰 목록 조회 API",description = "특정 가게의 리뷰들의 목록을 조회하는 API이며, 페이징을 포함합니다. query String 으로 page 번호를 주세요")
    @ApiResponses({
            @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200",description = "OK, 성공"),
            @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "AUTH003", description = "access 토큰을 주세요!",content = @Content(schema = @Schema(implementation = ApiResponse.class))),
            @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "AUTH004", description = "acess 토큰 만료",content = @Content(schema = @Schema(implementation = ApiResponse.class))),
            @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "AUTH006", description = "acess 토큰 모양이 이상함",content = @Content(schema = @Schema(implementation = ApiResponse.class))),
    })
    @Parameters({
            @Parameter(name = "storeId", description = "가게의 아이디, path variable 입니다!")
    })
    public ApiResponse<StoreResponseDTO.ReviewPreViewListDTO> getReviewList(@ExistStore @PathVariable(name = "storeId") Long storeId,@RequestParam(name = "page") Integer page){
        Page<Review> reviewList = storeQueryService.getReviewList(storeId,page);
        return ApiResponse.onSuccess(StoreConverter.reviewPreViewListDTO(reviewList));
    }
}

 

 

결과

코드 다 짜고 나서

내가 처음에 설계할 때 리뷰를 가게에 다는 것이 아닌 미션에 달도록 설계했다는 것을 깨달았다...

다 고치고 나서 다시 돌렸다. ㅜㅜ

+ Recent posts