[Spring] JPA를 이용한 일정관리앱 트러블 슈팅 (+약간의 회고..)

minjonyyy·2025년 2월 12일

[Spring]

목록 보기
3/6
post-thumbnail

1. 사용자 응답 처리

로그인 필터 로직에서, 로그인 해주세요 응답 처리하기

//	기존 코드
		if (!isWhiteList(requestURI)) {

            // 로그인 확인 -> 로그인하면 session에 값이 저장되어 있다는 가정.
            // 세션이 존재하면 가져온다. 세션이 없으면 session = null
            HttpSession session = httpRequest.getSession(false);

            // 로그인하지 않은 사용자인 경우
            if (session == null || session.getAttribute("sessionKey값") == null) {
                throw new RuntimeException("로그인 해주세요.");
            }

            // 로그인 성공 로직

        }

로그인 안 했을 때 접근하면 이렇게 에러만 뜬다.
아래 블로그를 참고하여 커스텀 에러를 만들어보려 했지만 모종의 이유에서 그만두고..

[참고] https://velog.io/@kimdy0915/Spring-Security-Filter-%EC%98%88%EC%99%B8%EC%B2%98%EB%A6%AC%EB%8A%94-%EC%96%B4%EB%96%BB%EA%B2%8C-%ED%95%A0%EA%B9%8C

            if(session == null || session.getId()==null) {
                httpResponse.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
                httpResponse.setContentType("application/json");
                httpResponse.getWriter().write("{\"error\":\"로그인 해주세요\"}");
                return;
            }

그냥 filter 내부에서 오류 메세지를 json으로 내보내주기로 했다.


근데 이렇게 물음표만????
-> 이건 백퍼 UTF-8 설정을 안해줘서 그럼

            if(session == null || session.getId()==null) {
                httpResponse.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
                httpResponse.setContentType("application/json; charset=utf-8");
                httpResponse.getWriter().write("{\"error\":\"로그인 해주세요\"}");
                return;
            }

이제 아래와 같이 잘 나오는 것을 볼 수 있다!

추가적으로, 다른 응답 처리들에 대해서도 GlobalExceptionHandler 클래스를 만들어서 컨트롤러에서 설정해둔 에러 메세지들이 응답에 잘 출력되도록 구현하였다!👏🏻

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<Map<String, String>> handleValidationExceptions(MethodArgumentNotValidException ex){
        Map<String, String> errors = new HashMap<>();

        ex.getBindingResult().getFieldErrors().forEach(error -> {
            errors.put(error.getField(), error.getDefaultMessage());
        });

        return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errors);
    }

    @ExceptionHandler(ResponseStatusException.class)
    public ResponseEntity<Map<String, Object>> handleResponseStatusException(ResponseStatusException ex, HttpServletRequest request){
        Map<String, Object> errors = new HashMap<>();

        errors.put("status", ex.getStatusCode().value());
        errors.put("message", ex.getMessage());
        errors.put("path", request.getRequestURI());

        return ResponseEntity.status(ex.getStatusCode()).body(errors);
    }

}

2. 댓글 갯수 세기 문제

    Long countCommentByTaskId(Long taskId);
    public Long countComments(Long taskId) {
        Comment findTask = commentRepository.findByIdOrElseThrow(taskId);
        return commentRepository.countCommentByTaskId(taskId);
    }

countRepository에서 이런 식으로 댓글 세기 구현했는데 오류가 발생하였다.

Caused by: org.springframework.data.mapping.PropertyReferenceException: No property 'id' found for type 'Task'; Traversed path: Comment.task

오류 원인은 CommentRepositorycountCommentByTaskId(Long taskId) 메서드에서 Task 엔티티에 id 속성이 존재하지 않는다는 문제이다.

음.. JPA가 내가 작성한 메서드를 이해하지 못하는 것 같다.

나는 헷갈리는 걸 방지하기 위해서 여러 entity들의 id들을 모두 ___Id 구성으로 만들었는데, 내가 만든 countCommentByTaskId 메서드는 id 필드로 찾고 있는 것임!

public interface CommentRepository extends JpaRepository<Comment, Long> {
    Long countCommentByTask_Id(Long taskId); // 기존 메서드 대신 수정
}

그래서 필드명을 id로 바꾸었고, 메서드도 countCommentByTask_Id(Long taskId)로 바꾸었다.
하지만!!!!!! 오류를 해결했어도 뭔가 계속 복잡해져서..

결국 ..

public class CommentService {
    private final CommentRepository commentRepository;
    private final TaskRepository taskRepository;
    private final UserRepository userRepository;

