Project/토이 프로젝트

[Cinemagram] JPA Native Query 팔로우 구현 및 예외처리 - (6)

Lea Hwang 2022. 12. 11. 22:39

팔로우에 대한 코드 구현에 바로 들어가기 앞서 용어 개념 정리부터 하겠습니다.

팔로우(Follow) : 내가 특정 유저의 게시물을 구독하겠다.
언팔로우(UnFollow) : 내가 구독한 유저를 더 이상 구독하지 않겠다.

팔로잉(Following) : 특정 유저를 구독하면 팔로잉으로 바뀌는데 현재 내가 이 유저를 구독하고 있다는 의미입니다.
팔로워(Follower) : 나를 구독하고 있는 유저 = 나의 팬

 

 

연관관계 매핑

1. 다대일, 일대다 관계
多쪽
이 연관관계 주인으로 외래키(FK)를 관리합니다.

주인의 반대편은 단순 조회만 가능합니다.
@OneToMany(mappedBy = "")

* 만약 一쪽이 FK를 가진다면 정규화의 원자성이 깨지게 됩니다.
** 원자성 : 하나의 칼럼에는 하나의 데이터만 들어가야 함( , 를 활용할 수 없음)


2. 다대다 관계
만약 다대다 관계일 시, 항상 중간 테이블을 생성합니다.
그럼 중간 테이블과의 관계를 파악해서 일대다, 다대일로 나눠 처리합니다.
(중간 테이블이가 되고 상대쪽이 一이 됩니다.) 

 

우리 프로젝트의 경우 User들끼리 팔로우가 가능하므로 다대다 관계입니다.

따라서 중간 테이블로 Follow을 생성하고, 多쪽인 Follow가 FK를 가지게 됩니다. 

 

 

Follow 모델 & FollowRepository 생성

중간 테이블 역할을 할 Follow엔티티입니다.

@NoArgsConstructor
@Getter
@Entity
@Table(
        uniqueConstraints = {
                @UniqueConstraint(
                        name = "follow_uk",
                        columnNames = {"fromUser_id", "toUser_id"}
                )
        }
)
public class Follow {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private int id;

    @ManyToOne
    private User fromUser;

    @ManyToOne
    private User toUser;

    private LocalDateTime createDate;

    @PrePersist
    public void createDate() {
        this.createDate = LocalDateTime.now();
    }


}

 

지금부터 @Data 어노테이션 사용을 자제하기 위해 빼고 진행하려고 합니다. 최종 목표는 엔티티와 DTO에 걸린 @Data 어노테이션을 전부 지우는 것입니다.

 

여기서 하나 리팩터링 하고 싶은 게 생겼습니다. 생성할 때 그리고 수정할 때 "시간"은 해당 엔티티뿐만 아니라 여기저기서 다 쓰입니다. 불필요한 중복 코드를 빼서 한 곳에서 관리하고자 합니다. (포스팅)

 

 

수정한 Follow 엔티티

@NoArgsConstructor
@Getter
@Entity
@Table(
        uniqueConstraints = {
                @UniqueConstraint(
                        name = "follow_uk",
                        columnNames = {"fromUser_id", "toUser_id"}
                )
        }
)
public class Follow extends BaseTimeEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private int id;

    @ManyToOne
    private User fromUser;

    @ManyToOne
    private User toUser;
    
}
만약 DB칼럼명을 바꾸고 싶다면?
@JoinColumn(name = "")

 

다중 컬럼 UNIQUE 제약 조건

여러 개의 칼럼을 동시에 중복 체크해야 하는 경우 여러 개를 묶어 unique 처리를 할 수 있습니다.

우리 프로젝트를 예로 들면 유저 A가 유저 B를 팔로우하는 데이터가 들어오면 또 다시 같은 유저A가 유저B를 팔로우하는 데이터가 들어오는 것을 막는 장치입니다. 

@Table(
        uniqueConstraints = {
                @UniqueConstraint(
                        name = "follow_uk",
                        columnNames = {"fromUser_id", "toUser_id"}
                )
        }
)

