ewhk9887/korean_code_reviews_from_github huggingface dataset:
@@ -0,0 +1,15 @@
+import { useQuery } from '@tanstack/react-query';
+
+import getTempleReviews from './axios';
+import { ReviewsResponse } from './type';
+
+const useGetTempleReviews = (templestayId: string, page: number) => {
+  const { data, isLoading, isError } = useQuery<ReviewsResponse>({
+    queryKey: ['reviews', templestayId, page],
+    queryFn: () => getTempleReviews(templestayId, page),
+  });
+
+  return { data, isLoading, isError };
+};
+
+export default useGetTempleReviews;

리뷰: 현재 쿼리키가 reviews와 page로만 정해져 있어서 모든 템플스테이 리뷰가 같은 쿼리키를 공유하고 있어서   queryKey: ['reviews', templestayId, page],로 templestayId를 쿼리키에 추가해주시면 각 템플스테이 리뷰를 구별할 수 있을 것 같습니다.
@@ -0,0 +1,15 @@
+import { useQuery } from '@tanstack/react-query';
+
+import getTempleReviews from './axios';
+import { ReviewsResponse } from './type';
+
+const useGetTempleReviews = (templestayId: string, page: number) => {
+  const { data, isLoading, isError } = useQuery<ReviewsResponse>({
+    queryKey: ['reviews', templestayId, page],
+    queryFn: () => getTempleReviews(templestayId, page),
+  });
+
+  return { data, isLoading, isError };
+};
+
+export default useGetTempleReviews;

리뷰: 넵 반영하겠습니다
@@ -0,0 +1,36 @@
+package me.misik.api.infra
+
+import me.misik.api.core.Chatbot
+import org.springframework.beans.factory.annotation.Value
+import org.springframework.context.annotation.Bean
+import org.springframework.context.annotation.Configuration
+import org.springframework.http.HttpHeaders
+import org.springframework.http.MediaType
+import org.springframework.web.reactive.function.client.WebClient
+import org.springframework.web.reactive.function.client.support.WebClientAdapter
+import org.springframework.web.service.invoker.HttpServiceProxyFactory
+
+@Configuration
+class ClovaChatbotConfiguration(
+    @Value("\${me.misik.chatbot.clova.url:https://clovastudio.stream.ntruss.com/}") private val chatbotUrl: String,
+    @Value("\${me.misik.chatbot.clova.authorization}") private val authorization: String,
+) {
+
+    @Bean
+    fun clovaChatbot(): Chatbot {
+        val webClient = WebClient.builder()
+            .baseUrl(chatbotUrl)
+            .defaultHeaders { headers ->
+                headers.add(HttpHeaders.AUTHORIZATION, "Bearer $authorization")
+                headers.add(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
+                headers.add(HttpHeaders.ACCEPT, MediaType.TEXT_EVENT_STREAM_VALUE)
+            }
+            .build()
+
+        val httpServiceProxyFactory = HttpServiceProxyFactory
+            .builderFor(WebClientAdapter.create(webClient))
+            .build()
+
+        return httpServiceProxyFactory.createClient(Chatbot::class.java)
+    }
+}

리뷰: hoxy 환경변수 등록하는거면 @Value 사용해도 될까요? 혹은 @Qualifier는 사용할 빈 객체 선택 시 쓰는걸로 알고있는데 환경변수 등록할 때도 쓸 수 있는 방법이 있는지용? 😁
@@ -0,0 +1,17 @@
+package me.misik.api.api
+
+import me.misik.api.app.ReCreateReviewFacade
+import org.springframework.web.bind.annotation.PathVariable
+import org.springframework.web.bind.annotation.PostMapping
+import org.springframework.web.bind.annotation.RestController
+
+@RestController
+class ReviewController(
+    private val reCreateReviewFacade: ReCreateReviewFacade,
+) {
+
+    @PostMapping("reviews/{id}/re-create")
+    fun reCreateReview(
+        @PathVariable("id") id: Long,
+    ) = reCreateReviewFacade.reCreateReviewInBackground(id)
+}

리뷰: 재생성된 리뷰도 캐싱에 활용하면 좋을 것 같은데 제가 #5 할 때 추가해봐도 될까요?

1) 재사용을 위해 DB에 저장
2) 이미 같은 유저에게 제공된 리뷰는 또 제공 X

