Troubleshooting: 무엇이 문제였는가?/취하여 프로젝트

1주 차: SQL Error 1062: Duplicate entry '1-3' for key 'user_keyword

writingforever162 2025. 2. 11. 23:44

[문제]

채용 공고에 있는 기술 스택(Tech Stack)을 키워드(Keyword)로 몇 개 넣은 다음, 사용자가 알림 받고자 하는 채용 키워드를 등록하는 API를 구현해서 Postman을 실행했더니 오류가 발생했다. 

'Duplicate entry '1-3' for key 'user_keyword'
// (a) 'user_keyword'라는 고유 키에 '1-3' 값이 중복되어 삽입됨
// (b) 1: user_id 값 
// (c) 3: keyword_id 값

 

[원인 및 해결 과정]

더보기
package com.project.cheerha.domain.keyword.entity;

import com.project.cheerha.domain.user.entity.User;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Entity
@Getter
@NoArgsConstructor
@Table(name = "user_keyword", uniqueConstraints = {
    @UniqueConstraint(columnNames = {"user_id", "keyword_id"})}
)
public class UserKeyword {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "user_id")
    private User user;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "keyword_id")
    private Keyword keyword;

    public static UserKeyword of(
        User user,
        Keyword keyword
    ) {
        UserKeyword userKeyword = new UserKeyword();
        userKeyword.user = user;
        userKeyword.keyword = keyword;

        return userKeyword;
    }
}

원인은 오류 메시지로 금방 찾을 수 있었다. SQL Error가 발생했다는 말은 데이터베이스(database)와 관련된 문제라는 뜻이고, 데이터베이스에는 엔티티(Entity)가 저장되므로 가장 먼저 중간 테이블인 UserKeyword 클래스(class)를 살폈다. 짐작대로 'uniqueConstraints' 속성을 설정했지만, 중복 검증 로직을 빠뜨려서 오류가 발생했다. 

 

@Table 어노테이션(annotation)에서 'uniqueConstraints' 속성을 사용하여 사용자 식별자(user_id)와 키워드 식별자(keyword_id)에 복합 고유 키 제약 조건을 설정했다. 이 제약 조건 덕분에 user_id와 keyword_id 조합은 데이터베이스 중복으로 저장되지 않았고, 사용자가 이미 구독 중인 키워드를 추가하려고 하면 'Duplicate entry' 오류가 발생했다. 이 문제를 해결하려면, 키워드를 저장하기 전에 해당 user_id와 keyword_id 조합이 이미 존재하는지 확인하는 로직을 추가해야 했다.

 

(1) UserKeywordRepository에 메서드(method) 추가하기 ▼

package com.project.cheerha.domain.keyword.repository;

import com.project.cheerha.domain.keyword.entity.Keyword;
import com.project.cheerha.domain.keyword.entity.UserKeyword;
import com.project.cheerha.domain.user.entity.User;
import org.springframework.data.jpa.repository.JpaRepository;

public interface UserKeywordRepository extends JpaRepository<UserKeyword, Long> {

    // 주어진 User와 Keyword의 조합이 존재하는지 확인하는 메서드
    boolean existsByUserAndKeyword (User user, Keyword keyword);
}

 

(2) (1)에서 작성한 메서드를 UserKeywordService에서 호출하기 ▼

더보기
package com.project.cheerha.domain.keyword.service;

import com.project.cheerha.common.dto.AuthUser;
import com.project.cheerha.common.exception.CustomException;
import com.project.cheerha.common.exception.ErrorCode;
import com.project.cheerha.domain.keyword.dto.request.CreateUserKeywordRequestDto;
import com.project.cheerha.domain.keyword.dto.response.CreateUserKeywordResponseDto;
import com.project.cheerha.domain.keyword.entity.Keyword;
import com.project.cheerha.domain.keyword.entity.UserKeyword;
import com.project.cheerha.domain.keyword.repository.KeywordRepository;
import com.project.cheerha.domain.keyword.repository.UserKeywordRepository;
import com.project.cheerha.domain.user.entity.User;
import com.project.cheerha.domain.user.repository.UserRepository;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
 