여기서는 columnNames = {"fromUser_id", "toUser_id"} 부분만 신경 써주시면 됩니다.

관리하고 싶은 칼럼을 적되 "DB칼럼명"을 적어야 합니다.

 

콘솔 확인

alter table Follow 
       add constraint follow_uk unique (fromUser_id, toUser_id)

 

만약 한 칼럼만 UNIQUE 제약 조건을 걸고 싶다면?
@Column(unique = true)
private String username;

 

 

FollowRepository

public interface FollowRepository extends JpaRepository<Follow, Integer> {

}

 

 

 

이어서 팔로우, 언팔로우 관련 API를 생성해보겠습니다.

필요한 클래스는 총 3개입니다.

  • FollowApiController
  • FollowService
  • FollowRepository

 

FollowApiController

@RequiredArgsConstructor
@RestController
public class FollowApiController {

    private final FollowService followService;

    @PostMapping("/api/follow/{toUserId}") 
    public ResponseEntity<?> follow(@AuthenticationPrincipal CustomUserDetails customUserDetails, @PathVariable int toUserId) { 
        return null;
    }

    @DeleteMapping("/api/follow/{toUserId}")
    public ResponseEntity<?> unFollow(@AuthenticationPrincipal CustomUserDetails customUserDetails, @PathVariable int toUserId) {
       return null;
    }
}

followAPI를 보면 세션정보와 toUserId를 받고 있습니다. 즉 로그인 한 유저가 toUserId를 팔로우하겠다는 의미입니다.

반대로 unFollowAPI는 로그인 한 유저가 toUserId를 언팔로우하겠다는 것입니다.

 

 

DB에 영향을 주기 위한 중간다리로 FollowService 코드를 적어보겠습니다.

DB에 영향을 준다? @Transactional 붙여줍니다. JPA에서 모든 데이터 변경이나 로직은 @Transactional안에서 해야 합니다.

@RequiredArgsConstructor
@Service
public class FollowService {

    private final FollowRepository followRepository;

    @Transactional
    public void follow(int fromUserId, int toUserId) {
        followRepository.save(); // 안 됨     
    }

    @Transactional
    public void unFollow(int fromUserId, int toUserId) {
        
    }
}

마음 같아선,  followRepository.save(); 를 사용해서 insert 하고 싶지만 문제가 있습니다.

 

save 할 땐 객체가 필요합니다. 하지만 우리가 받기로 한 fromUserId, toUserId는 int값인데 이것으로는 오브젝트를 만들지 못합니다.

 

이를 해결하기 위해서 JPA Native Query를 작성하도록 하겠습니다

 

❓❗ JPQL vs. SQL 차이 간단 정리
1. JPQL 문법
class name, field name 사용

2. native query (sql문법)
table name, column name 사용


nativeQuery속성이 false면 JPQL, true면 SQL
@Query("SELECT m FROM Member m") // JPQL
@Query("SELECT m.* FROM Member m", nativeQuery = true) // SQL

 

 

JPA Native Query

FollowRepository

public interface FollowRepository extends JpaRepository<Follow, Integer> {

    @Modifying
    @Query(value = "INSERT INTO follow(fromUserId,toUserId) VALUES(:fromUserId,:toUserId)", nativeQuery = true)
    void cFollow(@Param(value = "fromUserId") Integer fromUserId, @Param(value = "toUserId") Integer toUserId);

    @Modifying
    @Query(value = "DELETE FROM follow WHERE fromUserId =:fromUserId AND toUserId =:toUserId", nativeQuery = true)
    void cUnFollow(@Param(value = "fromUserId") Integer fromUserId, @Param(value = "toUserId") Integer toUserId);
    
}

@Modifying

INSERT, DELETE, UPDATE를 Native Query로 작성 시 해당 어노테이션 필요합니다.

 

 

: 의미

변수에 바인딩해서 넣겠다.

 

 

 

이를 FollowService 적용

@RequiredArgsConstructor
@Service
public class FollowService {

    private final FollowRepository followRepository;

    @Transactional
    public void follow(int fromUserId, int toUserId) {
    	followRepository.cFollow(fromUserId,toUserId);
    }