위를 위해서 device id 헤더를 받아오려고 합니당!
@@ -0,0 +1,24 @@
+# 리뷰 생성
+
+리뷰를 생성합니다.
+
+## Request
+
+### HTTP METHOD : `GET`
+
+### url : `https://api.misik.me/reviews/{id}`
+### Http Headers
+- device-id: 식별할 수 있는 값   
+  `미래에도 변하지 않아야함 앱을 삭제했다 다시 깔아도 안변하는 값으로 줄 수 있는지`
+
+### Response
+
+#### `Response Status 200 OK`
+
+```json
+{
+  "isSuccess": true, // 리뷰가 생성되지 않았으면 false, 전체가 성공되면 true
+  "id": "123456789",
+  "review": "카야토스는 숨겨져 있는 카야잼과 버터가 확실히 가득합니다. 또한, 가게의 분위기는 아늑하고 편안하고 바깥쪽에 있고 사랑하는 시간을 보낼 수 있는 공간입니다. 무엇보다 가격에 비해 음식의 품질이 정말 훌륭해서, 마음에 들었습니다."
+}
+```

리뷰: 이 부분 다음 엔지니어 회의 때 클라이언트 분들과 이야기 해보면 어떨까요? 😁
앞으로도 비슷한 조회 로직을 위해 합의해두면 좋을듯요!
지금처럼 조회할 때 값이 없으면 200을 주고 필드로 구분하게 할 것인지, 아니면 400을 내려줄지에 대해서용!
@@ -0,0 +1,17 @@
+package me.misik.api.api
+
+import me.misik.api.app.ReCreateReviewFacade
+import org.springframework.web.bind.annotation.PathVariable
+import org.springframework.web.bind.annotation.PostMapping
+import org.springframework.web.bind.annotation.RestController
+
+@RestController
+class ReviewController(
+    private val reCreateReviewFacade: ReCreateReviewFacade,
+) {
+
+    @PostMapping("reviews/{id}/re-create")
+    fun reCreateReview(
+        @PathVariable("id") id: Long,
+    ) = reCreateReviewFacade.reCreateReviewInBackground(id)
+}

리뷰: hoxy 여기 메서드명이 reCreateReview가 아닌 createReview인데 의도하신걸까요? 🥳
@@ -0,0 +1,24 @@
+# 리뷰 생성
+
+리뷰를 생성합니다.
+
+## Request
+
+### HTTP METHOD : `GET`
+
+### url : `https://api.misik.me/reviews/{id}`
+### Http Headers
+- device-id: 식별할 수 있는 값   
+  `미래에도 변하지 않아야함 앱을 삭제했다 다시 깔아도 안변하는 값으로 줄 수 있는지`
+
+### Response
+
+#### `Response Status 200 OK`
+
+```json
+{
+  "isSuccess": true, // 리뷰가 생성되지 않았으면 false, 전체가 성공되면 true
+  "id": "123456789",
+  "review": "카야토스는 숨겨져 있는 카야잼과 버터가 확실히 가득합니다. 또한, 가게의 분위기는 아늑하고 편안하고 바깥쪽에 있고 사랑하는 시간을 보낼 수 있는 공간입니다. 무엇보다 가격에 비해 음식의 품질이 정말 훌륭해서, 마음에 들었습니다."
+}
+```

