Spring Boot(제출용)

제이 용·2025년 11월 20일

스캐줄 심화

  • 기존 과제에 기능들이 추가됨에 따라 어려웠던 점들과 궁금했던 점들 위주로 작성하였습니다.

  • 기본적으로 연관관계 매핑이 들어감에 따라 서비스계층에서 데이터를 끌고오는 과정이 꽤나 어려웠고, 수정해야될 소요가 꽤나 있어서 난이도가 있었습니다.

  • 마지막으로 N+1이라는 문제가 어떠한 것인지 파악하게 되었고 해결하기 위해서는 쿼리문을 작성할 줄 알아야하는데, 제출 이후에도 지속도전할 예정입니다.

  • 필터를 적용함에 따라 url에 구분을 두어야하는데, 기존에 조회 관련해서는 로그인 기능이 필요 없었으나, 로그인, 회원가입 외에는 모두 로그인이 필요하게 되는 문제를 겪었고, 이는 각 url별로 따로 예외를 두어야되겠다는 생각이 들었고 해결은 가능하나 제출 후에도 계속 수정할 예정입니다.

N+1 경험과 상황

  • JPA나 ORM을 쓸 때 자주 발생하는 성능 문제입니다.

  • 상황: 한 엔티티(A)를 조회하면서 연관된 엔티티(B)를 같이 조회하고 싶은 경우

  • 문제: ORM이 기본적으로 지연 로딩(Lazy Loading) 을 사용할 때, 연관된 엔티티를 조회할 때마다 추가 쿼리가 발생합니다.

예시

List<User> users = userRepository.findAll(); // 1번 쿼리
for (User user : users) {
    System.out.println(user.getOrders()); // 각 User마다 Orders 조회 -> N번 쿼리
}

User가 10개면, User 조회: 1번 쿼리

각 User의 Orders 조회: 10번 쿼리

총 11번 쿼리 → N+1 문제

N+1 문제 = “한 번 조회한 뒤, 연관 데이터 때문에 추가로 N번 조회가 발생하는 상황”

  • 본인은 페이징 조회 기능 중 댓글 개수를 조회하기 위해 쿼리가 한번 더 도는 문제를 겪었다.

트러블 슈팅(1)

session.setAttribute("userId", user.getId()); 
session.setAttribute("userName", user.getUserName()); 
session.setAttribute("email", user.getEmail());
session.setAttribute("password", user.getPassWord())
  • 기존 키값을 다음과 같이 해주니 뭔가 눈에도 보기 안좋고 password에 키값을 넣는게 맞나? 라는 것이 문득 생각이 들었다.

  • 강의를 다시 보니 SessionUser라는 DTO를 다시 생성을 해주고 이를 통해 한번에 처리하게끔 하는 것을 알아보게 되었다.


session.setAttribute("loginUser", sessionUser);
  • 다음과 같이 한 줄로 볼 수 있게 되었고, 비밀번호가 세션에 저장된다는 것은 보안적으로 맞지 않다는 생각이 들어 제외하게 되었다.

강의도 다시보면 새로운 지식을 얻을 수 있고, 점점 뇌리에 박히는 것이 느껴지는 문제였다.


트러블 슈팅(2)

  • 이후 4Lv을 얼추 틀을 잡았다고 생각할 찰나에 다른 팀원분들과 코드 리뷰를 진행하였다.

  • 여기서 팀원 한분이 말씀하신 내용이

//로그인 기능 @Transactional(readOnly = true) 
public void login(UserLoginRequest request, HttpSession session) { 
User user = userRepository.findByEmail(request.getEmail())
.orElseThrow( () -> new ResponseStatusException(HttpStatus.UNAUTHORIZED,"이메일이 올바르지 않습니다.") ); 
if(!user.getPassword().equals(request.getPassword())){ 
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED,"비밀번호가 올바르지 않습니다."); 
} 
SessionUser sessionUser = new SessionUser(
user.getId(), 
user.getUserName(), 
user.getEmail()); 
session.setAttribute("loginUser", sessionUser); 
}
  • 위와 같은 코드를 보고 세션이 생성되는 부분이 서비스가 아니라 컨트롤러에 있어야되는 것이 아니냐? 라는 말씀을 하셨다.

  • 강의를 보고 따라했을 때에는 User user와 같이 편의상 컨트롤러에 만든 것을 보고 아 세션부분도 서비스에 빼줘야되겠구나 라는 생각이 들어 빼준 것이었는데 원리와 논리를 따지고 보면 정말 별 것도 아닌 문제였다.

  • 컨트롤러에서 요청을 받고 서비스에서 로그인에 대한 검증을 받으면, 컨트롤러에서 세션을 생성하여 응답을 해주는게 맞는 원리와 논리라는 것을 깨달았다.

