[사용기술]
Java, Spring Boot, Spring JPA, MySQL
[만들려는 것]
책을 위한 SNS.
[오늘 하려는 것]
타임라인(Member) 등록/조회 API 개발
TimelineApiController.java
1. 타임라인 등록
package seulgi.bookbookclub.api;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import seulgi.bookbookclub.domain.Book;
import seulgi.bookbookclub.domain.Timeline;
import seulgi.bookbookclub.dto.CreateTimelineRequest;
import seulgi.bookbookclub.dto.CreateTimelineResponse;
import seulgi.bookbookclub.dto.Result;
import seulgi.bookbookclub.service.TimelineService;
import java.util.List;
import java.util.stream.Collectors;
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/timelines")
public class TimelineApiController {
private final TimelineService timelineService;
//타임라인 등록
@PostMapping
public CreateTimelineResponse saveTimeline(@RequestBody CreateTimelineRequest request){
Book createBook = new Book(request.getBook().getIsbn(), request.getBook().getTitle()
,request.getBook().getAuthor(), request.getBook().getPublisher());
Integer timelineSeq = timelineService.createTimeline(request.getMemberSeq(), createBook, request.getContents());
return new CreateTimelineResponse(timelineSeq);
}
//코드생략//
}
- Member API와 비슷한 구조로 CreateTimelineRquest 객체를 활용해 요청 파라미터를 넘겨 받아 생성하고 CreateTimelineResonse 객체로 반환해줌.
CreateTimelineRquest.java
package seulgi.bookbookclub.dto;
import lombok.Data;
import lombok.Getter;
import lombok.Setter;
import seulgi.bookbookclub.domain.Book;
@Getter
@Setter
public class CreateTimelineRequest {
private Integer memberSeq;
private Book book;
private String contents;
}
CreateTimelineResonse.java
package seulgi.bookbookclub.dto;
import lombok.Data;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public class CreateTimelineResponse {
private Integer timelineSeq;
public CreateTimelineResponse(Integer timelineSeq) {
this.timelineSeq = timelineSeq;
}
}
TimelineApiController.java
2. 타임라인 (논리)삭제
package seulgi.bookbookclub.api;
import lombok.RequiredArgsConstructor;
import org.hibernate.sql.Delete;
import org.springframework.web.bind.annotation.*;
import seulgi.bookbookclub.domain.Book;
import seulgi.bookbookclub.domain.Timeline;
import seulgi.bookbookclub.dto.CreateTimelineRequest;
import seulgi.bookbookclub.dto.CreateTimelineResponse;
import seulgi.bookbookclub.dto.Result;
import seulgi.bookbookclub.dto.TimelineDto;
import seulgi.bookbookclub.service.TimelineService;
import java.util.List;
import java.util.stream.Collectors;
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/timelines")
public class TimelineApiController {
private final TimelineService timelineService;
//코드생략//
//타임라인 삭제
@PostMapping("/delete/{timelineSeq}")
public void deleteTimeline(@PathVariable("timelineSeq") Integer timelineSeq){
timelineService.deleteTimeline(timelineSeq);
}
}
- 논리 삭제라서 상태값만 변경해줌.
TimelineService.java
package seulgi.bookbookclub.service;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import seulgi.bookbookclub.domain.Book;
import seulgi.bookbookclub.domain.Follow;
import seulgi.bookbookclub.domain.Member;
import seulgi.bookbookclub.domain.Timeline;
import seulgi.bookbookclub.repository.BookRepository;
import seulgi.bookbookclub.repository.MemberRepository;
import seulgi.bookbookclub.repository.TimelineRepository;
import java.util.List;
import java.util.stream.Collectors;
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class TimelineService {
private final TimelineRepository timelineRepository;
private final MemberRepository memberRepository;
private final BookRepository bookRepository;
private final FollowService followService;
//코드생략//
//글 삭제
@Transactional
public void deleteTimeline(Integer timelineSeq){
Timeline timeline = timelineRepository.findById(timelineSeq);
if (timeline == null){
throw new IllegalArgumentException("해당 글이 존재하지 않습니다.");
}
timeline.delete();
}
}
Timeline.java
package seulgi.bookbookclub.domain;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import java.time.LocalDateTime;
import static jakarta.persistence.FetchType.*;
@Entity
@Getter
@EntityListeners(AuditingEntityListener.class)
@NoArgsConstructor
public class Timeline {
//코드생략//
//논리 삭제
public void delete() {
this.state = State.INACTIVE;
}
}
- 레파지토리에서 쿼리로 동작하지 않고 변경감지 기능(?)을 이용해서 엔티티에서 상태만 변경해줌.
[테스트 성공]
TimelineApiController.java
3. 특정회원이 쓴 타임라인 글 조회
ver1. 엔티티 그대로 반환하기
package seulgi.bookbookclub.api;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import seulgi.bookbookclub.domain.Book;
import seulgi.bookbookclub.domain.Timeline;
import seulgi.bookbookclub.dto.CreateTimelineRequest;
import seulgi.bookbookclub.dto.CreateTimelineResponse;
import seulgi.bookbookclub.dto.Result;
import seulgi.bookbookclub.dto.TimelineDto;
import seulgi.bookbookclub.service.TimelineService;
import java.util.List;
import java.util.stream.Collectors;
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/timelines")
public class TimelineApiController {
private final TimelineService timelineService;
//코드생략//
//특정회원이 업로드 한 타임라인 조회
@GetMapping("/upload/{memberSeq}")
public Result timelinesByMember(@PathVariable("memberSeq") Integer memberSeq){
List<Timeline> findTimelines = timelineService.getMemberTimelines(MemberSeq);
List<Timeline> collect = findTimelines.stream().map(t -> new Timeline(t.getMember(), t.getBook(), t.getContents()))
.collect(Collectors.toList());
return new Result(collect);
}
}
TimelineRepository.java
package seulgi.bookbookclub.repository;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import org.springframework.stereotype.Repository;
import seulgi.bookbookclub.domain.Follow;
import seulgi.bookbookclub.domain.State;
import seulgi.bookbookclub.domain.Timeline;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
@Repository
public class TimelineRepository {
@PersistenceContext
private EntityManager em;
//코드생략//
//특정회원이 업로드 한 타임라인 조회
public List<Timeline> findTimelinesByMember(Integer memberSeq) {
return em.createQuery("SELECT t FROM Timeline t WHERE t.member.memberSeq = :memberSeq AND t.state = :state ORDER BY t.createdDate DESC", Timeline.class)
.setParameter("memberSeq", memberSeq)
.setParameter("state", State.ACTIVE)
.getResultList();
}
}
[에러 발생]
@ManyToOne 관계가 포함된 엔티티를 그대로 반환하면 아래와 같은 에러를 뱉어 낸다.
com.fasterxml.jackson.databind.exc.InvalidDefinitionException: No serializer found for class org.hibernate.proxy.pojo.bytebuddy.ByteBuddyInterceptor and no properties discovered to create BeanSerializer (to avoid exception, disable SerializationFeature.FAIL_ON_EMPTY_BEANS) (through reference chain: seulgi.bookbookclub.dto.Result["data"]->java.util.ArrayList[0]->seulgi.bookbookclub.domain.Timeline["member"]->seulgi.bookbookclub.domain.Member$HibernateProxy$yv1BEjjx["hibernateLazyInitializer"]) at com.fasterxml.jackson.databind.exc.InvalidDefinitionException.from(InvalidDefinitionException.java:77) ~[jackson-databind-2.18.2.jar:2.18.2]
이 에러는 한마디로 객체를 JSON형식으로 변환할 때 생기는 문제이다.
구체적으로 말하면 지연로딩 기능 때문에 발생하는데, 하이버네이트는 데이터베이스에서 필요한 데이터를 바로 다 가져오지 않고, 나중에 필요할 때 가져오는 방식(=지연로딩)으로 작동하고 아직 가져오지 않은 데이터를 대신하는 가짜객체(=프록시객체)를 만들어 놓음.
문제는 Jackson라이브러리(JSON 변환라이브러리)가 이 프록시객체를 제대로 변환하지 못하다보니 생기는 에러!
그래서 아래와 같이 아래 500에러를 뱉어 버린다.
Ver2. DTO 객체 생성하여 반환하기
해결방법은 몇 가지가 있는데 일단 DTO객체 생성하여 반환하는 방법으로 풀어보자.
package seulgi.bookbookclub.api;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import seulgi.bookbookclub.domain.Book;
import seulgi.bookbookclub.domain.Timeline;
import seulgi.bookbookclub.dto.CreateTimelineRequest;
import seulgi.bookbookclub.dto.CreateTimelineResponse;
import seulgi.bookbookclub.dto.Result;
import seulgi.bookbookclub.dto.TimelineDto;
import seulgi.bookbookclub.service.TimelineService;
import java.util.List;
import java.util.stream.Collectors;
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/timelines")
public class TimelineApiController {
private final TimelineService timelineService;
//코드생략//
//특정회원이 업로드 한 타임라인 조회
@GetMapping("/upload/{memberSeq}")
public Result timelinesByMember(@PathVariable("memberSeq") Integer memberSeq){
List<Timeline> findTimelines = timelineService.getMemberTimelines(MemberSeq);
List<TimelineDto> collect = findTimelines.stream()
.map(t -> new TimelineDto(
t.getTimelineSeq(),
t.getMember().getMemberId(),
t.getMember().getNickname(),
t.getLikes(),
t.getBook().getTitle(),
t.getBook().getAuthor(),
t.getContents(),
t.getCreatedDate()))
.collect(Collectors.toList());
return new Result(collect);
}
}
- 기존에는 엔티티 객체인 Timeline객체를 그대로 반환했었는데 이번에는 TimelineDto 객체를 생성하여 반환타입을 바꿔주었음.
TimelineDto.java
package seulgi.bookbookclub.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.List;
import java.util.stream.Collector;
@Data
@AllArgsConstructor
public class TimelineDto {
private Integer timelineSeq;
private String memberId;
private String nickName;
private Integer likes;
private String bookTitle;
private String bookAuthor;
private String publisher;
private LocalDateTime createdDate;
}
- 객체타입은 없고 모두 기본 타입으로 변경해주었음.
결과는 조회 성공 그런데 문제가...!
데이터를 잘 가져오는데는 성공했지만 문제가 발생한다.
콘솔을 확인해보면
총 3번의 쿼리가 나간다. 그렇다...그 유명한 N+1 문제가 발생한다.
한명의 회원이 남기는 타임라인 글은 총 2개이고 데이터베이스 관점에서 봤을 때는 2개니까 2번의 쿼리만 나오면 될 것 같은데 지연로딩 때문에 그렇지가 않다. 이렇게 몇 건 안되는 조회를 할 때는 크게 문제가 되지 않지만 대용량의 데이터를 조회한다면 쿼리를 호출하는 횟수가 기하 급수적으로 늘어날 것이다. 그렇다면 문제가 너무 많음.......그래서 또 추가적인 해결방법이 필요하다.
ver3. DTO로 변환 + 패치조인 사용
package seulgi.bookbookclub.repository;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import org.springframework.stereotype.Repository;
import seulgi.bookbookclub.domain.Follow;
import seulgi.bookbookclub.domain.State;
import seulgi.bookbookclub.domain.Timeline;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
@Repository
public class TimelineRepository {
@PersistenceContext
private EntityManager em;
// 코드생략 //
public List<Timeline> findTimelinesByMember(Integer memberSeq) {
return em.createQuery("SELECT t FROM Timeline t " +
"JOIN FETCH t.member " +
"JOIN FETCH t.book " +
"WHERE t.member.memberSeq = :memberSeq " +
"AND t.state = :state " +
"ORDER BY t.createdDate DESC", Timeline.class)
.setParameter("memberSeq", memberSeq)
.setParameter("state", State.ACTIVE)
.getResultList();
}
// 코드생략 //
}
- 레파지토리에서 패치조인을 사용함.
당연히 조회는 잘되고
콘솔에도 2개의 타임라인만 한번에 조회 해준다.
패치조인은 한마디로 연관된 데이터를 한번해 조회히서 성능 최적화의 장점이 있지만
가져오는 데이터가 너무 많으면 오히려 성능이 느려질 수 있는 단점이 있고,
페이징 처리를 동시에 사용할 경우 패치조인은 여러 데이터를 한번에 가져오기 때문에
데이터 중복이 발생하거나 결과가 올바르지 않을 수도 있고,
보다 시피 쿼리가 아무래도 복잡해지는 단점들도 존재함.
TimelineApiController.java
4. 팔로잉한 회원들의 타임라인 조회
*** DTO변환 + 패치 조인을 디폴트로 사용함
package seulgi.bookbookclub.api;
import lombok.RequiredArgsConstructor;
import org.hibernate.sql.Delete;
import org.springframework.web.bind.annotation.*;
import seulgi.bookbookclub.domain.Book;
import seulgi.bookbookclub.domain.Timeline;
import seulgi.bookbookclub.dto.CreateTimelineRequest;
import seulgi.bookbookclub.dto.CreateTimelineResponse;
import seulgi.bookbookclub.dto.Result;
import seulgi.bookbookclub.dto.TimelineDto;
import seulgi.bookbookclub.service.TimelineService;
import java.util.List;
import java.util.stream.Collectors;
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/timelines")
public class TimelineApiController {
private final TimelineService timelineService;
//코드생략
//팔로잉한 회원들의 타임라인 조회
@GetMapping("/following/{memberSeq}")
public Result timelinesByFollowing(@PathVariable("memberSeq") Integer memberSeq){
List<Timeline> followedTimelines = timelineService.getFollowedTimelines(memberSeq);
List<TimelineDto> collect = followedTimelines.stream()
.map(t -> new TimelineDto(
t.getTimelineSeq()
, t.getMember().getMemberId()
, t.getMember().getNickname()
, t.getContents()
, t.getLikes()
, t.getBook().getTitle()
, t.getBook().getAuthor()
, t.getContents()
, t.getCreatedDate()))
.collect(Collectors.toList());
return new Result(collect);
}
}
TimelineService.java
package seulgi.bookbookclub.repository;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import org.springframework.stereotype.Repository;
import seulgi.bookbookclub.domain.Follow;
import seulgi.bookbookclub.domain.State;
import seulgi.bookbookclub.domain.Timeline;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
@Repository
public class TimelineRepository {
@PersistenceContext
private EntityManager em;
//코드생략//
// 팔로잉한 회원들의 타임라인 조회
public List<Timeline> findByFollowers(List<Integer> memberSeqs) {
return em.createQuery("SELECT t FROM Timeline t " +
"JOIN FETCH t.member " +
"JOIN FETCH t.book " +
"WHERE t.member.memberSeq IN :memberSeqs " +
"ORDER BY t.createdDate", Timeline.class)
.setParameter("memberSeqs", memberSeqs)
.getResultList();
}
}
TimelineRepository.java
package seulgi.bookbookclub.repository;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import org.springframework.stereotype.Repository;
import seulgi.bookbookclub.domain.Follow;
import seulgi.bookbookclub.domain.State;
import seulgi.bookbookclub.domain.Timeline;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
@Repository
public class TimelineRepository {
@PersistenceContext
private EntityManager em;
//코드생략//
//팔로잉한 회원들의 타임라인 조회
public List<Timeline> findByFollowers(List<Integer> memberSeqs) {
return em.createQuery("SELECT t FROM Timeline t " +
"JOIN FETCH t.member " +
"JOIN FETCH t.book " +
"WHERE t.member.memberSeq IN :memberSeqs " +
"ORDER BY t.createdDate", Timeline.class)
.setParameter("memberSeqs", memberSeqs)
.getResultList();
}
}
[테스트 성공]
TimelineApiController.java
5. 타임라인ID로 타임라인 글 단건 조회
*** DTO변환 + 패치 조인을 디폴트로 사용함
package seulgi.bookbookclub.api;
import lombok.RequiredArgsConstructor;
import org.hibernate.sql.Delete;
import org.springframework.web.bind.annotation.*;
import seulgi.bookbookclub.domain.Book;
import seulgi.bookbookclub.domain.Timeline;
import seulgi.bookbookclub.dto.CreateTimelineRequest;
import seulgi.bookbookclub.dto.CreateTimelineResponse;
import seulgi.bookbookclub.dto.Result;
import seulgi.bookbookclub.dto.TimelineDto;
import seulgi.bookbookclub.service.TimelineService;
import java.util.List;
import java.util.stream.Collectors;
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/timelines")
public class TimelineApiController {
private final TimelineService timelineService;
//코드생략//
//타임라인 ID로 타임라인 단건 조회
@GetMapping("/{timelineSeq}")
public Result timelineByTimelineId(@PathVariable("timelineSeq")Integer timelineSeq){
Timeline timeline = timelineService.getTimelineByTimeLineId(timelineSeq);
TimelineDto timelineDto = new TimelineDto(
timeline.getTimelineSeq()
, timeline.getMember().getMemberId()
, timeline.getMember().getNickname()
, timeline.getContents()
, timeline.getLikes()
, timeline.getBook().getTitle()
, timeline.getBook().getAuthor()
, timeline.getBook().getPublisher()
, timeline.getCreatedDate()
);
return new Result(timelineDto);
}
}
TimelineService.java
package seulgi.bookbookclub.service;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import seulgi.bookbookclub.domain.Book;
import seulgi.bookbookclub.domain.Follow;
import seulgi.bookbookclub.domain.Member;
import seulgi.bookbookclub.domain.Timeline;
import seulgi.bookbookclub.repository.BookRepository;
import seulgi.bookbookclub.repository.MemberRepository;
import seulgi.bookbookclub.repository.TimelineRepository;
import java.util.List;
import java.util.stream.Collectors;
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class TimelineService {
private final TimelineRepository timelineRepository;
private final MemberRepository memberRepository;
private final BookRepository bookRepository;
private final FollowService followService;
//코드생략//
// 타임라인 ID로 타임라인 단건 조회
public Timeline getTimelineByTimeLineId(Integer timelineSeq){
return timelineRepository.findById(timelineSeq);
}
}
TimelineRepository.java
package seulgi.bookbookclub.repository;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import org.springframework.stereotype.Repository;
import seulgi.bookbookclub.domain.Follow;
import seulgi.bookbookclub.domain.State;
import seulgi.bookbookclub.domain.Timeline;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
@Repository
public class TimelineRepository {
@PersistenceContext
private EntityManager em;
//코드생략//
// 타임라인 ID로 타임라인 단건 조회
public Timeline findById(Integer timelineSeq) {
return em.createQuery("SELECT t FROM Timeline t WHERE t.timelineSeq = :timelineSeq AND t.state = :state", Timeline.class)
.setParameter("timelineSeq", timelineSeq)
.setParameter("state", State.ACTIVE)
.getSingleResult();
}
}
[테스트 성공]
'💻 뚝딱뚝딱 > 북북클럽' 카테고리의 다른 글
[개발일지#007] 회원 등록/수정/조회 API 개발 (0) | 2025.01.21 |
---|---|
[개발일지#006] 조회용 샘플데이터 생성 (0) | 2025.01.21 |
[개발일지#005] 좋아요 도메인 개발 및 테스트 (0) | 2025.01.20 |
[개발일지#004] 팔로우 도메인 개발 및 테스트 (0) | 2025.01.20 |
[개발일지#003] 타임라인 도메인 개발 및 테스트 (0) | 2025.01.20 |