리뷰: 👍 넘 좋아요! streaming 방식 지원할거라서 생성시 리뷰 id만 반환하고, id로 여러 번 조회해오게 하기!! 좋습니당!!
@@ -0,0 +1,65 @@
+package me.misik.api.core
+
+import kotlinx.coroutines.flow.Flow
+import me.misik.api.domain.Review
+import org.springframework.web.bind.annotation.RequestBody
+import org.springframework.web.service.annotation.PostExchange
+
+fun interface Chatbot {
+
+    @PostExchange("/testapp/v1/chat-completions/HCX-003")
+    fun createReviewWithModelName(@RequestBody request: Request): Flow<Response>
+
+    data class Request(
+        val messages: List<Message>,
+        val includeAiFilters: Boolean = true,
+    ) {
+        data class Message(
+            val role: String,
+            val content: String,
+        ) {
+
+            companion object {
+
+                fun createSystem(content: String) = Message(
+                    role = "system",
+                    content = content,
+                )
+
+                fun createUser(content: String) = Message(
+                    role = "user",
+                    content = content,
+                )
+            }
+        }
+
+        companion object {
+            private val cachedSystemMessage = Message.createSystem(
+                """
+                    너는 지금부터 음식에 대한 리뷰를 하는 고독한 미식가야.
+                    답변에는 리뷰에 대한 내용만 포함해. 
+                    또한, 응답 메시지는 공백을 포함해서 300자가 넘으면 절대로 안돼.
+                """
+            )
+
+            fun from(review: Review): Request {
+                return Request(
+                    messages = listOf(
+                        cachedSystemMessage,
+                        Message.createUser(review.requestPrompt.text)
+                    )
+                )
+            }
+        }
+    }
+
+    data class Response(
+        val stopReason: String?,
+        val message: Message?,
+    ) {
+        data class Message(
+            val role: String,
+            val content: String,
+        )
+    }
+}

리뷰: HTTP Interface 👍 가독성 짱이네용
@@ -0,0 +1,24 @@
+# 리뷰 생성
+
+리뷰를 생성합니다.
+
+## Request
+
+### HTTP METHOD : `GET`
+
+### url : `https://api.misik.me/reviews/{id}`
+### Http Headers
+- device-id: 식별할 수 있는 값   
+  `미래에도 변하지 않아야함 앱을 삭제했다 다시 깔아도 안변하는 값으로 줄 수 있는지`
+
+### Response
+
+#### `Response Status 200 OK`
+
+```json
+{
+  "isSuccess": true, // 리뷰가 생성되지 않았으면 false, 전체가 성공되면 true
+  "id": "123456789",
+  "review": "카야토스는 숨겨져 있는 카야잼과 버터가 확실히 가득합니다. 또한, 가게의 분위기는 아늑하고 편안하고 바깥쪽에 있고 사랑하는 시간을 보낼 수 있는 공간입니다. 무엇보다 가격에 비해 음식의 품질이 정말 훌륭해서, 마음에 들었습니다."
+}
+```

리뷰: 아 이게 60초 대기때문에 추가된거군요!

1. 리뷰 생성 중, 아직 덜 생성됨
2. 아예 없는 리뷰id를 조회하려고 함
3. 리뷰 생성됨, 조회 가능 

