Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/main/kotlin/com/weeth/WeethApplication.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@ import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.context.properties.ConfigurationPropertiesScan
import org.springframework.boot.runApplication
import org.springframework.data.jpa.repository.config.EnableJpaAuditing
import org.springframework.scheduling.annotation.EnableAsync
import org.springframework.scheduling.annotation.EnableScheduling
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity

@EnableAsync
Copy link
Copy Markdown

@coderabbitai coderabbitai bot Mar 31, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "1) Async 관련 설정/코드 탐색"
rg -n -C3 --type=kt '@EnableAsync|@Async|AsyncConfigurer|TaskExecutor|ThreadPoolTaskExecutor'

echo
echo "2) TaskExecutor Bean 정의 확인"
rg -n -C3 --type=kt '@Bean|TaskExecutor|Executor'

echo
echo "3) application 설정에서 task execution 관련 프로퍼티 확인"
fd -i 'application*.yml' src/main/resources -x sh -c 'echo "== {} =="; rg -n -C2 "spring\\.task\\.execution|task\\.execution|async" "{}" || true'
fd -i 'application*.yaml' src/main/resources -x sh -c 'echo "== {} =="; rg -n -C2 "spring\\.task\\.execution|task\\.execution|async" "{}" || true'
fd -i 'application*.properties' src/main/resources -x sh -c 'echo "== {} =="; rg -n -C2 "spring\\.task\\.execution|task\\.execution|async" "{}" || true'

Repository: Team-Weeth/weeth-server

Length of output: 131


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "1) Async 관련 Kotlin 코드 탐색 (`@EnableAsync`, `@Async`, AsyncConfigurer, TaskExecutor 등)"
rg -n -C3 '@EnableAsync|@Async|AsyncConfigurer|TaskExecutor|ThreadPoolTaskExecutor' -g '*.kt' || echo "(No async-related annotations found)"

echo
echo "2) TaskExecutor Bean 정의 확인"
rg -n -C3 '@Bean|TaskExecutor|Executor' -g '*.kt' | head -100 || echo "(No bean definitions found)"

echo
echo "3) WeethApplication.kt 파일 확인"
cat -n src/main/kotlin/com/weeth/WeethApplication.kt

echo
echo "4) application 설정 파일 탐색"
find src/main/resources -type f \( -name 'application*.yml' -o -name 'application*.yaml' -o -name 'application*.properties' \) -exec sh -c 'echo "== {} =="; cat "{}"' \;

echo
echo "5) Spring task execution 프로퍼티 확인"
rg -n 'spring\.task\.execution|task\.execution|async' || echo "(No task execution properties found)"

Repository: Team-Weeth/weeth-server

Length of output: 16729


@EnableAsync 활성화 시 TaskExecutor 설정이 누락되었습니다. 즉시 수정이 필요합니다.

현재 코드베이스에서 @Async 메서드가 실제로 사용되고 있습니다:

  • NotionInquirySaveAdapter.save() (외부 Notion API 호출)
  • SlackInquiryNotifyAdapter.notify() (외부 Slack API 호출)

하지만 TaskExecutor 설정이 없어 Spring의 기본값인 SimpleAsyncTaskExecutor가 사용됩니다. 이는 각 비동기 작업마다 새로운 스레드를 생성하므로, 문의 폭주 시 스레드 무한 생성으로 인한 메모리 고갈 및 시스템 마비를 초래할 수 있습니다.