@Service
@RequiredArgsConstructor
public class UserKeywordService {

    private final KeywordRepository keywordRepository;
    private final UserKeywordRepository userKeywordRepository;
    private final UserRepository userRepository;

    @Transactional
    public CreateUserKeywordResponseDto createUserKeyword(
        AuthUser authUser,
        CreateUserKeywordRequestDto requestDto
    ) {
        Long userId = authUser.id();

        User foundUser = findUserById(userId);

        // 요청 DTO에서 키워드 식별자 목록을 가져옴 
        List<Long> keywordIdList = requestDto.keywordIdList();

        keywordIdList.forEach(
            keywordId -> {
            
                // 식별자로 키워드 엔티티 조회 
                Keyword foundKeyword = findKeywordById(keywordId);

                // [1/2]에서 작성한 메서드 호출
                boolean isKeywordAlreadyChosen = userKeywordRepository.existsByUserAndKeyword(
                    foundUser,
                    foundKeyword
                );

                if (isKeywordAlreadyChosen) {
                    throw new CustomException(ErrorCode.KEYWORD_ALREADY_CHOSEN);
                }

                // 중간 테이블인 UserKeyword 엔티티 생성 
                UserKeyword newUserKeyword = UserKeyword.of(
                    foundUser,
                    foundKeyword
                );

                // 생성된 UserKeyword를 데이터베이스에 저장
                userKeywordRepository.save(newUserKeyword);
            }
        );

        return CreateUserKeywordResponseDto.of(keywordIdList);
    }

    // 주어진 userId로 사용자 엔티티를 조회하는 
    // 존재하지 않으면 USER_NOT_FOUND 예외 발생
    private User findUserById(Long userId) {
        return userRepository.findById(userId)
            .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND));
    }

    // 주어진 keywordId로 키워드 엔티티를 조회하는 메서드
    // 존재하지 않으면 KEYWORD_NOT_FOUND 예외 발생
    private Keyword findKeywordById(Long keywordId) {
        return keywordRepository.findById(keywordId)
            .orElseThrow(() -> new CustomException(ErrorCode.KEYWORD_NOT_FOUND));
    }
}

1차로 수정한 다음 다시 확인했을 때 예외 처리가 잘 이루어졌다.

 

이때 문득 의문이 들었다.

 

'굳이 예외 처리를 해야 할까?'

 

'기존에 등록한 키워드가 있으면, 바로 응답 DTO를 보내줘도 되지 않을까?'

 

'그렇게 하면 비즈니스 로직을 더 간결하게 다듬을 수 있을 텐데?'

 

앞으로 프로젝트에 새로운 기능이 추가되는 만큼, 최소 기능 제품을 만들 때는 코드가 간결할수록 유지 보수가 쉬울 듯했다. 이런 이유로 예외 처리를 별도로 하지 않고 if문을 활용하여 로직을 수정했다. ▼

더보기
package com.project.cheerha.domain.keyword.service;

import com.project.cheerha.common.dto.AuthUser;
import com.project.cheerha.common.exception.CustomException;
import com.project.cheerha.common.exception.ErrorCode;
import com.project.cheerha.domain.keyword.dto.request.CreateUserKeywordRequestDto;
import com.project.cheerha.domain.keyword.dto.response.CreateUserKeywordResponseDto;
import com.project.cheerha.domain.keyword.entity.Keyword;
import com.project.cheerha.domain.keyword.entity.UserKeyword;
import com.project.cheerha.domain.keyword.repository.KeywordRepository;
import com.project.cheerha.domain.keyword.repository.UserKeywordRepository;
import com.project.cheerha.domain.user.entity.User;
import com.project.cheerha.domain.user.repository.UserRepository;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
public class UserKeywordService {

    private final KeywordRepository keywordRepository;
    private final UserKeywordRepository userKeywordRepository;
    private final UserRepository userRepository;
 
