들어가며
안녕하세요. 현재 플러피라는 온라인 시험 문제 제작 및 관리 서비스를 개발하고 있습니다. 최근에, 사용자들이 시험에 대해 피드백을 제공할 수 있도록 좋아요 기능을 추가했습니다. 서비스 사용자들은 좋아요 수를 통해 퀄리티가 높은 시험을 쉽게 찾아볼 수 있어, 서비스가 활성화될 수 있습니다. 서비스 개발자인 저는, 이 기능을 통해 수집한 데이터를 통해, 향후 개인 맞춤형 시험 추천 서비스 등을 제공할 수 있습니다.
제목은 좋아요 기능이라고 했지만, 반응 기능이 더 적합할 것 같은데요.
이번 글에서는 확장 가능한 좋아요 기능을 설계하고, 구현하는 방법에 대해 알아보겠습니다.
기능 요구 사항
좋아요 기능을 설계하기에 앞서, 기능 요구 사항을 정리해보겠습니다.
- 로그인한 사용자는 시험에 대해서 좋아요를 할 수 있다.
- 비로그인 시 좋아요를 할 수 없다.
- 좋아요를 한 사용자는 좋아요를 취소할 수 있다.
- 좋아요는 여러 번 할 수 없다.
- 사용자는 좋아요 여부를 확인할 수 있다.
- 미래에 시험 말고도 다른 컨텐츠에도 좋아요를 적용할 가능성이 높다.
- 미래에 좋아요 말고도, 싫어요, 어려워요 등등의 다른 반응을 추가할 가능성이 높다.
간단하게, 유튜브의 좋아요 기능이라고 생각하시면 좋을 것 같습니다. 로그인 한 사용자만 좋아요를 할 수 있고, 좋아요를 취소할 수 있습니다. 좋아요를 여러 번 할 수 없으며, 좋아요 여부를 확인할 수 있습니다. 유튜브의 싫어요 같이 다른 반응을 추가할 가능성도 있습니다.
설계
다음으로, 도메인을 설계해보겠습니다.
사실 시험에 대한 좋아요 기능만을 구현하고자 한다면, 사용자와 시험 ID 정보만을 저장하면 됩니다.
public ExamLike {
private Long examId;
private Long memberId;
}
하지만, 기능 요구 사항에 따르면, 미래에 다른 컨텐츠에 대해서도 좋아요를 적용할 가능성이 있습니다. 이를 고려하면 다음과 같이 변경할 수 있습니다.
public Like {
private String targetType;
private Long targetId;
private Long memberId;
}
targetType에는 시험, 댓글 등등의 다양한 컨텐츠 타입을 저장할 수 있습니다. targetId는 해당 컨텐츠의 ID를 저장합니다. 예를 들어, ID가 1인 시험에 대한 좋아요를 하고 싶다면, targetType에 시험, targetId에 1을 저장하면 됩니다.
이제, 다른 컨텐츠에 대해서도 좋아요를 적용할 수 있습니다. 하지만, 좋아요 이외의 다른 반응들도 추가될 수 있다고 했습니다. 싫어요를 추가한다고 가정해보겠습니다.
public Dislike {
private String targetType;
private Long targetId;
private Long memberId;
}
DisLike은 Like과 동일한 구조를 가지고 있지만, 이름만 다른 클래스입니다. 새로운 반응이 추가될 때마다, 새로운 클래스를 생성해야하는 것은 번거롭습니다. 이를 해결하기 위해, 다음과 같이 변경할 수 있습니다.
public Reaction {
private String targetType;
private Long targetId;
private Long memberId;
private ReactionType reactionType;
}
public enum ReactionType {
LIKE, DISLIKE
}
reactionType은 targetType이 String인 것과 다르게 Enum으로 사용한 부분이 의아하실 수 있습니다. ReactionType은 반응 도메인에서 사용하는 부분이기 때문에 Enum으로 사용했습니다. 반면, targetType을 Enum으로 하고, EXAM, COMMENT 등등으로 하면, 반응 도메인과 외부 도메인(시험, 댓글 등)이 간접적으로 연결되는 문제가 발생할 수 있습니다. 따라서, targetType을 String으로 사용했습니다.
ReactionType을 통해, 좋아요 뿐만 아니라 다른 반응들도 쉽게 사용할 수 있습니다.
사용자는 좋아요를 한 후 좋아요 취소를 할 수 있습니다. 지금까지의 설계로는, 좋아요를 취소하면, 해당 데이터를 삭제해야합니다. 일반적으로 삭제의 경우 수정보다 성능적으로 좋지 않습니다. 삭제의 경우 데이터베이스가 해당 행을 물리적으로 제거하면서, 인덱스를 재정렬하고, 로그를 남기는 등의 작업을 수행합니다. 수정의 경우에도 이 작업은 수행하지만, 삭제보다는 성능적으로 좋습니다. 그리고, 사용자가 좋아요를 한 후 취소한 경우도 추후에 분석을 위해 데이터로 남겨두는 것이 좋습니다. 이를 해결하기 위해, 다음과 같이 변경할 수 있습니다.
public Reaction {
private String targetType;
private Long targetId;
private Long memberId;
private ReactionType reactionType;
private ReactionStatus status;
}
public enum ReactionType {
LIKE, DISLIKE
}
public enum ReactionStatus {
ACTIVE, DELETED
}
public void active() {
this.status = ReactionStatus.ACTIVE;
}
public void delete() {
this.status = ReactionStatus.DELETED;
}
ReactionStatus를 통해, 좋아요를 취소하면, 해당 데이터를 삭제하는 것이 아닌, status를 DELETED로 변경합니다. 취소된 상태에서 다시 좋아요를 하면, status를 ACTIVE로 변경합니다.
데이터베이스에서 데이터를 실제로 삭제하는 것을 '물리 삭제', 위와 같이 실제로 삭제하는 것이 아닌 삭제된 상태로 표시하는 것을 '논리 삭제'라고 합니다.
구현
지금까지 설계한 내용을 바탕으로 구현해보겠습니다.
개발 환경
제가 개발한 환경은 다음과 같습니다.
- Spring Boot 3.3.5
- Java 21
- Lombok
- Spring Data JPA
- Querydsl
Reaction Entity 구현
먼저, Reaction 엔티티를 작성해보겠습니다. 참고로, AuditableEntity는 생성일, 수정일을 자동으로 관리하기 위한 추상 클래스입니다.
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
public class Reaction extends AuditableEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String targetType;
@Column(nullable = false)
private Long targetId;
@Column(nullable = false)
private Long memberId;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private ReactionType type;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private ReactionStatus status;
public Reaction(String targetType, Long targetId, Long memberId, ReactionType type) {
this(null, targetType, targetId, memberId, type, ReactionStatus.ACTIVE);
}
public Reaction(
Long id,
String targetType,
Long targetId,
Long memberId,
ReactionType type,
ReactionStatus status
) {
this.id = id;
this.targetType = targetType;
this.targetId = targetId;
this.memberId = memberId;
this.type = type;
this.status = status;
}
public void active() {
this.status = ReactionStatus.ACTIVE;
}
public void delete() {
this.status = ReactionStatus.DELETED;
}
}
public enum ReactionType {
LIKE
}
public enum ReactionStatus {
ACTIVE, DELETED
}
LikeService 구현
다음으로, LikeService라는 도메인 서비스를 구현하겠습니다.
먼저, Like과 LikeTarget 도메인을 만들겠습니다. LikeTarget은 좋아요를 적용할 수 있는 대상 타입이고, Like은 적용 대상 타입과 ID를 가집니다.
public enum LikeTarget {
EXAM
}
public record Like(LikeTarget target, Long targetId) {
}
LikeService 도메인 서비스는 Like과 memberId를 통해 좋아요를 하거나 좋아요 취소를 할 수 있습니다. like 메서드를 사용하면 targetType, targetId, memberId의 Like 반응을 찾고, 없으면 새로 생성합니다. 그리고 reaction.active()를 통해, 좋아요를 Active 상태로 변경합니다. removeLike 메서드를 사용하면, targetType, targetId, memberId의 Like 반응을 찾고, 없으면 BadRequestException을 발생시킵니다. 그리고 reaction.delete()를 통해, 좋아요를 Deleted 상태로 변경합니다.
@Service
@RequiredArgsConstructor
public class LikeService {
private final ReactionRepository reactionRepository;
@Transactional
public Long like(Like like, Long memberId) {
String targetType = like.target().name();
Long targetId = like.targetId();
Reaction reaction = reactionRepository
.findByTargetTypeAndTargetIdAndMemberIdAndType(targetType, targetId, memberId, ReactionType.LIKE)
.orElseGet(() -> {
Reaction newReaction = new Reaction(targetType, targetId, memberId, ReactionType.LIKE);
return reactionRepository.save(newReaction);
});
reaction.active();
return reaction.getId();
}
@Transactional
public Long removeLike(Like like, Long memberId) {
String targetType = like.target().name();
Long targetId = like.targetId();
Reaction reaction = reactionRepository
.findByTargetTypeAndTargetIdAndMemberIdAndType(targetType, targetId, memberId, ReactionType.LIKE)
.orElseThrow(() -> new BadRequestException("좋아요를 한 상태가 아닙니다."));
reaction.delete();
return reaction.getId();
}
}
LikeQueryService 구현
다음으로, LikeQueryService라는 도메인 서비스를 구현하겠습니다.
isLiked 메서드는 Like과 memberId를 통해, 해당 Like 반응이 Active 상태인지 확인합니다. 즉, 사용자가 해당 컨텐츠에 좋아요를 했는지 여부를 반환합니다. memberId가 -1인 경우가 무엇인지 궁금하실 수도 있는데요. 플러피 서비스에서 비로그인 사용자의 경우 memberId를 -1로 설정해서 사용하고 있습니다. 따라서, memberId가 -1인 경우는 비로그인 사용자이므로, 좋아요를 한 상태가 아님을 반환합니다.
@Service
@RequiredArgsConstructor
public class LikeQueryService {
private final ReactionRepository reactionRepository;
@Transactional(readOnly = true)
public boolean isLiked(Like like, Long memberId) {
if (memberId == null || memberId == -1) {
return false;
}
return reactionRepository.findByTargetTypeAndTargetIdAndMemberIdAndType(
like.target().name(),
like.targetId(),
memberId,
ReactionType.LIKE
).map(reaction -> reaction.getStatus() == ReactionStatus.ACTIVE).orElse(false);
}
}
비로그인 사용자의 경우 좋아요 여부 처리
플러피 서비스에서 시험 상세 정보를 조회할 때, 좋아요 여부도 포함됩니다. 로그인된 사용자의 좋아요 여부에 따라서 isLiked를 true 혹은 false를 반환합니다. 시험 상세 정보를 조회하는 것은 비로그인 사용자도 가능하기 때문에 적절한 처리가 필요합니다. 이 방법에 대해서 알아보겠습니다.
저는 인증 여부를 파악하기 위해, Accessor라는 클래스를 사용하고 있습니다. Accessor는 사용자의 ID를 가지고 있으며, 비로그인 사용자의 경우 -1이라는 ID를 가지게 됩니다. 이는 GUEST라는 상수로 정의되어 있습니다.
public record Accessor(Long id) {
private static final Long GUEST_ID = -1L;
public static final Accessor GUEST = new Accessor(GUEST_ID);
public boolean isGuest() {
return GUEST_ID.equals(id);
}
}
@Target(ElementType.PARAMETER)
@Retention(RetentionPolicy.RUNTIME)
public @interface Auth {
boolean required() default true;
}
쿠키에 담긴 액세스 토큰을 사용하여, 사용자의 ID를 가져옵니다. 토큰이 없는 경우, handleNoToken 메서드를 호출하여, GUEST를 반환합니다. 토큰이 있는 경우, handleToken 메서드를 호출하여, 사용자의 ID가 담긴 Accessor를 반환합니다.
@Component
@RequiredArgsConstructor
public class AuthArgumentResolver implements HandlerMethodArgumentResolver {
private final CookieManager cookieManager;
private final AuthService authService;
@Override
public boolean supportsParameter(MethodParameter parameter) {
boolean hasAuthAnnotation = parameter.hasParameterAnnotation(Auth.class);
boolean isAccessorClass = Accessor.class.isAssignableFrom(parameter.getParameterType());
return hasAuthAnnotation && isAccessorClass;
}
@Override
public Accessor resolveArgument(
@NonNull MethodParameter parameter,
ModelAndViewContainer mavContainer,
@NonNull NativeWebRequest webRequest,
WebDataBinderFactory binderFactory
) {
Auth auth = requireNonNull(parameter.getParameterAnnotation(Auth.class));
HttpServletResponse response = webRequest.getNativeResponse(HttpServletResponse.class);
String token = extractTokenFromCookie(webRequest);
if (token == null) {
return handleNoToken(auth); // Accessor.GUEST 반환
}
return handleToken(token, response); // 인증된 사용자의 ID가 담긴 Accessor 반환
}
// 나머지 메서드 부분은 지면 관계상 생략합니다.
}
시험 상세 정보에 대한 요약을 반환하는 API를 예시로 들어보겠습니다. @Auth(required = false)를 통해, 로그인 필수 여부를 설정할 수 있습니다. required=false로 설정했기 때문에, 로그인된 사용자는 사용자 ID가 담긴 Accessor를, 비로그인 사용자는 GUEST(ID가 -1인 경우)를 받게 됩니다.
@GetMapping("/api/v1/exams/{examId}/summary")
public ResponseEntity<ExamDetailSummaryResponse> getExamDetailSummary(
@PathVariable Long examId,
@Auth(required = false) Accessor accessor
) {
ExamDetailSummaryResponse response = examQueryService.getExamDetailSummary(examId, accessor);
return ResponseEntity.ok(response);
}
likeQueryService.isLiked 메서드를 통해, 로그인된 사용자의 경우, 좋아요 여부를 반환하고, 비로그인 사용자의 경우 memberId가 -1이므로, false를 반환합니다.
@Transactional(readOnly = true)
public ExamDetailSummaryResponse getExamDetailSummary(Long examId, Accessor accessor) {
ExamDetailSummaryDto dto = examRepository.findExamDetailSummary(examId)
.orElseThrow(() -> new NotFoundException("시험을 찾을 수 없습니다."));
boolean isLiked = likeQueryService.isLiked(new Like(LikeTarget.EXAM, examId), accessor.id());
return examMapper.toDetailSummaryResponse(dto, isLiked);
}
마치며
이번 글에서는 확장 가능한 좋아요 기능을 설계하고, 구현하는 방법에 대해 알아보았습니다. 좋아요 기능을 구현하면서, 미래에 다른 반응들도 추가될 수 있다는 점을 고려하여, Reaction 엔티티를 만들었습니다. 이를 통해, 좋아요 이외의 다른 반응들도 쉽게 추가할 수 있습니다. 또한, 좋아요를 취소하면, 해당 데이터를 삭제하는 것이 아닌, status를 DELETED로 변경하여, 성능적인 이점을 얻을 수 있습니다. 마지막으로, 로그인 여부에 따라 좋아요 여부를 처리하는 방법에 대해서도 알아보았습니다.
더 자세한 내용에 대해서는 플러피 서비스의 코드를 참고하시면 좋을 것 같습니다. 봐주셔서 감사합니다 :D
참고
'서버' 카테고리의 다른 글
고가용성을 위한 단일 장애 지점(SPOF) 해결 방법 (2) | 2025.01.25 |
---|---|
커버링 인덱스를 활용한 페이지네이션 성능 개선하기 (1) | 2025.01.19 |
Docker Desktop 오류, "'com.docker.vmnetd'에 악성 코드가 포함되어 있어서 열리지 않았습니다." 해결 방법 (1) | 2025.01.13 |
Spring REST Docs로 믿을 수 있는 API 문서 만들기 (1) | 2025.01.12 |
무중단 배포(블루/그린 배포)로 서비스 중단 없이 배포하기 (5) | 2025.01.09 |