프로젝트/Recipository

[Dev] 23.01.11. 사용자 정보 수정 (기능은 구현했으나 미완)

규글 2023. 1. 11. 19:00

 마이 페이지의 프로필에 대한 페이지 구성이 끝났으니, 이어서 사용자 정보를 수정하는 작업을 기록하겠다. 사실 이 작업은 생각과는 많이 달라졌다. 처음에는 당연하게도 닉네임과 비밀번호를 한 번에 수정하는 방향이 좋다고 생각했다. 하지만 닉네임만을 변경하고 싶고, 비밀번호만을 변경하고 싶을 수 있다는 점을 생각하게 되었다. 하지만 두 항목을 한 form에 몰아놓는 방향으로 구성하려니 괜히 더 복잡해지는 느낌이었다. 그래서 이 둘을 분리하게 되었다.

 

작업

프로필 정보 수정

UserController.java

    // 마이 페이지에서 닉네임 변경
    @PostMapping("/user/profile/nickname")
    public ResponseEntity<Map<String, Object>> updateNickname(@Valid UserDto userDto,
                                                             BindingResult bindingResult,
                                                             @AuthenticationPrincipal SpUser spUser){
        Map<String, Object> map = new HashMap<>();

        // Validation Error가 발생했을 때 error message list return
        if(bindingResult.hasErrors()){
            map.put("beUpdated", false);

            Map<String, Object> errorList = new HashMap<>();

            StringBuffer sb = new StringBuffer();
            bindingResult.getAllErrors().forEach(error -> {
                FieldError fieldError = (FieldError)error;
                String field = fieldError.getField();
                String errorMessage = error.getDefaultMessage();

                errorList.put(field, errorMessage);
                map.put("errorMessage", errorList);
            });

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

        // Authentication Principal 이 가진 email data를
        String email = spUser.getEmail();
        // nickname이 있는 UserDto에 setting
        userDto.setEmail(email);

        // Profile data update 성공 여부에 따라
        boolean beUpdated = userService.updateProfile(userDto);
        // 성공 시 Authentication Principal 의 nickname data를 수정해주고
        if(beUpdated){
            spUser.setName(userDto.getName());
        }

        // 성공 여부 return
        map.put("beUpdated", beUpdated);

        return ResponseEntity.ok(map);
    }

 Client에서 넘어오는 data는 nickname 정보만 들어있다. DB의 data를 조회해오는 방법은 여러 가지가 있겠지만, 필자는 로그인 된 Authentication Principal의 email, 혹은 username을 활용하는 방법을 택했다. 중복 방지가 되어있어서 그냥 name을 활용해도 DB의 data를 조회하는 것에는 무리가 없지만, 악의적으로 변형된 nickname data가 넘어오면 큰일이지 않은가? 안전하게 로그인 되어서 인증된 Principal의 email(username)을 활용하는 게 낫다고 생각했다.

 

 더하여 닉네임 변경에 성공했을 때, 인증된 Authentication Principa의 name 정보도 바꿔주어야 한다. 이를 바꾸지 않았을 경우에는 게시글의 작성자는 변경되었지만 Authentication의 값은 변경되지 않아서 마이 페이지에서 사용자가 작성한 게시글들을 확인할 수 없게 된다.

 

UserServiceImpl.java

    // 사용자의 프로필 정보를 수정하는 service logic
    @Transactional
    @Override
    public boolean updateProfile(UserDto userDto) {
        try {
            // Authentication Principal의 email data를 기반으로 DB에서 사용자 정보를 가져옴
            SpUser user = userRepository.getSpUserByEmail(userDto.getEmail());

            // 게시글 writer 변경
            // 작성자 정보로 작성한 게시글 정보를 가져와서
            List<Recipe> recipeList = recipeRepository.getAllByWriter(user.getName());
            // 각각에 대해 작성자 정보를 update하고
            recipeList.forEach(tmp -> {
                tmp.updateWriter(userDto.getName());
            });
            // save (update query)
            recipeRepository.saveAll(recipeList);

            // 댓글 writer 변경
            // 작성자 정보로 작성한 댓글 정보를 가져와서
            List<Comment> commentList = commentRepository.getAllByWriter(user.getName());
            // 각각에 대해 작성자 정보를 update하고
            commentList.forEach(tmp -> {
                tmp.updateWriter(userDto.getName());
            });
            // save (update query)
            commentRepository.saveAll(commentList);

            // 사용자 정보 또한 update 하고 save
            user.updateName(userDto);
            userRepository.save(user);

            return true;
        } catch (Exception e) {
            return false;
        }
    }

 프로필 정보를 수정하는 service logic이다. Logic은 단순하다. 인증된 사용자의 닉네임 정보를 기반으로 작성된 게시글과 댓글의 정보를 DB로부터 가져온 뒤, 해당 게시글과 댓글들의 작성자 정보를 수정해서 save하고 사용자도 마찬가지로 닉네임을 수정해서 save하는 방식이다. 하지만 사용자의 정보를 수정했을 때, 그와 관련된 게시글의 작성자나 댓글의 작성자 정보가 자연스럽게 수정되었으면 하는 생각이 들었다. 마치 게시글을 삭제했을 때, 관련된 댓글들 모두를 delete하는 것과 유사하게 말이다.

 

비밀번호 수정

UserController.java

    // 마이 페이지에서 비밀번호 변경
    @PostMapping("/user/profile/password")
    public ResponseEntity<Map<String, Object>> updatePassword(@Valid UserDto userDto,
                                                             BindingResult bindingResult,
                                                             @AuthenticationPrincipal SpUser spUser){
        Map<String, Object> map = new HashMap<>();

        // Validation Error가 발생했을 때 error message list return
        if(bindingResult.hasErrors()){
            map.put("beUpdated", false);

            Map<String, Object> errorList = new HashMap<>();

            StringBuffer sb = new StringBuffer();
            bindingResult.getAllErrors().forEach(error -> {
                FieldError fieldError = (FieldError)error;
                String field = fieldError.getField();
                String errorMessage = error.getDefaultMessage();

                errorList.put(field, errorMessage);
                map.put("errorMessage", errorList);
            });

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

        // Authentication Principal 이 가진 email data를
        String email = spUser.getEmail();
        // nickname이 있는 UserDto에 setting
        userDto.setEmail(email);

        // Profile data update 성공 여부에 따라
        boolean beUpdated = userService.updatePassword(userDto);
        // 성공 시 Authentication 을 authenticated false로 하여 로그아웃 처리
        if(beUpdated){
            Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
            authentication.setAuthenticated(false);
        }

        // 성공 여부 return
        map.put("beUpdated", beUpdated);

        return ResponseEntity.ok(map);
    }

 앞선 프로필 정보를 수정했을 때 인증된 Authentication Principal의 name 정보를 수정하는 것 대신에, 비밀번호 수정에 성공했을 때 다시 로그인하도록 인증 정보를 true에서 false로 조정해주었다. 이렇게 되면 인증 false가 발생하고, 인증을 다시 받아야하기 때문에 로그인이 필요한 page라면 로그인을 위해 로그인 page로 이동하게 된다.

 Javascript에는 index page로 이동하도록 작성해두었으나 우선 로그인 page로 이동하게 되며, 로그인에 성공했을 시 index page로 이동하는 방식으로 동작하는 것을 보았다.

 

UserServiceImpl.java

    // 사용자의 비밀번호를 수정하는 service logic
    @Override
    public boolean updatePassword(UserDto userDto) {
        try {
            // Authentication Principal의 email data를 기반으로 DB에서 사용자 정보를 가져옴
            SpUser user = userRepository.getSpUserByEmail(userDto.getEmail());
            String dbPassword = user.getPassword();
            String oldPassword = userDto.getOldPassword();
            // DB data와 입력한 data의 일치 여부에 따라
            boolean beMatched = BCrypt.checkpw(oldPassword, dbPassword);

            // 일치하는 경우
            if(beMatched){
                // 새로 입력한 password를 encoding 해서
                String newPassword = userDto.getPassword();
                BCryptPasswordEncoder encoder = new BCryptPasswordEncoder();
                String encodedPwd = encoder.encode(newPassword);

                // 사용자 정보를 update 하고 save
                user.updatePassword(encodedPwd);
                userRepository.save(user);

                return true;
            } else {
                return false;
            }
        } catch (Exception e) {
            return false;
        }
    }

 비밀번호를 수정하는 service logic이다. Client로부터 받는 data는 기존 비밀번호와 새로운 비밀번호의 두 가지이다. 우선 기존의 비밀번호가 일치하는지 여부를 확인해야 하며, 다른 경우는 false를 return 하도록 했다. 일치하는 경우에만 DB의 비밀번호 수정 logic이 수행되며, 새로운 비밀번호를 encoding 하여 비밀번호를 수정한 뒤에 save 하도록 했다.

 

 

사용자 정보의 수정 시, 게시글과 댓글 정보가 함께 수정되도록?

 게시글을 삭제하면 게시글에 포함된 링크 정보도 삭제하고, 댓글 정보도 삭제하도록 되어있다. 이것은 게시글과 링크 정보, 게시글과 댓글 정보가 서로 연관 관계가 있으며 cascade type에 REMOVE가 포함되어 있기 때문에 가능한 것이다. Recipe Entity가 Link Entity List와 Comment Entity List를 가지고 있어서, 이와 마찬가지의 형태로 사용자 정보를 수정하게 되면 관련된 게시글과 댓글의 작성자 정보가 수정되었으면 좋겠다. 이것이 가능할까?

 

 

Foreign Key를 Primary Key가 아닌 다른 column에 주는 방법

(feat. Table Foreign Key의 Constraint 변경)

 가장 처음 생각했던 방법이다. 이전 게시글에서 사용했던 이미지를 이용해서 간단하게 설명해보겠다.

 

Recipe.java / Link.java, Comment.java

 위 두 이미지는 게시글 수정 기능을 작업할 때 사용했던 것이다. 좌측 이미지(Recipe)의 빨간 네모 박스가 우측 이미지에서 위(Link) 아래(Comment)로 구분된 항목에 각각 match 된다. 이것은 Recipe와 Link, Recipe와 Comment가 각각 양방향 연관 관계를 이루도록 한 것이며 link와 comment table의 content_id와 target_id column은 parent table(부모 테이블) 이라고 할 수 있는 recipe table의 primary key인 content_id column을 reference로 하게 된다. 이는 Link와 Comment의 @JoinColumn annotation에 referencedColumnName 속성이 따로 없기 때문에 default 설정인 primary key의 값을 가져오게 되는 것이다.

 위 방식을 활용해서 이번에도 SpUser와 Recipe 사이에 양방향 연관 관계를 이루도록 하여, 사용자의 정보가 수정되었을 때 게시글이나 댓글 작성자의 정보도 수정되도록 하려고 하는 것이 이번 작업의 목적이다.

 

SpUser.java / Recipe.java

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "user_id")
    private Long userId;
    
    private String name;
    
    @OneToMany(mappedBy = "spUser", fetch = FetchType.LAZY, cascade = CascadeType.ALL)
    private List<Recipe> recipeList;

(SpUser)
---------------------------------------------------------------------------------------
(Recipe)

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "writer", referencedColumnName = "name")
    private SpUser spUser;

 SpUser 에 recipeList 라는 field를 만들어 @OneToMany annotation을 주었고, Recipe 에 spUser 라는 field를 만들어 @ManyToOne annotation을 주고 column name을 write로 하여 referenced column 을 지정해주었다. 이렇게 작성하면 SpUser의 recipeList cascade type이 ALL 이기 때문에 SpUser의 name field가 수정되면, Recipe의 spUser field도 수정되어야 한다.

 그리고 기존에 writer field와 관련된 method들을 약간 손봐주어야 한다. 예를 들면 writer를 update 할 때 String이 아닌 SpUser를 받아서 해주어야 하는 방식이다.

 

 하지만 역시나 이번에도 오류를 마주쳤고, 어떤 방식으로 해결했는지에 대해 언급하면서 지나가도록 하겠다. 항상 한 번에 여러 오류들을 해결하고 다시 처음부터 재현하는 것이 번거롭긴 하지만, 필자에게도 혹은 어떤 누군가에게도 도움이 되길 바란다. (사용자의 정보와 관련된 SpUser와 그 권한과 관련된 SpAuthority 를 함께 수정하면서 마주한 오류는 다음 게시글에 작성해보도록 하겠다.)

 

  • SQL Error 1451, state 23000 : Cannot delete or update a parent row: a foreign key constraint fails 

 이 오류는 이전에는 본 적이 없는데, 그 이유는 이전에는 따로 referencedColumnName 속성을 설정하지 않아서 default로 parent table의 primary key에 해당하는 column을 foreign key의 reference로 한 상황에 그 값들을 수정하거나 삭제하려는 시도를 한 적이 없기 때문이다. 하지만 지금은 primary key가 아닌 column을 foreign key로 하여 그것을 변동시키려고 했기 때문에 마주한 오류라고 할 수 있겠다.