    @Transactional
    public CreateUserKeywordResponseDto createUserKeyword(
        AuthUser authUser,
        CreateUserKeywordRequestDto requestDto
    ) {
        Long userId = authUser.id();

        User foundUser = findUserById(userId);

        List<Long> keywordIdList = requestDto.keywordIdList();

        keywordIdList.forEach(
            keywordId -> {
                Keyword foundKeyword = findKeywordById(keywordId);

                boolean isKeywordAlreadyChosen = userKeywordRepository.existsByUserAndKeyword(
                    foundUser,
                    foundKeyword
                );

                if (!isKeywordAlreadyChosen) {
                    UserKeyword newUserKeyword = UserKeyword.of(
                        foundUser,
                        foundKeyword
                    );

                    userKeywordRepository.save(newUserKeyword);
                }
            }
        );

        return CreateUserKeywordResponseDto.of(keywordIdList);
    }

    private User findUserById(Long userId) {
        return userRepository.findById(userId)
            .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND));
    }

    private Keyword findKeywordById(Long keywordId) {
        return keywordRepository.findById(keywordId)
            .orElseThrow(() -> new CustomException(ErrorCode.KEYWORD_NOT_FOUND));
    }
}

2차로 수정한 다음 Postman을 실행하여 데이터베이스에 저장된 키워드와 똑같이 키워드 Id를 입력한 결과, 별다른 예외 없이 '201 Created' 상태 메시지와 응답 DTO가 반환되었다.

두 번째 테스트로 새로운 키워드 Id를 입력했을 때도 문제없이 데이터베이스에 저장되고 첫 번째 테스트와 동일한 결과가 나왔다. ▼

세 번째로는 데이터베이스에 이미 저장된 키워드 식별자와 아직 저장되지 않은 식별자를 섞어서 요청을 보냈고, 데이터베이스에 저장되지 않은 키워드 식별자만 새롭게 저장된 점을 확인할 수 있었다.

이때 새로운 질문이 머릿속에 맴돌았다. 

 

'응답으로 키워드의 식별자만 전달하는 대신, 이름을 함께 반환하면 프론트엔드(Front-end)에서 더 편하게 사용할 수 있지 않을까?'

 

이런 이유로 DTO와 서비스 레이어(Service Layer)를 다시 수정했다. ▼

더보기
package com.project.cheerha.domain.keyword.dto.request;

import java.util.List;

public record CreateUserKeywordRequestDto(List<Long> keywordIdList) {

}
package com.project.cheerha.domain.keyword.dto.response;

import java.util.List;

public record CreateUserKeywordResponseDto(List<String> keywordList) {

    public static CreateUserKeywordResponseDto of(List<String> keywordList) {
        return new CreateUserKeywordResponseDto(keywordList);
    }
}
더보기
package com.project.cheerha.domain.keyword.service;

import com.project.cheerha.common.dto.AuthUser;
import com.project.cheerha.common.exception.CustomException;
import com.project.cheerha.common.exception.ErrorCode;
import com.project.cheerha.domain.keyword.dto.request.CreateUserKeywordRequestDto;
import com.project.cheerha.domain.keyword.dto.response.CreateUserKeywordResponseDto;
import com.project.cheerha.domain.keyword.entity.Keyword;
import com.project.cheerha.domain.keyword.entity.UserKeyword;
import com.project.cheerha.domain.keyword.repository.KeywordRepository;
import com.project.cheerha.domain.keyword.repository.UserKeywordRepository;
import com.project.cheerha.domain.user.entity.User;
import com.project.cheerha.domain.user.repository.UserRepository;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
public class UserKeywordService {

    private final KeywordRepository keywordRepository;
    private final UserKeywordRepository userKeywordRepository;
    private final UserRepository userRepository;

