N + 1 문제

  • 연관관계가 설정된 엔티티를 조회할 경우에 조회된 데이터 갯수만큼 연관관계의 조회 쿼리가 추가로 발생하는 것
  • 연관된 데이터를 조회할 때 비효율적인 쿼리가 발생하는 문제

만약 주인은 3명이 있고, 고양이는 9마리가 있다고 하자

주인 1명당 고양이를 3마리씩 소유하고 있음

이때 주인 엔티티를 조회하게 되면 쿼리는 총 4번 날라가게 된다!

SELECT * FROM Owner; → 회원 3명을 조회하는 1번의 쿼리
SELECT * FROM Cat WHERE owner_id = ?; → 각 회원마다 고양이를 조회하는 3번의 쿼리

 

해결방법

이렇게 여러번의 select 쿼리가 날라가지 않게 하기 위해서는 join쿼리를 날리고 싶기 마련이다

select * from owner left join cat on cat.owner_id = owner.id

 

JPQL을 이용한 일반 JOIN 쿼리

지난 프로젝트때 실제로 이렇게 쿼리를 작성해서 데이터를 조회했었다 🫣

하지만 이렇게 하면 실제로는 위와 똑같이  N+1 문제가 동일하게 발생한다

@Query("SELECT o FROM Owner o JOIN o.cats c")
List<Owner> findAllWithJoin();

 

JOIN과 FETCH JOIN 차이

  • JOIN은 관계된 엔티티를 결합하여 데이터를 조회하지만, 연관된 엔티티는 지연 로딩(Lazy Loading) 방식으로 처리되며, 추가적인 쿼리가 실행
  • FETCH JOIN은 관계된 엔티티를 즉시 로딩(Eager Loading)하며, 연관된 엔티티도 한 번의 쿼리로 조회하여 N+1 문제 방지

 

FETCH JOIN

  • Cat과 Owner를 즉시 로딩하므로, Cat과 Owner를 함께 한 번의 쿼리로 조회됨
  • FetchType을 Lazy로 해놓는것이 무의미
  • 하나의 쿼리문으로 가져오다 보니 페이징 단위로 데이터를 가져오는것이 불가능하므로 페이징 쿼리를 사용할 수 없음
  • inner join으로 호출
@Query("SELECT c FROM Cat c JOIN FETCH c.owner o")
List<Cat> findAllCatsWithOwners();

 

수행되는 쿼리 : 1번만 수행

SELECT c.id, c.name, o.id, o.name
FROM Cat c
JOIN Owner o ON c.owner_id = o.id;

 

EntityGraph

  • Cat과 Owner를 즉시 로딩하므로, Cat과 Owner를 함께 한 번의 쿼리로 조회됨
  • outer join으로 호출
@EntityGraph(attributePaths = "cats")
@Query("select o from Owner o")
List<Owner> findAllEntityGraph();

 

수행되는 쿼리 : 1번만 수행

SELECT o.*, c.*
FROM owner o
LEFT JOIN cat c ON o.id = c.owner_id;

 

 

Fetch Join과 EntityGraph 주의할 점

Cartesian Product가 발생하여 Owner의 수만큼 Cat이 중복 데이터가 존재 -> 중복된 데이터가 컬렉션에 존재하지 않도록 주의

이런식으로 데이터가 들어오기 때문에 set을 사용하여 데이터를 담아줘야 한다!

public List<OwnerDTO> convertToDTO(List<Owner> owners) {
    Set<OwnerDTO> ownerDTOs = new LinkedHashSet<>();  // Set 사용하여 중복 제거

    for (Owner owner : owners) {
        // 새로 OwnerDTO 생성
        List<CatDTO> catDTOs = new ArrayList<>();
        for (Cat cat : owner.getCats()) {
            catDTOs.add(new CatDTO(cat.getId(), cat.getName()));
        }

        // OwnerDTO를 Set에 추가
        OwnerDTO ownerDTO = new OwnerDTO(owner.getId(), owner.getName(), catDTOs);
        ownerDTOs.add(ownerDTO);  // 중복 제거
    }

    // Set을 List로 변환해서 반환
    return new ArrayList<>(ownerDTOs);
}

 

 

FETCH JOIN을 사용했는데 Page객체로 받았다면?

  • JPA는 페이징을 DB에서 하지 않고, 모든 데이터를 가져와서 메모리에서 페이징을 시도
  • 결과적으로 OutOfMemoryError 등의 성능 문제가 발생가능성이 생김
@Query("SELECT t FROM Todo t JOIN FETCH t.user ORDER BY t.modifiedAt DESC")
Page<Todo> findAllTodos(Pageable pageable);

 

정답

  • 역순 슬라이싱 : score[-1 : -1-m : -1] 혹은 score[-m:], 첫번째는 데이터가 반대로 흘러가서 역방향으로 조회됨
  • 리스트에서 해당 범위를 삭제 : del a[start:end]
