문제 설명

어떤 문장의 각 알파벳을 일정한 거리만큼 밀어서 다른 알파벳으로 바꾸는 암호화 방식을 시저 암호라고 합니다. 예를 들어 "AB"는 1만큼 밀면 "BC"가 되고, 3만큼 밀면 "DE"가 됩니다. "z"는 1만큼 밀면 "a"가 됩니다. 문자열 s와 거리 n을 입력받아 s를 n만큼 민 암호문을 만드는 함수, solution을 완성해 보세요.

제한 조건
  • 공백은 아무리 밀어도 공백입니다.
  • s는 알파벳 소문자, 대문자, 공백으로만 이루어져 있습니다.
  • s의 길이는 8000이하입니다.
  • n은 1 이상, 25이하인 자연수입니다.

1차 코드

class Solution {
    public String solution(String s, int n) {
        String answer = "";
        
        for(int i = 0; i < s.length(); i++){
            char c = s.charAt(i);
            
            if(c == ' '){
                answer += ' ';
            }
            else if(Character.isUpperCase(c) && c + n > 90){
                answer += (char)(c + n - 26);
            }
            else if(Character.isLowerCase(c) && c + n > 122){
                answer += (char)(c + n - 26);
            }
            else{
                answer += (char)(c + n);
            }
        }  

        return answer;
    }
}

 

조건문 내부에 answer += (c + n);을 그대로 answer에 넣게 되면 문자가 아니라 숫자가 문자열로 들어가기 때문에 (char)로 형변환을 꼭 해주어야 한다.

 


개선된 코드 - StringBuilder 사용

class Solution {
    public String solution(String s, int n) {
        String answer = "";
        StringBuilder sb = new StringBuilder(answer);
        
        for(int i = 0; i < s.length(); i++){
            char c = s.charAt(i);
            
            if(c == ' '){
                sb.append(' ');
            }
            else if(Character.isUpperCase(c) && c + n > 90){
                sb.append((char)(c + n - 26));
            }
            else if(Character.isLowerCase(c) && c + n > 122){
                sb.append((char)(c + n - 26));
            }
            else{
                sb.append((char)(c + n));
            }
        }  

        return sb.toString();
    }
}

 

String은 불변객체이기 때문에, 기존 코드대로 +를 사용해서 문자열을 붙이면 계속 새로운 객체를 생성해야 한다.

하지만 StringBuilder는 내부적으로 가변 배열(char[])을 사용해서, 문자열을 덧붙일 때마다 같은 객체에 계속 추가한다.

문자열 결합을 한 객체 안에서 처리하기 때문에 메모리도 아낄 수 있고 속도가 훨씬 빨라진다.

문제

코드 테스트 중 아래와 같은 에러가 발생했다.

Exception : class jakarta.validation.UnexpectedTypeException HV000030: No validator could be found for constraint 'jakarta.validation.constraints.NotBlank' validating type 'java.util.List<java.lang.String>'. Check configuration for 'categoryTypes'

 

에러 메세지를 보면 CategoryTypes와 Validation의 문제로 오류가 발생한 것 같았다.

당시 CategoryTypes의 유효성 검증을 하는 dto는 아래와 같이 작성되어 있었다.

@Getter
@Valid
@AllArgsConstructor
public class PostCreateRequestDto {
  @Size(min=1, max=1000, message = "게시글은 1000글자 이내여야 합니다.")
  @NotBlank(message = "게시글 내용은 필수값 입니다.")
  private final String content;

  private final List<String> imgUrls;

  @NotBlank(message = "카테고리는 필수값 입니다.")
  private final List<String> categoryTypes;

  @NotBlank(message = "공개범위는 필수값 입니다.")
  @ValidEnum(target = Visibility.class)
  private final String visibility;
}

 

 

원인

검색해보니 @NotBlank는 String 타입의 값이 하나만 들어왔을 때 적용 가능한 유효성 검증 어노테이션이었다.

따라서 이 오류는 하나의 값만 들어왔어야 할 필드에 리스트가 들어와서 데이터를 정상적으로 처리하지 못해 발생하는 문제였다.

이 문제를 해결하기 위해서는 @NotEmpty 또는 @Size(min = 1)을 사용해서 해당 값이 비어있지 않도록 검증해야 한다.

 

 

해결

@Getter
@Valid
@AllArgsConstructor
public class PostCreateRequestDto {
  @Size(min=1, max=1000, message = "게시글은 1000글자 이내여야 합니다.")
  @NotBlank(message = "게시글 내용은 필수값 입니다.")
  private final String content;

  private final List<String> imgUrls;

  @NotEmpty(message = "카테고리는 필수값 입니다.")
  private final List<String> categoryTypes;

  @NotBlank(message = "공개범위는 필수값 입니다.")
  @ValidEnum(target = Visibility.class)
  private final String visibility;
}

 

코드를 위와 같이 수정하면 프로그램이 제대로 동작하면서 게시글 생성 API가 데이터를 잘 저장하는 것을 볼 수 있었다.