다음 중 하나로 해결하세요:

  1. TaskExecutor Bean을 정의하고 스레드 풀 크기/큐 정책을 명시 (ThreadPoolTaskExecutor 권장)
  2. 또는 spring.task.execution.* 프로퍼티로 스레드 풀 설정 (예: spring.task.execution.pool.core-size, max-size, queue-capacity)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/kotlin/com/weeth/WeethApplication.kt` at line 11, You enabled
`@EnableAsync` but didn't provide a TaskExecutor, so Spring will use
SimpleAsyncTaskExecutor (unsafe under load); add a ThreadPoolTaskExecutor bean
(e.g., in the WeethApplication class or a new `@Configuration`) and configure
corePoolSize, maxPoolSize, queueCapacity and threadNamePrefix, then
return/initialize it as the async executor (or mark it `@Primary` or name it
"taskExecutor") so async methods like NotionInquirySaveAdapter.save() and
SlackInquiryNotifyAdapter.notify() run on a bounded thread pool; alternatively
set spring.task.execution.pool.* properties in application.properties/yaml to
provide the same pool sizing.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AsyncConfig도 추가해두는게 조을 것 같아욤!

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@hyxklee 동의해주셔서 감사합니다! 아래와 같이 AsyncConfig를 추가하시면 됩니다.

src/main/kotlin/com/weeth/global/config/AsyncConfig.kt 파일을 새로 생성해 주세요:

package com.weeth.global.config

import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor
import java.util.concurrent.Executor

`@Configuration`
class AsyncConfig {

    `@Bean`(name = ["taskExecutor"])
    fun taskExecutor(): Executor {
        return ThreadPoolTaskExecutor().apply {
            corePoolSize = 5
            maxPoolSize = 20
            queueCapacity = 100
            setThreadNamePrefix("async-inquiry-")
            initialize()
        }
    }
}

또는 별도 클래스 없이 application.yml에 다음을 추가하는 방법도 있습니다:

spring:
  task:
    execution:
      pool:
        core-size: 5
        max-size: 20
        queue-capacity: 100
      thread-name-prefix: async-inquiry-

설정값 근거 (Notion/Slack 같은 외부 API 호출 위주의 I/O-bound 작업 기준):

파라미터 권장값 이유
corePoolSize 5 기본 유지 스레드 수
maxPoolSize 20 트래픽 급증 시 최대 확장 한계
queueCapacity 100 최대 스레드 초과 시 대기 큐
threadNamePrefix async-inquiry- 스레드 덤프/로그에서 식별 용이

queueCapacity가 가득 차고 maxPoolSize에 도달하면 TaskRejectedException이 발생하므로, 필요 시 RejectedExecutionHandler도 함께 설정하시면 더욱 안전합니다.


🧠 Learnings used
Learnt from: hyxklee
Repo: Team-Weeth/weeth-server PR: 44
File: src/main/kotlin/com/weeth/domain/user/presentation/SocialCallbackController.kt:55-60
Timestamp: 2026-03-31T06:54:23.087Z
Learning: When reviewing Spring code in Kotlin, note that `UriComponentsBuilder.toUriString()` performs URL encoding internally (it effectively calls `encode().build().toUriString()`), including non-ASCII characters such as Korean. Therefore, do not flag `UriComponentsBuilder.toUriString()` as “missing encoding”. Only `UriComponents.toUriString()` is simple string concatenation and does not apply encoding by default.

@EnableScheduling
@EnableJpaAuditing
@EnableWebSecurity
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.weeth.domain.user.application.dto.request

import io.swagger.v3.oas.annotations.media.Schema
import jakarta.validation.constraints.Email
import jakarta.validation.constraints.NotBlank
import jakarta.validation.constraints.Size

data class CreateInquiryRequest(
@field:Schema(description = "이메일", example = "user@example.com")
@field:NotBlank
@field:Email
@field:Size(max = 255)
val email: String,
@field:Schema(description = "문의 내용", example = "서비스에 대해 문의드립니다.")
@field:NotBlank
@field:Size(max = 1000)
val message: String,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package com.weeth.domain.user.application.usecase.command

import com.weeth.domain.user.application.dto.request.CreateInquiryRequest
import com.weeth.domain.user.domain.port.InquiryNotifyPort
import com.weeth.domain.user.domain.port.InquirySavePort
import org.springframework.stereotype.Service

@Service
class CreateInquiryUseCase(
private val inquirySavePort: InquirySavePort,
private val inquiryNotifyPort: InquiryNotifyPort,
) {
fun execute(request: CreateInquiryRequest) {
Comment on lines +8 to +13
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# command usecase의 `@Transactional` 적용 현황 확인
fd -t f -e kt 'UseCase' src/main/kotlin/com/weeth/domain \
  | xargs rg -n -C1 '@Transactional|class .*UseCase|fun execute\('

Repository: Team-Weeth/weeth-server

Length of output: 26082


커맨드 UseCase에 @Transactional 애너테이션 추가 필요

CreateInquiryUseCase.execute() 메서드는 데이터 쓰기 작업(inquirySavePort.save())을 수행하는 커맨드 작업입니다. 가이드라인에 따라 커맨드 작업은 반드시 @Transactional 애너테이션을 적용해야 합니다.

수정 예시
 import com.weeth.domain.user.domain.port.InquirySavePort
 import org.springframework.stereotype.Service
+import org.springframework.transaction.annotation.Transactional

 `@Service`
 class CreateInquiryUseCase(
     private val inquirySavePort: InquirySavePort,
     private val inquiryNotifyPort: InquiryNotifyPort,
 ) {
+    `@Transactional`
     fun execute(request: CreateInquiryRequest) {
         inquirySavePort.save(request.email, request.message)
         inquiryNotifyPort.notify(request.email, request.message)
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/main/kotlin/com/weeth/domain/user/application/usecase/command/CreateInquiryUseCase.kt`
around lines 8 - 13, The CreateInquiryUseCase.execute() method performs a write
via inquirySavePort.save() and must be executed within a transaction; annotate
it (or the CreateInquiryUseCase class) with `@Transactional` so the save operation
is wrapped in a transactional boundary, and ensure the proper Spring transaction
annotation is imported and applied to the CreateInquiryUseCase class or the
execute(request: CreateInquiryRequest) method to enforce rollback semantics on
failure.