def solution(k, m, score):
    score.sort() # 오름차순 정렬
    ans = 0

    while len(score) >= m:
        s = score[-m:]
        del score[-m:]
        ans = ans + s[0] * m
    return ans

'Algorithm' 카테고리의 다른 글

소수 만들기 / 프로그래머스  (0) 2025.03.01
모의고사 / 프로그래머스  (0) 2025.02.28
카드 뭉치 / 프로그래머스  (0) 2025.02.24
2016년 / 프로그래머스  (0) 2025.02.21
명예의 전당 (1) / 프로그래머스  (0) 2025.02.17

AOP

  • 관점 지향 프로그래밍
  • 반복되고 공통적으로 사용되는 부분을 분리
  • 부가적인 기능을 핵심 로직으로부터 분리하여 핵심로직은 오로지 자신에게만 집중할 수 있음

 

발생한 문제 : NPE

LogAspect를 모두 작성하고 실행하려고 하는데 NullPointerException이 발생했다

Caused by: java.lang.NullPointerException: null

 

첫 번째 시도 : HttpServletRequest 수정

HttpServletRequest를 불러오는 부분에서 문제가 있었나 싶어서 해당 부분을 인텔리제이가 추천하는 코드로 변경했다

 

변경 전

HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();

 

변경 후

HttpServletRequest request = ((ServletRequestAttributes) Objects.requireNonNull(RequestContextHolder.getRequestAttributes())).getRequest();

 

하지만 동일한 에러는 계속 발생했다

 

해결 : Pointcut 수정

기존에 작성했던 포인트컷은 @Pointcut("execution(* ..Admin.(..))") 이다

요구사항에서 로깅을 찍어야 하는 컨트롤러는 모두 이름에 Admin이 들어가야 했기 때문에 저렇게 작성을 해줬는데 뭔가 저 포인트컷의 내용이 모호하게 잡혀있어서 제대로 반영되지 못하고 AOP가 모든 요청에 대해서 실행하는 것 같은 기분이 들었다

그래서 좀 더 명시적으로 포인트컷을 수정했다!

  @Pointcut("execution(* org.example.expert.domain.user.controller.UserAdminController.changeUserRole(..))")
    public void userAdminController() {
    }

   @Pointcut("execution(* org.example.expert.domain.comment.controller.CommentAdminController.CommentAdminController(..))")
    public void commentAdminController() {
    }

 

패키지 경로부터 시작해서 아예 관리자 컨트롤러 내부에 있는 메서드까지 명시하니까 에러가 사라지고 잘 작동했다!

 

 

발생한 문제 : HttpServletRequest의 getInputStream() 호출 시 에러 발생 

requestBody내역을 로그에 찍어야 하기 때문에 LogAspect에서 getInputStream()을 호출하는 코드를 작성했다!

 public String getRequestBody(HttpServletRequest request) throws IOException {
        ObjectMapper objectMapper = new ObjectMapper();
        return objectMapper.readTree(request.getInputStream()).toString();
    }

 

하지만 에러가 발생했다

Request processing failed: java.lang.IllegalStateException: getInputStream() has already been called for this request

 

HTTP 요청에 대해 getInputStream() 메서드를 한 번 이상 호출했을 때 발생하는 에러가 발생한 것이다!

찾아보니 HttpServletRequest에서 Stream형태로 RequestBody에 접근해 데이터를 가져갈경우에 스트림이 소비되면서 딱 한번만 읽을 수 있기 때문에 getInputStream() 또는 getReader()를 호출하면, 스트림이 소비되어 이후 다시 읽을 수 없다는 것이다

AOP를 사용하면서 먼저 RequestBody를 읽어서 스트림을 소비하고, 그 다음에 컨트롤러에서 한번 더 읽으려고 했는데 이미 스트림은 소비된 후라서 발생하게 된 것!!

 

해결 : HttpServletRequest의 Body를 여러번 읽을 수 있도록 바꾸기

RequestWrapper 추가

@Getter
public class RequestWrapper extends HttpServletRequestWrapper {
    // 요청 바디를 저장하는 변수 (다시 읽을 수 있도록 캐싱)
    private final String body;

    public RequestWrapper(HttpServletRequest request) {
        super(request);

        StringBuilder stringBuilder = new StringBuilder();
        try (BufferedReader bufferedReader = request.getReader()) { // 요청 바디를 읽을 BufferedReader 생성
            char[] charBuffer = new char[128]; // 128자씩 읽어올 버퍼 생성
            int bytesRead;
            while ((bytesRead = bufferedReader.read(charBuffer)) > 0) {
                // 읽어온 데이터를 StringBuilder에 추가
                stringBuilder.append(charBuffer, 0, bytesRead);
            }
        } catch (IOException e) {
            // 요청 바디를 읽다가 예외가 발생하면 400 Bad Request 예외 발생
            throw new BadRequestException();
        }

        // 읽은 요청 바디를 문자열로 변환하여 저장
        body = stringBuilder.toString();
    }