2023-01-09 19:05:06.255  WARN 1164 --- [nio-8080-exec-5] o.h.engine.jdbc.spi.SqlExceptionHelper   
: SQL Error: 1451, SQLState: 23000
2023-01-09 19:05:06.256 ERROR 1164 --- [nio-8080-exec-5] o.h.engine.jdbc.spi.SqlExceptionHelper   
: Cannot delete or update a parent row: a foreign key constraint fails 
(`recipository`.`recipe`, CONSTRAINT `FKnuhrfnfh1x9kudq1bejp3oxf4` FOREIGN KEY (`writer`) REFERENCES `sp_user` (`name`))

java.sql.SQLIntegrityConstraintViolationException
: Cannot delete or update a parent row: a foreign key constraint fails 
(`recipository`.`recipe`, CONSTRAINT `FKnuhrfnfh1x9kudq1bejp3oxf4` FOREIGN KEY (`writer`) REFERENCES `sp_user` (`name`))

 글자 그대로 parent row를 delete 하거나 update 할 수 없다는 것이다. 현재 recipe table의 writer는 sp_user table의 name을 reference로 하고 있는데, reference가 되는 값이 바뀌면 어떨까? 비유를 해보자. 필자는 친구들의 휴대폰 번호를 알고 있고 그 번호로 카카오톡 sns와 연결하여 목록에서 친구들의 목록을 확인할 수 있는데, 갑자기 한 친구가 개명을 했다고 상상해보자. 친구가 개명을 했지만 필자는 그 사실을 알 수 없고, 후에 개명 사실을 알게 된다면 당황하지 않을까? 필자는 아직 친구를 A라고 저장했지만, 친구는 A가 아니라 Z가 된다면 여러모로 당황스러울 것이다.

 

 DB의 table은 그래서 이런 당황스러움을 사전에 막기 위해서 parent table의 reference column을 foreign key로 하는 child table이 있을 때, child table에서 참고하고 있는 값을 변동할 수 없도록 한 것 같다. 

 반대로도 마찬가지로 parent table에 없는 값으로 변동하는 것은 가능하기는 하나, parent table column에 이미 존재하는 값으로만 가능하다. 없는 값으로 변경하려고 할 때는 위 오류 번호가 1 증가한 1452이고, child row를 delete 하거나 update 할 수 없다는 메시지를 확인할 수 있다. 없는 data를 참고할 수는 없기 때문이다.

 

 그렇다면 방법이 없을까? 두 가지 방법을 떠올렸다. 우선 다음을 보자.

 

