본문 바로가기
프로젝트/Recipository

[Dev] 23.01.18. 게시글 작성 및 수정, 댓글 작성 (feat. Database Normalization(데이터베이스 정규화))

by 규글 2023. 1. 18.

 국비 과정에서 필자가 작업했을 때도, 이번 작업에서도 필자는 동일하게 Database Normalization에 대한 생각을 하지 않고 있었다. Database Normalization (데이터베이스 정규화)관계형 데이터베이스의 설계에서 중복을 최소화하도록 데이터를 구조화하는 과정이라고 하는데, 필자는 이에 대한 고민을 전혀 하고 있지 않았던 것이다. 고민을 하고 있지 않다는 사실을 지난 밤 지인에게 다른 항목에 대한 방향성을 의논하던 중에 알게 되었으며, 다른 것보다 이것이 중요하다고 판단되어 먼저 고찰을 하고 있다.

 

현 상황

 지금 상황은 다음과 같다.

  • 사용자의 data를 저장하는 table에서는 사용자의 닉네임 data를 저장하고 있다.
  • 사용자가 작성한 게시글의 data를 저장하는 table에서는 작성한 사용자의 닉네임 data를 저장하고 있다.

 사용자 table과 게시글 table에서는 공통적으로 사용자의 닉네임 data를 저장하고 있는데, 이는 database normalization 관점에서 중복이라고 할 수 있다.

 

 사실 필자가 게시글 table에도 작성자의 닉네임을 저장하도록 한 이유는 딱히 없다. 하나의 table을 조회하여 한 번에 data를 가져올 수 있으면 좋겠다고 생각한 정도이다. 이전에도 그렇게 했었기 때문에 취한 방식이긴 하나, 이런 필자 스스로의 태도가 아주 한심하다.

 

 만약 현 상태로 계속해서 작업하면 생기는 문제는 무엇일까? 사용자의 닉네임이 포함된 게시글과 댓글의 data가 존재할 때, 사용자의 닉네임을 수정하면 나머지 table의 data 항목도 연쇄적으로 수정해주어야 하는 것이다. 지난 게시글에서 고민한 것이 바로 이것이다. 하지만 아예 게시글이나 댓글 table에서 작성자의 이름을 저장하지 않고 대신 사용자 table의 primary key 값을 저장한다면 연쇄적인 수정 작업을 하지 않아도 된다.

 

 

작업 전

 그럼 어떤 방식으로 지난 작업을 뜯어 고쳐야 할까? 사용자와 게시글 table 만을 놓고 이야기해보면, 우선 Recipe Entity에 있던 String type의 writer field를 없애고 사용자의 primary key의 값을 저장할 수 있도록 SpUser Entity와 연관 관계를 맺어주어야 한다. 그리고 게시글을 저장할 때는 사용자의 이름을 넣어 저장하는 것이 아니라 User Entity를 넣어 저장하여 그 primary key 값을 저장할 수 있도록 해야하고, 반대로 조회할 때는 사용자 table의 primary key를 들고 나올 수 있도록 해야 한다.

 

 그러면 가장 먼저 작업해야하는 것은 사용자 table과 사용자 권리 table 의 fetch type을 EAGER에서 LAZY로 바꿔야 한다. 그렇지 않는다면 게시글에서 가져올 사용자의 닉네임 정보를 조회할 때, 그 권리 table까지 조회하게 된다.

 

 

작업

User Entity에서 Authority Loading Lazy로 변경

SpUser.java

    @OneToMany(fetch = FetchType.LAZY, cascade = CascadeType.ALL)
    @JoinColumn(name = "user_id", foreignKey = @ForeignKey(name = "user_id"))
    private Set<SpAuthority> authorities;

 기존에 SpUser Entity에 있던 fetch type을 EAGER에서 LAZY로 변경해주었다.

 

SpUserService.java

    @Transactional
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        Optional<SpUser> spUser = userRepository.findSpUserByEmail(username);
        Set<SpAuthority>  authorities = spUser.get().getAuthorities();
        System.out.println(authorities);

        return spUser
                .orElseThrow(() -> new UsernameNotFoundException(username));
    }

 그리고 로그인 과정에 참여하는 UserDetailsService인 SpUserService의 loadUserByUsername method에서 UserDetails인 SpUser에 더해서 authorities까지 loading 하게 한 뒤에 return 하도록 하여, return 되는 UserDetails에 권한 정보까지 담도록 했다. 기존에 fetch type EAGER의 경우에는 한 번에 loading 되어서 이렇게 할 필요가 없으나, LAZY의 경우에는 권한에 대한 data는 loading 되지 않으므로 해당 loading 이 이루어지도록 한 뒤에 return 해주어야 한다.

 따로 console 창에 print 하는 작업은 필요 없으나, 해당 field에 대한 활용이 없으면 authorities를 loading 하지 않은 것으로 판단하여 작성한 부분이다. 따로 debugging 과정에서는 문제 없었으나, 실제로 동작에는 권한에 대한 data가 없어 정상적인 로그인이 수행되지 않았다.

 