도메인에서 공통적으로 사용하던 orElseThrow문 공통 클래스에서 처리

@Component
@RequiredArgsConstructor
public class EntityFetcher {

    private final UserRepository userRepository;
    private final PostRepository postRepository;
    private final PostLikeRepository postLikeRepository;
    private final CommentRepository commentRepository;
    private final CommentLikeRepository commentLikeRepository;
    private final FriendRepository friendRepository;

    public User getUserOrThrow(Long userId) {

        return userRepository.findById(userId)
                .orElseThrow(NotFoundUserException::new);
    }

    public Post getPostOrThrow(Long postId) {
        return postRepository.findById(postId)
                .orElseThrow(() -> new BaseException(ErrorCode.NOT_FOUND_POST_ID));
    }

    public PostLike getPostLikeOrThrow(PostLikeId postLikeId) {
        return postLikeRepository.findById(postLikeId)
                .orElseThrow(NotFoundPostException::new);
    }

    public Comment getCommentOrThrow(Long commentId) {
        return commentRepository.findById(commentId)
                .orElseThrow(() -> new BaseException(ErrorCode.NOT_FOUND_COMMENT_ID));
    }

    public CommentLike getCommentLikeOrThrow(CommentLikeId commentLikeId) {
        return commentLikeRepository.findById(commentLikeId)
                .orElseThrow(() -> new BaseException(ErrorCode.NOT_FOUND_LIKE_ID));
    }

    public FriendRequest getInteractiveFriendRequestOrThrow(User user1, User user2){
        return friendRepository.findInteractiveRequest(user1, user2)
                .orElseThrow(FriendRequestAlreadyHandledException::new);
    }

    public FriendRequest getFriendRequestOrThrow(User user1, User user2){
        return friendRepository.findByReceiverAndRequester(user1, user2)
                .orElseThrow(FriendRequestAlreadyHandledException::new);
    }

}

 

기존의 각 도메인별 서비스 레이어에서 공통적으로 사용중이던 orElseThrow문을 EntityFetcher 클래스로 묶어서 공통적으로 처리했다.

이렇게 해서 각 서비스 레이어의 가독성을 향상시킬 수 있고, user 정보를 뽑아오는 메서드의 경우는 대부분의 도메인에서 모두 사용했었기 때문에 이 방법이 관리에 수월했다.

 


엔티티 필드에 @Column(columnDefinition = "longtext") 사용

JPA는 기본적으로 자바 타입을 기반으로 적절한 DB 타입을 생성하지만, columnDefinition을 통해 직접 DB 타입을 지정할 수 있다.

위 어노테이션은 MySQL의 TEXT 계열 중 가장 큰 용량을 지원한다.

어노테이션 없이 String 타입 필드로만 정의하면 JPA는 기본적으로 VARCHAR(255)로 생성하기 때문에 최대 255자까지 저장이 가능하다.

물론 @Column(length = 1000) 어노테이션을 사용해서 최대 1000자까지 저장도 가능하다.

따라서 긴 게시글, 댓글, HTML 콘텐츠, 블로그 포스트를 필드로 저장해야 하거나 JSON과 같은 비정형 데이터를 그대로 문자열로 저장해야 할 때 @Column(columnDefinition = "longtext")를 사용하면 좋다.

 

사용 시 주의사항

- 인덱스 제한

인덱싱이 제한적이기 때문에 전체 문자열을 대상으로 LIKE 키워드를 사용해서 검색을 하면 성능 이슈가 발생할 수 있다.

 

- DBMS 종속성

MySQL 전용 문법이기 때문에 PostgreSQL, Oracle 등의 다른 DB와 호환되지 않을 수 있어서 추후 DB 변경 시 주의해야 한다.

 

- 메모리 사용량

너무 긴 데이터를 자주 불러오면 JPA Entity 조회 성능 저하 및 메모리 사용 증가 위험이 있다.

 


JPQL에 CONCAT('%', ?1, '%') 사용

LIKE 검색 시 검색어를 동적으로 생성하기 위해 사용한다.

만약 임의의 문자열이 아니라, 정확히 1개의 임의의 문자를 조건으로 검색하고 싶다면 `%` 대신 `_`을 사용하면 된다.

SELECT * FROM user WHERE username LIKE CONCAT('%', ?1, '%')

 

`?1`은 첫 번째 파라미터를 바인딩하기 위해 사용된다.

만약 ?1 = "john"이라면 이 sql문은 아래처럼 해석된다.

username LIKE '%john%'

 

 

nativeQuery 속성값을 true로 설정한다고 해도, 위와 같이 직접 문자열을 쿼리에 넣어서 작성하게 되면 SQL Injection 문제가 발생할 수 있고, 무엇보다 코드 재사용이 불가능하기 때문에 동적으로 문자열을 주입해주는 게 좋다.

 

동적 쿼리를 생성하는 방법은 위 예시처럼 concat을 사용하거나, 애초에 파라미터로 %가 포함된 문자열을 전달하는 방법이 있다.

