주제: OTT 추천 커뮤니티 서버
시청한 영화 및 드라마를 공유하여 다른 유저에게 소개하고 함께 소통하는 커뮤니티 제공
팀명 : threethreethreethree
팀원 : 팀장 정현준, 임현태, 박희은, 송선호, 조우석
프로젝트 기간: 2024.02.07 - 2024.02.15
repository : https://github.com/wooseok50/OTT_Suggestion?tab=readme-ov-file
GitHub - wooseok50/OTT_Suggestion: 팀 프로젝트 2. OTT Suggestion Community
팀 프로젝트 2. OTT Suggestion Community. Contribute to wooseok50/OTT_Suggestion development by creating an account on GitHub.
github.com
주요 기능
- 회원 기능(login, signup, email 인증)
- 사용자 프로필 (닉네임, 소개, 이미지)
- 영화 및 드라마 소개 (게시글 작성, 이미지 업로드)
- 커뮤니티 기능 (댓글, 좋아요, 팔로우)
- 랭킹 조회 시스템 (좋아요를 가장 많이 받은 게시물 3개를 웹 페이지 상단에 노출)
기술스택
- Spring Boot
- Spring Security
- Java Mail
- Swagger
- AWS EC2 & RDS
구현 기능
- 게시글 및 댓글 CRUD
- 이메일 인증
- 좋아요
- TOP3 좋아요 게시물 조회
- 이미지 업로드
- 팔로우
- SOFT DELETE
- Refresh Token
프로젝트 설계
1. 와이어 프레임

2. API 명세서
https://teamsparta.notion.site/d24c590d55e645bba7523e67051b4fd9?v=4ab5a98eaa7e47a3accfd35cd0113595
API 명세서 | Notion
Built with Notion, the all-in-one connected workspace with publishing capabilities.
teamsparta.notion.site

3. ERD

기술 구현
1. 이메일 인증
userDetails에서 isEnabled 메서드를 사용자의 enbled 상태에 맞춰 변경되도록 설정합니다.
@Override
public boolean isEnabled() {
return user.isEnabled();
}
자바 메일 라이브러리를 사용하여 회원가입 시 아래의 메일 양식에 맞춰 입력한 이메일 정보로 메일 수신.
private void sendVerificationEmail(User user, String siteURL) throws MessagingException, UnsupportedEncodingException {
String toAddress = user.getEmail();
String fromAddress = "test!";
String senderName = "three";
String subject = "Please verify your registration";
String content = "Dear [[name]],<br>"
+ "Please click the link below to verify your registration:<br>"
+ "<h3><a href=\"[[URL]]\" target=\"_self\">VERIFY</a></h3>"
+ "Thank you,<br>"
+ "Your company name.";
MimeMessage message = mailSender.createMimeMessage();
MimeMessageHelper helper = new MimeMessageHelper(message);
helper.setFrom(fromAddress, senderName);
helper.setTo(toAddress);
helper.setSubject(subject);
content = content.replace("[[name]]", user.getNickname());
String verifyURL = siteURL + "/auth/verify?code=" + user.getVerificationCode();
content = content.replace("[[URL]]", verifyURL);
helper.setText(content, true);
mailSender.send(message);
}
회원 가입 시 입력한 이메일 정보로 메일 확인. 전송된 메일 → VERIFY 클릭 시 인증 확인

인증이 완료되면 확인 메시지를 보여줍니다.