따라서 다시 세션을 밖으로 빼주었다.

@PostMapping("/users/login")
    public ResponseEntity<UserLoginResponse> login(@RequestBody UserLoginRequest request, HttpSession session) {
        UserLoginResponse userLoginResponse = userService.login(request);

        SessionUser sessionUser = new SessionUser(userLoginResponse.getId(), userLoginResponse.getUserName(), userLoginResponse.getEmail());
        session.setAttribute("loginUser", sessionUser);

        return ResponseEntity.ok(userLoginResponse);
    }
  • 마냥 강의를 따라가려고만 하는 잘못된 점이 보인 그런 질문이었다. 다만 내 목표는 강의를 통해 프로젝트를 빠르게 완성하고 다시 부신다음에 흐름을 파악하여 재작성하는 것이 내 목표이기 때문에 그 과정 중 이 일부를 미리 클리어 했다 라고 생각하기로 했다.

  • 그래도 제3자의 입장에서 내 코드가 어떻게 보이는지 코드리뷰는 정말 귀중하다고 느끼는 문제였다.


트러블 슈팅(3)

  • 세션을 적용함에 따라 여러 예외 처리가 발생하고 그걸 컨트롤러와 서비스에 남발함에 따라 점점 서비스와 컨트롤러에 중복되는 예외처리가 발생하면 어쩌지? 라는 생각이 들었다.
public ResponseEntity<Void> deleteUser(
        @SessionAttribute(name = "loginUser", required = false) SessionUser sessionUser,
        @PathVariable Long userId) {

    if (sessionUser == null) {
        return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
    }
    if (!sessionUser.getId().equals(userId)) {
        return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
    }

    userService.delete(userId);
    return ResponseEntity.noContent().build();
}


---------------------------------------

User user = userRepository.findById(userId)
                .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 유저입니다."));

        if (!user.getPassword().equals(password)) {
            throw new IllegalArgumentException("비밀번호가 올바르지 않습니다.");
        }
        if (!user.getId().equals(loginUserId)) {
            throw new IllegalArgumentException("해당 유저의 권한은 없습니다.");
        }
        
        
        
 ---------------------------------------
 
 else if(!sessionUser.getId().equals(userId)) { return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); }
 
 
 
  • 다음과 같이 여러 에러를 보고 만들다보니 유저 아이디를 검증하는 로직이 중복되는 상황이 발생했다.

  • 그래서 고민을 해보았다. 결국엔 컨트롤러에 역할과 서비스에 역할이 뭐지? 이러한 문제가 발생안되게 하기 위해 객체지향인 자바를 쓰고 있는 것이 아닌가? 라는

  • 따라서 컨트롤러는 로그인 여부에 대한 검증, 비밀번호나 아이디 일치, 해당된 유저만 할 수있는 권한 검증은 서비스에서 처리하도록 하였다.

  • 구글링을 통해 비즈니스 로직을 다루는 서비스 라는 정의가 있기에 대부분의 예외처리는 서비스 에서 다루되 로그인에 대한 유무를 체크하는 것은 컨트롤러에서 담당하도록 설계하였다.

  • 이로써 설계하기전에 구상을 잘 해야되는 이유인 것 같다.


트러블 슈팅(4)

  • 이후 만든 코드들을 테스트하기 위해 포스트맨을 활용하는데, 위에 앞서 얘기한 것들과 같이 로그인한 상태에서 내가 아닌 다른유저의 일정을 수정/삭제할 수 있는 관리자급 권한을 쥐어주는 진귀한 상황을 만날 수 있었다.

  • 코드를 짜면서 지속적으로 테스트하는 것도 좋지만, 어 설마? 하는 부분도 테스트해보니 당연히 예외처리안한 오류가 발생하게 되었다.

if (!user.getId().equals(loginUserId)) {
            throw new IllegalArgumentException("해당 유저의 권한은 없습니다.");
        }

 if (!schedule.getId().equals(loginUserId)) {
            throw new IllegalArgumentException("해당 유저의 권한은 없습니다.");
        }
  • 다음과 같은 코드를 넣어주었지만 또 문제가 발생하였다.. 유저는 잘 작동하였으나, 일정관리에서 오류가 떴다.

  • 코드를 다시 읽어보니 user에서 쓴 코드와 schedule에서 쓴 코드가 길이가 거의 일치했다는 것이다.

  • 가만생각해보니 스캐줄은 user의 주인인데 어떻게나 user로부터 가져와야하는 입장에서 user랑 코드 길이가 비슷하면 되나? 라는 생각이 들었는데