@Query(value = "SELECT * FROM user WHERE username LIKE CONCAT('%', :keyword, '%')", nativeQuery = true)
List<User> findByUsernameContaining(@Param("keyword") String keyword);
@Query(value = "SELECT * FROM user WHERE username LIKE :keyword", nativeQuery = true)
List<User> findByUsernameLike(@Param("keyword") String keyword);

// 호출 시:
repository.findByUsernameLike("%john%");

 


SQL 쿼리에서 DTO 사용

"SELECT new inxj.newsfeed.domain.user.dto.response.SearchUsersResponseDto(u.username,u.profileImageUrl)" +
            " FROM User u WHERE u.username LIKE CONCAT('%', :username, '%') AND u.deletedAt IS NULL"

 

SQL 쿼리에서 new DTO로 코드를 작성하면, 반환에 필요한 데이터만 select해서 서비스 레이어로 반환할 수 있다.

주의할 점은 JPA가 해당 DTO 클래스의 생성자를 리플렉션으로 찾아서 실행하기 때문에, 패키지 전체 경로를 지정해줘야 정확히 어떤 클래스인지 알 수 있다.

그리고 이 DTO 내부에 반드시 해당 필드들을 받는 생성자가 필요하다.

반대로 DTO 생성자 파라미터가 바뀌면 해당 쿼리도 수정해야 한다는 단점이 있다.

 

 

 


Object.equals(a, b)

두 데이터가 같은지 null-safe하게 비교하는 코드이다.

즉, a.equals(b)를 NPE 없이 비교할 수 있도록 도와주는 메서드이다.

Long id = null;
Long loginUserId = 1L;

// id.equals(loginUserId); <- NPE 발생

Objects.equals(id, loginUserId); // false

 

내부적으로는 아래와 같이 동작한다.

return (a == b) || (a != null && a.equals(b));

 

 

문제

서버를 실행하고 포스트맨으로 내 파트 도메인을 테스트하던 중 친구 목록 조회 API를 실행하니까 아래와 같은 오류가 발생했다.

No serializer found for class org.hibernate.proxy.pojo.bytebuddy.ByteBuddyInterceptor
(through reference chain: ...["requester"]->inxj.newsfeed.user.entity.User$HibernateProxy$EWjtGRtm["hibernateLazyInitializer"])

 

 

원인

기존의 코드를 보면 서비스 레이어가 레포지토리에서 받아온 데이터를 dto에 담아 전달한다.

이때 dto는 아래와 같이 엔티티 자체를 가지고 있는 상태였다.

그리고 이 FriendRequest 엔티티는 지연로딩되는 User 엔티티를 필드로 가지고 있다.

public class FriendRequestResponseDto {
    private FriendRequest friendRequest;
}
public class FriendRequest extends BaseEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "requester_id", nullable = false)
    private User requester;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "receiver_id", nullable = false)
    private User receiver;

    @Enumerated(EnumType.STRING)
    @Column(nullable = false)
    private Status status = PENDING;

    public FriendRequest(User requester, User receiver) {
        this.requester = requester;
        this.receiver = receiver;
    }
}

 

 

이 dto를 클라이언트로 전달하는 과정에서 dto의 데이터를 직렬화하게 되는데, 엔티티 자체가 dto에 담겨있으면, 엔티티 내부에 있는 User 엔티티도 직렬화를 시도하게 된다.

하지만 User는 지연로딩되기 때문에, 이 엔티티를 사용하기 전까지는 프록시 객체가 내부에 들어있다.

이 프록시 객체는 실제 객체가 아니기 때문에 위와 같은 오류가 발생하게 된다.

 

 

해결

지연로딩되어야 하는 엔티티를 직렬화하지 않도록, 엔티티 자체가 아닌 필요한 데이터만 필드로 가지는 dto로 수정했다.

그리고 서비스 레이어에서도 dto에 엔티티 자체를 넘기지 않고, 필드로 분리해서 집어넣도록 코드를 수정했다.

public class FriendRequestResponseDto {
    private String username;
    private String name;
    private String profileImageUrl;
}

 

return friendRepository.findByUserAndStatus(user, ACCEPT).stream()
        // FriendRequest -> User
        .map(friendRequest ->
                friendRequest.getReceiver().equals(user) ? friendRequest.getRequester() : friendRequest.getReceiver())
        // User -> Dto
        .map(friend -> new FriendResponseDto(
                friend.getUsername(),
                friend.getName(),
                friend.getProfileImageUrl()))
        .toList();

 

 

결과

아직 값을 넣지 않은 상태라 빈 배열을 반환했지만, 클라이언트로 제대로 값을 전달하는 것을 볼 수 있다.

로그인한 유저 정보를 받아오는 방식

1. HttpServletRequest

- 클라이언트 요청 전체를 담고 있는 객체

- 세션을 포함한 모든 요청 정보에 접근 가능(쿠키, 헤더 등)

request.getSession().getAttribute("user")

 

2. HttpSession

- 서버와 클라이언트 간의 세션 저장소