    @Transactional
    public CreateUserKeywordResponseDto createUserKeyword(
        AuthUser authUser,
        CreateUserKeywordRequestDto requestDto
    ) {
        Long userId = authUser.id();

        User foundUser = findUserById(userId);

        List<Long> keywordIdList = requestDto.keywordIdList();

        List<String> keywordNameList = findKeywordNameListByIdList(keywordIdList);

        saveUserKeywordIfNotExist(keywordIdList, foundUser);

        return CreateUserKeywordResponseDto.of(keywordNameList);
    }

    // 키워드 식별자 목록으로 조회한 키워드 이름 목록을 반환하는 메서드
    private List<String> findKeywordNameListByIdList(List<Long> keywordIdList) {
        return keywordIdList.stream()
            .map(this::findKeywordById)
            .map(Keyword::getName)
            .toList();
    }

    // 키워드 식별자 목록으로 UserKeyword 객체를 저장하는 메서드
    private void saveUserKeywordIfNotExist(
        List<Long> keywordIdList,
        User foundUser
    ) {
        keywordIdList.forEach(
            keywordId -> {
                Keyword foundKeyword = findKeywordById(keywordId);

                boolean isKeywordAlreadyChosen = userKeywordRepository.existsByUserAndKeyword(
                    foundUser,
                    foundKeyword
                );

                if (!isKeywordAlreadyChosen) {
                    UserKeyword newUserKeyword = UserKeyword.of(
                        foundUser,
                        foundKeyword
                    );

                    userKeywordRepository.save(newUserKeyword);
                }
            }
        );
    }

    private User findUserById(Long userId) {
        return userRepository.findById(userId)
            .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND));
    }

    private Keyword findKeywordById(Long keywordId) {
        return keywordRepository.findById(keywordId)
            .orElseThrow(() -> new CustomException(ErrorCode.KEYWORD_NOT_FOUND));
    }
}

수정 후 Postman을 실행하니 원하는 대로 결과가 잘 나오긴 했는데……. ▼

'특정 식별자에 해당하는 키워드 객체를 굳이 두 번 조회해야 하나?' 고개가 갸우뚱했다. ▼

private Keyword findKeywordById(Long keywordId) {
    return keywordRepository.findById(keywordId)
        .orElseThrow(() -> new CustomException(ErrorCode.KEYWORD_NOT_FOUND));
}

불필요한 로직을 줄이고자 우선 메서드에 한 가지 동작만 있도록 수정했다. ▼

더보기
package com.project.cheerha.domain.keyword.service;

import com.project.cheerha.common.dto.AuthUser;
import com.project.cheerha.common.exception.CustomException;
import com.project.cheerha.common.exception.ErrorCode;
import com.project.cheerha.domain.keyword.dto.request.CreateUserKeywordRequestDto;
import com.project.cheerha.domain.keyword.dto.response.CreateUserKeywordResponseDto;
import com.project.cheerha.domain.keyword.entity.Keyword;
import com.project.cheerha.domain.keyword.entity.UserKeyword;
import com.project.cheerha.domain.keyword.repository.KeywordRepository;
import com.project.cheerha.domain.keyword.repository.UserKeywordRepository;
import com.project.cheerha.domain.user.entity.User;
import com.project.cheerha.domain.user.repository.UserRepository;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
public class UserKeywordService {

    private final KeywordRepository keywordRepository;
    private final UserKeywordRepository userKeywordRepository;
    private final UserRepository userRepository;

    @Transactional
    public CreateUserKeywordResponseDto createUserKeyword(
        AuthUser authUser,
        CreateUserKeywordRequestDto requestDto
    ) {
        Long userId = authUser.id();
        User foundUser = findUserById(userId);
        List<Long> idList = requestDto.keywordIdList();

        List<Keyword> keywordList = findKeywordListByIdList(idList);

        createNewUserKeywordIfNotExist(keywordList, foundUser);

        List<String> keywordNameList = extractKeywordNameList(keywordList);

        return CreateUserKeywordResponseDto.of(keywordNameList);
    }

    private List<String> extractKeywordNameList(List<Keyword> keywordList) {
        return keywordList.stream()
            .map(Keyword::getName)
            .toList();
    }