    @Override
    public ServletInputStream getInputStream() {
        // 저장된 body 문자열을 ByteArrayInputStream으로 변환 (다시 읽을 수 있도록 함)
        final ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(body.getBytes());

        return new ServletInputStream() {
            @Override
            public boolean isFinished() {
                return false; // 스트림이 끝났는지 여부 (false로 설정하여 계속 읽을 수 있도록 함)
            }

            @Override
            public boolean isReady() {
                return false; // 스트림이 준비되었는지 여부 (false로 설정)
            }

            @Override
            public void setReadListener(ReadListener readListener) {
                throw new UnsupportedOperationException(); // 비동기 읽기 미지원
            }

            @Override
            public int read() {
                return byteArrayInputStream.read(); // 저장된 요청 바디를 한 글자씩 읽어서 반환
            }
        };
    }

    @Override
    public BufferedReader getReader() {
        // getInputStream()을 기반으로 BufferedReader 생성하여 반환 (요청 바디를 여러 번 읽을 수 있도록 함)
        return new BufferedReader(new InputStreamReader(this.getInputStream()));
    }
}

 

필터에 적용

  @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        HttpServletRequest httpRequest = (HttpServletRequest) request;
        HttpServletResponse httpResponse = (HttpServletResponse) response;

        request = new RequestWrapper(httpRequest);

 

 

발생한 문제 : HttpServletResponse의 Body조회 불가

응답 객체의 body도 조회해야 했기 때문에 Request의 Body를 조회할 때처럼 유사하게 하면 된다고 생각했다

 

첫 번째 시도 : RequestWrapper 사용 

public class RequestWrapper extends HttpServletRequestWrapper {
    private final String body;  // 요청 본문을 저장할 변수

    public RequestWrapper(HttpServletRequest request) throws IOException {
        super(request);
        StringBuilder stringBuilder = new StringBuilder();
        BufferedReader bufferedReader = request.getReader();
        String line;
        while ((line = bufferedReader.readLine()) != null) {
            stringBuilder.append(line).append("\n");
        }
        body = stringBuilder.toString(); // 본문 저장
    }

    @Override
    public BufferedReader getReader() {
        return new BufferedReader(new InputStreamReader(new ByteArrayInputStream(body.getBytes())));
    }

    @Override
    public ServletInputStream getInputStream() {
        ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(body.getBytes());
        return new ServletInputStream() {
            @Override
            public boolean isFinished() {
                return false;
            }

            @Override
            public boolean isReady() {
                return true;
            }

            @Override
            public void setReadListener(ReadListener readListener) {}

            @Override
            public int read() {
                return byteArrayInputStream.read();
            }
        };
    }

    public String getBody() {
        return body;
    }
}

 

필터 적용

@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
    HttpServletRequest httpRequest = (HttpServletRequest) request;
    HttpServletResponse httpResponse = (HttpServletResponse) response;

    RequestWrapper requestWrapper = new RequestWrapper(httpRequest);
    chain.doFilter(requestWrapper, httpResponse);
}

 

하지만 로그를 찍어보니 body는 공백으로 나왔다

한참동안 이 방법으로 해결하기 위해서 수많은 삽질을 했다 😂

 

두 번째 시도 : returning = "returnObj" 사용해보기 (안될 것 같긴 했음..)

다른 예시들을 찾아보니 returning = "returnObj"를 사용하여 응답객체를 받아와서 출력하도록 했다

그래서 이 방법으로 응답객체를 출력하기 위해서 시도해봤다

(하지만 그 예시는 @AfterReturning이긴 했다..ㅎㅎ)

(그래도 @Around도 실행 전후에 발생시키는 거니까 응답객체를 사용할 수 있지 않을까하는 일말의 희망으로 사용해봤다.,.)