- 세션에 직접 접근

session.getAttribute("user")

3. @sessionAttribute

- 컨트롤러에서 세션값을 파라미터로 바로 받음

- Spring MVC에서 지원

- required 속성을 생략하면 true로 자동 설정되어, null일 때 예외 발생

public String myPage(@SessionAttribute("user") User user) {
    ...
} 
// 세션에 "user" 속성이 없으면 HttpSessionRequiredException 발생
public String myPage(@SessionAttribute(value = "user", required = false) User user) {
    if (user == null) {
        return "redirect:/login";
    }
    // required = false로 지정하면 조건문으로 예외처리 가능 
}

엔티티 필드에 지연로딩을 적용했을 때 장점

지연로딩은 필드에 접근하는 순간, 프록시가 실제 객체를 로딩

@ManyToOne(fetch = FetchType.LAZY)

성능 최적화

- 기본값인 EAGER(즉시로딩)는 연관된 데이터를 전부 함께 가져옴 

- LAZY(지연로딩)는 실제로 데이터가 사용되는 시점에 가져옴

- 엔티티를 하나 조회하면 연관된 엔티티 N개를 각각의 쿼리로 추가 조회하는 N+1 문제 최소화 가능

N+1 문제
List<Post> posts = postRepository.findAll(); // 게시글 전체 조회
for (Post post : posts) {
    System.out.println(post.getUser().getUsername()); // 각 게시글 작성자 정보
}​


findAll()에서 10개의 게시글 조회 (+1 쿼리)
post.getUser()를 반복문을 돌 때마다 실행 (+10 쿼리)


하지만 위 문제의 경우는 지연로딩을 사용해도 N+1 발생 (post.getUser 할 때마다 쿼리 실행)
기본적으로 LAZY로 설정하고, 필요한 경우의 아래 방법을 사용해서 로딩 제어


해결 방법 1. fetch join
한 번의 쿼리로 연관된 엔티티를 조인해서 가져옴
@Query("SELECT p FROM Post p JOIN FETCH p.user")
List<Post> findAllWithUser();​


해결 방법 2. EntityGraph
어노테이션 기반이며, 레포지토리에서 user 필드를 미리 fetch해서 조회

@EntityGraph(attributePaths = "user")
List<Post> findAll();​

순환 참조 문제 회피

양방향 관계에서 즉시 로딩을 사용하면 객체가 서로를 참조하면서 무한 루프 가능성 있음

지연로딩은 객체 참조가 실제 사용되기 전까지 프록시 객체로 남아있어서 객체 생성 단계에서 순환 참조가 시작하지 않는다.


엔티티 내부에서 setter 대신 update 메서드를 사용하는 경우

1. 엔티티의 캡슐화를 확실히 할 때

2. 데이터를 수정(update)할 때마다 저장할 값의 유효성 검증이 필요한 경우

// null이 아닌 경우에만 update
public void updateProfile(String nickname, String profileImage) {
    if (nickname != null) this.nickname = nickname;
    if (profileImage != null) this.profileImage = profileImage;
}

 

만약 setter를 사용해서 데이터를 검증하기 위해서는 데이터를 수정하는 곳마다 검증하는 로직이 필요하기 때문에 코드가 반복되고 가독성이 떨어질 수 있다.

 

문제1

public void deleteFriend(Long userId) {
    friendRepository.findByIdAndSetStatusDeleted(userId);
}

 

친구를 삭제하고 싶을 때 전달받은 유저의 id로 해당 테이블에서 데이터를 조회하고, 그 데이터의 Status 값을 DELETED로 변경하고자 했다.

Spring Data JPA는 메서드명을 주어진 형식으로 사용해야 자동으로 JPA가 DB에 데이터를 처리해줄 수 있다.

위 코드처럼 findByIdAndSetStatusDeleted 같이 명확하지 않으면서 형식으로 주어지지 않은 메서드명은 사용할 수 없었다.

 

해결

먼저 테이블에서 내가 원하는 데이터를 가져오려면 로그인한 유저와 상대 유저의 id가 모두 있어야 했다.

그러므로 findByReceiverAndRequester를 사용해서 두 유저 자체를 레포지토리에 전달해서 해당하는 객체를 반환받았다.

그리고 반환받은 객체의 Status 필드를 변경 감지를 통해 변경하도록 코드를 수정했다.

User receiver = userRepository.findById(loginedUserId).orElseThrow(NoSuchElementException::new);
User requester = userRepository.findById(userId).orElseThrow(NoSuchElementException::new);

// 상대 유저가 requester, 로그인 유저가 receiver인 friendRequest 반환
FriendRequest foundFriendRequest = friendRepository.findByReceiverAndRequester(receiver, requester).orElseThrow(NoSuchElementException::new);

// Status가 PENDING 상태인 경우 ACCEPT로 변경
if (foundFriendRequest.getStatus() == PENDING) {
    foundFriendRequest.setStatus(ACCEPT);
}else {
    // Status가 PENDING이 아닌 경우 예외 발생(이미 요청을 거절, 수락하거나 친구 삭제한 경우)
    throw new AlreadyProcessedException();
}

 