inquirySavePort.save(request.email, request.message)
inquiryNotifyPort.notify(request.email, request.message)
Comment on lines +14 to +15
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

저장 성공 후 알림 실패 시 요청 재시도로 중복 저장 위험이 있습니다.

현재 순서에서는 저장이 이미 성공한 상태에서 알림 실패 예외가 나면 클라이언트는 실패로 인식해 재시도할 수 있고, 그 경우 동일 문의가 다시 저장될 수 있습니다. 저장과 알림의 성공 기준을 분리(예: 알림 비동기/재시도 큐)하는 쪽이 안전합니다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@src/main/kotlin/com/weeth/domain/user/application/usecase/command/CreateInquiryUseCase.kt`
around lines 14 - 15, 현재 CreateInquiryUseCase의 호출 순서(inquirySavePort.save(...);
inquiryNotifyPort.notify(...))는 저장 성공 후 알림 실패 시 클라이언트 재시도로 중복 저장이 발생할 수 있으므로,
inquirySavePort.save는 동기적으로 그대로 두고 inquiryNotifyPort.notify 호출을 비동기/내결함 방식으로
변경하세요; 구체적으로 CreateInquiryUseCase의 notify 호출을 즉시 백그라운드 작업(예: 비동기 실행기/메시지 큐로
전송)으로 옮기거나 notify 호출을 try-catch로 감싸 실패 시 예외를 전파하지 않고 retry 큐에 넣도록 구현해 저장과 알림의 성공
기준을 분리하고 중복 저장을 방지하세요 (참조: inquirySavePort.save, inquiryNotifyPort.notify,
CreateInquiryUseCase).

}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.weeth.domain.user.domain.port

interface InquiryNotifyPort {
fun notify(
email: String,
message: String,
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.weeth.domain.user.domain.port

interface InquirySavePort {
fun save(
email: String,
message: String,
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package com.weeth.domain.user.infrastructure

import com.weeth.domain.user.domain.port.InquirySavePort
import com.weeth.global.config.properties.NotionProperties
import org.slf4j.LoggerFactory
import org.springframework.http.HttpHeaders
import org.springframework.scheduling.annotation.Async
import org.springframework.stereotype.Component
import org.springframework.web.client.RestClient
import java.time.LocalDate

@Component
class NotionInquirySaveAdapter(
private val notionProperties: NotionProperties,
restClientBuilder: RestClient.Builder,
) : InquirySavePort {
private val restClient = restClientBuilder.baseUrl("https://api.notion.com").build()
private val log = LoggerFactory.getLogger(javaClass)

@Async
override fun save(
email: String,
message: String,
) {
val body =
mapOf(
"parent" to
mapOf(
"type" to "database_id",
"database_id" to notionProperties.inquiryDatabaseId,
),
"properties" to
mapOf(
"문의내용" to
mapOf(
"title" to listOf(mapOf("text" to mapOf("content" to message))),
),
"이메일" to
mapOf(
"email" to email,
),
"날짜" to
mapOf(
"date" to mapOf("start" to LocalDate.now().toString()),
),
),
)

runCatching {
restClient
.post()
.uri("/v1/pages")
.header(HttpHeaders.AUTHORIZATION, "Bearer ${notionProperties.token}")
.header("Notion-Version", notionProperties.version)
.header(HttpHeaders.CONTENT_TYPE, "application/json")
.body(body)
.retrieve()
.toBodilessEntity()
}.onFailure { e -> log.warn("Notion 저장 실패: {}", e.message) }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package com.weeth.domain.user.infrastructure

import com.weeth.domain.user.domain.port.InquiryNotifyPort
import com.weeth.global.config.properties.SlackProperties
import org.slf4j.LoggerFactory
import org.springframework.scheduling.annotation.Async
import org.springframework.stereotype.Component
import org.springframework.web.client.RestClient

@Component
class SlackInquiryNotifyAdapter(
private val slackProperties: SlackProperties,
restClientBuilder: RestClient.Builder,
) : InquiryNotifyPort {
private val restClient = restClientBuilder.build()
private val log = LoggerFactory.getLogger(javaClass)

@Async
override fun notify(
email: String,
message: String,
) {
val text = "*[랜딩 문의하기]*\n*이메일:* $email\n*문의 내용:*\n```$message```"

runCatching {
restClient
.post()
.uri(slackProperties.webhookUrl)
.header("Content-Type", "application/json")
.body(mapOf("text" to text))
.retrieve()
.toBodilessEntity()
}.onFailure { e -> log.warn("Slack 알림 전송 실패: {}", e.message) }
}
}
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
package com.weeth.domain.user.presentation

import com.weeth.domain.user.application.dto.request.AgreeTermsRequest
import com.weeth.domain.user.application.dto.request.CreateInquiryRequest
import com.weeth.domain.user.application.dto.request.SocialLoginRequest
import com.weeth.domain.user.application.dto.request.UpdateUserProfileRequest
import com.weeth.domain.user.application.dto.response.SocialLoginResponse
import com.weeth.domain.user.application.exception.UserErrorCode
import com.weeth.domain.user.application.usecase.command.AgreeTermsUseCase
import com.weeth.domain.user.application.usecase.command.AuthUserUseCase
import com.weeth.domain.user.application.usecase.command.CreateInquiryUseCase
import com.weeth.domain.user.application.usecase.command.SocialLoginUseCase
import com.weeth.domain.user.application.usecase.command.UpdateUserProfileUseCase
import com.weeth.global.auth.annotation.CurrentUser
Expand Down Expand Up @@ -38,6 +40,7 @@ class UserController(
private val socialLoginUseCase: SocialLoginUseCase,
private val updateUserProfileUseCase: UpdateUserProfileUseCase,
private val agreeTermsUseCase: AgreeTermsUseCase,
private val createInquiryUseCase: CreateInquiryUseCase,
private val tokenCookieProvider: TokenCookieProvider,
) {
@PostMapping("/social/kakao")
Expand Down Expand Up @@ -104,6 +107,16 @@ class UserController(
return CommonResponse.success(UserResponseCode.USER_UPDATE_SUCCESS)
}

@PostMapping("/inquiries")
@Operation(summary = "문의하기")
@SecurityRequirements
fun createInquiry(
@RequestBody @Valid request: CreateInquiryRequest,
): CommonResponse<Void> {
createInquiryUseCase.execute(request)
return CommonResponse.success(UserResponseCode.INQUIRY_SEND_SUCCESS)
}

private fun <T> buildTokenResponse(
body: CommonResponse<T>,
accessToken: String,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,5 @@ enum class UserResponseCode(
JWT_REFRESH_SUCCESS(10902, HttpStatus.OK, "토큰 재발급에 성공했습니다."),
SOCIAL_LOGIN_SUCCESS(10903, HttpStatus.OK, "소셜 로그인이 성공적으로 처리되었습니다."),
USER_TERMS_AGREE_SUCCESS(10904, HttpStatus.OK, "약관 동의가 성공적으로 처리되었습니다."),
INQUIRY_SEND_SUCCESS(10905, HttpStatus.OK, "문의가 성공적으로 접수되었습니다."),
}
19 changes: 19 additions & 0 deletions src/main/kotlin/com/weeth/global/config/AsyncConfig.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package com.weeth.global.config

import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor
import java.util.concurrent.Executor

@Configuration
class AsyncConfig {
@Bean(name = ["taskExecutor"])
fun taskExecutor(): Executor =
ThreadPoolTaskExecutor().apply {
corePoolSize = 5
maxPoolSize = 10
queueCapacity = 50
setThreadNamePrefix("async-")
initialize()
}
}
1 change: 1 addition & 0 deletions src/main/kotlin/com/weeth/global/config/SecurityConfig.kt
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ class SecurityConfig(
"/api/v4/users/social/kakao",
"/api/v4/users/social/apple",
"/api/v4/users/social/refresh",
"/api/v4/users/inquiries",
).permitAll()
.requestMatchers("/health-check")
.permitAll()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.weeth.global.config.properties

import jakarta.validation.constraints.NotBlank
import org.springframework.boot.context.properties.ConfigurationProperties
import org.springframework.validation.annotation.Validated

@Validated
@ConfigurationProperties(prefix = "notion")
data class NotionProperties(
@field:NotBlank val token: String,
@field:NotBlank val version: String,
@field:NotBlank val inquiryDatabaseId: String,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.weeth.global.config.properties

import jakarta.validation.constraints.NotBlank
import org.springframework.boot.context.properties.ConfigurationProperties
import org.springframework.validation.annotation.Validated

@Validated
@ConfigurationProperties(prefix = "slack")
data class SlackProperties(
@field:NotBlank val webhookUrl: String,
)
8 changes: 8 additions & 0 deletions src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,3 +49,11 @@ app:
career-net:
key: ${CAREER_NET_API_KEY}
base-url: https://www.career.go.kr/cnet/openapi/getOpenApi

slack:
webhook-url: ${SLACK_WEBHOOK_URL}

notion:
token: ${NOTION_TOKEN}
version: ${NOTION_VERSION}
inquiry-database-id: ${NOTION_INQUIRY_DATABASE_ID}