    private void createNewUserKeywordIfNotExist(
        List<Keyword> keywordList,
        User foundUser
    ) {
        keywordList.forEach(
            foundKeyword -> {
                boolean isKeywordAlreadyChosen = userKeywordRepository.existsByUserAndKeyword(
                    foundUser,
                    foundKeyword
                );

                if (!isKeywordAlreadyChosen) {
                    UserKeyword newUserKeyword = UserKeyword.of(
                        foundUser,
                        foundKeyword
                    );

                    userKeywordRepository.save(newUserKeyword);
                }
            }
        );
    }

    private List<Keyword> findKeywordListByIdList(List<Long> keywordIdList) {
        return keywordIdList.stream()
            .map(this::findKeywordById)
            .toList();
    }

    private Keyword findKeywordById(Long keywordId) {
        return keywordRepository.findById(keywordId)
            .orElseThrow(() -> new CustomException(ErrorCode.KEYWORD_NOT_FOUND));
    }

    private User findUserById(Long userId) {
        return userRepository.findById(userId)
            .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND));
    }
}

메서드를 잘게 쪼개고 나니, 데이터베이스와 소통하지 않는 메서드를 뽑아낼 수 있었다. 해당 메서드는 키워드 엔티티 내부로 옮겼다. ▼

더보기
package com.project.cheerha.domain.keyword.entity;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.util.List;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Entity
@Getter
@NoArgsConstructor
@Table(name = "keyword")
public class Keyword {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(length = 50, nullable = false)
    private String name;

    // 키워드 객체 목록에서 이름을 추출하는 메서드 
    public static List<String> extractNameListFromEntityList(List<Keyword> keywordList) {
        return keywordList.stream()
            .map(Keyword::getName)
            .toList();
    }
}
더보기
package com.project.cheerha.domain.keyword.service;

import com.project.cheerha.common.dto.AuthUser;
import com.project.cheerha.common.exception.CustomException;
import com.project.cheerha.common.exception.ErrorCode;
import com.project.cheerha.domain.keyword.dto.request.CreateUserKeywordRequestDto;
import com.project.cheerha.domain.keyword.dto.response.CreateUserKeywordResponseDto;
import com.project.cheerha.domain.keyword.entity.Keyword;
import com.project.cheerha.domain.keyword.entity.UserKeyword;
import com.project.cheerha.domain.keyword.repository.KeywordRepository;
import com.project.cheerha.domain.keyword.repository.UserKeywordRepository;
import com.project.cheerha.domain.user.entity.User;
import com.project.cheerha.domain.user.repository.UserRepository;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
public class UserKeywordService {

    private final KeywordRepository keywordRepository;
    private final UserKeywordRepository userKeywordRepository;
    private final UserRepository userRepository;

    @Transactional
    public CreateUserKeywordResponseDto createUserKeyword(
        AuthUser authUser,
        CreateUserKeywordRequestDto requestDto
    ) {
        Long userId = authUser.id();
        User foundUser = findUserById(userId);
        List<Long> idList = requestDto.keywordIdList();

        List<Keyword> keywordList = findKeywordListByIdList(idList);

        createNewUserKeywordIfNotExist(keywordList, foundUser);

        List<String> keywordNameList = Keyword.extractNameListFromEntityList(keywordList);

        return CreateUserKeywordResponseDto.of(keywordNameList);
    }

    // 등록된 UserKeyword가 없을 시 객체를 생성하고 저장하는 메서드 
    private void createNewUserKeywordIfNotExist(
        List<Keyword> keywordList,
        User foundUser
    ) {
        keywordList.forEach(
            foundKeyword -> {
                boolean isKeywordAlreadyChosen = userKeywordRepository.existsByUserAndKeyword(
                    foundUser,
                    foundKeyword
                );

                if (!isKeywordAlreadyChosen) {
                    UserKeyword newUserKeyword = UserKeyword.of(
                        foundUser,
                        foundKeyword
                    );

                    userKeywordRepository.save(newUserKeyword);
                }
            }
        );
    }