    @Transactional
    public void unFollow(int fromUserId, int toUserId) {
        followRepository.cUnFollow(fromUserId,toUserId);
    }
}

 

 

FollowApiController

@RequiredArgsConstructor
@RestController
public class FollowApiController {

    private final FollowService followService;

    @PostMapping("/api/follow/{toUserId}")
    public ResponseEntity<?> follow(@AuthenticationPrincipal CustomUserDetails customUserDetails, @PathVariable int toUserId) {
        followService.follow(customUserDetails.getUser().getId(),toUserId);
        return new ResponseEntity<>(new ResDto<>(1,"팔로우 성공", null), HttpStatus.OK);
    }

    @DeleteMapping("/api/follow/{toUserId}")
    public ResponseEntity<?> unFollow(@AuthenticationPrincipal CustomUserDetails customUserDetails, @PathVariable int toUserId) {
        followService.unFollow(customUserDetails.getUser().getId(), toUserId);
        return new ResponseEntity<>(new ResDto<>(1,"언팔로우 성공", null), HttpStatus.OK);
    }
}

 

 

확인

팔로우, 언팔로우 관련  API는 다 만들었습니다. 하지만 아직 타임리프 연결 등을 하지 않았으므로 Postman으로 진행하겠습니다.

 

 

0. 회원가입

id username
1 apple
2 banana
3 mango

 

1. apple로 로그인합니다.

 

 

2. 팔로우하기

apple(1) → banana(2), mango(3) 팔로우

 

 

3. DB 확인

 

 

 

 

어라... 왜 생성시간(createdDate)이 자동으로 나오지 않는 걸까요?

 

 Native Query에 생성시간을 추가하고 ddl-auto: create로 초기화한 후에 다시 해보겠습니다.

@Query(value = "INSERT INTO follow(fromUserId,toUserId,createdDate) VALUES(:fromUserId,:toUserId,now())", nativeQuery = true)

 

다행히 잘 나오는 것을 확인할 수 있었습니다.

 

 

 

4. 다중 칼럼 UNIQUE제약조건 확인

 

500 에러가 나면서 콘솔에는 Duplicate entry '1-3' for key 'follow.follow_uk'가 찍힙니다.

 

 

 

예외처리

생성한 API가 잘 작동하는 것을 확인할 수 있었습니다. 그럼 마지막으로 Custom 하게 Exception을 하나 만들어서 Duplicate 에러가 나는 부분을 try ~ catch로 잡아보도록 하겠습니다.

 

필요한 클래스는 총 3개입니다.

  • CustomApiException
  • ControllerExceptionHandler
  • FollowService

 

앞으로 Validation 이외의 API 관련 Exception은 전부 CustomApiException에서 처리할 예정입니다.

CustomApiException

@Getter
@NoArgsConstructor
public class CustomApiException extends RuntimeException {
    private static final long serialVersionUID = 1L;

    private String message;
    private Map<String, String> errors;

    public CustomApiException(String message) {
        this.message = message;
    }
}

 

ControllerExceptionHandler

@RestController
@ControllerAdvice
public class ControllerExceptionHandler {

    @ExceptionHandler(CustomApiException.class)
    public ResponseEntity<?> apiException(CustomApiException e){
        return new ResponseEntity<>(new ResDto<>(-1,e.getMessage(),null), HttpStatus.BAD_REQUEST);
    }
}

 

FollowService

@RequiredArgsConstructor
@Service
public class FollowService {

    private final FollowRepository followRepository;


    @Transactional
    public void follow(int fromUserId, int toUserId) {
        try{
            followRepository.cFollow(fromUserId,toUserId);
        }catch (Exception e) {
            throw new CustomApiException("이미 팔로우 하고 있는 유저입니다.");
        }
    }
}

 

 

Postman 확인

1. apple로 로그인

 

2. 이미 팔로우하고 있는 유저를 다시 팔로우하려고 했을 시

 

 

 

message까지 잘 출력됨을 확인했습니다. 여기까지 JPA Native Query 팔로우 구현 및 예외처리로 push 하였습니다.