Skip to content

[그리디] 김민욱 Spring Data JPA 6단계 제출합니다. - #271

Open
hapdaypy wants to merge 63 commits into
next-step:hapdaypyfrom
hapdaypy:hapdaypy4
Open

[그리디] 김민욱 Spring Data JPA 6단계 제출합니다.#271
hapdaypy wants to merge 63 commits into
next-step:hapdaypyfrom
hapdaypy:hapdaypy4

Conversation

@hapdaypy

@hapdaypy hapdaypy commented Jul 20, 2026

Copy link
Copy Markdown

🙋‍♂️소개

안녕하세요 이진 리뷰어님 ~
세종대 그리디 4기 백엔드 김민욱입니다!

사다리 미션, 로또 미션 리뷰에 대한 답변을 진행했을 때, 정말 찐하게 한 듯한 기억이 있어서 그때 생각하면 아직도 얼얼해요...ㅎ
이번에는 학기 중과 다르게 방학이라 시간도 충분히 있어서 리뷰에 대한 내용을 쫌 더 깊게 공부할 수 있습니다 !

깊은 내용이 꼭 아니더라도 키워드나 방향성만 제시해 주신다면, 스스로 공부를 이어나갈 수 있습니다 !

PR 본문은 다음과 같이 구성되어 있습니다.

  • 단계별 구현 사항: 각 단계별로 구현한 사항을 적어두었습니다.
  • 학습 내용 정리: 미션의 요구사항내에서 미션에 관한 키워드에 대해서 의심해보고 학습하고 이해한 내용을 적었습니다.
  • 의논사항: 정답이 없을 것 같은 주제에 대해서 리뷰어님과 의논하고 싶은 사항을 작성하였습니다.

리뷰어님께서 시간이 없으시다면, 셀프 리뷰를 바탕으로 부족한 부분등에 대해서 자유롭게 리뷰를 남겨주시면 감사하겠습니다!


💻단계별 구현 사항

🚀JPA 6단계

  • 순수 JPA를 활용하던 DAO를 JpaRepository 방식으로 변경
  • 예약 대기 목록 기능 추가

💡학습 내용 정리

순수 JPA를 Spring Data JPA의 JpaRepository 방식으로 변경해도 괜찮을까?

이것을 고민했던 이유는 순수 JPA와 Spring Data JPA의 JpaRepository 방식이 서로 다른 기술이라고 생각했기 때문입니다.
학습하면서 Spring Data JPA는 JPA를 더 편리하게 사용할 수 있도록 Repository 추상화를 제공하는 기술이라는 것을 알게 됐습니다.

관계를 간단히 정리하면 다음과 같습니다.

JPA
└── Hibernate: JPA 구현체

Spring Data JPA
├── JpaRepository: 기본 CRUD 제공
├── 메서드 이름 기반 쿼리
├── @Query
└── 사용자 정의 Repository
    ├── EntityManager
    └── QueryDSL 등을 활용한 쿼리 구현

순수 JPA의 EntityManager 방식에서 Spring Data JPA의 JpaRepository 방식으로 변경해도 되는지 의문이 있었습니다.

기존 DAO에서는 단순한 저장, 단건 조회, 삭제와 같은 CRUD 로직을 직접 구현하고 있었습니다. 이러한 로직은 JpaRepository가 제공하는 기본 메서드로 대체할 수 있고, 반복되는 코드를 줄일 수 있다고 판단하여 JpaRepository 방식으로 변경하였습니다.

추가적으로,

날짜가 입력됐을 때만 날짜 조건을 추가하고, 테마가 입력됐을 때만 테마 조건을 추가해야 하는 경우처럼, 검색 조건이 실행 중에 달라지는 동적 쿼리는 메서드 이름이나 고정된 JPQL만으로 작성하기 어려울 수 있다고 합니다.

