개발지식 먹는 하마 님의 블로그

[내일배움캠프 34일차]_JPA 활용 일정 관리 프로그램 트러블 슈팅 본문

내일배움캠프 (CS25)

[내일배움캠프 34일차]_JPA 활용 일정 관리 프로그램 트러블 슈팅

devhippo 2025. 4. 4. 13:39

📅 일정 관리 프로그램

Spring JPA를 이용해 구현한 일정 관리 프로그램입니다.

일정 관리 프로그램은 아래의 기능들을 제공합니다.

🛠️ 기능

  • 회원가입, 로그인, 로그아웃
  • 유저 이름 및 비밀번호 변경, 삭제
  • 일정 생성, 수정, 삭제
  • 댓글 생성, 수정, 삭제
  • 특정 유저가 작성한 댓글 조회
  • 특정 일정에 작성된 댓글 조회
  • 유저 아이디 또는 수정일에 따른 일정 조회

🛠️ 유저 관련 기능

☑️ 회원가입

이름, 이메일, 비밀번호를 입력해 회원가입을 할 수 있습니다.

API : /users/signup

🔖 예외) 이메일 중복

이때, 이메일은 다른 유저와 중복될 수 없습니다.
만약 이메일이 중복인 경우 해당 이메일로 가입을 진행할 수 없습니다.

이메일 중복 시, 반환되는 상태

🔖 예외) 잘못된 비밀번호 형식

비밀번호는 영문과 숫자의 조합만 가능합니다.

회원가입 유효성 검증

☑️ 로그인

이메일, 비밀번호를 입력해 로그인을 할 수 있습니다.

로그인 성공 시, 유효시간이 30분인 세션이 생성됩니다.

API : /users/login

🔖 예외) 이메일 또는 비밀번호 불일치

🔖 예외) 이미 로그인한 상태인 경우

☑️ 로그아웃 

API : /users/logout

로그아웃하면 유저가 가진 세션이 무효화됩니다.

☑️ 마이페이지

API : /users/me

내 정보를 확인할 수 있습니다.

☑️ 유저 이름, 비밀번호 변경

API : /users/name
API : /users/password

유저 이름과 비밀번호를 별도의 API에서 변경합니다.

유저의 이름은 별도의 비밀번호를 확인하지 않고 쉽게 바꿀 수 있지만
비밀번호는 현재 비밀번호와 새 비밀번호를 입력받고 현재 비밀번호가 일치하지 않으면 변경할 수 없습니다.

🔖 예외) 현재 비밀번호가 불일치

🔖 예외) 변경값이 현재값과 동일

☑️ 유저 삭제

API : /users/me

비밀번호 검증 후, 내 계정을 삭제할 수 있습니다.
계정 삭제 후, 기존 세션이 무효화됩니다.


🛠️ 일정 관련 기능

일정과 댓글의 생성, 수정, 삭제는 세션이 가지고 있는 유저 아이디를 사용해 유저를 식별하기 때문에
일정과 댓글에 필요한 내용만 전달합니다.

✅ 일정 생성

API : /tasks

일정 생성 예제

일정의 제목과 내용을 전달받으면 세션에 의해서 유저 정보가 적용됩니다.

✅ 일정 수정, 삭제

API : /tasks/{id}

일정의 제목 또는 내용을 각각 수정할 수 있습니다.
일정의 수정과 삭제는 본인의 일정만 가능합니다.

🔖 예외) 본인의 일정이 아닐 때 


🛠️ 댓글 관련 기능

☑️ 댓글 생성

API : /tasks/{task_id}/comments

☑️ 댓글 수정, 삭제

API : comments/{id}

댓글의 수정과 삭제는 본인의 댓글만 가능합니다.

🔖 예외) 본인의 댓글이 아닐때


✅ 유저가 작성한 댓글 전체 조회

API : /users/{user_id}/comments

유재석이 작성한 댓글들의 일부

✅ 일정에 속하는 댓글 전체 조회

API : /tasks/{task_id}/comments

핑계고 녹화 일정에 달린 댓글들 (격한 반응으로 하하의 댓글은 삭제됨)

✅ 일정 전체 조회 (유저 아이디, 수정일 조건 설정 가능)

API : /tasks

4월 4일, 하하의 일정 전체 조회

조건에 따른 일정 또는 댓글이 존재하지 않을 때는 0개의 결과가 반환됩니다.

더 자세한 API 명세와 예제는 Postman 문서 링크를 참조해 주세요!

[유저 API] https://documenter.getpostman.com/view/43241868/2sB2cU9NAg
[일정 API] https://documenter.getpostman.com/view/43241868/2sB2cU9NAf
[댓글 API] https://documenter.getpostman.com/view/43241868/2sB2cU9NAe

 

댓글

The Postman Documenter generates and maintains beautiful, live documentation for your collections. Never worry about maintaining API documentation again.

documenter.getpostman.com

 


📈 Entity 연관 관계 with ERD