@Around("userAdminController() || commentAdminController()", returning = "returnObj")
public Object logging(ProceedingJoinPoint joinPoint, Object returnObj) throws Throwable {
@Around("userAdminController() || commentAdminController()")
public Object logging(ProceedingJoinPoint joinPoint, @Return("returnObj") Object returnObj) throws Throwable

 

하지만 인텔리제이에서 빨간줄이 뜨면서 실행할 수 없었다..ㅎㅎ

자세하게 이해하지 못하고 이것저것 끌어서 사용하려니 이런 불상사가 발생한 것이다!

하지만 이것 저것 해봐도 계속 실패했기에 이런거라도 시도해보고 싶었다..

 

해결  :  joinPoint.proceed()의 return 객체로 조회하기

여러번의 시도를 해봤지만 응답객체의 바디값은 나오지 않았고.. 시도했던 이상한 중구난방 클래스들과 메서드들을 모두 지우고 처음부터 다시 찾아보기로 마음을 잡았다

사실 저 첫 번째 시도때 사용했던 코드들은 이해가 잘 되지 않았다 (gpt에게 냅다 물어봐서 얻은 코드이기 때문..)

이걸 쓰면서도 이게 뭔지도 모르고 자꾸 에러나고 하라는대로 하면서 계속 삽질을 했기 때문이다

다시 구글링을 열심히 하면서 찾아본 결과!!

// 메서드 실행
Object result = joinPoint.proceed();

 

이 result객체가 메서드를 실행한 이후의 응답 객체라는 것이다!!!!

엥?? 그러면 그냥

// 메서드 실행
Object result = joinPoint.proceed();

// 메서드 실행 후
LocalDateTime responseTime = LocalDateTime.now();
String responseBody = getResponseBody(result);

 

이러면 되는거 아닌가?

 

실행결과 responseBody값이 드디어 나와 주었다!!😳❤️❤️🥳🩷

 

API 로깅 전체 코드

LogAspect

@Aspect
@Slf4j
@Component
@RequiredArgsConstructor
public class LogAspect {

    @Pointcut("execution(* org.example.expert.domain.user.controller.UserAdminController.changeUserRole(..))")
    public void userAdminController() {
    }

    @Pointcut("execution(* org.example.expert.domain.comment.controller.CommentAdminController.CommentAdminController(..))")
    public void commentAdminController() {
    }

    @Around("userAdminController() || commentAdminController()")
    public Object logging(ProceedingJoinPoint joinPoint) throws Throwable {
        // 메서드 실행 전
        Class clazz = joinPoint.getTarget().getClass();
        Logger logger = LoggerFactory.getLogger(clazz);
        HttpServletRequest request = ((ServletRequestAttributes) Objects.requireNonNull(RequestContextHolder.getRequestAttributes())).getRequest();
        HttpServletResponse response = ((ServletRequestAttributes) RequestContextHolder.currentRequestAttributes()).getResponse();
        Long userId = (Long) request.getAttribute("userId");
        LocalDateTime requestTime = LocalDateTime.now();
        String requestBody = getRequestBody(request);
        Map<String, Object> params = new HashMap<>();

        try{
            String decodedURL = URLDecoder.decode(request.getRequestURI(), "UTF-8");
            params.put("userId", userId);
            params.put("requestTime", requestTime);
            params.put("requestUrl", decodedURL);
            params.put("requestBody", requestBody);
        }catch (Exception e) {
            logger.error("LoggerAspect error", e);
        }

        log.info("Request - User : {} | Time : {} | Url : {} | Body : {}",
                params.get("userId"),
                params.get("requestTime"),
                params.get("requestUrl"),
                params.get("requestBody"));

        // 메서드 실행
        Object result = joinPoint.proceed();

        // 메서드 실행 후
        LocalDateTime responseTime = LocalDateTime.now();
        String responseBody = getResponseBody(result);

        params.put("responseTime", responseTime);
        params.put("responseBody", responseBody);

        log.info("Response - User : {} | Time : {} | Url : {} | Body : {}",
                params.get("userId"),
                params.get("responseTime"),
                params.get("requestUrl"),
                params.get("responseBody"));

        return result;
    }

    private static String getResponseBody(Object result) {
        ObjectMapper objectMapper = new ObjectMapper();
        JsonNode resultNode = objectMapper.valueToTree(result);
        JsonNode bodyNode = resultNode.path("body");
        return bodyNode.toString();
    }

    public String getRequestBody(HttpServletRequest request) throws IOException {
        ObjectMapper objectMapper = new ObjectMapper();
        return objectMapper.readTree(request.getInputStream()).toString();
    }

}

 

Filter

@RequiredArgsConstructor
public class JwtFilter implements Filter {

    private final JwtUtil jwtUtil;

    @Override
    public void init(FilterConfig filterConfig) throws ServletException {
        Filter.super.init(filterConfig);
    }

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        HttpServletRequest httpRequest = (HttpServletRequest) request;
        HttpServletResponse httpResponse = (HttpServletResponse) response;

        request = new RequestWrapper(httpRequest); // 여기서 요청 객체 저장(캐싱)하기

       
 }

 

 

느낀점

  • 응답 body를 출력하기 위해서 구글링을 하던 도중 내가 원하는 자료가 많이 보이지 않아서 그냥 gpt에게 물어보면서 해결하려 했는데(첫 번째 시도 : RequestWrapper 사용 부분), 무슨 코드인지도 모르는 상태에서 계속 질문하고 답은 안되고 뱅글뱅글 반복하면서 시간을 너무 허비한 것 같았다..
  • 사실 gpt를 사용해서 코드를 짜는 걸 별로 좋아하지 않는데 (내가 짠 코드도 아니고 이게 뭔지도 모르면서 쓰는 것도 찝찝하고 감당이 안되서.. ) 역시 직접적인 문제해결은 내가 일단 이해하고 뭔지 안 다음에 되는 것 같다는 생각이 들었다
  • Object result = joinPoint.proceed();이것만 잠시 생각해 봐도 금방 해결할 수 있는 문제를 오히려 gpt를 사용하면서 더 헛수고를 하게 된 것이다..!!!
  • 지금 단계에서는 역시 내가 감당하고 이해할 수 있는 코드만 만들고 gpt를 사용한 문제해결을 최대한 지양해야겠다는 생각이 더 들게 됐다

결론 : 문제 해결을 구글링이나 자료보고 생각해보고 하기!

문제 : 에러 메시지가 코드에 직접 작성됨

  • 다양한 경우의 수의 에러 메시지가 비지니스 코드 내에서 직접 작성되어있기 때문에, 어떤 에러 메시지가 있는지 한눈에 알아보기 힘듦
 @Transactional
    public SignupResponse signup(SignupRequest signupRequest) {
        if (userRepository.existsByEmail(signupRequest.getEmail())) {
            throw new InvalidRequestException("이미 존재하는 이메일입니다.");
        }
 public SigninResponse signin(SigninRequest signinRequest) {
        User user = userRepository.findByEmail(signinRequest.getEmail()).orElseThrow(
                () -> new InvalidRequestException("가입되지 않은 유저입니다."));

        // 로그인 시 이메일과 비밀번호가 일치하지 않을 경우 401을 반환합니다.
        if (!passwordEncoder.matches(signinRequest.getPassword(), user.getPassword())) {
            throw new AuthException("잘못된 비밀번호입니다.");
        }

 

  • 동일한 내용의 에러 메시지가 코드 내에서 여러번 재사용되고 있음
  • 만약 같은 내용이지만 여러 곳에 분포되어 있는 에러 메시지가 추후에 변경된다면 직접 그곳을 찾아다니며 수정해줘야 함
    @Transactional
    public CommentSaveResponse saveComment(AuthUser authUser, long todoId, CommentSaveRequest commentSaveRequest) {
        User user = User.fromAuthUser(authUser);
        Todo todo = todoRepository.findById(todoId).orElseThrow(() ->
                new InvalidRequestException("Todo not found"));
public TodoResponse getTodo(long todoId) {
        Todo todo = todoRepository.findByIdWithUser(todoId)
                .orElseThrow(() -> new InvalidRequestException("Todo not found"));
@Transactional(readOnly = true)
    public List<ManagerResponse> getManagers(long todoId) {
        Todo todo = todoRepository.findById(todoId)
                .orElseThrow(() -> new InvalidRequestException("Todo not found"));

 

에러 메시지를 Enum Class에 담아서 관리하여 개선

에러 객체 분리에 대한 고민

  • ServerException을 좀더 구체적으로 나눌까(관리에러, 날씨 api에러 등..) 고민
    • 구체적으로 나눈다면 추후에 기능이 추가될 때마다 예외 객체들 또한 무수히 많아질 것 같고, 에러를 발생시킨 책임의 주체만 분명히 하고(서버인지, 클라이언트인지) 어차피 구체적인 내용은 에러 메시지를 통하여 전달하면 된다고 생각함
    • 기존에 AuthException(인증, 인가 에러), ServerException(서버에서 발생하는 에러), InvalidRequestException(클라이언트가 잘못된 요청으로 인해 발생하는 에러)를 유지함

 

Enum Class 생성

  • common 패키지 하위에 errorcode를 만들고 에러 메시지를 한번에 관리할 수 있는 ErrorCode  Enum Class를 생성
  • 각각의 Exception Class별로 구분하여 에러 메시지를 배치
public enum ErrorCode {
    // AuthException
    EMAIL_ALREADY_EXISTS("이미 사용 중인 이메일입니다."),
    USER_NOT_FOUND_BY_EMAIL("해당 이메일로 등록된 유저가 없습니다."),
    INVALID_PASSWORD("비밀번호가 잘못되었습니다."),
    AUTH_AND_AUTHUSER_REQUIRED("@Auth와 AuthUser 타입은 함께 사용되어야 합니다."),

    // ServerException
    WEATHER_DATA_FETCH_FAILED("날씨 데이터를 가져오는데 실패했습니다."),
    NO_WEATHER_DATA_FOUND("날씨 데이터가 없습니다."),
    NO_WEATHER_DATA_FOR_TODAY("오늘에 해당하는 날씨 데이터를 찾을 수 없습니다."),
    TOKEN_NOT_FOUND("토큰을 찾을 수 없습니다."),

    // InvalidRequestException
    TODO_NOT_FOUND("해당하는 할일이 없습니다."),
    MANAGER_NOT_FOUND("관리자가 없습니다"),
    NOT_ASSIGNED_TO_SCHEDULE("해당 일정에 등록된 담당자가 아닙니다."),
    CANNOT_REGISTER_AS_SELF("일정 작성자는 본인을 담당자로 등록할 수 없습니다."),
    USER_NOT_FOUND("유저가 존재하지 않습니다."),
    INVALID_SCHEDULE_CREATOR("담당자를 등록하려고 하는 유저가 일정을 만든 유저가 유효하지 않습니다."),
    INVALID_USER_ROLE("유효하지 않은 UserRole입니다."),
    USING_PASSWORD("이미 사용 중인 비밀번호 입니다.");


    private String message;
    private ErrorCode(String message){
        this.message = message;
    }

    public String getMessage() {
        return message;
    }
}

 

ExceptionClass 수정

기존

public class InvalidRequestException extends RuntimeException {
    public InvalidRequestException(String message) {
        super(message);
    }
}

 

수정 후

public class InvalidRequestException extends RuntimeException {
    private ErrorCode errorCode;
    public InvalidRequestException(ErrorCode errorCode) {
        this.errorCode = errorCode;
    }

    public ErrorCode getErrorCode(){
        return this.errorCode;
    }
}

 

 

GlobalExceptionHandler 수정

기존

@ExceptionHandler(InvalidRequestException.class)
    public ResponseEntity<Map<String, Object>> invalidRequestExceptionException(InvalidRequestException ex) {
        HttpStatus status = HttpStatus.BAD_REQUEST;
        return getErrorResponse(status, ex.getMessage());
    }

 

수정 후

@ExceptionHandler(InvalidRequestException.class)
    public ResponseEntity<Map<String, Object>> invalidRequestExceptionException(InvalidRequestException ex) {
        HttpStatus status = HttpStatus.BAD_REQUEST;
        return getErrorResponse(status, ex.getErrorCode().getMessage());
    }

 

 

개선점

  • enum을 사용하여 에러 메시지를 한곳에 모아뒀기 때문에, 한눈에 에러 메시지를 확인할 수 있음
  • 추후에 에러 메시지를 유지보수하기 용이해짐 (동일한 에러 메시지를 코드에서 직접 찾아서 변경할 필요 없이 enum 한 곳에서만 변경하면 됨)
  • 중복된 에러 메시지를 일일히 타이핑할일 없이 enum에 있는 메시지를 쓰면 되기 때문에 재사용성이 좋아짐 
  • 비지니스 로직에서 해당 에러 메시지를 봤을 때, 가독성 측면에서 향상됨

 

데이터 비교

개선 전 - 동일한 메시지가 여러 곳에서 사용되어 있고, 직접 서비스 코드 내에서 에러 메시지가 작성되어있어 유지보수에 용이하지 않음

 

개선 후 - Enum Class를 사용하여 에러 메시지를 한곳에서 관리하기 용이해짐

 

HttpMessageConverter

@RequestBody, @ResponseBody 을 사용할때 사용됨 → 요청이나 응답 모두에 사용된다는 의미

  • 요청시에는 Argument Resolver가 사용
  • 응답시에는 ReturnValueHandler가 사용
  • JSON을 객체로 변환하는데는 Jackson 라이브러리 사용 (@JsonFormat, Deserializer)

 

HttpMessageConverter 우선순위

  1. ByteArrayHttpMessageConverter : byte[] Data를 처리
  2. StringHttpMessageConverter : String Data를 처리
  3. MappingJackson2HttpMessageConverter : JSON Data를 처리

 

HttpMessageConverter 동작 순서

읽기

  1. 클라이언트가 HTTP 요청을 보냄
  2. Spring MVC가 컨트롤러의 @RequestBody를 확인하고 MessageConverter를 실행
  3. MessageConverter는 canRead() 메서드로 읽기가능 여부를 조회
    • 요청 데이터 타입이 지원 가능한지 (ex: application/json )
    • 변환할 객체 타입이 적절한지 (ex: RequestDto를 만들 수 있는지)

4. read() 메서드를 호출하여 객체 생성 : JSON 데이터를 RequestDto 객체로 변환

 

쓰기

  1. 컨트롤러 메서드에서 객체 반환 : return responseDto
  2. @ResponseBody가 붙어 있으므로 스프링에서 HttpMessageConverter가 실행
  3. canWrite()를 호출하여 데이터 변환 가능 여부 확인
    • 반환 클래스가 byte[], String, Object 인지 여부 확인
    • 요청 헤더 Accept 의 Media Type 지원여부 확인(produces)
  4. write() 메서드를 호출 : HTTP Response Message Body에 데이터를 입력

 

Spring MVC 구조

  • HandlerMappingList를 조회하여 적절한 컨트롤러 찾기
    • 여러 개의 HandlerMapping을 순차적으로 조회하면서 요청을 처리할 컨트롤러(Handler)를 찾음
    • ex) RequestMappingHandlerMapping : @RequestMapping으로 해당 핸들러를 찾음
    • GET /users/1 요청이 오면 RequestMappingHandlerMapping **@RequestMapping("/users/{id}")가 있는 컨트롤러를 찾아 반환
  • HandlerAdapter는 찾은 컨트롤러를 실행할 수 있도록 변환해 주는 역할
    • 찾은 컨트롤러의 실행을 담당
  • 찾은 컨트롤러의 메서드를 실행하고 결과를 반환
    • @RequestMapping("/users/{id}")가 있는 컨트롤러 메서드 실행

 

RequestMappingHandlerAdapter

  • @RequestMapping (@PostMapping , @GetMapping)을 처리하는 HandlerAdapter의 구현체

 

ArgumentResolver

  • 요청이 컨트롤러 메서드에 전달될 때 각 파라미터를 적절한 객체로 변환하여 주입하는 것을 담당
  • RequestMappingHandlerAdapter는 ArgumentResolver를 호출하여 Controller가 필요한 다양한 파라미터의 값을 생성
  • HttpServletRequest, Model, HttpEntity,@ModelAttribute, @RequestBody, @RequestParam 등 다양한 파라미터 바인딩을 할 수 있는 이유
  • HandlerMethodArgumentResolver
    • ArgumentResolver의 실제 이름, 인터페이스로 구성됐기에 mplements 하여 확장 가능

 

ReturnValueHandler

  • ModelAndView, @ResponseBody, HttpEntity<> 등이 있으면 응답에 필요한 값으로 변환
  • Controller에서 String으로 ViewName을 반환하여도 View가 동작하는 이유
  • HandlerMethodReturnValueHandler
    • ReturnValueHandler의 실제 이름, 인터페이스로 구성됐기에 mplements 하여 확장 가능

 

첫 번째 시도

  • IndexError: list index out of range 직면
  • cards1 또는 cards2가 빈 리스트인 경우에 cards1[0]처럼 참조하면 오류가 나기 때문임
  • cards1[0]과 같이 참조하기 전에 길이를 먼저 체크하도록 변경해야 함
def solution(cards1, cards2, goal):
    answer = "Yes"
    while(len(cards1) or len(cards2)):
        if len(goal) == 0:
            break
        elif cards1[0] == goal[0] and len(cards1) > 0:
            cards1.pop(0)
            goal.pop(0)
        elif cards2[0] == goal[0] and len(cards2) > 0:
            cards2.pop(0)
            goal.pop(0)
        else:
            answer = "No"
            break
            
    return answer

 

 

정답

  • 리스트를 참조하기 전에 길이를 먼저 체크하도록 변경
def solution(cards1, cards2, goal):
    answer = "Yes"
    while(len(cards1) or len(cards2)):
        if len(goal) == 0:
            break
        elif len(cards1) > 0 and cards1[0] == goal[0]:
            cards1.pop(0)
            goal.pop(0)
        elif len(cards2) > 0 and cards2[0] == goal[0]:
            cards2.pop(0)
            goal.pop(0)
        else:
            answer = "No"
            break
            
    return answer

 

'Algorithm' 카테고리의 다른 글

모의고사 / 프로그래머스  (0) 2025.02.28
과일장수 / 프로그래머스  (0) 2025.02.27
2016년 / 프로그래머스  (0) 2025.02.21
명예의 전당 (1) / 프로그래머스  (0) 2025.02.17
콜라 문제 / 프로그래머스  (0) 2025.02.16

 

정답

  • 시작하는 날을 기준으로 요일 배열을 나열
  • 경과일을 기준으로 요일을 적용해야 하므로 (배열은 0부터 시작하니까) 경과일에 -1을 빼줘야 함
def solution(a, b):
    # 2016년 각 월의 일 수
    month = [0, 31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]  

    # 2016년 1월 1일은 금요일 (FRI)
    week = ["FRI", "SAT", "SUN", "MON", "TUE", "WED", "THU"]
    
    # 1월 1일은 금요일, 1월 1일을 입력했다면 1월 1일로부터 경과일은 0일임
    elapsed_date = sum(month[:a]) + b - 1
    
    return week[elapsed_date % 7]

친구 관리 기능 관련 ERD

  • 역방향 친구 신청 등록을 방지하기 위해서 친구 요청을 보내게 되면 정방향으로 1번 역방향으로 1번 쿼리가 insert됨
    • 1. 정방향 데이터 추가 : A가 B한테 친구신청을 보내서, from_id : a, to_id : b, is_friend : True를 insert
    • 2. 역방향 데이터 추가 : from_id : b, to_id : a, is_friend : False를 insert

 

문제 : 의도하지 않은 inner join 수행 결과값

  • 친구 신청한 목록을 조회하기 위해서 friend 테이블에 inner join을 수행했는데, 중복컬럼이 반복해서 나오게 됨

1번은 지금 2번과 3번에게 친구 신청을 보낸 상태

 

inner join을 사용하여 정방향 역방향을 합쳐서 데이터 조회를 수행함

select *
from friend f
inner join friend r
on f.from_id = r.to_id

 

하지만 조회 결과를 보니 중복된 컬럼이 나옴!!!

 

하지만 아무리 생각해도 f.from_id = r.to_id 이 조건이 전혀 틀린 것 같다는 생각이 들지 않았음

근데 더 고민을 해보니, 결국 inner join 이라 함은 자기 자신을 자기 자신과 조인하는 거기 때문에 역방향 기준도 생각을 해서 조인을 시켜줘햐 한다는 것을 깨달았다!

 

r.from_id = f.to_id 조건 추가!

select *
from friend f
inner join friend r
on f.from_id = r.to_id and r.from_id = f.to_id

 

중복없이 제대로 된 결과를 조회 할 수 있었다!

 

결론 : join 조건을 걸 때 inner join의 경우 한번 더 생각해서 조건을 걸어줘야 한다! 결국 friend 테이블에서는 from_id와 to_id는 같은 역할의 컬럼이기 때문에 당연히 동등하게 조건을 걸어 줘야 하는 것이다!!

 

문제 : 변경되지 않은 코드에서 갑자기 쿼리 수행이 제대로 되지 않음

친구 관련 기능을 모두 구현하고 이제 dev 브랜치에서 pull 을 받고 마저 테스트를 하는 중이었다..

그런데...!!!! 친구 관련 쪽 코드는 전혀 건들지 않았는데 갑자기 친구 수락 기능이 제대로 작동하지 않는 것이었다 ㅜㅜ

아무리 생각해도 변경된 코드도 없고 안될 이유가 없는데 너무 당황한 나머지 진짜 멘붕이 왔다...

 

첫번째 시도 : 스키마 자체를 날리고 다시 만들기

-> 스키마를 아예 삭제해버리고 application.properties의 속성도 변경해주고 친구 수락을 수행했다

spring.jpa.hibernate.ddl-auto=create

 

그러나 여전히 똑같은 실패 😵

 

두번째 시도 : 로그 찍기

아무래도 이쯤되니 쿼리가 문제인가 싶어서 로그를 찍어서 실제 값이 어떻게 넘어오는지 확인해보았다...

 

여기서는 friendReceivedtList 이게 분명히 조회가 잘 되는데!!!

그 결과 여기서는 리스트 안에 값이 정상적으로 담겨오고

여기서는 friendReceivedtList의 값이 빈값으로 나온다!!!!ㅜㅜㅜㅜㅜ

 

둘이 아예 똑같은 쿼리인데도 위에서는 잘 조회되고, 아래에서는 조회가 안되는 것이다....

기한이 하루 남았는데 갑자기 잘 되던 기능이 안되니까 정말 멘붕이 왔다!!!

 

세번째 시도 : 캐시 지우기

이쯤되니 정말 자포자기 하는 심정으로 구글링과 gpt한테 하소연을 했다...

이때 쿼리가 제대로 적용되지 않는 이유가 캐시 때문일 수도 있으니 캐시를 지우고 시도해보라는 답을 보게 되었다!!

@Service
public class FriendService {

    @PersistenceContext
    private EntityManager entityManager;

    @Transactional
    public void accept(FriendRequestAcceptDto acceptRequestDto, Long memberId) {
        // 세션을 clear하여 캐시를 초기화
        entityManager.clear();

        // 이후 코드
        List<Long> friendReceivedtList = friendRepository.getFriendReceivedList(memberId);
    }
}

 

엔티티 매니저를 가지고 와서 캐시를 초기화해 줬더니 해결되었다!!!

 

Hibernate 1차 캐시

  • Hibernate는 성능 최적화를 위해 1차 캐시 사용
  • 세션 레벨에서 관리되는 캐시로, 같은 세션 내에서 동일한 엔티티가 여러 번 조회되면 DB에서 쿼리를 실행하지 않고 캐시된 데이터를 반환
  • clear()를 호출하기 전에는, 데이터베이스에서 데이터를 가져와도 세션에 이미 캐시된 값이 존재하면 그것을 사용함
  • 이로 인해 최근 변경된 데이터새로 조회된 데이터가 반영되지 않아서 조회가 제대로 되지 않았을 수 있음

 

캐시의 존재를 알고 있었지만 이렇게 내가 개발하면서 몸소 느끼게 될 줄은 몰랐다..😂

다음에 또 이런 오류가 생길 때 고생했던 만큼 금방 해결할 수 있을거라고 생각한다

 

결론 : 쿼리가 결과가 이상하게 나온다면 캐싱을 의심해 봐아 한다!

 

+ Recent posts