๐ ๊ธฐ๋ฅ๋ณ ์์ฐ ์์ 1
๐ ๊ธฐ๋ฅ๋ณ ์์ฐ ์์ 2
ํ์์ ์ผ์์ด๋ ์ฌํ ์ถ์ต๋ค์ ๊ธฐ๋กํด ์ถ์ตํ๊ธฐ ์ํจ
๐ ์๋น์ค
๐ ํ๋ก์ ํธ ๋
ธ์
๋ณด๊ธฐ
2025.02 ~ ing
graph TD
A[ํ์๊ฐ์
/๋ก๊ทธ์ธ] -->|๋ก๊ทธ์ธ ์ฑ๊ณต| B[ํ ํ๋ฉด]
B --> C[์ผ์ ๊ธฐ๋ก ๋ณด๊ธฐ]
B --> D[์ผ์ ๊ธฐ๋ก ํ๊ธฐ]
B --> E[ํ๋กํ ๋ณด๊ธฐ]
B --> H[์ผ์ ๊ฒ์]
C --> F[๋๊ธ ์์ฑ - todo]
C --> G[๊ฒ์๋ฌผ ์ข์์ - todo]
E --> I[ํ๋กํ ์์ ]
D --> J[์ผ์์ ๋ณด๋ธ ์ฅ์ ์ถ๊ฐ]
J --> N[์ฌ์ง ์
๋ก๋]
F --> K[๋๋๊ธ ์์ฑ - todo]
H --> L[ํค์๋ ๊ธฐ๋ฐ ๊ฒ์]
H --> M[์ฅ์ ๊ธฐ๋ฐ ๊ฒ์ - todo]
H --> O[์นดํ
๊ณ ๋ฆฌ ๊ธฐ๋ฐ ๊ฒ์ - todo]
- ํ์๊ฐ์ / ๋ก๊ทธ์ธ
- ๊ตฌ๊ธ, ์นด์นด์ค ๋ก๊ทธ์ธ
- ํ๋กํ ์กฐํ / ์์
- ์ผ์ ๊ธฐ๋ก / ์์ / ์ญ์
- ์ผ์ ๊ธฐ๋ก ์์ธ ์กฐํ (๋ชจ๋ ์ฅ์ ์กฐํ)
- ์ฅ์ ์์ฑ / ์์
- ์ด๋ฏธ์ง ์ ๋ก๋ / ์ญ์
- ๊ธฐ๋ก ์ญ์ ์ ๊ธฐ๋ก์ ํฌํจ๋ ๋ชจ๋ ์ฅ์์ญ์ (์ด๋ฏธ์ง ํฌํจ)
- ํค์๋ ๊ธฐ๋ฐ ๊ฒ์
โ ์ฌ์ฉ ์ด์
Java์ Spring Boot๋ก ๊ฐ๋ฐ์ ํด์์ง๋ง ๊ฐ์ธ ํ๋ก์ ํธ๋ฅผ ์งํํ๋ฉด์ ์๋ก์ด ์ธ์ด์ธ Kotlin์ ๊ฒฝํํด๋ณด๊ณ ์ถ์์.
Kotlin์ ๊ฐ๋ ์ฑ๊ณผ ๋ ์ฒ๋ฆฌ ๋ฐฉ์(Null Safety)์ด ๋ฐ์ด๋๊ณ ๊ฐ๋ฐ ์์ฐ์ฑ์ ๋์ฌ์ค ์ ์๋ค๊ณ ํ๋จํ์ฌ ๋์ ํ๊ฒ ๋จ.
๐๐ป ์ฅ์
Null Safety
- Kotlin์ ํ์ ์์คํ ์ ํตํด NPE(NullPointerException) ๊ฐ๋ฅ์ฑ์ ์ปดํ์ผ ํ์์ ์๋ฐฉ.
- ๋ช ์์ ์ธ ?, ?:, !! ์ฐ์ฐ์ ์ฌ์ฉ์ผ๋ก ์ฝ๋ ์์ ์ฑ์ ๋์.
ํ์ฅ ํจ์
- ๊ธฐ์กด ํด๋์ค์ ๊ธฐ๋ฅ์ ์ถ๊ฐํ ์ ์๋ Kotlin ํ์ฅ ํจ์๋ฅผ ์ ๊ทน ํ์ฉํ์ฌ ์ฝ๋ ์ฌ์ฌ์ฉ์ฑ๊ณผ ๊ฐ๋ ์ฑ์ ๋์.
Data Class
- ๊ฐ๊ฒฐํ๊ฒ DTO, Entity ์ ์๋ฅผ ์ํด data class๋ฅผ ์ ๊ทน ํ์ฉ
- equals/hashCode/toString ์๋ ์์ฑ์ ์ด์ ์ ํ์ฉ.
โ
์ฌ์ฉ ์ด์
Kotlin + Coroutine ํ๊ฒฝ์์ ๋น๋๊ธฐ DB ํต์ ์ด ํ์ํด R2DBC๋ฅผ ์ ํํ์ต๋๋ค.
Hibernate Reactive๋ ๊ฒํ ํ์ผ๋ ์ฝ๋ฃจํด๊ณผ์ ํธํ์ฑ ์ธก๋ฉด์์ R2DBC๊ฐ ๋ ์ ํฉํ๋ค๊ณ ํ๋จํ์ต๋๋ค.
๐๐ป ์ฅ์
- Kotlin Coroutine ์ง์: suspend ํจ์ ๊ธฐ๋ฐ์ผ๋ก ์์ฐ์ค๋ฝ๊ฒ ๋น๋๊ธฐ ์ฝ๋ ์์ฑ ๊ฐ๋ฅ
- Spring ๊ณต์ ์ง์: ํธ๋์ญ์ ๋ฑ Spring ์ํ๊ณ์ ์ฐ๋์ด ์ฌ์
- ORM ๊ธฐ๋ฅ ๋ฏธ์ง์: JPA ์ด๋ ธํ ์ด์ ์ฌ์ฉ ๋ถ๊ฐ, ์ํฐํฐ ๋งคํ ์๋ ์ฒ๋ฆฌ ํ์
- ์คํค๋ง ์๋ ์์ฑ ์์: ์ง์ SQL ์คํฌ๋ฆฝํธ๋ ๋ง์ด๊ทธ๋ ์ด์ ๋๊ตฌ ์ฌ์ฉ
๐ ์์
interface UserRepository : CoroutineCrudRepository<User, Long>
suspend fun findUserById(id: Long): User? = userRepository.findById(id)โ ์ฌ์ฉ ์ด์
-
๋ฐ์ดํฐ ์ ํฉ์ฑ ๋ณด์ฅ: ์ผ์(story) ์ญ์ ์ ์ฐ๊ฒฐ๋ ์ฅ์(place) ์ญ์ ๋ฅผ ๋น๋๊ธฐ์ ์ผ๋ก ์ฒ๋ฆฌํ๋ฉด์๋ ํธ๋์ญ์ ์์ ์ฑ ํ๋ณด
-
์ ๋ขฐ์ฑ: DB ํธ๋์ญ์ ๊ณผ Kafka ๋ฉ์์ง ๋ฐํ์ ์์์ (atomic) ์ผ๋ก ์ฒ๋ฆฌํด ๋ฉ์์ง ์ ์ค ๋ฐฉ์ง
-
์์คํ ๋ถ๋ฆฌ: ์ฅ์ ์ญ์ ์ฒ๋ฆฌ๋ฅผ ์ด๋ฒคํธ ๊ธฐ๋ฐ์ผ๋ก ๋ถ๋ฆฌํด ์๋น์ค ๊ฐ ๊ฒฐํฉ๋ ๊ฐ์
๐ ๏ธ ๊ตฌํ ๋ฐฉ๋ฒ
- ์ด๋ฒคํธ ์ ์ฅ (story service)
@Transactional
suspend fun delete(userId: Long, storyId: Long) {
storyDeletePort.deleteStory(userId, storyId) // 1. ์ผ์ ์ญ์
storyOutboxPort.saveStoryDeleteMessage(storyId) // 2. ์์๋ฐ์ค ํ
์ด๋ธ ์ ์ฅ
}- message ๋ฐํ
@Scheduled(cron = "0 */10 * * * *") // 10๋ถ ์ฃผ๊ธฐ ์คํ
@Transactional
suspend fun publishPendingMessages() {
val pendingMessages = outboxRepository.findAllByOrderByCreatedAtAsc(batchSize)
runBlocking {
val successfulIds = publishPendingMessagesAsync(pendingMessages) // 3. Kafka ๋ฐํ
outboxRepository.deleteAllById(successfulIds) // 4. ์ฑ๊ณตํ ๋ฉ์์ง ์ญ์
}
}- consume ํ ์ญ์ ์ฒ๋ฆฌ(place service)
@KafkaListener(topics = ["\${kafka.topic.story-outbox}"])
fun handleStoryOutboxEvent(payload: String) {
runBlocking {
try {
val storyOutboxPayload = Json.decodeFromString<StoryOutboxPayload>(payload)
when (storyOutboxPayload.type) {
StoryOutboxType.STORY_DELETED -> handleStoryDeletedEvent(storyOutboxPayload.storyId)
}
} catch (e: Exception) {
logger.error("โ Kafka ๋ฉ์์ง ์ฒ๋ฆฌ ์ค ์ค๋ฅ ๋ฐ์: ${e.message}")
throw e
}
}
}๐ ๋ฐฐ๊ฒฝ ๋ฐ ๋ฌธ์ ์ํฉ
๋จ์ํ ์ฌ์ฉ์๋ค์ด "์ ๋ชฉ" ๋๋ "์ค๋ช
"์ ํฌํจ๋ ํค์๋๋ก LIKE '%keyword%' ๊ฒ์์ ์ํํ๋๋ก ์ค๊ณํ์ง๋ง ์ด๋ ํ
์ด๋ธ ํ ์ค์บ ๋ฐฉ์์ผ๋ก ๋ฐ์ดํฐ๊ฐ ๋ง์์ง์๋ก ์ฑ๋ฅ์ด ์ข์ง ์์ ๊ฒ์ด๋ผ ์๊ฐ
๐ ๏ธ ํด๊ฒฐ์ฑ : Full-Text Search + n-gram
ALTER TABLE table_A ADD FULLTEXT INDEX idx_fulltext_title_desc (title, description);EXPLAIN
SELECT * FROM table_A WHERE MATCH(title, description) AGAINST('์ฌํ' IN BOOLEAN MODE);ํธ๋์ญ์
์์๋ฐ์ค ํจํด์ ์ฌ์ฉํ์ฌ outbox ํ
์ด๋ธ์์ ๋ฐฐ์น ๋จ์๋ก ๋ฐ์ดํฐ๋ฅผ ๊ฐ์ ธ์ Kafka๋ก ๋ฉ์์ง๋ฅผ ๋ฐํํ์ฌ ๋ฐ์ดํฐ์ ์ ํฉ์ฑ์ ๋ง์ถ๋ ๊ณผ์ ์์
๋ฉ์์ง๊ฐ ์ค์ ๋ก Kafka์ ์ ์์ ์ผ๋ก ์ ๋ฌ๋์๋์ง๋ฅผ ํ์
ํ๊ณ ์ ํ์ต๋๋ค.
์ด๊ธฐ์๋ KafkaTemplate.send().get() ๋ฅผ ํตํด ๋๊ธฐ์ ์ผ๋ก ๋ฉ์์ง๊ฐ ์ ์์ ์ผ๋ก ์ ๋ฌ๋์๋์ง ํ์ธํ์ต๋๋ค.
๐ฃ๋ฌธ์ ์
- ๋๊ธฐ์ ์ฒ๋ฆฌ๋ก ๋ฉ์์ง๋น ์๋ต์ ๊ธฐ๋ค๋ ค์ผ ํจ
- ๋๋ ๋ฉ์์ง ์ฒ๋ฆฌ ์ ์ฑ๋ฅ ์ ํ
- Kafka์ ๋น๋๊ธฐ์ ํน์ฑ์ ํ์ฉํ์ง ๋ชปํจ
- ์์ฐจ์ ์ฒ๋ฆฌ๋ก ์ ์ฒด ์์ ์๊ฐ ์ฆ๊ฐ
์ฝ๋ฃจํด ๊ธฐ๋ฐ์ ๋น๋๊ธฐ ๋ฉ์์ง ์ ์ก ๋ฐฉ์ ์ผ๋ก ํด๊ฒฐ
ํด๊ฒฐ ๊ณผ์ - blog
๐ ๋ฌธ์ ์ํฉ
Spring Data R2DBC๋ ๋ฆฌ์กํฐ๋ธ ํน์ฑ์ Page/Slice ๋ฐํ์ ๊ณต์ ์ง์ โ
๊ธฐ์กด JPA์ PagingAndSortingRepository์ฒ๋ผ ์๋ํ๋ ํ์ด์ง์ด ๋ถ๊ฐ๋ฅํด ์กฐํ + ์ด ๊ฐ์ ์ฟผ๋ฆฌ๋ฅผ ์๋์ผ๋ก ๊ฒฐํฉ
๐ ๏ธ ํด๊ฒฐ ์ ๋ต
์ปค์คํ ๋ ํฌ์งํ ๋ฆฌ ๊ตฌํ
- DatabaseClient๋ฅผ ์ด์ฉํด LIMIT/OFFSET ๊ธฐ๋ฐ SQL ์ง์ ์์ฑ
(์์)
val contentSql = """
SELECT * FROM story
WHERE title = :title
LIMIT :limit OFFSET :offset
"""
val countSql = "SELECT COUNT(*) FROM story WHERE title = :title"๋ณ๋ ฌ ์ฟผ๋ฆฌ ์คํ
- ์ปจํ ์ธ ์กฐํ์ ์ด ๊ฐ์ ์กฐํ๋ฅผ ์ฝ๋ฃจํด ๋ณ๋ ฌ ์ฒ๋ฆฌ
suspend fun paginate(...): Page<StoryEntity> {
val content = contentQuery()
val total = countQuery()
return PageImpl(content, pageable, total)
}- SQL ์ค๋ณต: ์ปจํ ์ธ /๊ฐ์ ์ฟผ๋ฆฌ์ ์กฐ๊ฑด๋ฌธ์ด ๋๊ธฐํ๋์ง ์์ผ๋ฉด ๋ฐ์ดํฐ ๋ถ์ผ์น ๋ฐ์
- ์ฑ๋ฅ ์ ํ: OFFSET ๊ฐ์ด ํด์๋ก ์ฟผ๋ฆฌ ์ฑ๋ฅ์ด ์ ํ์ ์ผ๋ก ๊ฐ์
- ํธ๋์ญ์ ๋ถ๋ฆฌ: ๊ฐ์ ์ฟผ๋ฆฌ์ ์ปจํ ์ธ ์ฟผ๋ฆฌ๊ฐ ๋ค๋ฅธ ์ค๋ ์ท์ ๋ณผ ์ ์์
๐ ์ต์ ํ ๋ฐฉ์(todo)
- ์ปค์ ๊ธฐ๋ฐ ํ์ด์ง (๊ณ ๋ ค)
WHERE id > :lastId ORDER BY id LIMIT :size- OFFSET ๋์ ID ๋ฒ์ ์กฐ๊ฑด ์ฌ์ฉ
- R2dbcEntityTemplate ํ์ฉ
template.select(StoryEntity::class)
.matching(Query.query(where("title").`is`(title)).with(pageable))
.all()- ์บ์ ์ ๋ต
@Cacheable("storyCount")
suspend fun getTotalCount(): Long- ๊ฐ์ ์ ๋ณด์ ๋ํด ์บ์ฑ์ผ๋ก ์กฐํ ์ฑ๋ฅ์ด ํฅ์๋ ๊ฒ์ด๋ผ ์์