이럴 경우에는 사용자 정의 Repository에서 EntityManager, Specification 또는 QueryDSL을 사용할 수 있다고 하네요.


🕵️논의 사항

예약 대기에 대한 정책은 언제 정할 수 있을까요?
현재는 비어 있는 시간에도 대기를 등록할 수 있고, 같은 회원이 동일한 날짜, 테마, 시간에 여러 번 대기할 수도 있습니다.
미션 요구사항에는 구체적인 서비스 정책이 주어지지 않았지만, 다음 정책을 직접 정의하고 기능을 추가해도 괜찮을지 논의하고 싶습니다.


🌱마무리

소중한 시간을 내어 리뷰해 주시는 만큼, 남겨주신 내용을 바탕으로 이번 주 학습 내용을 더 깊이 정리해 보겠습니다.

mgim9316-a11y and others added 30 commits June 29, 2026 13:08
@hapdaypy
hapdaypy changed the base branch from main to hapdaypy July 20, 2026 08:27
@hapdaypy hapdaypy changed the title [그리디] 김민욱 Spring Data JPA 6~8 단계 제출합니다. [그리디] 김민욱 Spring Data JPA 6단계 제출합니다. Jul 21, 2026

@2Jin1031 2Jin1031 left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

민욱님!! 오랜만에 뵙네요 ㅎ.ㅎ
미션 진행하시느라 고생 많으셨습니다~

항상 열심히 하는 거는 칭찬이 자자 해서 알고 있던 사실이지만, 코드로도 열정이 보이는 듯 하네요 :)
특히 JpaRepository 의 전환이 자연스럽고 멱등성까지 고려한 부분에서 깊이가 보였습니다 ㅎㅎ 최고!

리뷰 코멘트를 작성하다 길어져서 다음 리뷰 코멘트로도 찾아보려고 파트를 나눠봤습니다~
추가로 궁금한 점이 있다면 언제든 먼저 질문 남겨주셔도 됩니다!!
화이팅 🔥🔥🔥


예약 대기에 대한 정책은 언제 정할 수 있을까요?
현재는 비어 있는 시간에도 대기를 등록할 수 있고, 같은 회원이 동일한 날짜, 테마, 시간에 여러 번 대기할 수도 있습니다.
미션 요구사항에는 구체적인 서비스 정책이 주어지지 않았지만, 다음 정책을 직접 정의하고 기능을 추가해도 괜찮을지 논의하고 싶습니다.

좋습니다~ 대기 미션은 제약 사항이 먼저 주어지지 않은 만큼, 민욱님이 생각하시기에 어떤 정책이 추가되어야 하는 지를 주체적으로 파악해서 그 이유와 함께 기능 구현해주시면 그에 맞춰 확인해보도록 하겠습니다!! 좋은데요?

import org.springframework.web.bind.annotation.ExceptionHandler;

@ControllerAdvice
@ControllerAdvice // 아 컨트롤러에서 발생한 예외를 가로챔 ~ ㅋ

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

..? ㅋㅋㅋㅋㅋ

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

스프링 이론을 다시 공부할 때 주석을 달면서 공부했었는데... 안 지우고 올라가졌네요 ....!