alter table table_name drop foreign key foreign_key_name

alter table recipe drop foreign key FKnuhrfnfh1x9kudq1bejp3oxf4

(기존 Foreign key 삭제)
----------------------------------------------------------------
(새로운 Foreign key 추가)

alter table table_name
add constraint foreign_key_name
foreign key (foreign_key_column) references parent_table_name(column_name)
on update cascade

alter table recipe
add constraint FKnuhrfnfh1x9kudq1bejp3oxf4
foreign key (writer) references sp_user(name)
on update cascade

 위 내용은 기존의 foreign key constraint를 제거하고 새로 추가하는 과정에서 작성했던 query 문이다. 보면 마지막에 'on update cascade' 라는 부분이 있는데, 이 친구를 foreign key를 추가하는 시점에 함께 작성하는 것으로 parent table의 reference column이 되는 값을 수정했을 때 child table에서도 그 값이 수정되도록 할 수 있게 되며, delete도 마찬가지의 방식으로 가능해진다.

 

 관련 내용을 다루고 있는 블로그들이 많지만 몇 가지만 기록한다.[각주:1] [각주:2] [각주:3]

 

 나머지 하나의 방식은 아래의 title에서 이어서 작성하도록 하겠다.

 

Foreign Key를 Primary Key로 주는 column을 추가하는 방법

 위와 같이 primary key가 아닌 다른 column에 대해 foreign key를 설정하면서 cascade option을 주어도 동작은 하지만, 이는 일반적으로 막아둔 방식을 변형하여 사용하는 느낌이 들었다. 관련 정보를 찾던 중 필요한 정보를 얻을 수 있었다.[각주:4]

 

 해당 온라인 강의 컨텐츠를 제공하는 강사의 답변에서는 'referencedColumnName 속성을 통해서 primary key가 아닌 다른 column에 직접 지정할 수 있지만 정규화의 관점에서 권장하지 않는다.' 고 언급했다.

 동일한 분의 또다른 질문에 답변한 내용에서는 '설계 관점에서 모든 연관 관계는 primary key를 보도록 설계하는 것이 좋다' 고 말하며, 그렇지 않은 경우에는 java의 직렬화(Serialization) 기능을 사용하기 위해 Serializable 을 implements 해야한다고 언급하고 있다.[각주:5]

 

 그래서 생각한 방법은 기존의 작성자 정보에 대한 writer field는 그대로 두고, 새롭게 sp_user의 primary key를 reference로 하는 field를 추가해서 SpUser와 연관 관계를 맺어주는 방식으로 작업하는 것이다. 대신 이전에 게시글을 작성할 때와 마찬가지로 Recipe와 Link 사이에 연관 관계를 맺어주고, Link 정보를 수정해서 Recipe 를 save 하는 것으로 Link도 save 하는 방식은 동일해진다.

 