    // 키워드 식별자 목록으로 해당 키워드 엔티티를 조회하는 메서드 
    private List<Keyword> findKeywordListByIdList(List<Long> keywordIdList) {
        return keywordIdList.stream()
            .map(this::findKeywordById)
            .toList();
    }

    // 키워드 식별자로 키워드를 조회하는 메서드 
    // 키워드가 존재하지 않을 시 에외 발생 
    // 목록 대신 식별자 하나로 조회할 수 있으므로 findKeywordListByIdList와 분리함
    private Keyword findKeywordById(Long keywordId) {
        return keywordRepository.findById(keywordId)
            .orElseThrow(() -> new CustomException(ErrorCode.KEYWORD_NOT_FOUND));
    }

    // 사용자 식별자로 사용자를 조회하는 메서드 
    // 사용자가 없을 시 예외 발생
    private User findUserById(Long userId) {
        return userRepository.findById(userId)
            .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND));
    }
}

 

5번째 수정 후, 사용자가 1번부터 5번 키워드를 알림 받겠다고 이미 등록한 상태에서 Postman을 실행한 결과, 문제없이 원하는 값을 받을 수 있었다. 아래 사진은  기존에 등록한 키워드로만 요청을 보냈을 때 결과이다. ▼

그다음 사진은 기존에 등록한 5번 키워드와 아직 등록하지 않은 6번 키워드를 요청 값으로 보낸 결과이다. 6번 키워드는 데이터베이스에 저장되고 기존에 등록한 5번 키워드와 함께 응답 값에 포함되었다. ▼

 

 

---- 최종 ----

더보기
package com.project.cheerha.domain.keyword.entity;

import com.project.cheerha.domain.user.entity.User;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Entity
@Getter
@NoArgsConstructor
@Table(name = "user_keyword", uniqueConstraints = {
    @UniqueConstraint(columnNames = {"user_id", "keyword_id"})}
)
public class UserKeyword {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "user_id")
    private User user;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "keyword_id")
    private Keyword keyword;

    public static UserKeyword of(
        User user,
        Keyword keyword
    ) {
        UserKeyword userKeyword = new UserKeyword();
        userKeyword.user = user;
        userKeyword.keyword = keyword;

        return userKeyword;
    }
}
더보기
package com.project.cheerha.domain.keyword.entity;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.util.List;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Entity
@Getter
@NoArgsConstructor
@Table(name = "keyword")
public class Keyword {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(length = 50, nullable = false)
    private String name;

    // 키워드 객체 목록에서 이름을 추출하는 메서드
    public static List<String> extractNameFromEntity(List<Keyword> keywordList) {
        return keywordList.stream()
            .map(Keyword::getName)
            .toList();
    }
}

 

더보기
package com.project.cheerha.domain.keyword.dto.request;

import java.util.List;

public record CreateUserKeywordRequestDto(List<Long> keywordIdList) {

}
package com.project.cheerha.domain.keyword.dto.response;

import java.util.List;

public record CreateUserKeywordResponseDto(List<String> keywordList) {

    public static CreateUserKeywordResponseDto toDto(List<String> keywordList) {
        return new CreateUserKeywordResponseDto(keywordList);
    }
}
더보기
package com.project.cheerha.domain.keyword.repository;

import com.project.cheerha.domain.keyword.entity.UserKeyword;
import org.springframework.data.jpa.repository.JpaRepository;

public interface UserKeywordRepository extends JpaRepository<UserKeyword, Long> {

    // userId와 keywordId로 UserKeyword가 존재하는지 확인하는 메서드
    boolean existsByUserIdAndKeywordId(
        Long userId,
        Long keywordId
    );
}
더보기
package com.project.cheerha.domain.keyword.service;

