class Solution {
public int solution(int left, int right) {
// left -> right 탐색하며 약수 개수를 세고 홀/짝에 따라 계산해 리턴한다.
int answer = 0;
for(int i = left; i <= right; i++) {
int cnt = 0; // 약수의 개수
for(int j = 1; j <= i; j++){if(i%j==0) cnt++;}
if(cnt % 2 == 0) {answer += i;}
else answer-=i;
}
return answer;
}
}
처음에 문제를 잘못 이해하고 약수의 개수에 따라 각 수를 합한것 또는 각 수를 뺀 것으로 생각해 for문 하나로 처리하려고했다.
테스트 케이스의 결과값에 음수가 없다는걸 늦게 알아채고 for 문 두 개로 완성했다.
테스트 케이스 예시결과도 잘 봐야겠다.
각자 원하는 도메인을 골라서 분배하는 방식으로 역할 분담을 맡았다.
API구현에 경험이 적어 나는 구현이 늦는 편이라 생각해 먼저 팀원의 코드를 참고해 감을 잡을 수 있도록 후반부에 구현해도 괜찮을 리뷰 도메인을 맡고싶다고 했다.
지난 번 회의 내용과 같이 기능에 따라 나눈 브랜치를 생성하기 위한 issue를 생성했다.
DB 연결과 API의 틀을 우선 구현한 뒤에, S3를 통해 이미지를 업로드 하고 응답하는 기능을 추가하려고 이미지데이터를 제외한 CRUD 구현을 목표로 잡았다.