2. SOFT DETETE
게시글, 댓글의 경우 서버 DB에 삭제를 해도 남아 있어야 할 경우(ex. 악성 글 등)
Post, Comment 엔티티에 삭제 시간을 넣고 softDelete()가 호출되면 현재시간을 기록하고 이후 @SQLRestriction 을 추가하여 select시 deleted_at이 null인 로우만 호출하도록 하였습니다.
@Column(name = "deleted_at")private LocalDateTime deletedAt;
@SQLRestriction("deleted_at is NULL")
//
@Column(name = "deleted_at")
private LocalDateTime deletedAt;
//
public void softDelete() {
this.deletedAt = LocalDateTime.now();
}
3. 팔로우
로그인한 User의 Id와 @PathVariable로 받은 팔로우 하려는 유저의 아이디를 받아 저장하고, 삭제합니다.
@Transactional
public void createFollow(User fromUser, Long toUserId) {
userService.findUser(toUserId);
Follow follow = Follow.builder()
.fromUserId(fromUser.getId())
.toUserId(toUserId)
.build();
followRepository.save(follow);
}
@Transactional
public void deleteFollow(User fromUser, Long toUserId) {
userService.findUser(toUserId);
Follow follow = followRepository.findByFromUserIdAndToUserId(fromUser.getId(), toUserId)
.orElseThrow(
() -> new InvalidInputException("해당 팔로우를 찾을 수 없습니다.")
);
followRepository.delete(follow);
}
로그인한 유저가 현재 팔로우하고 있는 모든 유저의 게시글을 한 번에 조회할 수 있습니다.
public List<PostResponseDto> getAllFollowingPost(User user) {
List<Follow> follows = followRepository.findAllByFromUserId(user.getId());
List<Post> posts = new ArrayList<>();
for (Follow follow : follows) {
Long toUserId = follow.getToUserId();
posts.addAll(postRepository.findByUserId(toUserId));
}
return posts.stream()
.map(PostResponseDto::new)
.toList();
}
4. 좋아요
userId와 postId를 입력받아 user_post_likes DB 테이블에 좋아요 데이터가 저장됩니다.
또한 좋아요 생성과 취소 시 결과 메시지를 전달합니다.
로그인을 한 사용자만이 유저들의 게시글에 좋아요 를 누를 수 있습니다.
@Transactional
public String likePost(Long postId, User user) {
postService.findPost(postId);
if (likeRepository.findByUserIdAndPostId(user.getId(), postId).isEmpty()) {
Like like = new Like(postId, user.getId());
likeRepository.save(like);
return "좋아요";
}
Like like = likeRepository.findByUserIdAndPostId(user.getId(), postId).orElseThrow(
() -> new InvalidInputException("좋아요를 할 수 없습니다.")
);
likeRepository.delete(like);
return "좋아요 취소";
}
5. 랭킹 시스템 (좋아요를 가장 많이 가진 게시글 3개 조회)
좋아요 수가 가장 많은 상위 3개의 postId와 좋아요 수를 담은 Object[] 형태의 리스트가 반환됩니다.
postId를 기준으로 그룹화한 후 likeCount를 기준으로 내림 차순 정렬하여 결과 값을 3개로 정의합니다.
public interface LikeRepository extends JpaRepository<Like, Long> {
@Query("SELECT l.postId, COUNT(DISTINCT l.userId) AS likeCount " +
"FROM Like l " +
"GROUP BY l.postId " +
"ORDER BY likeCount DESC LIMIT 3"
)
List<Object[]> findTop3LikedPosts();
}
highCountSorted() 메서드는 likeRepository.findTop3LikedPosts()를 호출하여 좋아요 수가 가장 높은 상위 3개의 postId와 좋아요 수를 담은 Object[] 형태의 리스트를 가져옵니다.
이후 result 리스트에 있는 Object[] 를 순회하여 likes 리스트에 Like 객체를 생성 후 추가합니다.
public List<Like> highCountSorted() {
List<Object[]> results = likeRepository.findTop3LikedPosts();
List<Like> likes = new ArrayList<>();
for (Object[] result : results) {
likes.add(new Like((Long) result[0], null));
}
return likes;
}
좋아요 수가 가장 많은 상위 3개의 게시물을 가져와서 PostResponseDto 형태로 반환합니다.
반복문 내에서 PostResponseDto를 생성하고, postService.findPost(like.getPostId())를 호출하여 해당 Like 객체의 postId를 사용하여 게시물을 찾습니다.
public List<PostResponseDto> getLikeTopThreePosts() {
List<Like> topThreeLikes = highCountSorted();
List<PostResponseDto> result = new ArrayList<>();
for (Like like : topThreeLikes) {
PostResponseDto postResponseDto = new PostResponseDto(
postService.findPost(like.getPostId()));
result.add(postResponseDto);
}
return result;
}
6. Refresh Token
Refresh Token은 Redis와 같은 캐시에 저장하는 것이 일반적이나 이번 프로젝트에서는 DB에 추가하여 사용.
하나의 사용자에게 하나의 Refresh Token을 부여해주어야 하기 때문에 OneToOne 관계로 설정하였습니다. 이후에는 로그인을 성공하게 된다면 Access Token은 헤더에 담아 보내주고 Refresh Token은 DB에 저장합니다.
@Entity
@Table(name = "refresh_tokens")
@Getter
@NoArgsConstructor
public class RefreshToken {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "refresh_token", nullable = false)
private String refreshToken;
@OneToOne
@JoinColumn(name = "user_id", nullable = false)
private User user;
public RefreshToken(String refreshToken, User user) {
this.refreshToken = refreshToken;
this.user = user;
}
}
Access Token이 만료되고 Refresh Token이 남아있다면, Access Token을 재발급받습니다.
두 토큰이 모두 만료된다면 다시 로그인하라는 메시지를 반환하도록 구현했습니다.
String tokenValue = jwtUtil.getJwtFromHeader(req);
if (StringUtils.hasText(tokenValue)) {
int tokenStatus = jwtUtil.validateToken(tokenValue);
if (tokenStatus == 1) {
try {
Claims info = jwtUtil.getUserInfoFromExpriedToken(tokenValue);
UserDetailsImpl userDetails = (UserDetailsImpl) userDetailsService.loadUserByUsername(
info.getSubject());
RefreshToken refreshToken = refreshTokenRepository.findByUserId(userDetails.getUser().getId());
if (refreshToken != null && jwtUtil.validateToken(refreshToken.getRefreshToken()) == 0) {
String newAccessToken = jwtUtil.createAccessToken(info.getSubject());
res.addHeader(jwtUtil.AUTHORIZATION_HEADER, newAccessToken);
res.setStatus(HttpServletResponse.SC_OK);
String jsonResponse = objectMapper.writeValueAsString(CommonResponse.<Void>builder().message("새로운 Acces Token이 발급되었습니다.").build());
res.setContentType("application/json");
res.setCharacterEncoding("UTF-8");
res.getWriter().write(jsonResponse);
return;
} else {
res.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
String jsonResponse = objectMapper.writeValueAsString(CommonResponse.<Void>builder().message("Access Token과 Refresh Token이 모두 만료되었습니다.").build());
refreshTokenRepository.delete(refreshToken);
res.setContentType("application/json");
res.setCharacterEncoding("UTF-8");
res.getWriter().write(jsonResponse);
return;
}
} catch (Exception e) {
log.error(e.getMessage());
return;
}
}
else if (tokenStatus == 2) {
log.error("Token Error");
return;
}
문제 및 해결
좋아요 기능을 구현할 때, user_post_likes DB 테이블의 user_id와 post_id 컬럼을 users 테이블의 id post 테이블의 id를 N:1 관계로 연관 관계를 생성해서 진행했습니다. 하지만 api요청이 나뉘어 있기 때문에 연관관계의 필요성을 없다고 판단 결국 별개의 테이블로 저장하게 되었습니다. post_id는 @PathVariable로 받아오고 user_i는 @AuthenticationPrincipal로 받아오도록 구현했습니다.
좋아요 TOP3 게시글 조회를 조회할 때, 쿼리메서드를 사용해서 간편하게 한 줄로 구현해보려고 했지만 쿼리메서드에서는 group by 키워드가 적용되지 않는다고 하여 @Query 어노테이션을 사용하여 직접 쿼리를 작성하여 구현하게 되었습니다.
mockito를 이용해 테스트 코드를 작성해 보려고 시도했습니다. 하지만 사용자 인증을 통과하는 mock유저를 만드는데 어려움을 겪게 되었고 시간상 학습을 하는데 많은 비용이 발생할 것 같아 테스트코드 없이 진행하게 되었습니다.
회고
Keep
- 기획 단계에서 세세한 부분까지 토의한 후 진행한 점.
- 커밋 컨벤션과 브랜치 보호 규칙을 세워서 PR을 날리면서 협업한 점.
- 깃 컨벤션과 브랜치 전략을 문서화하여 conflict를 최소화 한 점
- 자유롭게 의견을 내어 새로운 추가 기능들을 시도하고 완성하려고 팀원 모두가 노력한 점.
Problem
- 테스트 코드를 작성했다면 git action을 사용하여 좀 더 정확하고 상세한 협업을 할 수 있었을 것 같다.
- 충돌 없이 머지했지만 코드 리뷰를 조금 더 상세하게 진행했다면 코드 수정이 적었을 것 같다.
Try
- 프로젝트를 진행하며 좀 더 경험을 통해 실력을 쌓는 것
- 테스트 코드를 작성할 수 있도록 Mockito를 학습하고 git action에 대해서 공부
- 해결하기 어려운 점 또는 고민을 팀 회의로 해결하기
- PR시 체크리스트 템플릿 만들어서 지키기 (코드 포맷 지키기, 의존성 체크하기 등)
'Project' 카테고리의 다른 글
| Project Day 3 : 프로젝트 제목 선정, 역할분담, todo (0) | 2024.03.28 |
|---|---|
| Project Day 2 : S.A 작성 (0) | 2024.03.27 |
| 팀 프로젝트 3. Trello 2일차: 댓글 생성시 card의 모든 worker 에게 알림 보내기 (0) | 2024.03.19 |
| 팀 프로젝트 3. Trello 1일차 (프로젝트 스코프 정하기) (1) | 2024.03.18 |
| 팀 프로젝트 1. 학생 관리 프로그램 (0) | 2024.01.17 |