import com.project.cheerha.common.exception.CustomException;
import com.project.cheerha.common.exception.ErrorCode;
import com.project.cheerha.domain.keyword.dto.request.CreateUserKeywordRequestDto;
import com.project.cheerha.domain.keyword.dto.response.CreateUserKeywordResponseDto;
import com.project.cheerha.domain.keyword.entity.Keyword;
import com.project.cheerha.domain.keyword.entity.UserKeyword;
import com.project.cheerha.domain.keyword.repository.KeywordRepository;
import com.project.cheerha.domain.keyword.repository.UserKeywordRepository;
import com.project.cheerha.domain.user.entity.User;
import com.project.cheerha.domain.user.repository.UserRepository;
import java.util.ArrayList;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
public class UserKeywordService {

    private final KeywordRepository keywordRepository;
    private final UserKeywordRepository userKeywordRepository;
    private final UserRepository userRepository;

    @Transactional
    public CreateUserKeywordResponseDto createUserKeyword(
        Long userId,
        CreateUserKeywordRequestDto requestDto
    ) {
        List<Long> idList = requestDto.keywordIdList();

        List<Keyword> keywordList = createNewUserKeywordIfNotExist(
            userId,
            idList
        );

        List<String> keywordNameList = Keyword.extractNameFromEntity(keywordList);

        return CreateUserKeywordResponseDto.toDto(keywordNameList);
    }

    // UserKeyword 객체가 없을 시 객체를 생성하고 저장하는 메서드
    private List<Keyword> createNewUserKeywordIfNotExist(
        Long userId,
        List<Long> keywordIdList
    ) {
        List<Keyword> keywordList = new ArrayList<>();

        keywordIdList.forEach(keywordId -> {
                Keyword foundKeyword = keywordRepository.findById(keywordId)
                    .orElseThrow(
                        () -> new CustomException(ErrorCode.KEYWORD_NOT_FOUND)
                    );

                if (!isKeywordAlreadyChosen(userId, keywordId)) {
                    User foundUser = userRepository.findById(userId)
                        .orElseThrow(
                            () -> new CustomException(ErrorCode.USER_NOT_FOUND)
                        );

                    UserKeyword newUserKeyword = UserKeyword.of(
                        foundUser,
                        foundKeyword
                    );

                    userKeywordRepository.save(newUserKeyword);
                }

                keywordList.add(foundKeyword);
            }
        );

        return keywordList;
    }

    // 키워드가 이미 선택되었는지 확인하는 메서드
    private boolean isKeywordAlreadyChosen(
        Long userId,
        Long keywordId
    ) {
        return userKeywordRepository.existsByUserIdAndKeywordId(
            userId,
            keywordId
        );
    }
}
더보기
package com.project.cheerha.domain.keyword.controller;

import com.project.cheerha.common.annotation.Auth;
import com.project.cheerha.common.dto.AuthUser;
import com.project.cheerha.domain.keyword.dto.request.CreateUserKeywordRequestDto;
import com.project.cheerha.domain.keyword.dto.response.CreateUserKeywordResponseDto;
import com.project.cheerha.domain.keyword.service.UserKeywordService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RequestMapping("/users/keywords")
@RestController
@RequiredArgsConstructor
public class UserKeywordController {

    private final UserKeywordService userKeywordService;

    @PostMapping
    public ResponseEntity<CreateUserKeywordResponseDto> createUserKeywordList(
        @RequestBody CreateUserKeywordRequestDto requestDto,
        @Auth AuthUser authUser
    ) {
        Long userId = authUser.id();

        CreateUserKeywordResponseDto responseDto = userKeywordService.createUserKeyword(
            userId,
            requestDto
        );

        return ResponseEntity.status(HttpStatus.CREATED).body(responseDto);
    }
}
-- (1) 외래 키 체크 비활성화
SET FOREIGN_KEY_CHECKS = 0;

-- (2) 'user_keyword' 테이블의 모든 데이터 삭제
TRUNCATE TABLE user_keyword;

-- (3) 'user_keyword' 테이블의 자동 증가 값을 1로 설정
ALTER TABLE user_keyword AUTO_INCREMENT = 1;

-- (4) 외래 키 체크 재활성화
SET FOREIGN_KEY_CHECKS = 1;