이렇게 3가지 경우가 있어보여요!
@@ -0,0 +1,36 @@
+package me.misik.api.infra
+
+import me.misik.api.core.Chatbot
+import org.springframework.beans.factory.annotation.Value
+import org.springframework.context.annotation.Bean
+import org.springframework.context.annotation.Configuration
+import org.springframework.http.HttpHeaders
+import org.springframework.http.MediaType
+import org.springframework.web.reactive.function.client.WebClient
+import org.springframework.web.reactive.function.client.support.WebClientAdapter
+import org.springframework.web.service.invoker.HttpServiceProxyFactory
+
+@Configuration
+class ClovaChatbotConfiguration(
+    @Value("\${me.misik.chatbot.clova.url:https://clovastudio.stream.ntruss.com/}") private val chatbotUrl: String,
+    @Value("\${me.misik.chatbot.clova.authorization}") private val authorization: String,
+) {
+
+    @Bean
+    fun clovaChatbot(): Chatbot {
+        val webClient = WebClient.builder()
+            .baseUrl(chatbotUrl)
+            .defaultHeaders { headers ->
+                headers.add(HttpHeaders.AUTHORIZATION, "Bearer $authorization")
+                headers.add(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
+                headers.add(HttpHeaders.ACCEPT, MediaType.TEXT_EVENT_STREAM_VALUE)
+            }
+            .build()
+
+        val httpServiceProxyFactory = HttpServiceProxyFactory
+            .builderFor(WebClientAdapter.create(webClient))
+            .build()
+
+        return httpServiceProxyFactory.createClient(Chatbot::class.java)
+    }
+}

리뷰: 오 오타입니다! ㅋㅋㅋㅋ
수정했습니다 [b838a91](https://github.com/Nexters/misik-api/pull/7/commits/b838a916d60cf5b000f306c15caf7652e9ee0082)
@@ -0,0 +1,17 @@
+package me.misik.api.api
+
+import me.misik.api.app.ReCreateReviewFacade
+import org.springframework.web.bind.annotation.PathVariable
+import org.springframework.web.bind.annotation.PostMapping
+import org.springframework.web.bind.annotation.RestController
+
+@RestController
+class ReviewController(
+    private val reCreateReviewFacade: ReCreateReviewFacade,
+) {
+
+    @PostMapping("reviews/{id}/re-create")
+    fun reCreateReview(
+        @PathVariable("id") id: Long,
+    ) = reCreateReviewFacade.reCreateReviewInBackground(id)
+}

리뷰: 넵 당연하죵 수정해주셔도 됩니다!

추가로, 구현하면서 출시전에 해야할거 같은 캐싱을 발견했는데요.

현재는 리뷰를 생성할때, streaming방식으로 DB에 하나씩 적재를 하는 방식을 사용하고 있습니다.
즉, 쿼리가 streaming의 응답갯수 만큼 나가고, 위 사진에서는 총 77번 쿼리가 수행되었습니다.

이렇게 로직을 짜는건 DB에 부담이 되고, 성능도 나오기 힘드니
쓰기 캐싱을 해야할거 같아요.

두가지 플로우에 레디스 혹은 인메모리로 캐싱을 추가해야할거 같은데요.

1. 실시간 폴링 리뷰 조회 (1차 MVP에서는 없음) -> redis에서 생성중인 리뷰를 먼저 조회하고 없다면 DB를 조회하는 방향으로 수정
2. 리뷰 생성 및 재생성 -> DB에 리뷰를 저장하기 전에 redis에 전부다 저장해두고, 리뷰 생성이 다 되면 DB에 flush (77번 쿼리를 1번으로 수정)
@@ -0,0 +1,24 @@
+# 리뷰 생성
+
+리뷰를 생성합니다.
+
+## Request
+
+### HTTP METHOD : `GET`
+
+### url : `https://api.misik.me/reviews/{id}`
+### Http Headers
+- device-id: 식별할 수 있는 값   
+  `미래에도 변하지 않아야함 앱을 삭제했다 다시 깔아도 안변하는 값으로 줄 수 있는지`
+
+### Response
+
+#### `Response Status 200 OK`
+
+```json
+{
+  "isSuccess": true, // 리뷰가 생성되지 않았으면 false, 전체가 성공되면 true
+  "id": "123456789",
+  "review": "카야토스는 숨겨져 있는 카야잼과 버터가 확실히 가득합니다. 또한, 가게의 분위기는 아늑하고 편안하고 바깥쪽에 있고 사랑하는 시간을 보낼 수 있는 공간입니다. 무엇보다 가격에 비해 음식의 품질이 정말 훌륭해서, 마음에 들었습니다."
+}
+```

리뷰: 넵넵 서버사이드 폴링 (60초 대기) 때문에 추가된 부분입니다!
리뷰가 생성중이면 false, 리뷰가 생성되었으면 true를 응답해주려고 해요.

> 아예 없는 리뷰id를 조회하려고 함

에러 응답이랑은 다른내용같아서, 에러 응답템플릿은 따로 정의해보면 될 것 같습니다!
현재는 없는 리뷰를 조회하려고 하면 400 BadRequest로 응답하고 있씁니다.
@@ -0,0 +1,17 @@
+package me.misik.api.api
+
+import me.misik.api.app.ReCreateReviewFacade
+import org.springframework.web.bind.annotation.PathVariable
+import org.springframework.web.bind.annotation.PostMapping
+import org.springframework.web.bind.annotation.RestController
+
+@RestController
+class ReviewController(
+    private val reCreateReviewFacade: ReCreateReviewFacade,
+) {
+
+    @PostMapping("reviews/{id}/re-create")
+    fun reCreateReview(
+        @PathVariable("id") id: Long,
+    ) = reCreateReviewFacade.reCreateReviewInBackground(id)
+}

리뷰: 오타입니다! 수정했습니다 🥳
@@ -0,0 +1,17 @@
+package me.misik.api.api
+
+import me.misik.api.app.ReCreateReviewFacade
+import org.springframework.web.bind.annotation.PathVariable
+import org.springframework.web.bind.annotation.PostMapping
+import org.springframework.web.bind.annotation.RestController
+
+@RestController
+class ReviewController(
+    private val reCreateReviewFacade: ReCreateReviewFacade,
+) {
+
+    @PostMapping("reviews/{id}/re-create")
+    fun reCreateReview(
+        @PathVariable("id") id: Long,
+    ) = reCreateReviewFacade.reCreateReviewInBackground(id)
+}

리뷰: 조회, 생성(및 재생성) 두 케이스 다 캐싱 가시죠! 👍
@@ -0,0 +1,57 @@
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+public class Lotto {
+    private final static int LOTTO_NUM_SIZE = 6;
+    private final List<LottoNo> lottoNums;
+
+    public Lotto(List<LottoNo> lottoNums) {
+        checkSizeValid(lottoNums);
+        checkNumbersValid(lottoNums);
+        checkDuplicated(lottoNums);
+
+        this.lottoNums = lottoNums;
+    }
+
+    public List<Integer> getNums() {
+        List<Integer> lottoNumbers = new ArrayList<>();
+
+        for (LottoNo lottoNo : lottoNums) {
+            lottoNumbers.add(lottoNo.getLottoNumber());
+        }
+
+        return lottoNumbers;
+    }
+
+    public List<LottoNo> getLottoNums() {
+        return lottoNums;
+    }
+
+    public void checkSizeValid(List<LottoNo> lotto) {
+        if (!isValidLottoSize(lotto)) {
+            throw new IllegalArgumentException("6개의 로또 번호를 입력해 주세요.");
+        }
+    }
+
+    public void checkNumbersValid(List<LottoNo> lotto) {
+        if (lotto.contains(LottoNo.INVALID_LOTTO_NO)) {
+            throw new IllegalArgumentException("1 ~ 45 안의 정수를 입력하세요.");
+        }
+    }
+
+    public void checkDuplicated(List<LottoNo> lotto) {
+        if (!isValidLottoSize(new HashSet<>(lotto))) {
+            throw new IllegalArgumentException("로또 번호는 중복될 수 없습니다.");
+        }
+    }
+
+    boolean isValidLottoSize(List<LottoNo> lotto) {
+        return lotto.size() == LOTTO_NUM_SIZE;
+    }
+
+    boolean isValidLottoSize(Set<LottoNo> lotto) {
+        return lotto.size() == LOTTO_NUM_SIZE;
+    }
+}

리뷰: final 로 선언하신 이유가 궁금합니다~
@@ -0,0 +1,57 @@
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+public class Lotto {
+    private final static int LOTTO_NUM_SIZE = 6;
+    private final List<LottoNo> lottoNums;
+
+    public Lotto(List<LottoNo> lottoNums) {
+        checkSizeValid(lottoNums);
+        checkNumbersValid(lottoNums);
+        checkDuplicated(lottoNums);
+
+        this.lottoNums = lottoNums;
+    }
+
+    public List<Integer> getNums() {
+        List<Integer> lottoNumbers = new ArrayList<>();
+
+        for (LottoNo lottoNo : lottoNums) {
+            lottoNumbers.add(lottoNo.getLottoNumber());
+        }
+
+        return lottoNumbers;
+    }
+
+    public List<LottoNo> getLottoNums() {
+        return lottoNums;
+    }
+
+    public void checkSizeValid(List<LottoNo> lotto) {
+        if (!isValidLottoSize(lotto)) {
+            throw new IllegalArgumentException("6개의 로또 번호를 입력해 주세요.");
+        }
+    }
+
+    public void checkNumbersValid(List<LottoNo> lotto) {
+        if (lotto.contains(LottoNo.INVALID_LOTTO_NO)) {
+            throw new IllegalArgumentException("1 ~ 45 안의 정수를 입력하세요.");
+        }
+    }
+
+    public void checkDuplicated(List<LottoNo> lotto) {
+        if (!isValidLottoSize(new HashSet<>(lotto))) {
+            throw new IllegalArgumentException("로또 번호는 중복될 수 없습니다.");
+        }
+    }
+
+    boolean isValidLottoSize(List<LottoNo> lotto) {
+        return lotto.size() == LOTTO_NUM_SIZE;
+    }
+
+    boolean isValidLottoSize(Set<LottoNo> lotto) {
+        return lotto.size() == LOTTO_NUM_SIZE;
+    }
+}

리뷰: check 메서드들이 public 인 이유가 있나요~?
@@ -0,0 +1,57 @@
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+public class Lotto {
+    private final static int LOTTO_NUM_SIZE = 6;
+    private final List<LottoNo> lottoNums;
+
+    public Lotto(List<LottoNo> lottoNums) {
+        checkSizeValid(lottoNums);
+        checkNumbersValid(lottoNums);
+        checkDuplicated(lottoNums);
+
+        this.lottoNums = lottoNums;
+    }
+
+    public List<Integer> getNums() {
+        List<Integer> lottoNumbers = new ArrayList<>();
+
+        for (LottoNo lottoNo : lottoNums) {
+            lottoNumbers.add(lottoNo.getLottoNumber());
+        }
+
+        return lottoNumbers;
+    }
+
+    public List<LottoNo> getLottoNums() {
+        return lottoNums;
+    }
+
+    public void checkSizeValid(List<LottoNo> lotto) {
+        if (!isValidLottoSize(lotto)) {
+            throw new IllegalArgumentException("6개의 로또 번호를 입력해 주세요.");
+        }
+    }
+
+    public void checkNumbersValid(List<LottoNo> lotto) {
+        if (lotto.contains(LottoNo.INVALID_LOTTO_NO)) {
+            throw new IllegalArgumentException("1 ~ 45 안의 정수를 입력하세요.");
+        }
+    }
+
+    public void checkDuplicated(List<LottoNo> lotto) {
+        if (!isValidLottoSize(new HashSet<>(lotto))) {
+            throw new IllegalArgumentException("로또 번호는 중복될 수 없습니다.");
+        }
+    }
+
+    boolean isValidLottoSize(List<LottoNo> lotto) {
+        return lotto.size() == LOTTO_NUM_SIZE;
+    }
+
+    boolean isValidLottoSize(Set<LottoNo> lotto) {
+        return lotto.size() == LOTTO_NUM_SIZE;
+    }
+}

리뷰: java 8 의 stream map 을 써보면 어떨까요?
@@ -0,0 +1,57 @@
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+public class Lotto {
+    private final static int LOTTO_NUM_SIZE = 6;
+    private final List<LottoNo> lottoNums;
+
+    public Lotto(List<LottoNo> lottoNums) {
+        checkSizeValid(lottoNums);
+        checkNumbersValid(lottoNums);
+        checkDuplicated(lottoNums);
+
+        this.lottoNums = lottoNums;
+    }
+
+    public List<Integer> getNums() {
+        List<Integer> lottoNumbers = new ArrayList<>();
+
+        for (LottoNo lottoNo : lottoNums) {
+            lottoNumbers.add(lottoNo.getLottoNumber());
+        }
+
+        return lottoNumbers;
+    }
+
+    public List<LottoNo> getLottoNums() {
+        return lottoNums;
+    }
+
+    public void checkSizeValid(List<LottoNo> lotto) {
+        if (!isValidLottoSize(lotto)) {
+            throw new IllegalArgumentException("6개의 로또 번호를 입력해 주세요.");
+        }
+    }
+
+    public void checkNumbersValid(List<LottoNo> lotto) {
+        if (lotto.contains(LottoNo.INVALID_LOTTO_NO)) {
+            throw new IllegalArgumentException("1 ~ 45 안의 정수를 입력하세요.");
+        }
+    }
+
+    public void checkDuplicated(List<LottoNo> lotto) {
+        if (!isValidLottoSize(new HashSet<>(lotto))) {
+            throw new IllegalArgumentException("로또 번호는 중복될 수 없습니다.");
+        }
+    }
+
+    boolean isValidLottoSize(List<LottoNo> lotto) {
+        return lotto.size() == LOTTO_NUM_SIZE;
+    }
+
+    boolean isValidLottoSize(Set<LottoNo> lotto) {
+        return lotto.size() == LOTTO_NUM_SIZE;
+    }
+}

리뷰: 접근제어자를 package-private 으로 하신 이유가 있을까요?
@@ -0,0 +1,57 @@
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+public class Lotto {
+    private final static int LOTTO_NUM_SIZE = 6;
+    private final List<LottoNo> lottoNums;
+
+    public Lotto(List<LottoNo> lottoNums) {
+        checkSizeValid(lottoNums);
+        checkNumbersValid(lottoNums);
+        checkDuplicated(lottoNums);
+
+        this.lottoNums = lottoNums;
+    }
+
+    public List<Integer> getNums() {
+        List<Integer> lottoNumbers = new ArrayList<>();
+
+        for (LottoNo lottoNo : lottoNums) {
+            lottoNumbers.add(lottoNo.getLottoNumber());
+        }
+
+        return lottoNumbers;
+    }
+
+    public List<LottoNo> getLottoNums() {
+        return lottoNums;
+    }
+
+    public void checkSizeValid(List<LottoNo> lotto) {
+        if (!isValidLottoSize(lotto)) {
+            throw new IllegalArgumentException("6개의 로또 번호를 입력해 주세요.");
+        }
+    }
+
+    public void checkNumbersValid(List<LottoNo> lotto) {
+        if (lotto.contains(LottoNo.INVALID_LOTTO_NO)) {
+            throw new IllegalArgumentException("1 ~ 45 안의 정수를 입력하세요.");
+        }
+    }
+
+    public void checkDuplicated(List<LottoNo> lotto) {
+        if (!isValidLottoSize(new HashSet<>(lotto))) {
+            throw new IllegalArgumentException("로또 번호는 중복될 수 없습니다.");
+        }
+    }
+
+    boolean isValidLottoSize(List<LottoNo> lotto) {
+        return lotto.size() == LOTTO_NUM_SIZE;
+    }
+
+    boolean isValidLottoSize(Set<LottoNo> lotto) {
+        return lotto.size() == LOTTO_NUM_SIZE;
+    }
+}

리뷰: Set<LottoNo> 로 선언하는 것은 어떻게 생각하시나요~
@@ -0,0 +1,46 @@
+import java.util.Objects;
+
+public class LottoNo implements Comparable<LottoNo> {
+    private final static int INVALID_NUM = 0;
+    public final static LottoNo INVALID_LOTTO_NO = new LottoNo(INVALID_NUM);
+
+    private int lottoNumber;
+
+    public LottoNo(int lottoNumber) {
+        try {
+            this.lottoNumber = validateLottoNo(lottoNumber);
+        } catch (IllegalArgumentException e) {
+            this.lottoNumber = INVALID_NUM;
+        }
+    }
+
+    public int getLottoNumber() {
+        return lottoNumber;
+    }
+
+    private int validateLottoNo(int lottoNumber) {
+        if (lottoNumber < 1 || lottoNumber > 45) {
+            throw new IllegalArgumentException();
+        }
+
+        return lottoNumber;
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hashCode(lottoNumber);
+    }
+
+    @Override
+    public boolean equals(Object obj) {
+        if (lottoNumber == ((LottoNo)obj).lottoNumber)
+            return true;
+
+        return false;
+    }
+
+    @Override
+    public int compareTo(LottoNo o) {
+        return lottoNumber - o.lottoNumber;
+    }
+}

리뷰: LottoNo 는 변경되지 않는 lottoNumber 하나를 가지고 있어야하므로
final 키워드를 넣어도 될 것 같네요 ㅎㅎ
@@ -0,0 +1,46 @@
+import java.util.Objects;
+
+public class LottoNo implements Comparable<LottoNo> {
+    private final static int INVALID_NUM = 0;
+    public final static LottoNo INVALID_LOTTO_NO = new LottoNo(INVALID_NUM);
+
+    private int lottoNumber;
+
+    public LottoNo(int lottoNumber) {
+        try {
+            this.lottoNumber = validateLottoNo(lottoNumber);
+        } catch (IllegalArgumentException e) {
+            this.lottoNumber = INVALID_NUM;
+        }
+    }
+
+    public int getLottoNumber() {
+        return lottoNumber;
+    }
+
+    private int validateLottoNo(int lottoNumber) {
+        if (lottoNumber < 1 || lottoNumber > 45) {
+            throw new IllegalArgumentException();
+        }
+
+        return lottoNumber;
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hashCode(lottoNumber);
+    }
+
+    @Override
+    public boolean equals(Object obj) {
+        if (lottoNumber == ((LottoNo)obj).lottoNumber)
+            return true;
+
+        return false;
+    }
+
+    @Override
+    public int compareTo(LottoNo o) {
+        return lottoNumber - o.lottoNumber;
+    }
+}

리뷰: 음.. RuntimeException 을 발생시키고 catch 를 굳이 하는 이유가 있을까요?
checkedException 과 uncheckedException 에 대해서 공부해보시면 좋을 것 같습니다~
@@ -0,0 +1,46 @@
+import java.util.Objects;
+
+public class LottoNo implements Comparable<LottoNo> {
+    private final static int INVALID_NUM = 0;
+    public final static LottoNo INVALID_LOTTO_NO = new LottoNo(INVALID_NUM);
+
+    private int lottoNumber;
+
+    public LottoNo(int lottoNumber) {
+        try {
+            this.lottoNumber = validateLottoNo(lottoNumber);
+        } catch (IllegalArgumentException e) {
+            this.lottoNumber = INVALID_NUM;
+        }
+    }
+
+    public int getLottoNumber() {
+        return lottoNumber;
+    }
+
+    private int validateLottoNo(int lottoNumber) {
+        if (lottoNumber < 1 || lottoNumber > 45) {
+            throw new IllegalArgumentException();
+        }
+
+        return lottoNumber;
+    }
+
+    @Override
+    public int hashCode() {
+        return Objects.hashCode(lottoNumber);
+    }
+
+    @Override
+    public boolean equals(Object obj) {
+        if (lottoNumber == ((LottoNo)obj).lottoNumber)
+            return true;
+
+        return false;
+    }
+
+    @Override
+    public int compareTo(LottoNo o) {
+        return lottoNumber - o.lottoNumber;
+    }
+}

리뷰: 굳이 메서드로 빼지 않아도 될 것 같아요