문제2

user1과 user2를 사용해서 레포지토리에서 데이터를 반환하려고 할 때, 두 유저 모두 receiver 또는 requester가 될 수 있는 상황이라 양방향으로 체크해야 했다.

이 경우는 user1이 receiver일 때와, user2가 receiver일 때를 각각 조회해야 하는지 고민을 했던 것 같다.

 

해결

이런 상황은 직접 쿼리를 작성하는데 더 효율적인 방식인 것 같아서 검색해보니 JPQL을 사용하면 쉽게 해결할 수 있었다.

JPQL은 엔티티 객체를 기준으로 쿼리를 작성하는 방법이다.

테이블 이름, 컬럼 이름이 아니라 엔티티 이름과 필드 이름을 사용한다.

@Query("select f from FriendRequest f where (f.receiver = :loginedUser and f.requester = :friend)"
+ "or (f.receiver = :friend and f.requester = :loginedUser)")
Optional<FriendRequest> findInteractiveRequest(@Param("loginedUser") User loginedUser, @Param("friend")User friend);

 

@Query 어노테이션을 통해 내 의도대로 쿼리를 작성한다.

그리고 기존에 JDBC template을 사용할 때 ?로 prepared 방식으로 sql문을 사용했던 것과 유사하게, @Param과 :을 사용해서 파라미터를 바인딩한다.

 

만약 엔티티, 필드명이 아니라 실제 데이터베이스의 테이블 이름과 컬럼 이름을 사용하고 싶다면 @Query 어노테이션의 속성으로 nativeQuery=true로 설정하면 된다.

기본적인 엔티티 생성하기

내가 맡은 친구 도메인은 친구 요청 정보를 관리하는 FriendRequest 엔티티가 필요하다.

FriendRequest 엔티티는 고유 id, 친구 요청을 받는 사용자, 친구 요청을 보내는 사용자, 친구 요청의 상태를 가지는 status를 필드로 가진다.

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

    @ManyToOne
    @JoinColumn(name = "requester_id")
    @Column(nullable = false)
    private User requester;

    @ManyToOne
    @JoinColumn(name = "receiver_id")
    @Column(nullable = false)
    private User receiver;

    @Enumerated(EnumType.STRING)
    @Column(nullable = false)
    private Status status = PENDING;

    public FriendRequest(){}

    public FriendRequest(User requester, User receiver, Status status){
        this.requester = requester;
        this.receiver = receiver;
        this.status = status;
    }

 

알게 된 부분

@Enumerated는 Enum 타입 필드를 JPA가 데이터베이스에 삽입할 때 어떤 타입으로 변환하여 삽입할 건지를 설정할 수 있다.

이 어노테이션을 설정하지 않아도 JPA는 자동으로 ordinal 타입으로 Enum을 삽입한다.

ordinal 타입은 각 Enum을 0, 1, 2와 같이 숫자로 변환해서 관리하는 옵션인데, 이렇게 되면 개발 중에는 큰 문제가 없지만,

혹시라도 추후에 Enum을 변경하게 되면 매핑할 때 오류가 발생하게 된다.

그러므로 @Enumerated(EnumType.STRING)으로 설정해서 문자열로 데이터베이스에 저장하는 것은 사실상 필수이다.

 


 

복합키 매핑 방법

@EmbeddedId 방식

복합키 자체를 하나의 객체로 다루는 방식이다.

@Entity
public class CommentLike {

    @EmbeddedId
    private CommentLikeId id;  // 복합키 전체를 하나로 묶음

    private LocalDateTime likedAt;
}
@Embeddable
public class CommentLikeId implements Serializable {

    @Column(name = "comment_id")
    private Long commentId;

    @Column(name = "user_id")
    private Long userId;

    // 기본 생성자
    public CommentLikeId() {}

    public CommentLikeId(Long commentId, Long userId) {
        this.commentId = commentId;
        this.userId = userId;
    }

    // equals()와 hashCode() 오버라이딩 필수
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof CommentLikeId)) return false;
        CommentLikeId that = (CommentLikeId) o;
        return Objects.equals(commentId, that.commentId) &&
               Objects.equals(userId, that.userId);
    }

    @Override
    public int hashCode() {
        return Objects.hash(commentId, userId);
    }
}

 

equals()를 오버라이딩 해야하는 이유

데이터를 조회할 때 id를 사용해서 조회하는데, 복합키를 사용하면 복합키 객체를 가지고 동일한 데이터인지 비교해야 한다.

 

만약, dto를 통해 전달받은 데이터를 엔티티의 생성자를 사용해서 인스턴스를 만들었다고 가정하자.

그리고 해당 인스턴스를 레포지토리 레이어에 전달하여, DB에 저장되어 있는 데이터 중 동일한 값이 있는지 비교 후 값을 반환받는다.

단순히 비스니스적인 관점에서는 아래와 같이 복합키의 각 id 값을 꺼내서 비교하면 된다.