org.hibernate.LazyInitializationException: 
could not initialize proxy [com.example.recipository.domain.SpUser#1] - no Session

 이렇게 fetch type을 LAZY로 변경했을 때, 위와 비슷한 에러 메시지를 확인할 수도 있다. 메시지에서는 proxy를 initialize 할 수 없었다고 말하는데, 이는 lazy loading 과정에서 영속을 유지할 수 없었기 때문이라고 한다.[각주:1] 그럴 때는 잠시 조금 위의 method에 작성된 @Transactional을 보자. 영속이라는 것은 하나의 transaction과 생명 주기를 같이 한다고 생각할 수 있는데, 따라서 @Transactional annotation으로 해당 오류를 해결할 수 있다.

 

게시글 table 변경

SpUser.java / Recipe.java, Comment.java

 첫 번째 이미지는 사용자의 data를 담는 SpUser, 두 번째 이미지는 붉은 선을 기준으로 위아래로 각각 게시글 data를 담는 Recipe, 댓글 data를 담는 Comment Entity이다. SpUser 이미지의 위아래에 각각 대응된다. SpUser에는 기존에 Recipe List나 Comment List가 없었고, 마찬가지로 Recipe나 Comment 에는 String type의 writer field가 있었을 뿐이다. 작성한 사용자의 이름 대신 user id를 save 하기 위해서 SpUser와 Recipe, SpUser와 Comment 사이에 양방향 연관 관계를 맺도록 했다.

 

AuthInterceptor.java

 이런 작업을 하게 되면, 기존에 String type의 writer를 활용하고 있던 모든 method 등을 수정해주어야 한다. 대표적으로는 이전에 만들었던 interceptor 객체를 들 수 있는데, 밑줄 친 부분에서 기존에는 각 Entity의 writer field의 값으로 비교했었다. 하지만 이제는 writer 대신 SpUser 객체가 그 자리를 대신하고 있으므로 그에 맞도록 수정해주어야 하는 것이다.

 

 이와 마찬가지로 게시글 작성 및 수정하는 service logic, 댓글을 작성하는 service logic에서 기존에 작성자 data를 넣어주는 과정을 지워주고 대신에 현재 로그인 하고 있는 Principal 객체인 SpUser를 field에 담도록 한다. 그러면 기존 게시글과 댓글 table에서 작성자의 이름을 저장하고 있던 것을 작성자의 user id를 저장하는 방식이 되며, 작성자의 이름을 수정하더라도 따로 각각의 table에서 수정해주는 작업은 이제 불필요한 단계가 되었다고 할 수 있다.

 

UserServiceImpl.java

 실제로 프로필 정보를 수정하는 service logic도 상당히 간소해져서, 왼쪽의 내용들을 통째로 들어낼 수 있었다.

 

 

 작업을 마치고 게시글 작성과 수정, 댓글 작성에 대한 기능이 그대로 동작하고 있는 것을 확인했다.

 

 

이어지는 고찰

Spring Security를 활용한 로그인 방식에 의존성이 생긴다?

 사실 작업의 과정은 어렵지 않았고, 오래 걸리는 것도 아니었다. 하지만 바꾸고 보니 드는 생각이 있었다.

 이런 식으로 로그인에 성공한 Principal 객체를 service logic에서 활용한다면, 이와 같은 로그인 방식을 사용하는 사이트에 한정되는 개발 방식이라는 생각이다. 이 상황에 만약 로그인 방식이 바뀌기라도 한다면? 그럼 활용했던 UserDetails 인 SpUser 객체와 관련된 모든 항목에 대한 수정 작업이 동반될 것이다.

 

 그럼 로그인에 사용되는 UserDetails인 SpUser 외에, 따로 Member에 대한 Entity를 만들고 이를 table로 해야하는 것일까? 회원 가입 시에는 이 Member에 data를 담아 save 하여 table에 저장하고, 로그인 시에는 로그인 과정에 SpService의 loadUserByUsername method에서 member table로부터 data를 받아 UserDetails에 넣어 return 해주고, 게시글을 작성할 때는 SpUser가 아니라 Member data와 연관 관계를 맺도록 하여 service layer에 UserDetails 객체를 넘기지 않도록 하여 Spring Security의 로그인 방식에 의존하지 않을 수 있게 되는 것이 아닐까?

 

 

select 두 번 vs. join 한 번

 Recipe 입장에서 Link와 Comment는 모든 정보를 필요로 한다지만 Recipe 입장에서 writer는 SpUser의 모든 정보를 필요로 하는 것이 아니다. 현 상황에서 Recipe 하나의 정보를 가져오는 것에 수행되는 query 문은 recipe table select 한 번, sp_user table select 한 번으로 총 두 번이다. 그런데 이를 아예 jpql을 사용해서 한 번의 select로 가져오는 건 어떨까? 두 번 select 하여 값을 DTO에 담아 response 하는 것이 아니라, query 문의 join을 활용하여 한 번에 조회한 뒤에 DTO에 담에 response 하는 방식이 더 좋은 방식이지 않을까?

 

Reference

댓글