@ControllerAdvice
@ControllerAdvice // 아 컨트롤러에서 발생한 예외를 가로챔 ~ ㅋ
public class ExceptionController {
private static final Logger log = LoggerFactory.getLogger(ExceptionController.class);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Log를 추가해주셨네요~ 추가하게 된 계기가 있을까요? 이점을 경험하셨나요?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

저도 언제부터인지 모르겠는데... 자연스럽게 Log의 존재를 습득(?)했습니다...

아직 대단한 이점은 경험해보지는 않았습니다!

@Controller
public class PageController {
@GetMapping("/admin")
@AdminOnly

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

어노테이션을 직접 만들어주셨네요 👍

}

public Long getId() {
public Long id() {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getId 네이밍이 id 로 수정되었네요 아마 실수인듯합니다~

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

넵, 맞습니다!..
해당 부분은 제가 실수로 getId()를 id()로 변경했습니다.

값을 반환하는 메서드 같은 경우는 간결한 접근자보다 getter 형식이 의도가 명확하다고 생각하여, getId(), getName()과 같은 형식으로 통일하는 것이 맞다고 생각합니다.

Member 뿐만 아니라 Reservation, Waiting, Time 도메인에도 해당 내용을 적용해보도록 하겠습니다.

또한 메서드 네이밍을 통일한 뒤에 사용되지 않는 중복 접근자는 제거하겠습니다.

피드백 감사합니다!

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Member 엔티티와 연관관계 없이 Long으로 저장해주고 계시네요~ 이유가 궁금합니다!

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이것은 저의 실수입니다.

현재 Member 객체와 연결하지 않고 회원의 ID 숫자만 저장한 것입니다.

JPA 입장에서는 이 값이 Member.id를 의미하는지 알 수 없습니다.

Member member + @manytoone → Member 엔티티와 JPA 연관관계 설정이 필요합니다!

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id")
private Member member;

@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private String date;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

dateString으로 관리하고 있네요~ String으로 관리하면 어떤 점이 어려울까요?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

검증이 어렵습니다!

이유

String 은 말 그대로 문자열을 의미합니다.

날짜가 문자열로 들어와서 문자열로 관리가 된다면 형식과 유형성을 개발자가 직접 검증해야합니다.

그렇기 때문에 날짜라는 의미와 기본 검증을 제공하는 LocalDate 타입을 사용하는 것이 더 바람직합니다 !

하지만 LacalDate 타입이 모든 것을 검증해 주는 것이 아니라 과거 날짜 제한 과 같은 비즈니스 로직은 개발자가 별도로 처리해야합니다 !

private final SimpleJdbcInsert simpleJdbcInsert;
@Query("select new roomescape.reservation.ReservationMineResponse(r.id, th.name, r.date, t.value, '예약') "
+ "from Reservation r join r.theme th join r.time t where r.member.id = :memberId")
List<ReservationMineResponse> findByMemberId(@Param("memberId") Long memberId);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DTO Projection 를 사용해주셨네요, 어떤 다른 방법들이 있고 그 중에 이 방법을 선택하신 이유를 설명해주셔도 좋을 것 같아요! (이건 저의 면접 질문 중 하나였답니다 ㅎ)

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이전리뷰 에서 테이블을 3개를 Fetch 조인을 하게 된다면, 한 번의 쿼리로 조회할 수 있지만,

연관 엔티티의 전체 필드를 조회하고 영속성 컨텍스트에서 엔티티를 관리하여 쿼리가 무거워 질 수 있음을 학습했습니다!

따라서 필요한 값만 가져올 수 있도록 DTO Projection 를 사용하여 응답에 필요한 칼럼을 가져와 DTO를 생성하기 위해서 사용하였습니다.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

다른 방법들

  1. 엔티티를 조회하고 서비스에 DTO로 변환
  2. Fetch Join으로 엔티티를 한 번에 조회한 후 DTO로 변환
  3. JPQL 생성자 DTO Projection(현재 사용하는 방식)
  4. Spring Data 인터페이스 Projection
public interface ReservationProjection {
    Long getId();
    String getName();
}
  1. Native Query 또는 QueryDSL을 이용한 Projection

4번 5번에 대한 내용은 있다는 것만 알고 제가 좀 더 시간을 가지고 공부를 해보겠씁니다 ... ㅜㅜ

import java.util.concurrent.ConcurrentHashMap;

@Service
@Transactional(readOnly = true)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

서비스 레벨에 @Transactional(readOnly = true)를 사용해주셨네요! 이 어노테이션의 readOnly 옵션에 대해 좀 더 설명해주실 수 있나요? 어느메서드에는 @Transactional 를 사용하신 건가요?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

불필요한 변경 감지와 flush를 줄이기 위해 @transactional(readOnly = true)를 사용하였습니다.

사용법

클래스 단위에서 @transactional(readOnly = true)를 적용하여 조회 전용 트랜잭션임을 명시했습니다.

이를 통해 Spring과 JPA 구현체가 불필요한 변경 감지와 flush를 줄일 수 있도록 했습니다.

예약 저장을 담당하는 save()와 삭제를 담당하는 deleteById()에는 메서드 단위@Transactional을 적용했습니다.

메서드 단위 설정이 클래스 단위 설정보다 우선하므로 해당 메서드들은 readOnly = false인 일반 트랜잭션으로 동작합니다.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

우선순위도 알고 계시는 군요! 좋습니다 :)

} catch (IllegalArgumentException e) {
return ResponseEntity.status(401).build();
}
public ResponseEntity<Void> login(@RequestBody MemberRequest memberRequest, HttpServletResponse response) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Controller가 Servlet API에 직접 의존하고 있는데 민욱님의 의도가 있다면 좀 더 들어보고 싶습니다~!

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이 부분에 대해서 깊이 생각하지는 않았습니다!

Interceptor 나 ArgumentResolver 가 Servlet 타입을 사용하기 때문에 Servlet 의 의존성 존재 여부를 생각하는 것에 있어서 크게 중요도를 부여하지는 않았었습니다.

하지만 그것과는 별개임을 학습할 수 있었습니다 ㅎ

우선 Interceptor 의 경우는 요청을 검사해야하기 때문에 Servlet 타입 사용이 인터페이스상 필수이고
ArgumentResolver 의 경우에는 Http 요청을 객체로 반환하기 때문에 Servlet 사용이 자연스럽습니다.

반면 Controller 의 경우에는 Resolver 가 만들어준 객체만 반으면 되기 때문에 Servlet API를 직접 사용할 필요가 없습니다 !

그렇다면 Controller 에서는 HttpServletRequest 를 직접 받아서 쿠키나 토큰을 꺼내지 않고 ArgumentResolver 가 만들어준 객체를 입력 받는 식으로 리팩토링을 하면 됩니다!

Controller가 Servlet API에 직접 의존하는 것을 지양해야하는 이유는 다음과 같습니다.