SpUser.java / Recipe.java

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "user_id")
    private Long userId;
    
    private String name;
    
    @OneToMany(mappedBy = "spUser", fetch = FetchType.LAZY, cascade = CascadeType.ALL)
    private List<Recipe> recipeList;

(SpUser)
---------------------------------------------------------------------------------------
(Recipe)

    private String writer;
    
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "user_id")
    private SpUser spUser;

 이전 방식에서의 SpUser는 동일하지만 Recipe 에서 기존의 writer field를 지우지 않고 foreign key에 해당하는 field를 새롭게 추가해주는 것이 다르다.

 

 

 어떤 방식이 더 나은 방향인지 고민하는 과정에서 지인에게 자문을 구했다. 필자의 생각과는 다르게 지인은 저 두 가지가 아닌 새로운 방향, 정확히는 필자가 잘못 생각하고 작업하고 있던 부분을 짚어주었다. 포인트는 다음과 같다.

  • 현재 사용자 table과 게시글 table에는 공통적으로 사용자의 이름 정보가 들어있다.
    하지만 Database Normalization (데이터베이스 정규화)의 관점에서 이는 올바른 설계가 아니다.
  • 따라서 게시글 table에 작성자의 정보를 column으로 저장하는 것이 아니라, 사용자 table에 있는 pk를 게시글 table에서 저장하도록 하여 data를 조회하도록 하는 것이 좋다.

 이때 Database Normalization 이라는 것은 관계형 데이터베이스의 설계에서 중복을 최소화하도록 데이터를 구조화하는 과정이라고 한다. 이에 대한 작업을 먼저 하는 것이 좋을 것 같다. 결과적으로는 이번 게시글을 작성하면서 작업한 내용은 아무것도 없게 되었지만, 아무것도 얻지 않은 것은 아니기 때문에 그 자체로도 의의가 있을 것이다.

 

Reference