schedule.getId()SchedulePK(id)
loginUserId는 로그인한 유저의 PK(id)
  • 그래서 Schedule id와 User id를 비교하는 구조인데, 이 둘은 당연히 다르다는게 보였다. 따라서 권한 에러가 발생하는 것이었다..
 if (!schedule.getUser().getId().equals(loginUserId)) {
            throw new IllegalArgumentException("해당 유저의 권한은 없습니다.");
        }
  • 다음과 같이 바꿔주니 잘 작동하였다.

  • 오류가 나도 당황하지말고 코드를 잘 비교해가며 뜯어보자.


트러블 슈팅(5)

  • 또 포스트맨을 통해 기능들을 테스트해보면서 삭제 기능을 써보니 user를 삭제하면서 오류가 떴다.

  • 오류 코드

java.sql.SQLIntegrityConstraintViolationException: Cannot delete or update a parent row: a foreign key constraint fails
  • 뭔가 SQL이 있고 Cannot delete가 있으며,, forekgn key가 적혀있는거 보니 DB에 있는 데이터를 지울 수가 없고 이유는 뭔가 FK에 관련되 있는 것 같았다.

  • 어디서 본 것 같은 느낌이 들었는데 전에 과제를 만들 때에도 보았던 똑같은 오류코드였다.

  • 일정을 생성한 user로부터 user를 삭제하려고 하니 schedule과 연관관계가 맺어져 있는 키값이 관계가 끊어지려고 하니 오류가 난 것이었다.

  • 저번에 배운
    orphanRemoval, cascade = CascadeType.ALL
    두 기능을 통해 해결해보려고 다시 찾아보니 결국에는 user에서도 scheudule을 알아야하고 schedule에서도 user을 알아야하는 상황이라 양방향이어야지만 가능하다는 것을 알게되었다..

  • 한마디로 자식 컬랙션이 부모에 있어야 동작한다는 제한 조건이 있기 때문이다.

  • 따라서 전에 했던 방법인 유저에 있는 스캐줄을 먼저 삭제하고, 유저를 삭제하도록 코드를 수정하도록 하였다.

scheduleRepository.deleteAllByUser(user);
userRepository.delete(user);
  • 쿼리 메서드를 통해서 어떻게 해결하는 방법도 있긴 하겠지만, 아직은 내가 가진 상식 내에서 해결해야 조금 의미가 있지 않을까라는 고집이 있었다.

  • 다음엔 피드백이든 구글링이든 통해서 더 효율적인 방법을 모색해야겠다.


지속적으로 테스트해보며 끝도없는 예외 처리 지옥에 빠지게 되었고, 이는 키오스크 프로젝트를 하면서도 동일했다. 다음 과정으로 빠지기 위해 예외처리를 여기서 잠깐 스탑하고 내일 이 예외처리들을 모두 throw new IllegalArgumentException()처리를 해놨기 때문에 커스텀 예외 클래스와 전역 예외 처리 클래스를 생성해서 도전기능으로 빠르게 넘어가야겠다. 오늘 문제가 많았어서 해결하면서도 뿌듯한 날이었다. 다만 처리하지못한, 보지못한 예외부분들이 신경이 쓰여 아쉽기도 한 날 이었다.


CustomException 클래스 생성

@Getter
public class CustomException extends RuntimeException{ // RuntimeException 상속
	//속성
    private final ErrorCode errorCode;
    
	//생성자
    public CustomException(ErrorCode errorCode) { 
        super(errorCode.getMessage());
        this.errorCode = errorCode;
    }
}
  • 문득 생각이 든 것이 커스텀 예외는 왜 런타임을 상속을 받아야될까가 궁금했다.

  • 결론(3가지)

  1. Checked Exception을 피할 수 있음
    커스텀 예외를 정의할 때 RuntimeException을 상속받으면 언체크 예외가 됩니다. 이는 호출자가 예외를 처리하도록 강제받지 않기 때문에 코드가 더 간결해질 수 있습니다.

  2. 사용자 편의성
    개발자가 특정한 상황에서 발생하는 예외를 명확하게 정의할 수 있어, 코드의 가독성을 높이고 예외 처리 로직을 간단하게 유지할 수 있습니다. 불필요한 예외 처리를 피할 수 있는 장점이 있습니다.

  3. 비즈니스 로직과의 일관성
    대부분의 비즈니스 로직에서 발생하는 예외는 런타임 예외로 간주되므로, 커스텀 예외도 이를 따르는 것이 자연스럽습니다.

  • 다음과 같이 Custom 클래스를 하나만 만들어준 이유는 가독성과 무분별한 파일생성을 막기 위해 만들었다.

  • 왜냐하면 Enum class를 활용해서 내가 커스텀한 예외들을 관리할 것이기 때문이다.