    @Transactional
    public CommentResponseDto createComment(HttpServletRequest request, Long taskId, CommentRequestDto requestDto) {

        HttpSession session = request.getSession();
        Long userId = (Long) session.getAttribute(Const.LOGIN_USER_ID);

        User user = userRepository.findByIdOrElseThrow(userId);

        Task findTask = taskRepository.findByIdOrElseThrow(taskId);

        Comment comment = new Comment(requestDto.getContent(), findTask, user);
        Comment saved = commentRepository.save(comment);
        
		// 이 부분!!!!
        findTask.updateNumOfComments(findTask.getNumOfComments() + 1);

        return new CommentResponseDto(saved.getId(), saved.getContent());
    }

Comment saved = commentRepository.save(comment);

게시물을 조회할 때마다 댓글을 count해주는 게 아니라 애초에 comment를 달 때 새로 업데이트를 해줘야하는 것!
추가로 update요소가 들어갔기 때문에 @Transactional 어노테이션이 꼭 필요하다!

왜 댓글 달 때 카운트를 추가해줄 수 있는 걸 생각을 못하고 헛짓을 했을까........

➡️ 중간 피드백 중 튜터님께서 이 부분에 대한 이야기를 해주셨다!!
나도 처음에는 무조건 db에 저장된 댓글 수를 count 하려 했는데, 비정규화/역정규화 와 관련되어 있다고 한다.

  • 인스타그램 저스틴비버 문제 라고 검색하면 나옴

결론은 내가 한 방식대로 +1 해주는게 역정규화 라고 하고, 잘 구현했다는 뜻!!

💫 깨달은 점

Entity 설정할 때에 필드 이름이나 메서드 이름을 어떻게 작성해야할지 고민하게 되었다는 것
코딩할 때에는 나 편하자고 그냥 무슨무슨id로 적어놨었는데 흠;...
모르겠다!!

➡️ 이것도 피드백에서 여쭤봤는데 맘대로 해도 괜찮다고 하심.
아마 인식 못했던 거는 내가 메서드 구현을 잘못했을 가능성이 크다는 것.. ㅎㅎ
(실제로 다 알게되고 다시 생각해보니 내가 잘못 작성한 게 맞았다.)

  • 레파지토리에서 메서드를 작성할 때, By 뒷부분은 SQL에서 WHERE절과 동일하다.
    By+(엔티티)+(엔티티필드명) 이런 구조인 것 같다! 이 부분이 가장 헷갈렸는데 이제야 이해를 어느정도 한 것 같다.

3. 로그인되어있는 사용자가 로그인 하려할때

중간 피드백을 받는데 튜터님께서 고민해보라고 하셨다.
이미 로그인이 되어있는데, 서버 문제로 인하여 다시 로그인 호출을 하게 된다면?!

//로그인 컨트롤러

    @PostMapping("/login")
    public ResponseEntity<String> login(@Valid @RequestBody LoginRequestDto requestDto, HttpServletRequest request) {
        HttpSession session = request.getSession(false);

        if(session != null && session.getAttribute("userId") != null){
            log.info("******** 이미 로그인 되어있을때 getSession"+request.getSession().getId()+"************");
            throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "이미 로그인되어 있습니다. 로그아웃 후 다시 시도해주세요.");
        }

        LoginResponseDto user = loginService.login(requestDto);

        if(user == null){
            throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "로그인 실패: 아이디 또는 비밀번호를 확인하세요.");
        }

        Long userId = user.getUserId();

        HttpSession newSession = request.getSession();

        UserResponseDto loginUser = userService.findUserById(userId);

        newSession.setAttribute(Const.LOGIN_USER, loginUser);
        newSession.setAttribute(Const.LOGIN_USER_ID, userId);

        return ResponseEntity.ok(loginUser.getUsernamme()+"님 로그인 성공!");

    }
if(session != null && session.getAttribute("userId") != null){
	log.info("******** 이미 로그인 되어있을때 getSession"+request.getSession().getId()+"************");
	throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "이미 로그인되어 있습니다. 로그아웃 후 다시 시도해주세요.");
}

어떻게하면 첫 로그인인데 로그인된 사용자라고 뜨고 🥲
아님 아예 이 검증이 안되고 난리가 났다. 저 if문 자체에 안 걸리는 것..

그래서 아래처럼 고쳐봤다! 로그를 찍어서 어느 부분이 Null 이길래 검증이 안되는 것인지 확인해보자.

<검증>