if (like.getId().getUserId().equals(requestUserId) 
	&& like.getId().getCommentId().equals(commentId)) {
    // 같음
}

 

하지만 JPA는 이렇게 각 데이터를 꺼내서 비교하는 방식으로 동작하지 않는다.

JPA는 equals()와 hashCode()를 사용해서 데이터를 조회하는 시스템이기 때문에, 복합키를 사용하는 상황에서는 반드시 두 메서드가 필요하다.

 

그리고 Object.equals()는 내부적으로 ==으로 값을 비교하는 로직이기 때문에, 두 데이터의 동등성을 비교하기 위해서는 오버라이딩을 해주어야 한다.

그동안 String의 동등성을 비교할 때 equals를 사용했던 것은 String.equals()를 내부적으로 이미 오버라이딩하고 있었기 때문이다.

 

 

hashCode()를 오버라이딩 해야하는 이유

해시 기반 자료구조에서 equals가 true인 객체는 hashcode도 같아야 정상 작동한다.

JPA가 직접적으로 hashMap을 사용하는 건 아니지만 내부적으로 객체 식별을 위해 HashMap처럼 동작하는 구조를 사용한다.

1차 캐시가 거의 Map<Id, Entity> 구조여서, 같은 트랜잭션 안에서 같은 ID값을 가진 엔티티는 동일한 객체로 유지된다.

 


@IdClass 방식

엔티티에 @Id를 여러 개 지정하고, 별도의 ID 클래스를 연결하는 방식이다.

즉, 첫 번째 방법과 다르게 엔티티에 각 id가 필드로 존재한다.

@Entity
@IdClass(CommentLikeId.class) // 복합키 클래스 연결
public class CommentLike {

    @Id
    private Long commentId;

    @Id
    private Long userId;

    private LocalDateTime likedAt;
}
public class CommentLikeId implements Serializable {
    private Long commentId;
    private Long userId;

    // 기본 생성자, equals(), hashCode() 필수
}

 

@IdClass 방식의 단점은 재사용과 관리가 어렵다.

@EmbeddedId는 ID 클래스를 객체로 다루니까 재사용, null 체크, 로직 캡슐화가 쉽다.

반면에 @IdClass는 단순 값 필드의 모음이기 때문에 로직이나 유효성 검사 등을 넣기 힘들다.

1. 와이어프레임 설계

2. ERD 설계

3. API 명세서 작성

 


1. 와이어프레임 설계

 


2. ERD 설계

어려웠던 부분

- id 설정

따로 id 컬럼을 생성할건지, 복합키를 사용할건지에 대해 고민하는 시간이 꽤 길었던 것 같다.

댓글 좋아요, 게시글 좋아요 테이블은 따로 id값이 필요하지 않아서 복합키를 사용했다.

댓글 테이블의 경우는 한 사람이 한 게시글에 댓글을 여러 번 작성할 수 있기 때문에 id값이 따로 필요했고, 

친구 요청 테이블도 상대방이 친구 요청을 거절했을 때, 여러 번 친구 요청을 보낼 수 있기 때문에 id값을 따로 만들었다.

 

- 다중값을 가지는 컬럼 처리

게시글 테이블의 경우, 카테고리와 사진 컬럼이 다중값을 가지기 때문에 List를 사용하여 객체지향에서 다루도록 했다.

그런데 이렇게 하면 JPA가 List를 처리할 수 없기 때문에 문제가 생긴다.

이 방식을 해결하려면 카테고리와 사진의 중간테이블을 따로 생성해서, 아래와 같이 생성해야했다.

아래 테이블은 2번 게시글이 id가 1인 카테고리와 id가 2인 카테고리에 모두 포함되는 경우이다.

게시글 id 카테고리 id
2 1
2 2

 

JPA는 List를 직접 DB에 삽입하지는 못하지만, 개발자가 설정하면 자동으로 이 중간 테이블을 생성해준다.

// 중간테이블 설정 예시 (추후 개발하고 코드 첨부하기)

 


3. API 명세서

개선할 부분

- 클라이언트에서 page값을 서버에 전달할 때, @PathVariable 보다는 Query Parameter를 사용하는 걸 권장한다.

page는 리소스 고유 식별자가 아니라 리소스를 필터링, 조작하는 옵션값이기 때문에 쿼리 파라미터가 적절하다.

 

- 서버에서 클라이언트로 요청 성공을 알리기 위해 데이터를 보내기 보다는 상태코드만 전달하는 걸 권장한다.

예를 들어 "데이터 처리가 성공했습니다."와 같은 String을 클라이언트로 전달하게 되면, 클라이언트 측에서 관리할 사항만 늘어나기 때문이다.

에러에 대한 응답은 클라이언트 측에서 예외 처리에 필요한 정보이기 때문에, 값을 전달하는 게 맞지만 요청이 성공한 경우에는 응답 데이터가 불필요한 데이터가 된다.

 

- 조회의 경우, 클라이언트는 PK값을 서버에 전달하는 게 가장 좋다.