ErrorCode enum 클래스 생성

@Getter
public enum ErrorCode {
	//관리할 상수화시킬 필드
    USER_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 유저입니다."),
    USER_UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "로그인이 필요합니다."),
    USER_FORBIDDEN(HttpStatus.FORBIDDEN, "해당 유저는 권한이 없습니다."),
    EMAIL_ALREADY_EXISTS(HttpStatus.BAD_REQUEST, "이미 존재하는 이메일입니다."),
.
.
.
;
	//속성
    private HttpStatus status;
    private String message;
	//생성자
    ErrorCode(HttpStatus status, String message) {
        this.status = status;
        this.message = message;
    }
}
  • Enum 또한 클래스 이기 때문에 속성과 생성자를 통해 데이터를 받아올 수 있게 설계를 해주어야하고 특별한 점은 상수화시킬 데이터들을 가지고 있다는 것이다.

Enum을 써야하는 이유(중요)

  • 우리가 프로젝트를 하다보면 소규모인 프로젝트임에도 불구하고 여러개의 예외들을 전부 처리해줘야하는 상황이 나온다.
  • 다만 이걸 throw로만 처리를 해버리면 응답에 서버에러인 500에러가 뜰 수 밖에 없고, 정확한 에러가 난 지점을 유추하기가 힘들어진다.
  • 하지만 커스텀 예외를 통해 내가 이 에러를 어떤 문구로 설명을 할 것이고, 상태코드 또한 정의해줄 수 있기 때문에 커스텀 예외를 사용하게 된다.
  • 다만, 그러한 커스텀 예외들이 여러개가 된다면 클래스가 많아질 것이고, 구분하기도 어려워지는 상태가 된다.
  • 따라서 커스텀 예외 클래스 자체를 Enum을 필드로 가져옴으로써 클래스를 따로 생성하지 않고 Enum에서 관리를 하게 된다면 가독성과 유지보수하기가 굉장히 용이해진다는 장점이 있다!

저번에는 Enum을 사용하지 않고 커스텀 클래스를 하나하나 작성했는데 정말 편리하다는 것을 느꼈고,

초반 과제에서 어거지로 사용해보라는 이유를 조금은 알 수 있었던 파트였지 않았나 싶다.

결국에는 쓰이는 곳이 있고 알고 있어야지만 응용할 수 있는 것이고, 조금이라도 만져본 사람은 얼추

느낌만 알고 있을 지언정 이러한 과정을 통해 지식을 확고하게 다질 수 있게 되는 것 같다.

끝내지 못한 숙제를 할 수 있게된 날이었기에 속이 좀 시원한 날이었다.


작성간 유의사항으로는 모두 만들고나서 오류가 났었는데 그 이유는 레퍼지토리에 들어가있는 메서드의 형식이 어긋나 있었다.

List findAllbyScheduleId(Long scheduleId);
위와 같이 중간에 대문자 B가 아닌 b가 들어가 작동하지 않는 현상을 보았고, 카멜형식을 제대로 지켜야겠다는 생각이 들었다..


Service 수정

  • 서비스에 코드가 유저에 비해 한 줄이 더 추가되어야한다. 이걸 깨닫는게 초보자 입장에서는 어려울 수도 있을 것 같은 접근이었다.
User user = schedule.getUser();

        if (!passwordEncoder.matches(password, user.getPassword())) {
            throw new CustomException(ErrorCode.INVALID_PASSWORD);
        }
  • 일단 비밀번호와 암호화라는 것이 유저에서 진행되고 유저에 저장되는 비밀번호기 때문에 유저라는 변수를 꺼내와야만한다.

  • 따라서 스캐줄로부터 유저를 뽑아와 User 타입의 user에 넣어줌으로써 유저로부터의 비밀번호를 꺼내올 수 있게 된다.

KeyPointCode

스캐줄 서비스
User user = schedule.getUser();
코멘트 서비스
User user = comment.getUser();
  • 연관관계 매핑이 되어있는 엔터티를 다룰 수록 서비스나, 레퍼지토리, 컨트롤러에 비교적 영향을 코드적으로 끼칠 수 있다는 점이 주의해야한다.

0개의 댓글