if(session != null){
	log.info("*** 이미 로그인 되어있는 사용자라면?");
	log.info("*** session.getAttribute (userId) : "+session.getAttribute("userId"));
	log.info("*** session.getAttribute(loginUser) : "+session.getAttribute("loginUser"));
	log.info("*** getSession().getId() : "+request.getSession().getId()+"************");
	throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "이미 로그인되어 있습니다. 로그아웃 후 다시 시도해주세요.");
}

<결과>

로그인 필터 로직 실행
2025-02-12T20:25:35.109+09:00  INFO 9931 --- [ScheduleProject_develop] [nio-8080-exec-5] c.e.s.auth.AuthController                : *** 이미 로그인 되어있는 사용자라면?
2025-02-12T20:25:35.109+09:00  INFO 9931 --- [ScheduleProject_develop] [nio-8080-exec-5] c.e.s.auth.AuthController                : *** session.getAttribute (userId) : null
2025-02-12T20:25:35.109+09:00  INFO 9931 --- [ScheduleProject_develop] [nio-8080-exec-5] c.e.s.auth.AuthController                : *** session.getAttribute(loginUser) : com.example.scheduleproject_develop.user.dto.UserResponseDto@1fe6836c
2025-02-12T20:25:35.109+09:00  INFO 9931 --- [ScheduleProject_develop] [nio-8080-exec-5] c.e.s.auth.AuthController                : *** getSession().getId() : 7FFD4BF4EFE31C83209BD386E63C832A************

로그 보니까 userId는 왜 안나오는지..>??흠냐

if(session != null){
            log.info("*** 이미 로그인 되어있는 사용자라면?");
            log.info("*** session.getAttribute(userId) : "+session.getAttribute(Const.LOGIN_USER_ID));
            log.info("*** session.getAttribute(loginUser) : "+session.getAttribute(Const.LOGIN_USER));
            log.info("*** getSession().getId() : "+request.getSession().getId()+"************");
            throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "이미 로그인되어 있습니다. 로그아웃 후 다시 시도해주세요.");
        }
        
------------------------------------------------------------------------------
        
로그인 필터 로직 실행
2025-02-12T20:31:22.535+09:00  INFO 10039 --- [ScheduleProject_develop] [nio-8080-exec-4] c.e.s.auth.AuthController                : *** 이미 로그인 되어있는 사용자라면?
2025-02-12T20:31:22.535+09:00  INFO 10039 --- [ScheduleProject_develop] [nio-8080-exec-4] c.e.s.auth.AuthController                : *** session.getAttribute(userId) : 1
2025-02-12T20:31:22.535+09:00  INFO 10039 --- [ScheduleProject_develop] [nio-8080-exec-4] c.e.s.auth.AuthController                : *** session.getAttribute(loginUser) : com.example.scheduleproject_develop.user.dto.UserResponseDto@42c6b81
2025-02-12T20:31:22.536+09:00  INFO 10039 --- [ScheduleProject_develop] [nio-8080-exec-4] c.e.s.auth.AuthController                : *** getSession().getId() : 6EB9E0FF3CA241D24FE61AF4A26E1FAC************

이렇게 바꾸니까 정상적으로 로그가 찍힌다. 그래서 보니까

// Const.java
package com.example.scheduleproject_develop.common;

public interface Const {
    String LOGIN_USER = "loginUser";
    String LOGIN_USER_ID = "loginUserId";
}

하하핳 내가 이렇게 정해두었었네..
(아마 강의자료 복붙했어서 그런가보다... 강의자료라도 제대로 이해하고 기억하고 사용하자!!!)
완전 삽질했다 ㅡㅡ,,

if(session != null){
	log.info("*** 이미 로그인 되어있는 사용자라면?");
	log.info("*** session.getAttribute(userId) : "+session.getAttribute("loginUserId"));
	log.info("*** session.getAttribute(loginUser) : "+session.getAttribute("loginUser"));
	log.info("*** getSession().getId() : "+request.getSession().getId()+"************");
	throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "이미 로그인되어 있습니다. 로그아웃 후 다시 시도해주세요.");
}

최종 수정 이렇게 하니까 잘 출력도 되고, 로그인된 사용자라고 session 검출도 잘 되었다!


4. 사용자 삭제 오류

2025-02-13T00:14:43.690+09:00  WARN 11529 --- [ScheduleProject_develop] [nio-8080-exec-2] o.h.engine.jdbc.spi.SqlExceptionHelper   : SQL Error: 1451, SQLState: 23000
2025-02-13T00:14:43.690+09:00 ERROR 11529 --- [ScheduleProject_develop] [nio-8080-exec-2] o.h.engine.jdbc.spi.SqlExceptionHelper   : Cannot delete or update a parent row: a foreign key constraint fails (`task`.`task`, CONSTRAINT `FK2hsytmxysatfvt0p1992cw449` FOREIGN KEY (`user_id`) REFERENCES `user` (`user_id`))
2025-02-13T00:14:43.695+09:00 ERROR 11529 --- [ScheduleProject_develop] [nio-8080-exec-2] o.a.c.c.C.[.[.[/].[dispatcherServlet]    : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed: org.springframework.dao.DataIntegrityViolationException: could not execute statement [Cannot delete or update a parent row: a foreign key constraint fails (`task`.`task`, CONSTRAINT `FK2hsytmxysatfvt0p1992cw449` FOREIGN KEY (`user_id`) REFERENCES `user` (`user_id`))] [/* delete for com.example.scheduleproject_develop.user.User */delete from user where user_id=?]; SQL [/* delete for com.example.scheduleproject_develop.user.User */delete from user where user_id=?]; constraint [null]] with root cause

java.sql.SQLIntegrityConstraintViolationException: Cannot delete or update a parent row: a foreign key constraint fails (`task`.`task`, CONSTRAINT `FK2hsytmxysatfvt0p1992cw449` FOREIGN KEY (`user_id`) REFERENCES `user` (`user_id`))
	at com.mysql.cj.jdbc.exceptions.SQLError.createSQLException(SQLError.java:109) ~[mysql-connector-j-9.1.0.jar:9.1.0]

  • 참조 무결성
  • 외래키 제약 조건

user 테이블에서 userId를 삭제할 때, 해당 userId를 참조하는 다른 테이블의 데이터(예: task, comment)가 존재하면 외래키 제약 조건으로 인해 삭제가 불가능하다.

userRepository.delete(findUser) 메서드로 사용자를 삭제하려고 하지만, task 테이블에서 해당 userId를 참조하는 레코드가 있다면 MySQL에서 외래키 제약 오류가 발생한다.

-> 따라서, userId를 참조하는 데이터들을 삭제하거나 업데이트해야 한다.
예를 들어, task 테이블에서 userId를 참조하는 모든 레코드를 먼저 삭제하거나 무결성을 유지할 수 있는 방법을 선택해야 한다.

그래서 사용자를 삭제하기 전에 taskService에서 관련 Task들을 먼저 삭제해주려고 했다.

//UserController
    @DeleteMapping("/{userId}")
    public ResponseEntity<Void> deleteUserById(@PathVariable Long userId){
        taskService.deleteTasksByUserId(userId);
        userService.deleteUserById(userId);
        return new ResponseEntity<>(HttpStatus.NO_CONTENT); //삭제 성공
    }

//TaskService
        public void deleteTasksByUserId(Long userId) {
        List<Task> taskByUserId = taskRepository.findTaskByUser_Id(userId);
        taskRepository.deleteAll(taskByUserId);
    }

//TaskRepository
    List<Task> findTaskByUser_Id(Long userId);

또 오류 대환장파티~
아까 위에서 말했던 것과 비슷한? 같은?오류같다.
List<Task> findTaskByUser_Id(Long userId);
이 부분이 문제 같은데.. 저 By뒤의 userID를 아무리 바꿔도 못찾는단다ㅠ

@ManyToOne
@JoinColumn(name = "user_id")  
private User user;

위에서 말했듯이, 이렇게 엔티티에서 @Id 필드명을 user_id로 이름을 지정했을 경우 메서드를 아래처럼 작성해야함!!!

public List<Task> findTaskByUser_UserId(Long userId);  

테스트 해보니 사용자 삭제를 전송하니 기존 일정 게시물들도 다 삭제가 되었다!
(근데 댓글은 상관 없나?....) -> 댓글 추가해보고 하려니 똑같은 오류 발생ㅎ
와중에 댓글도 똑같은 방식으로 구현하니 또 오류 발생 (또뭐가문젠데!!!!!!!)

2025-02-13T00:43:00.223+09:00  WARN 12600 --- [ScheduleProject_develop] [nio-8080-exec-8] o.h.engine.jdbc.spi.SqlExceptionHelper   : SQL Error: 1451, SQLState: 23000
2025-02-13T00:43:00.223+09:00 ERROR 12600 --- [ScheduleProject_develop] [nio-8080-exec-8] o.h.engine.jdbc.spi.SqlExceptionHelper   : Cannot delete or update a parent row: a foreign key constraint fails (`task`.`comment`, CONSTRAINT `FKfknte4fhjhet3l1802m1yqa50` FOREIGN KEY (`task_id`) REFERENCES `task` (`id`))
2025-02-13T00:43:00.229+09:00 ERROR 12600 --- [ScheduleProject_develop] [nio-8080-exec-8] o.a.c.c.C.[.[.[/].[dispatcherServlet]    : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed: org.springframework.dao.DataIntegrityViolationException: could not execute statement [Cannot delete or update a parent row: a foreign key constraint fails (`task`.`comment`, CONSTRAINT `FKfknte4fhjhet3l1802m1yqa50` FOREIGN KEY (`task_id`) REFERENCES `task` (`id`))] [/* delete for com.example.scheduleproject_develop.task.Task */delete from task where id=?]; SQL [/* delete for com.example.scheduleproject_develop.task.Task */delete from task where id=?]; constraint [null]] with root cause

java.sql.SQLIntegrityConstraintViolationException: Cannot delete or update a parent row: a foreign key constraint fails (`task`.`comment`, CONSTRAINT `FKfknte4fhjhet3l1802m1yqa50` FOREIGN KEY (`task_id`) REFERENCES `task` (`id`))
	at com.mysql.cj.jdbc.exceptions.SQLError.createSQLException(SQLError.java:109) ~[mysql-connector-j-9.1.0.jar:9.1.0]

이번에는요.. 이미 댓글이 있는 task를 지우려하니 안된답니다!ㅎㅎㅎㅎㅎㅎㅎ

아니그데 너무 어려워지는데?ㅠ 아니 userID로 Task들 찾아서 다 지우는데...

역시나 .. 나는 또 삽질을 한 것이다... 분명 배웠는데 까먹은 것!!!!!!!!!!

연관관계 매핑할 때,
cascade = CascadeType.ALL
이 속성을 적어주면 자동으로 연결되어있는 것들을 다 지워준다.

-> 근데 실무에서는 cascade나 ondelete를 안 쓴다고 한다.. (hard delete)
대신 soft-delete 사용해보자!
: 실제로 db에서는 지우지 않는데 지우는 척을 하는 거임!

🪄 Soft Delete

@SQLDelete(sql = "UPDATE user SET deleted = true WHERE user_id = ?")
@SQLRestriction("deleted = false")

이렇게 엔티티마다 어노테이션을 붙여주고,

@Getter
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class BaseEntity {

    @CreatedDate
    @Column(updatable = false)
    private LocalDateTime createdAt;

    @LastModifiedDate
    private LocalDateTime modifiedAt;

    @Column(nullable = false)
    private Boolean deleted = Boolean.FALSE;
}

일단 모든 엔티티에 적용할 거니까 BaseEntity에 deleted 컬럼을 넣어주면??

회원 탈퇴를 진행하면, deleted 플래그를 true로 바꾸어주고, 사용자에게는 탈퇴된 것처럼/ 게시물이 삭제된 것처럼 보여지게 한다.

😲 약간 갤러리나 SNS를 이용할 때에도 일단 휴지통에 넣어놓지만, 일정 기간(ex.30일) 내에는 복구가 가능하도록 해둔 게 이런 기능 덕분일까??

물론 지금은 작은 규모의 과제이고, 회원탈퇴와 동시에 영구삭제가 되어도 상관없지만 ㅎ
일단 이런 게 있구나라는 걸 알아두는 정도로만 생각해보겠다.


[참고]
https://0soo.tistory.com/133
https://resilient-923.tistory.com/417
https://yusang.tistory.com/103


이걸 하면서 드는 생각인데,,
회원 삭제를 하면 바로 로그아웃이 되도록 해야겠군!!!!!

또 이걸 하면서 드는 생각인데
회원 삭제=탈퇴니까 Auth로 가야하는 것 같기도!!!

=> 이 부분들은 다 반영하였다 ㅎㅎ


확실히 도메인이 늘어날 수록 고려해야할 것들도 많아지고.. 어려운 것 같다.

대신 이런 과정을 겪으며 어떤 기능을 개발할 때엔 어떤 것들을 고려해야하는지 더욱 알아가고 있는 것 같다!!!!

그리고 문법 공부하다가 하니까 넘나 재밌음🥹

하지만...
처음 코드를 짤 때부터 모든 고려사항들을 떠올리고, 활용할 줄 아는 사람이 되고싶다.
스프링은 처음 배우는 거니까 그런 거겠지????ㅜㅜ

0개의 댓글