구현 내용에 대해 하나의 체크 박스를 한 개의 커밋 단위로 두려고 했는데, 지금 와서 생각해보니 다른 팀원들에 비해 커밋 단위가 너무 큰가 싶긴하다.
리뷰 도메인에 해당하는 리뷰 엔티티, 가게별 리뷰 통계 엔티티를 다음과 같이 정의했다.
팀원이 Audit에 해당하는 생성 시간, 수정시간을 BaseTimeEntity에서 구현을 해놓아서
나머지 작업자 정보, softdelete 에 해당하는 Base Entity를 BaseTimeEntity를 상속받아 구현하였다.
@Getter
@MappedSuperclass
// (TimeEntity) + 작업자 + softDelete = AuditEntity
public abstract class BaseAuditEntity extends BaseTimeEntity {
@Column(name = "created_by", nullable = false, updatable = false)
protected UUID createdBy;
@Column(name = "updated_by", nullable = false)
protected UUID updatedBy;
@Column(name = "is_deleted", nullable = false)
protected boolean isDeleted;
@Column(name = "deleted_at")
protected LocalDateTime deletedAt;
@Column(name = "deleted_by")
protected UUID deletedBy;
protected void markCreated(UUID actorId) {
this.createdBy = actorId;
this.updatedBy = actorId;
this.isDeleted = false;
}
protected void markUpdated(UUID actorId) {
this.updatedBy = actorId;
}
protected void markDeleted(UUID actorId, LocalDateTime now) {
this.isDeleted = true;
this.deletedAt = now;
this.deletedBy = actorId;
this.updatedBy = actorId;
}
}
@Entity
@Table(name = "p_review")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Review extends BaseAuditEntity {
@Id
@GeneratedValue
@Column(nullable = false, updatable = false)
private UUID id;
@Column(name = "user_id", nullable = false, updatable = false)
private UUID userId;
@Column(name = "order_id", nullable = false, updatable = false)
private UUID orderId;
@Column(name = "review_rating", nullable = false)
private double rating;
@Column(name = "review_comment", length = 300)
private String comment;
// TODO: 리뷰 이미지 연관
/* @OneToMany(mappedBy = "review", cascade = CascadeType.ALL, orphanRemoval = true)
private List<ReviewImage> images = new ArrayList<>();
public void addImage(ReviewImage image) {
images.add(image);
image.setReview(this);
}*/
public static Review create(
UUID userId,
UUID orderId,
double rating,
String comment,
UUID actorId
) {
// 도메인 규칙 : 별점
/* if (rating < 0 || rating > 5) {
throw new IllegalArgumentException("rating must be between 0 and 5");
}*/
Review review = new Review();
review.userId = userId;
review.orderId = orderId;
review.rating = rating;
review.comment = comment;
review.markCreated(actorId);
return review;
}
public void updateReview(double newRating, String newComment, UUID actorId) {
this.rating = newRating;
this.comment = newComment;
markUpdated(actorId);
}
public void softDelete(UUID actorId, LocalDateTime now) {
markDeleted(actorId, now);
}
}
package com.sparta.omin.app.model.review.entity;
import com.sparta.omin.common.entity.BaseAuditEntity;
import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
import java.util.UUID;
@Entity
@Table(name = "p_review")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Review extends BaseAuditEntity {
@Id
@GeneratedValue
@Column(nullable = false, updatable = false)
private UUID id;
@Column(name = "user_id", nullable = false, updatable = false)
private UUID userId;
@Column(name = "order_id", nullable = false, updatable = false)
private UUID orderId;
@Column(name = "review_rating", nullable = false)
private double rating;
@Column(name = "review_comment", length = 300)
private String comment;
// TODO: 리뷰 이미지 연관
/* @OneToMany(mappedBy = "review", cascade = CascadeType.ALL, orphanRemoval = true)
private List<ReviewImage> images = new ArrayList<>();
public void addImage(ReviewImage image) {
images.add(image);
image.setReview(this);
}*/
public static Review create(
UUID userId,
UUID orderId,
double rating,
String comment,
UUID actorId
) {
// 도메인 규칙 : 별점
/* if (rating < 0 || rating > 5) {
throw new IllegalArgumentException("rating must be between 0 and 5");
}*/
Review review = new Review();
review.userId = userId;
review.orderId = orderId;
review.rating = rating;
review.comment = comment;
review.markCreated(actorId);
return review;
}
public void updateReview(double newRating, String newComment, UUID actorId) {
this.rating = newRating;
this.comment = newComment;
markUpdated(actorId);
}
public void softDelete(UUID actorId, LocalDateTime now) {
markDeleted(actorId, now);
}
}
기능 명세서에 따라 별점은 0~5 까지의 값을 가지기로 했는데, DTO에서 검증을 하기 때문에 엔티티에도 별점 검증을 넣어야 하나? 고민이 생겼다. (물론 넣는게 안전하다고 생각하긴 한다.)
사실 어제 create 응답과 요청까지 완료를 했지만, 오늘 요청과 응답을 보니 엥? 이 정보를 주고 받는게 맞나? 싶은 부분이 많았다.
예전엔 생략했던 JWT 인증이나 Audit 필드 등이 추가되니까 더 헷갈리기 시작했다.
그래서 오늘 다시 도메인에 대한 이해를 하면서 API명세부터 다시 보기로 했다.
예전에는 백에서 DTO를 어떻게 만들던, 문서에 작성된 대로 보고 프론트에서 알아서 처리했고,
프론트에서 쉽게 구현하려고 REST 구조를 무시하는 등 주먹구구 식으로 작성을 했었다.
(이제 생각해보니 풀스택 과정이라 욕 안 먹은 듯)
DTO를 CRUD마다 다르게 해야 할까? 중복되는 부분이 있는데,
몽땅 넣은 객체에서 뽑아내서 사용하나? 의문이 많았다.
거기에다, 지금은 모놀리스 아키텍처를 사용하곤 있지만 도메인 분류로 협업을 하고 있기도 하고, MSA 확장 가능성도 염두에 두고 개발을 해야했다.
| 구분 | 필요 필드가 다름 |
|---|---|
| Create | orderId, rating, comment |
| Update | reviewId, rating, comment |
| Response | reviewId, orderId, rating, comment, createdAt, (updatedAt) |
| List 조회 | reviewId, rating, comment (Response를 가볍게) |
실무에서는 필드가 매우 복잡한 경우에 요청 DTO를 캡슐화 해서 사용하기도 하지만
응답의 경우 상속 구조가 오히려 복잡해질 수 있어 잘 사용하지 않는다고 한다.
그래서 crud 요청은 각자 다르게, 민감한 데이터를 포함하지 않은 경우에는 대부분 (Create, Read, Update)응답을 통일한다고 한다.
그래서 표와 같이 Create, Update, 단일 응답, 리스트 응답 4가지로 나눠 DTO를 작성하기로 했다.
public record ReviewCreateRequest(
// Spring Security 부분과 합칠 것을 대비해 userId를 따로 뺐다.
UUID orderId,
@Min(0)
@Max(5)
double rating,
String comment
) {}
public record ReviewResponse(
UUID reviewId,
UUID orderId,
UUID userId,
double rating,
String comment,
LocalDateTime createdAt,
LocalDateTime updatedAt
) {}
@Transactional
public ReviewResponse createReview(UUID userId, ReviewCreateRequest request) {
// 1. 주문 조회
Order order = orderRepository.findById(request.orderId())
.orElseThrow(() -> new IllegalStateException("주문이 존재하지 않습니다."));
// 2. 주문 상태 COMPLETED 아니면 예외
if (!order.isCompleted()) {
throw new IllegalStateException("주문 완료 후에만 리뷰 작성이 가능합니다.");
}
// 3. 주문일 + 2일 초과면 예외
if (order.getCreatedAt().
plusDays(2).
isBefore(LocalDateTime.now())
) {
throw new IllegalStateException("주문일로부터 2일이 초과되어 리뷰를 작성할 수 없습니다.");
}
// 4. 이미 리뷰 작성했으면 예외
Optional<Review> oldReview = reviewRepository.findByOrderId(request.orderId());
if (oldReview.isPresent()) {
throw new IllegalStateException("이미 해당 주문 건으로 작성된 리뷰가 있습니다.");
}
// 5. Review 생성
Review newReview = Review.create(
userId, // TODO: securityContext
request.orderId(),
request.rating(),
request.comment()
// TODO: image
);
reviewRepository.save(newReview);
// 7. 해당 가게에 기존 평점 통계가 존재하는지 확인
Optional<StoreRatingStat> oldStoreRatingStat = statRepository.findByStoreId((order.getStoreId()));
if (oldStoreRatingStat.isPresent()) {
oldStoreRatingStat.get().increase(request.rating());
} else {
statRepository.save(StoreRatingStat.create(order.getStoreId(), request.rating()));
}
// 8. 단일 응답 생성
return new ReviewResponse(
newReview.getId(),
order.getStoreId(),
userId,
newReview.getRating(),
newReview.getComment(),
newReview.getCreatedAt(),
newReview.getUpdatedAt()
);
}
postgres-# \c omindb
You are now connected to database "omindb" as user "omin_user".
omindb=# INSERT INTO p_order (id, store_id, status, created_at, updated_at, created_by, updated_by, is_deleted)
VALUES (
'550e8400-e29b-41d4-a716-446655440000',
'660e8400-e29b-41d4-a716-446655441111',
'COMPLETED',
'2027-01-01', -- 2일 이내 작성 검증 피하기
NOW(),
'550e8400-e29b-41d4-a716-446655440000', -- 가짜 UUID
'550e8400-e29b-41d4-a716-446655440000', -- 가짜 UUID
false
);
INSERT INTO p_order (id, store_id, status, created_at, updated_at, created_by, updated_by, is_deleted)
VALUES (
'550e8400-e29b-41d4-a716-446655440001',
'660e8400-e29b-41d4-a716-446655441111',
'COMPLETED',
'2027-01-01', -- 2일 이내 작성 검증 피하기
NOW(),
'550e8400-e29b-41d4-a716-446655440000', -- 가짜 UUID
'550e8400-e29b-41d4-a716-446655440000', -- 가짜 UUID
false
);
INSERT 0 1
INSERT 0 1
@PostMapping("/reviews")
public ResponseEntity<ReviewResponse> create(@Valid @RequestBody ReviewCreateRequest request) {
UUID userId = UUID.randomUUID(); // 지금은 임시값 (시큐리티 붙으면 교체)
ReviewResponse response =
reviewService.createReview(userId, request);
URI uri = ServletUriComponentsBuilder
.fromCurrentRequest()
.path("/{id}")
.buildAndExpand(response.reviewId())
.toUri();
return ResponseEntity.created(uri).body(response);
}
생성한 각 주문에 대한 리뷰 데이터를 body로 전달한다.
{
"orderId" : "550e8400-e29b-41d4-a716-446655440001" ,
"rating": 1.5,
"comment" : "진짜 맛없어요! ㅜㅜ 배달도 느리고 최악입니다"
}

{
"orderId" : "550e8400-e29b-41d4-a716-446655440001" ,
"rating": 4.5,
"comment" : "진짜 맛있어요! 배달도 빠르고 최고입니다!"
}


두 리뷰가 더해져 평균이 계산돼 저장되는것을 확인할 수 있었다.
구현 단계에서 원하는 결과가 바로 나오면 이렇게 짜릿할 수가 없다.(왜 되지?😜)
항상 이렇게 DB에 접속하고, 요청을 날릴 수 없으니 간편한 테스트를 위해 테스트 코드를 작성한다.
Swagger를 사용하게 되면 비즈니스 로직과 관련없는 어노테이션을 추가해야한다고 코드가 오염되는 단점이 있어 Swagger 대신 Spring docs 의 적용을 시도하게 되었다.