@GetMapping("/")
@Transactional
@Operation(
summary = "get education Content",
security = @SecurityRequirement(name = SECURITY_SCHEME_NAME),
responses = {
@ApiResponse(
responseCode = "200",
description = "Successful operation",
content = @Content(
mediaType = "application/json",
schema = @Schema(implementation = SuccessResponse.class)
)
),
@ApiResponse(
responseCode = "400",
description = "Bad request",
content = @Content(
mediaType = MediaType.APPLICATION_JSON_VALUE,
schema = @Schema(implementation = ErrorResponse.class)
)
),
@ApiResponse(
responseCode = "401",
description = "Bad credentials",
content = @Content(
mediaType = MediaType.APPLICATION_JSON_VALUE,
schema = @Schema(implementation = ErrorResponse.class)
)
)
}
)
public ResponseEntity<?> educationContent(@ModelAttribute GetEducationContentRequest request) throws JsonProcessingException {
long id = Long.parseLong(request.getEducationContentId());
EducationContentDTO dto = educationContentService.findById(id);
if(dto == null){
ErrorResponse errorResponse = ErrorResponse.builder()
.message("The requested content does not exist.")
.build();
return ResponseEntity.badRequest().body(errorResponse);
}
ObjectMapper mapper = new ObjectMapper();
mapper.registerModule(new JavaTimeModule());
mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
return ResponseEntity.ok().body(mapper.writeValueAsString(dto));
}
security = @SecurityRequirement(name = SECURITY_SCHEME_NAME)
을 추가하여, authentication이 존재할 때만 해당 메소드를 실행할 수 있도록 만들었다.CommandLineRunner는 Spring Boot에서 애플리케이션이 시작될 때 특정 로직을 실행할 수 있도록 해주는 인터페이스이다.
이 인터페이스는 단순히 run이라는 하나의 메서드를 가지고 있으며, 이 메서드는 애플리케이션이 완전히 시작된 후 호출된다.
@Override
public void run(String... args) throws Exception {
if (educationContentService.count() == 0) {
log.info("Creating education content...");
createEducationConetent();
log.info("education content created.");
}
}
private void createEducationConetent() {
EducationContentDTO educationContentDTO = EducationContentDTO.builder()
.contents("더미 컨텐츠")
.title("Zero-Shot Prompting")
.subject("Techniques")
.createdAt(LocalDateTime.parse("2024-04-15T10:15:30"))
.updatedAt(LocalDateTime.parse("2024-04-15T10:15:30"))
.build();
educationContentService.save(educationContentDTO);
}
@Entity
@Getter
@Table(name = "comment")
public class Comment {
@Id
@Column(name = "comment_id")
@GeneratedValue(strategy = GenerationType.IDENTITY)
@JsonManagedReference
Long commentId;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", referencedColumnName = "id")
@JsonIgnore
User user;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "education_content_id", referencedColumnName = "content_id")
@JsonIgnore
EducationContent educationContent;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name="parent_comment_id", referencedColumnName = "comment_id")
Comment parentComment;
@OneToMany(mappedBy = "parentComment", cascade = CascadeType.ALL)
@JsonBackReference
List<Comment> childCommentsList = new ArrayList<>();
@Column(name = "body")
String body;
@Column(name = "created_at")
LocalDateTime createdAt;
@Column(name = "modified_at")
LocalDateTime modifiedAt;
@Builder
public Comment(Long commentId, User user, EducationContent educationContent, Comment parentComment, List<Comment> childCommentsList, String body, LocalDateTime createdAt, LocalDateTime modifiedAt) {
this.commentId = commentId;
this.user = user;
this.educationContent = educationContent;
this.parentComment = parentComment;
this.childCommentsList = childCommentsList;
this.body = body;
this.createdAt = createdAt;
this.modifiedAt = modifiedAt;
}
public void addChildComment(Comment childComment){
childCommentsList.add(childComment);
childComment.parentComment = this;
}
public Comment() { }
}
@PostMapping("/{educationContentId}/new-comment")
@Operation(
summary = "post new comment to a specific Education Content",
security = @SecurityRequirement(name = SECURITY_SCHEME_NAME),
responses = {
@ApiResponse(
responseCode = "200",
description = "Successful operation",
content = @Content(
mediaType = "application/json",
schema = @Schema(implementation = SuccessResponse.class)
)
),
@ApiResponse(
responseCode = "400",
description = "Bad request",
content = @Content(
mediaType = MediaType.APPLICATION_JSON_VALUE,
schema = @Schema(implementation = ErrorResponse.class)
)
),
@ApiResponse(
responseCode = "401",
description = "Bad credentials",
content = @Content(
mediaType = MediaType.APPLICATION_JSON_VALUE,
schema = @Schema(implementation = ErrorResponse.class)
)
)
}
)
public ResponseEntity<CommentDTO> newComment(
@PathVariable("educationContentId") String educationContentId,
@RequestBody CommentRequest request ) {
DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyyMMdd'T'HH:mm:ss");
CommentDTO dto = CommentDTO.builder()
.educationContentId(Long.valueOf(educationContentId))
.userId(UUID.fromString(request.getUserId()))
.parentCommentId(Long.valueOf(request.getParentCommentId()))
.body(request.getBody())
.createdAt(LocalDateTime.parse(request.getCreatedAt(), formatter))
.modifiedAt(LocalDateTime.parse(request.getModifiedAt(), formatter))
.childCommentsIdList(null)
.build();
Long savedCommentId = commentService.save(dto);
if(Long.valueOf(request.getParentCommentId()) != -1){
// 대댓글안 경우, parent Comment에 자식 comment가 달렸음을 알려줘야 함.
// 부모 Comment의 childCommentList에 새로운 노드 추가
Long parentCommentId = dto.getParentCommentId(); // 기존에 존재하던 댓글 id
Long childCommentId = savedCommentId; // 새로 추가된 대댓글 id
commentService.updateChildCommentsList(parentCommentId, childCommentId);
}
dto.setCommentId(savedCommentId);
return ResponseEntity.ok().body(dto);
}
일단 댓글에 관련한 정보를 사용자에게 받는다.
댓글을 작성할 때, 자식 댓글은 없는 상태이므로 null 값을 삽입한다.
만약, 부모 댓글이 존재하는 경우 (대댓글인 경우), 해당 부모 댓글의 childCommentsList에 새로운 자식을 추가해주어야 한다.
PersistentBag는 Hibernate에서 컬렉션을 관리하기 위해 사용하는 클래스 중 하나이다.
Hibernate는 JPA 구현체로서, 엔티티 간의 관계를 관리하기 위해 여러 가지 컬렉션 타입을 제공한다.
PersistentBag은 그 중 하나로, 주로 java.util.List를 구현한다.
@OneToMany(mappedBy = "educationContent")
private ArrayList<Comment> commentList = new ArrayList<>();
ArrayList는 PersistentBag이 관리하지 않는다. 따라서, 다음과 같이 수정해야 한다.
<해결>
@OneToMany(mappedBy = "educationContent")
private List<Comment> commentList = new ArrayList<>();
@JoinColumn(name = "id") 주의
JoinColumn에서 외래키의 주인 쪽 컬럼 명과 매핑되는 쪽의 컬럼 명이 다르면 referencedColumnName 어노테이션을 사용해서 명시적으로 지정해야 된다.
오류메시지: org.springframework.dao.InvalidDataAccessApiUsageException: The given id must not be null
userRepository.findById(commentDTO.getUserId()).get()
➜
User user = userRepository.findById(commentDTO.getUserId())
.orElseThrow(() -> new IllegalArgumentException("Invalid user ID"));
왜 그런건지 공부 필요
org.hibernate.LazyInitializationException
는 영속성 컨텍스트가 종료되어 버려서, 지연 로딩을 할 수 없어서 발생하는 오류이다.
<해결>
@Transactional
을 컨트롤러의 메소드에 추가해주니 잘 실행되었다.
Docker 환경에서는 애플리케이션이 잘 돌아가는데, Local에서 돌리면 다음과 같은 오류가 발생했다.
org.springframework.data.mapping.mappingexception: parameter org.springframework.data.mapping.parameter@97b61336 does not have a name
<해결>
빌드 설정을 Intellij 에서 Gradle로 바꿔주니 잘 동작했다.
이러한 오류가 발생한 원인은 이 포스트에서 잘 설명해주셨다.
요약하자면, Spring 6.1 버전에서
LocalVariableTableParameterNameDiscoverer
라는 구현체가 삭제되었는데, Intellij에서 빌드할 때 사용하는-g
옵션은 해당 구현체를 사용해서 파라미터의 이름을 가져온다.
따라서, Intellij에서 빌드하면 파라미터의 이름을 가져오지 못하는 MappingException이 발생했던 것이다.
Resolved [com.fasterxml.jackson.databind.JsonMappingException: Infinite recursion (StackOverflowError) (through reference chain: org.hibernate.collection.spi.PersistentSet[0]->com.cluster23.promptfordev.entity.User$HibernateProxy$j1Hdqelu["terms"]->org.hibernate.collection.spi.PersistentBag[0]->com.cluster23.promptfordev.entity.Term["users"]
<해결>
웬만하면 양방향 매핑을 사용하지 말자.
@JsonIgnore
어노테이션을 사용해서 직렬화할 때 특정 필드를 무시하도록 만든다.
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", referencedColumnName = "id")
@JsonIgnore
User user;
이 설정을 통해, user 객체는 JSON에 포함되지 않으며 user_id 필드만이 포함된다. 여기서 user_id는 데이터베이스의 comment 테이블에 실제로 존재하는 값이고, JSON으로 변환될 때 포함된다. 그러나 User 객체 자체, 즉 user_id가 참조하는 User 테이블의 다른 컬럼들(예: role, term 등)은 JSON으로 변환되지 않는다. 이는 user_id는 포함하지만, User 테이블의 내용을 직렬화하지 않는다는 것을 의미한다.
JPA와 관계형 DB의 연관관계를 잘못 이해했다.
대댓글 서비스를 만들던 중, 부모 댓글에 자식 댓글 목록을 추가하는 로직을 작성했다.
근데, 아무리 추가를 해도 Comment 테이블의 child_comments_list 컬럼에는 아무 정보도 작성되지 않았다.
당연한거다.
childCommentsList 라는 필드는 양방향 연관관계 매핑을 위한 필드이다.
실제 Comment 테이블에는 존재하지 않는다.
DB와 객체를 너무 똑같은 선상에서 생각을 했던 것 같다.
객체지향과 DB를 혼동하고 있는 사람이라면, 필자와 같은 의문을 갖게 될지도 모른다.
객체지향과 DB를 정확히 알고있는 사람이라면, 위의 의문이 왜 생기는지 조차 이해할 수 없을 것이다.
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name="parent_comment_id", referencedColumnName = "comment_id")
Comment parentComment;
@OneToMany(mappedBy = "parentComment", cascade = CascadeType.ALL)
@JsonBackReference
List<Comment> childCommentsList = new ArrayList<>();
왜
mappedBy = "parentComment"
이지?
필자는mappedBy = "commentId"
가 되어야 한다고 생각했다.
@OneToMany
에서 One은 현재 댓글을 의미하고, Many는 자식 댓글들을 의미한다고 생각했다.
childCommentsList는 "현재 댓글"의 자식 댓글 리스트이지, "현재 댓글의 부모 댓글"의 자식 댓글 리스트가 아니다.
따라서, mappedBy = "commentId"
로 "현재 댓글"에 매핑되어야 한다고 생각했다.
근데 이것은 객체 지향적인 관점이다.
DB의 관점에서 생각해보자.
Team과 Member 테이블이 있을 때, Team A에 속한 member들을 조회하려면 어떻게 하는가?
모든 member 중 Team이 A인 것들을 찾는다.
위와 똑같이, 댓글 1번에 속한 대댓글들을 조회하려면 어떻게 해야 할까?
모든 Comment 중 ParentComment가 1인 것들을 찾는다.
따라서, mappedBy = "parentComment"
가 되어야 하는 것이다.