조회에서 느낄 수 있듯이 유저와 일정, 댓글은 1대 다 연관관계를 가지고 있습니다.

ERD

  • 유저는 0 ~ N개의 일정을 수행할 수 있지만, 일정은 이를 수행할 유저가 없을 수는 없습니다.
  • 유저는 0 ~ N개의 댓글을 작성할 수 있지만, 댓글을 작성한 유저가 없을 수는 없습니다.
  • 일정에는 0 ~ N개의 댓글이 달릴 수 있지만, 댓글은 일정이 없다면 머물 위치가 없습니다.
유저가 삭제되면 유저의 일정과 댓글이, 일정이 삭제되면 해당 일정에 달린 댓글이 삭제되도록
Cascade를 설정했습니다.

📏 주요 구현 방식

💡Session을 이용한 로그인

  1. 클라이언트가 보낸 HTTP 요청을 담은 HttpServletRequest에서 getSession으로 세션을 받아온다
  2. 이때 getSession(false)이면 세션이 없을 때, 새로운 세션을 생성하지 않는다.
  3. 현재 존재하는 세션이 있다면 "이미 로그인된 상태"임을 알린다.
  4. 현재 존재하는 세션이 없다면 getSession(true)로 새로운 세션을 생성한다.
  5. "loginUser"라는 이름의 객체에 사용자 정보를 저장한다.
  6. 세션 유효시간을 설정 후, "로그인 성공"임을 알린다.
    @PostMapping("/login")
    public ResponseEntity<String> login(
            @Valid @RequestBody LoginRequestDto loginRequest,
            HttpServletRequest request
    ){
        HttpSession existingSession  = request.getSession(false);

        //세션이 존재하고 loginUser가 null이 아니라면
        if(existingSession!=null && existingSession.getAttribute("loginUser") != null){
            return ResponseEntity.badRequest().body("이미 로그인된 상태입니다.");
        }

        UserSessionDto loginUser = userService.login(loginRequest);
        //로그인 성공 시, 새로운 세션 생성
        HttpSession session = request.getSession(true);
        //세션에 사용자 정보 저장
        session.setAttribute("loginUser", loginUser);
        //세션 유효시간 30분
        session.setMaxInactiveInterval(30 * 60);
        return ResponseEntity.ok("로그인 성공");
    }

💡@SessionAttribute 사용

세션에 저장된 유저 아이디를 불러와 여러 번 사용하기 때문에
세션이 존재하는지 검증하고, 세션이 존재하는 경우 값을 가져와 사용하는 코드를 반복해서 사용하는 부분이 있었다.

이를 @SessionAttribute로 대체하여 사용하였다.

@SessionAttribute("loginUser")UserSessionDto loginUser

@SessionAttrbute는 세션에서 특정 속성을 가져와 컨트롤러 메서드의 매개변수로 바인딩하는 역할을 하기 때문에
컨트롤러의 메서드에서 간편하게 접근할 수 있다.

💡 비밀번호 암호화

제공받은 암호화 코드를 기반으로 입력받은 비밀번호를 암호화해 저장한다.

String encodedPassword = passwordEncoder.encode(signUpDto.getPassword());

User Entity에서 입력받은 비밀번호와 DB에 저장된 해시된 비밀번호를 BCrypt 해싱 검증을 통해 비교하여
일치 여부를 확인하도록 하였다.

public boolean checkPassword(String password, PasswordEncoder passwordEncoder){
   return passwordEncoder.matches(password, this.password);
}

💡 일정 전체 조회 페이징 + (각 일정의 댓글 개수 필드 추가)

@Query의 사용은 확장성, 자원 소모 등의 이유로 사용하는 것이 좋지 않다고 해서
동적으로 쿼리문을 확장하는 방법이 무엇이 있는지 찾아보다가 Specification을 사용하게 되었다.

유저 아이디가 입력된 경우, user 엔티티의 id의 값이 userId와 같은 것만 조회하도록 한다.
ex) userId = 1일 때 → WHERE user.id = 1

//userId가 null이 아닐 때, Task의 user의 id와 userId를 비교하는 조건 추가
if(userId != null){
    spec = spec.and(((root, query, cb) ->
            cb.equal(root.get("user").get("id"), userId)));
}

 

같은 방식으로 수정일에 대한 조건도 조회한다.
yyyy-mm-dd에 형식으로 비교하기 위해 LocalDate를 사용한다.
ex) updatedAt = "2024-04-01"일 때 → WHERE DATE(updatedAt) = '2024-04-01' 조건이 추가됨.

if(updatedAt != null && !updatedAt.isBlank()){
    //문자열을 LocalDate로 변환 (YYYY-mm-dd)
    //캘린더에서 선택하는 방식을 사용한다는 가정하에 DATE 형식이 맞지 않는다는 가정은 제외함
    //아닐 경우 형식 검사 추가 필요
    LocalDate date = LocalDate.parse(updatedAt, DateTimeFormatter.ISO_LOCAL_DATE);
    //문자열
    spec = spec.and((root, query, cb) ->
            cb.equal(cb.function("DATE", LocalDate.class, root.get("updatedAt")), date));
}


이 부분이 까다로웠다. 
간단한 페이징이었다면 위의 조건에 따라 필터링된 결과를 바로 Page로 반환했을 것이다.
그러나 요구사항에 따라 각 일정에 몇 개의 댓글이 달렸는지 그 개수도 포함해야했다.

@Query의 사용은 별로 권장되지는 않으나 Specification을 사용하면 코드가 오히려 복잡해질 것 같았다.
또 댓글 개수는 일정 전체를 조회할 때 뿐만 아니라 각 일정 별로도 필요했기 때문에 재사용성을 위하여
@Query를 사용해 Repository 계층에 아래와 같은 메서드를 작성해 사용하였다.

    @Query("SELECT c.task.id, COUNT(c) FROM Comment c WHERE c.task.id IN :taskIds GROUP BY c.task.id")
    List<Object[]> countByTaskIds(@Param("taskIds") List<Long> taskIds);
  1. 조회된 일정들의 id를 모은다. 
  2. 모은 Id 리스트를 넘겨 받아 위의 메서드를 이용해 구한 일정의 개수를 반환받아,
    Id에 따른 일정의 개수를 매핑한다.
  3. Task와 댓글 개수를 TaskResponseDto로 변환하여 클라이언트에 반환한다.
 Page<Task> taskPage = taskRepository.findAll(spec, pageable);

 //페이지 내의 일정 id를 모아옴
 List<Long> taskIds = taskPage.getContent().stream()
        .map(Task::getId)
        .toList();

//id에 따른 개수를 매핑
Map<Long, Long> commentCounts = commentRepository.countByTaskIds(taskIds).stream()
        .collect(Collectors.toMap(
                row -> (Long) row[0],
                row -> (Long) row[1]
        ));

//Task와 count로 TaskRespinseDto 리스트 생성
return taskPage.map(task -> {
    Long count = commentCounts.getOrDefault(task.getId(), 0L);
    return new TaskResponseDto(task, count);
});

🤷 트러블 슈팅

@Transactional 사용

🤦‍♀️ 문제 현상

@Transactional을 사용하면 데이터의 변경을 JPA가 자동으로 감지하고 직접 save를 호출하지 않아도
변경사항이 반영되어 DB에 저장된다고 하였다.

그러나 실제로 프로그램 실행 시, 저장이 되지 않았다.
save를 직접 호출해도 저장이 되지 않았다.

✏️ 문제 해결

@Transactional을 제거하니 정상적으로 저장되었다.
(주말동안 배운 내용들을 점검하며 원인을 파악할 예정)


댓글의 개수를 누가 알고 있어야 할까?

🤦‍♀️ 고민한 내용

일정 또는 유저를 반환할 때 일정은 일정에 달린 댓글의 개수를, 유저는 자신이 작성한 댓글의 개수 포함해야했다.

그런데 이 댓글의 개수 엔티티가 계속 가지고 있어야 할까?
아니면 댓글의 개수가 필요할때마다 이를 조회 후 반환해야할까?

이 두 부분이 고민이 되었다.
왜냐하면, 두 방법 모두 좋은 방법은 아니라는 생각이 들었기 때문이다.

댓글의 개수를 엔티티가 가지고 있는 것은 엔티티의 책임 혹은 부담이 늘어나는 것 같고,
필요할 때마다 매번 조회 후 반환하는 것은 자원 소모가 심할 것이라는 생각이 들었다.

✏️ 튜터님의 답변

우선 댓글의 개수는 프론트엔드에서 해당 값을 사용해 페이징을 해야하기 때문에 넘겨줄 필요가 있다. 그리고

댓글의 개수는 필요할 때마다 조회하는 것이 자원 소모가 발생하더라도 훨씬 나은 방법이다!

그 이유는 동시성 제어에 있다!

인스타그램에서 2명이 1개의 피드에 동시에 하트를 눌렀다고 해보자.
댓글의 개수를 Entity가 가지고 있다면 동시성 제어가 어려워
0이던 하트의 개수가 2명이 눌렀음에도 본인이 누른 1만 증가한 결과가 보이게 될 수 있다.

Entity에서 이를 다뤄도 동시성 제어를 할 수 있는 방법이 있긴 있으나 매우 복잡하고 어렵다!
차라리 쿼리를 통해 매번 조회하는 것이 더 효율적이다!

📚 마무리 말

JPA를 처음으로 사용하다보니 아직 어설픈 부분이 많지만,
이번 프로젝트를 통해 어느 정도 감을 잡을 수 있었다.

주말 동안 부족한 부분을 한 번 더 복습하고 정리한 후,
JWT 등 배웠으나 사용하지 않은 방법들도 직접 실습을 해봐야겠다고 생각했다.

(정리할게 산더미...)