  1. Spring MVC 가 아닌 다음 웹 기술로 변경하기 어려움
  2. 컨트롤러가 인증 처리와 HTTP 쿠키 조작을 동시에 함 (그러면 테스트의 책임이 많아져서, 유지보수가 어려워질 수 있습니다.)

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

현재 코드에서 login(), logout() 에서 응답에 쿠키를 추가하는 방식을 사용하고 있습니다.
위와 같은 이유로 servlet 의 의존성을 제거하기 위해서는 ResponseEntity의 Set-Cookie 헤더 사용할 수 있습니다 !

뿐만 아니라 refreshToken()은 요청 쿠키를 읽고 응답 쿠키도 추가하기 때문에 두 Servlet 의존성을 모두 제거하기 위해서는 @CookieValue 를 사용하여 요청 쿠키를 읽는 방식을 변경하고 ResponseEntity의 Set-Cookie 헤더를 사용하여 응답 쿠키를 추가하는 방식으로 수정할 수 있습니다 !

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

그렇다면 간단하게 @CookieValue와 ResponseEntity의 Set-Cookie 헤더를 정리해보겠습니다.

@CookieValue란?

요청에 담긴 쿠키 값을 컨트롤러 매개변수로 바로 받아주는 Spring 애너테이션입니다.
-> 요청 받은 쿠키를 읽는 방식입니다.

이것을 사용한다면

HttpServletRequest request
authCookieProvider.extractRefreshToken(request)

다음과 같은 쿠키를 읽는 코드를 생략할 수 있습니다.


Set-Cookie란?

서버가 클라이언트에게 쿠키를 저장하고 지시하는 HTTP 응답 헤더라고 합니다.

따라서 Servlet 응답 객체를 Servlet 이 직접 변경하는 명령형 방식에서 반환할 HTTP 응답 선언한 다음에 Spring 이 반환을 할 수 있도록 변경할 수 있습니다.

정리: Servlet 이 응답 객체를 직접 변경하는 방식 vs 반환할 HTTP 응답을 값으로 구성하는 방식(Spring방식)

}