PK를 사용해서 데이터를 조회해야 DB에서 검색의 성능이 최적화되기 때문이다.

 

 

EntityManager

JPA에서 엔티티를 관리하는 인터페이스

 

DB와의 연결을 통해 CRUD를 처리하고, 영속성 컨텍스트(1차 캐시)를 관리한다.

Spring + JPA 환경에서는 트랜잭션이 시작되면(@Transactional이 적용된 메서드가 실행되면) EntityManager가 자동으로 주입된다.


flush()

영속성 컨텍스트의 변경 내용을 DB에 반영(트랜잭션은 계속 유지)

- insert 쿼리 실행

- @GeneratedValue(strategy = GenerationType.IDENTITY)로 설정되지 않은 경우, 생성된 ID를 얻기 위해 미리 insert가 필요할 때 사용

- @GeneratedValue(strategy = GenerationType.IDENTITY)로 설정하면 persist() 호출 시 ID를 얻기 위해 바로 insert 실행 됨

 


detach()

특정 객체만 더 이상 JPA가 관리하지 않게 만드는 메서드

- 변경 감지를 하고 싶지 않을 때 사용

- 트랜잭션 안에 있더라도 더 이상 DB에 반영되지 않음

실무에서 거의 사용하지 않음 (flush(), clear()로 객체 관리)

 


clear()

영속성 컨텍스트 전체 초기화 (EntityManager 초기화)

- entityManager를 새로 만들지 않고 메모리를 효율적으로 초기화

실무에서 대량 insert / update 작업에서 필수적으로 사용
ex. 1000건씩 배치 처리하면서 clear()로 중간마다 정리

 


merge()

영속 상태가 아닌 객체를 DB에 반영

- 대부분 JPA가 관리하는 영속 상태 객체만 다루기 때문에 merge()는 잘 사용하지 않음

- 외부에서 받은 데이터(DTO)는 영속 상태가 아니므로, 해당 데이터를 저장하거나 갱신할 때 주로 사용

@PostMapping("/save")
public void save(@RequestBody MemberDto dto) {
    Members m = new Members();
    m.setId(dto.getId());         // ← ID가 존재하면 update / 존재하지 않으면 insert
    m.setName(dto.getName());

    em.merge(m); // → 이 객체는 영속 상태가 아님. merge로 반영
}
// detached 상태가 된 member
Members member = em.find(Members.class, 1L);
em.detach(member);  // JPA 더이상 관리 안 함

member.setName("수정된 이름");

// 다시 저장하려면?
em.merge(member);   // → DB 반영됨 (update 쿼리)

 

 

 

practice1 로그 분석

@GetMapping("/practice1")
    public void practice1() {
        // JPA 기능을 가지고 있다 (영속성 컨텍스트)
        EntityManagerFactory emf = Persistence.createEntityManagerFactory("hello");
        EntityManager em = emf.createEntityManager();

        // 비영속 상태
        Members member = new Members();
        member.setId(103L);
        member.setTitle("테스트맨");

        // transaction
        EntityTransaction transaction = em.getTransaction();
        transaction.begin();

        System.out.println("BEFORE"); // before 찍힘
        em.persist(member); // insert 날라감
        System.out.println("AFTER"); // after 찍힘

        transaction.commit();
        em.close();
        emf.close();
    }

commit()문에 breakpoint를 걸고 디버깅하면 after가 출력될 때까지 insert 되지 않는 것을 볼 수 있다.

 

  1. `transaction.begin();`
    • 트랜잭션 시작
  2. `System.out.println("BEFORE");`
    • 콘솔에 Before 출력
  3. `em.persist(member);`
    • member를 1차 캐시(영속성 컨텍스트)에 저장
    • JPA가 member 관리
  4. `System.out.println("AFTER");`
    • 콘솔에 AFTER 출력
  5. `transaction.commit();`
    • 트랜잭션 종료
    • member를 DB에 저장

practice2 로그 분석

@GetMapping("/practice2")
    public void practice2() {
        EntityManagerFactory emf = Persistence.createEntityManagerFactory("hello");
        EntityManager em = emf.createEntityManager();

        Members member = new Members();
        member.setId(1001L);
        member.setTitle("회원");

        EntityTransaction transaction = em.getTransaction();
        transaction.begin();

        //1차 캐시에 저장됨
        em.persist(member); // insert

        //104번 멤버 조회
        Members member1 = em.find(Members.class, 1001L); // select

        System.out.println("조회 결과 : " + member1.getTitle());

        transaction.commit();
        em.close();
        emf.close();
    }

  1. `transaction.begin();`
    • 트랜잭션 시작
  2. `em.persist(member);`
    • member를 1차 캐시(영속성 컨텍스트)에 저장
    • JPA가 member 관리
  3. `Members member1 = em.find(Members.class, 1001L);`
    • 1차 캐시에 저장되어 있는 데이터 중 id가 1001L인 데이터를 찾아 member1에 저장
  4. `System.out.println("조회 결과 : " + member1.getTitle());`
    • 결과 출력
  5. `transaction.commit();`
    • 트랜잭션 종료
    • member를 DB에 저장

practice3 로그 분석

@GetMapping("/practice3")
    public void practice3() {
        EntityManagerFactory emf = Persistence.createEntityManagerFactory("hello");
        EntityManager em = emf.createEntityManager();

        EntityTransaction transaction = em.getTransaction();
        transaction.begin();

        //105번 멤버 조회
        Members member1 = em.find(Members.class, 105L);
        //105번 멤버 조회
        Members member2 = em.find(Members.class, 105L);

        System.out.println("비교 결과 : " + (member1 == member2));

        transaction.commit();
        em.close();
        emf.close();
    }

  1. `transaction.begin();`
    • 트랜잭션 시작
  2. `Members member1 = em.find(Members.class, 105L);`
    • 객체를 persist한 적 없으므로 1차 캐시에 존재하는 데이터 없음
    • spring.jpa.hibernate.ddl-auto=create로 설정되어 있으므로, DB에도 존재하는 데이터 없음
    • 따라서 105L라는 id를 가진 데이터가 존재하지 않으므로 member1에는 null이 담김
  3. `Members member2 = em.find(Members.class, 105L);`
    • member1과 마찬가지로 member2에 null이 담김
  4. `System.out.println("비교 결과 : " + (member1 == member2));`
    • null==null을 비교하므로 true 출력
    • 만약 105L라는 id값을 가진 데이터가 1차 캐시에 존재했다면, 1차 캐시에서 동일한 객체를 꺼내와서 동일성을 비교하기 때문에 마찬가지로 true가 출력됨.
  5. `transaction.commit();`
    • 트랜잭션 종료
    • member를 DB에 저장
데이터 조회는 트랜잭션과 무관하게 해당 쿼리를 실행하는 순간에 1차 캐시와 DB에서 모두 즉시 조회함.
데이터의 수정, 삽입, 삭제만 commit()이 실행되는 순간에 실제로 DB에서 실행됨.
+ 가져온 값이 null이라면 동일한 데이터를 조회해도 select문이 두 번 발생한다.(1차 캐시에 있는 null을 가져오지 않고, DB에서 두 번 조회)

practice4 로그 분석

@GetMapping("/practice4")
    public void practice4() {
        EntityManagerFactory emf = Persistence.createEntityManagerFactory("hello");
        EntityManager em = emf.createEntityManager();

        EntityTransaction transaction = em.getTransaction();
        transaction.begin();

        Members member1 = new Members();
        member1.setId(100002L);
        member1.setTitle("회원님1");

        Members member2 = new Members();
        member2.setId(100011L);
        member2.setTitle("회원님2");


        // 이 때 Insert 쿼리를 보내게 될까?
        em.persist(member1);
        em.persist(member2);

        System.out.println("=============================");

        transaction.commit();
        em.close();
        emf.close();
    }

 

practice1, 2와 마찬가지로 commit 시점에 DB에 insert

 


practice5 로그 분석

@GetMapping("/practice5")
    public void practice5() {
        EntityManagerFactory emf = Persistence.createEntityManagerFactory("hello");
        EntityManager em = emf.createEntityManager();

        EntityTransaction transaction = em.getTransaction();
        transaction.begin();

        Members member1 = em.find(Members.class, 103L);
        member1.setTitle("바뀔까");

        transaction.commit();
        em.close();
        emf.close();
    }

  1. `transaction.begin();`
    • 트랜잭션 시작
  2. `Members member1 = em.find(Members.class, 103L);`
    • practice1에서 저장했던 데이터를 DB에서 조회해서 member1에 저장
  3. `member1.setTitle("바뀔까");`
    • commit 전 시점에서 데이터에 변경이 일어나면 JPA의 변겸감지로 인해 1차 캐시에 변경된 내용이 저장됨
  4. `transaction.commit();`
    • 트랜잭션 종료
    • member를 DB에 저장
1차 캐시는 EntityManager마다 개별로 관리됨.
따라서 practice1에서 103L id값을 가지는 데이터를 1차 캐시에 저장했어도 해당 엔티티 매니저는 close되고, 새로운 엔티티 매니저를 생성했기 때문에 1차 캐시는 비어 있는 상태.
따라서 DB에 저장된 데이터를 가지고 와야 함.
+ set으로 특정 필드만 변경해도 update문은 전체 필드에 대해 날라감
@DynamicUpdate를 엔티티에 적용하면 변경된 부분만 update 쿼리가 날라감
@DynamicUpdate는 그냥 JPA에서도 적용되는 어노테이션

'언어, 프레임워크 > Spring' 카테고리의 다른 글

Converter, Formatter  (0) 2025.04.15
EntityManager Method  (0) 2025.04.06
JPA 알아보기 (+Spring Data JPA 적용)  (0) 2025.03.31
SOLID 원칙과 Bean 등록 방법  (0) 2025.03.28
@Transactional 알아보기  (0) 2025.03.26