@Transactional
public ReservationResponse save(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

요 부분은 다음 리뷰에서 함께하시죠 ㅎㅎ

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

넵 ... !

@hapdaypy hapdaypy left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

~

import org.springframework.web.bind.annotation.ExceptionHandler;

@ControllerAdvice
@ControllerAdvice // 아 컨트롤러에서 발생한 예외를 가로챔 ~ ㅋ

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

스프링 이론을 다시 공부할 때 주석을 달면서 공부했었는데... 안 지우고 올라가졌네요 ....!

}

public Long getId() {
public Long id() {

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

넵, 맞습니다!..
해당 부분은 제가 실수로 getId()를 id()로 변경했습니다.

값을 반환하는 메서드 같은 경우는 간결한 접근자보다 getter 형식이 의도가 명확하다고 생각하여, getId(), getName()과 같은 형식으로 통일하는 것이 맞다고 생각합니다.

Member 뿐만 아니라 Reservation, Waiting, Time 도메인에도 해당 내용을 적용해보도록 하겠습니다.

또한 메서드 네이밍을 통일한 뒤에 사용되지 않는 중복 접근자는 제거하겠습니다.

피드백 감사합니다!

} catch (IllegalArgumentException e) {
return ResponseEntity.status(401).build();
}
public ResponseEntity<Void> login(@RequestBody MemberRequest memberRequest, HttpServletResponse response) {

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이 부분에 대해서 깊이 생각하지는 않았습니다!

Interceptor 나 ArgumentResolver 가 Servlet 타입을 사용하기 때문에 Servlet 의 의존성 존재 여부를 생각하는 것에 있어서 크게 중요도를 부여하지는 않았었습니다.

하지만 그것과는 별개임을 학습할 수 있었습니다 ㅎ

우선 Interceptor 의 경우는 요청을 검사해야하기 때문에 Servlet 타입 사용이 인터페이스상 필수이고
ArgumentResolver 의 경우에는 Http 요청을 객체로 반환하기 때문에 Servlet 사용이 자연스럽습니다.

반면 Controller 의 경우에는 Resolver 가 만들어준 객체만 반으면 되기 때문에 Servlet API를 직접 사용할 필요가 없습니다 !

그렇다면 Controller 에서는 HttpServletRequest 를 직접 받아서 쿠키나 토큰을 꺼내지 않고 ArgumentResolver 가 만들어준 객체를 입력 받는 식으로 리팩토링을 하면 됩니다!

Controller가 Servlet API에 직접 의존하는 것을 지양해야하는 이유는 다음과 같습니다.

  1. Spring MVC 가 아닌 다음 웹 기술로 변경하기 어려움
  2. 컨트롤러가 인증 처리와 HTTP 쿠키 조작을 동시에 함 (그러면 테스트의 책임이 많아져서, 유지보수가 어려워질 수 있습니다.)

} catch (IllegalArgumentException e) {
return ResponseEntity.status(401).build();
}
public ResponseEntity<Void> login(@RequestBody MemberRequest memberRequest, HttpServletResponse response) {

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

현재 코드에서 login(), logout() 에서 응답에 쿠키를 추가하는 방식을 사용하고 있습니다.
위와 같은 이유로 servlet 의 의존성을 제거하기 위해서는 ResponseEntity의 Set-Cookie 헤더 사용할 수 있습니다 !

뿐만 아니라 refreshToken()은 요청 쿠키를 읽고 응답 쿠키도 추가하기 때문에 두 Servlet 의존성을 모두 제거하기 위해서는 @CookieValue 를 사용하여 요청 쿠키를 읽는 방식을 변경하고 ResponseEntity의 Set-Cookie 헤더를 사용하여 응답 쿠키를 추가하는 방식으로 수정할 수 있습니다 !

} catch (IllegalArgumentException e) {
return ResponseEntity.status(401).build();
}
public ResponseEntity<Void> login(@RequestBody MemberRequest memberRequest, HttpServletResponse response) {

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

그렇다면 간단하게 @CookieValue와 ResponseEntity의 Set-Cookie 헤더를 정리해보겠습니다.

@CookieValue란?

요청에 담긴 쿠키 값을 컨트롤러 매개변수로 바로 받아주는 Spring 애너테이션입니다.
-> 요청 받은 쿠키를 읽는 방식입니다.

이것을 사용한다면

HttpServletRequest request
authCookieProvider.extractRefreshToken(request)

다음과 같은 쿠키를 읽는 코드를 생략할 수 있습니다.


Set-Cookie란?

서버가 클라이언트에게 쿠키를 저장하고 지시하는 HTTP 응답 헤더라고 합니다.

따라서 Servlet 응답 객체를 Servlet 이 직접 변경하는 명령형 방식에서 반환할 HTTP 응답 선언한 다음에 Spring 이 반환을 할 수 있도록 변경할 수 있습니다.

정리: Servlet 이 응답 객체를 직접 변경하는 방식 vs 반환할 HTTP 응답을 값으로 구성하는 방식(Spring방식)

@hapdaypy hapdaypy left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

안녕하세요 이진 리뷰어님 ...
제가 많이 늦었습니다 !

리뷰에 대한 답변을 하기 위해, 공부하면서 내용을 정리를 하다 보니 많이 늦어졌습니다!...

변명 할 말이 없습니다...

다음 앞으로는 더 개선된 모습을 보여드리겠습니다 ...

import lombok.Getter;

@Getter
public class MemberRequest {

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

현재 애플리케이션에서 DTO의 흐름은?

모두 변경할 필요가 없는 단순 데이터 전달 객체의 형태입니다.
이 경우는 record의 형태일 때가 좋다고 생각합니다.
이 주장에 대한 근거는 다음과 같습니다.

import lombok.Getter;

@Getter
public class MemberRequest {

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lombok @Getter 과 record의 차이

이것 두개가 비슷해보여서 비슷한 기능을 하기 때문에 그냥 취향차이인가? 싶었습니다. 하지만 두 개는 근본적인 차이점이 존재하기 때문에 상황에 맞게 사용해야합니다 !

  • 차이점
    record는 값을 고정 시켜서 보냄
    lombok @Getter는 값을 변환 시킬 수 있음

에를 들어 JPA 같은 경우에는 상태 변경이 필요한데 이때 사용할 수 있는데 Lombok 의 JPA 입니다.

따라서,

record와 Lombok 은 반복되는 코드를 줄이기 위해서 사용한다는 공통점이 있지만 값을 변경할 수 있냐 없냐의 차이가 있기 때문에 상황에 맞게 사용하면 됩니다.


  • 추가
    record는 final 이고 필드도 final 이기 때문에 JPA 엔티티로 사용할 수 없습니다.

import lombok.Getter;

@Getter
public class MemberRequest {

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

수동 getter 와 Lombok @Getter 의 차이

Lombok은 수동 getter의 반복되는 코드를 줄이기 위해서 나타났습니다.
반복 되는 코드는 Lombok 라이브러리를 사용함으로써 생략이 가능합니다 !

하지만 모든 것을 전부 라이브러리로 대처할 수는 없습니다.


수동 getter 로 처리해야할 경우

수동 getter가 필요한 대표적인 경우는 Lombok이 만들어주는 단순 getter와 다른 동작이 필요할 때 수동 getter를 사용하는데

예를 들어서 getName() 이라는 메서드가 있다고 했을 때

@Getter
public class Member {
    private String name;
    private String email;

    // getName()은 수동 getter
    public String getName() {
        return name == null ? null : name.trim();
    }
}

이런식으로 getName()을 사용자의 목적에 맞게 커스텀할 때 사용하게 됩니다.

이것은 Lombok이 생성한 getName()을 수정하는 것이 아니라, getName()을 직접 작성해서 Lombok이 해당 getter를 생성하지 않도록 하는 것입니다.

결과적으로

단순 getter 는 Lombok에게 맡기고 특별한 getter 만 직접 구현하는 방식으로 할 수 있게 사용할 수 있습니다.


주의점

이렇게 getName()에 trim 을 추가하게 된다면 예상치 못한 코드 변환이 일어났을 때, 원인을 찾기 힘들다고 하네여 ...

그럴 경우를 대비해서 trimGetName() 처럼 메서드명을 확실하게 해줌으로써 문제를 해결 할 수 있다고 합니다 !

@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private String date;

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

검증이 어렵습니다!

이유

String 은 말 그대로 문자열을 의미합니다.

날짜가 문자열로 들어와서 문자열로 관리가 된다면 형식과 유형성을 개발자가 직접 검증해야합니다.

그렇기 때문에 날짜라는 의미와 기본 검증을 제공하는 LocalDate 타입을 사용하는 것이 더 바람직합니다 !

하지만 LacalDate 타입이 모든 것을 검증해 주는 것이 아니라 과거 날짜 제한 과 같은 비즈니스 로직은 개발자가 별도로 처리해야합니다 !

private final SimpleJdbcInsert simpleJdbcInsert;
@Query("select new roomescape.reservation.ReservationMineResponse(r.id, th.name, r.date, t.value, '예약') "
+ "from Reservation r join r.theme th join r.time t where r.member.id = :memberId")
List<ReservationMineResponse> findByMemberId(@Param("memberId") Long memberId);

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이전리뷰 에서 테이블을 3개를 Fetch 조인을 하게 된다면, 한 번의 쿼리로 조회할 수 있지만,

연관 엔티티의 전체 필드를 조회하고 영속성 컨텍스트에서 엔티티를 관리하여 쿼리가 무거워 질 수 있음을 학습했습니다!

따라서 필요한 값만 가져올 수 있도록 DTO Projection 를 사용하여 응답에 필요한 칼럼을 가져와 DTO를 생성하기 위해서 사용하였습니다.

@ControllerAdvice
@ControllerAdvice // 아 컨트롤러에서 발생한 예외를 가로챔 ~ ㅋ
public class ExceptionController {
private static final Logger log = LoggerFactory.getLogger(ExceptionController.class);

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

저도 언제부터인지 모르겠는데... 자연스럽게 Log의 존재를 습득(?)했습니다...

아직 대단한 이점은 경험해보지는 않았습니다!

public void deleteById(Long id) {
jdbcTemplate.update("UPDATE time SET deleted = true WHERE id = ?", id);
}
public interface TimeDao extends JpaRepository<Time, Long> {

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

DAO는 Data Access Object의 약자로, 데이터 저장소에 접근하는 책임을 분리한 객체입니다.

이번 리뷰를 통해 DAO와 Repository의 차이를 다시 고민해 보았습니다. ..ㅜ


고민했던 지점

두 방식 모두 결과적으로 데이터베이스에 접근하기 때문에 처음에는 프록시 사용 여부나 EntityManager 사용 여부가 차이라고 생각했습니다.

하지만 구현 방식은 DAO와 Repository를 구분하는 기준이 아니라는 것을 알게 되었습니다.

DAO도 구현체 내부에서 EntityManager를 사용할 수 있기 때문입니다.


차이점: 초점을 맞추는 포인트

DAO: 데이터 접근 기술과 CRUD 작업
Repository: 추상화

리포지토리는 도메인 객체를 저장하고 조회하는 DB나 SQL을 직접 다루는 느낌을 숨기고, 메모리에 있는 객체 모음에서 객체를 꺼내고 추가하는 것처럼 표현합니다.

이러한 차이 때문에 현재 인터페이스는 Spring Data JPA의 JpaRepository<Time, Long>를 직접 상속하고 있으므로,

그 역할을 명확하게 표현하기 위해 TimeDao보다 TimeRepository로 이름을 변경하겠습니다.


추상화의 흐름

  • 레포지토리 작업
timeRepository.save(time);
timeRepository.findById(id);
timeRepository.findAll();
timeRepository.delete(time);
  • 실제 sql 작업
INSERT INTO time ...
SELECT * FROM time WHERE id = ?
SELECT * FROM time
DELETE FROM time WHERE id = ?

서비스는 SQL을 알 필요가 없습니다.

Time time = timeRepository.findById(id)
        .orElseThrow();

public List<AvailableTime> getAvailableTime(String date, Long themeId) {
List<Reservation> reservations = reservationDao.findByDateAndThemeId(date, themeId);
List<Long> reservedTimeIds = reservationDao.findReservedTimeIdsByDateAndThemeId(date, themeId);
List<Time> times = timeDao.findAll();

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

조회 메서드마다 삭제 조건을 하나씩 붙인다면 명시적이고 이해하기 쉬울 수 있겠지마, 조회 메서드가 많아진다면 반복되는 코드가 발생할 수 있고 조건 누락 가능성이 커질 수 있습니다. !

그렇기 때문에 이것을 전역으로 처리하는 방법이 효과적일 수 있습니다.

지금처럼 누락되어 있습니다 ㅜ

따라서 전역적으로 처리할 수 있는 방식은 다음과 같습니다 .

  1. Hibernate의 전역 필터를 사용
    Time 엔티티에 @SQLRestriction을 적용할 수 있습니다.

Spring Boot 3.1은 Hibernate 6.2를 사용하므로 @SQLRestriction을 사용할 수 없습니다. @SQLRestriction은 Hibernate 6.3부터 제공됩니다.

  1. Hibernate의 @where를 사용

import org.hibernate.annotations.Where;

@entity
@where(clause = "deleted = false")
public class Time {
}
그러면 일반 조회마다 DeletedFalse를 붙일 필요가 없어집니다.

자동 조건을 해제하기 어렵

private final SimpleJdbcInsert simpleJdbcInsert;
@Query("select new roomescape.reservation.ReservationMineResponse(r.id, th.name, r.date, t.value, '예약') "
+ "from Reservation r join r.theme th join r.time t where r.member.id = :memberId")
List<ReservationMineResponse> findByMemberId(@Param("memberId") Long memberId);

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

다른 방법들

  1. 엔티티를 조회하고 서비스에 DTO로 변환
  2. Fetch Join으로 엔티티를 한 번에 조회한 후 DTO로 변환
  3. JPQL 생성자 DTO Projection(현재 사용하는 방식)
  4. Spring Data 인터페이스 Projection
public interface ReservationProjection {
    Long getId();
    String getName();
}
  1. Native Query 또는 QueryDSL을 이용한 Projection

4번 5번에 대한 내용은 있다는 것만 알고 제가 좀 더 시간을 가지고 공부를 해보겠씁니다 ... ㅜㅜ

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

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이것은 저의 실수입니다.

현재 Member 객체와 연결하지 않고 회원의 ID 숫자만 저장한 것입니다.

JPA 입장에서는 이 값이 Member.id를 의미하는지 알 수 없습니다.

Member member + @manytoone → Member 엔티티와 JPA 연관관계 설정이 필요합니다!

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id")
private Member member;

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants