-
Notifications
You must be signed in to change notification settings - Fork 0
SCRUM-117 식당 주인은 사용자가 예약을 하면 해당 알림을 받을 수 있다 #6
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
e5dadde
c9e5ccd
59fd16e
16612bf
6844014
686a9a0
0d3eb61
ba9b9c3
18b83b3
f033261
6c0b07c
4d271b4
ddbcc1b
e162c4e
743dc12
818e796
bab4543
a47767e
95c2db1
0bc8e9e
c33a28e
8e7b480
a3f42c1
21282c0
8c23522
8a63bf9
6ad2375
ef393b7
dc5a993
785b672
4a924e9
bd3cae6
7ec23bc
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,78 @@ | ||
| name: dev-cd | ||
|
|
||
| on: | ||
| push: | ||
| branches: | ||
| - "develop" | ||
|
|
||
| permissions: | ||
| contents: read | ||
| checks: write | ||
| actions: read | ||
| pull-requests: write | ||
|
|
||
| jobs: | ||
| test: | ||
| uses: ./.github/workflows/Dev_CI.yml | ||
| secrets: inherit | ||
|
|
||
| build: | ||
| needs: test | ||
| runs-on: ubuntu-latest | ||
|
|
||
| steps: | ||
| - name: Checkout Code | ||
| uses: actions/checkout@v4 | ||
|
|
||
| - name: Setting dev-secret.yml | ||
| run: | | ||
| echo "${{ secrets.DEV_SECRET_YML }}" > ./src/main/resources/dev-secret.yml | ||
|
|
||
| - name: Set up JDK 21 | ||
| uses: actions/setup-java@v4 | ||
| with: | ||
| distribution: 'temurin' | ||
| java-version: '21' | ||
|
|
||
| - name: Make gradlew executable | ||
| run: chmod +x gradlew | ||
|
|
||
| - name: bootJar with Gradle | ||
| run: ./gradlew bootJar --info | ||
|
|
||
| - name: Change artifact file name | ||
| run: mv build/libs/*.jar build/libs/app.jar | ||
|
|
||
| - name: Upload artifact file | ||
| uses: actions/upload-artifact@v4 | ||
| with: | ||
| name: app-artifact | ||
| path: ./build/libs/app.jar | ||
| if-no-files-found: error | ||
|
|
||
| - name: Upload deploy scripts | ||
| uses: actions/upload-artifact@v4 | ||
| with: | ||
| name: deploy-scripts | ||
| path: ./scripts/dev/ | ||
| if-no-files-found: error | ||
|
|
||
| deploy: | ||
| needs: build | ||
| runs-on: dev | ||
|
|
||
| steps: | ||
| - name: Download artifact file | ||
| uses: actions/download-artifact@v4 | ||
| with: | ||
| name: app-artifact | ||
| path: ~/app | ||
|
|
||
| - name: Download deploy scripts | ||
| uses: actions/download-artifact@v4 | ||
| with: | ||
| name: deploy-scripts | ||
| path: ~/app/scripts | ||
|
|
||
| - name: Replace application to latest | ||
| run: sudo sh ~/app/scripts/replace-new-version.sh |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,93 @@ | ||
| name: dev-ci | ||
|
|
||
| on: | ||
| pull_request: | ||
| branches: | ||
| - develop | ||
| workflow_call: | ||
|
|
||
| permissions: | ||
| contents: read | ||
| checks: write | ||
| pull-requests: write | ||
|
|
||
| jobs: | ||
| build-and-push: | ||
| runs-on: ubuntu-latest | ||
| timeout-minutes: 10 | ||
| env: | ||
| TEST_REPORT: true | ||
|
|
||
| services: | ||
| mysql: | ||
| image: mysql:8.0 | ||
| env: | ||
| MYSQL_ROOT_PASSWORD: "" | ||
| MYSQL_ALLOW_EMPTY_PASSWORD: "yes" | ||
| MYSQL_DATABASE: wellmeet_noti_test | ||
| ports: | ||
| - 3306:3306 | ||
| options: >- | ||
| --health-cmd="mysqladmin ping" | ||
| --health-interval=10s | ||
| --health-timeout=5s | ||
| --health-retries=3 | ||
|
|
||
| zookeeper: | ||
| image: confluentinc/cp-zookeeper:7.0.1 | ||
| env: | ||
| ZOOKEEPER_CLIENT_PORT: 2181 | ||
| ZOOKEEPER_TICK_TIME: 2000 | ||
| ports: | ||
| - 2181:2181 | ||
| options: >- | ||
| --health-cmd="curl -f http://localhost:8080/commands/stat || exit 1" | ||
| --health-interval=10s | ||
| --health-timeout=10s | ||
| --health-retries=5 | ||
|
|
||
| kafka: | ||
| image: confluentinc/cp-kafka:7.0.1 | ||
| env: | ||
| KAFKA_BROKER_ID: 1 | ||
| KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181 | ||
| KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: PLAINTEXT:PLAINTEXT | ||
| KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://localhost:9092 | ||
| KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1 | ||
| KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1 | ||
| KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1 | ||
| KAFKA_AUTO_CREATE_TOPICS_ENABLE: true | ||
| ports: | ||
| - 9092:9092 | ||
| options: >- | ||
| --health-cmd="timeout 10s bash -c 'until printf \"\" 2>>/dev/null >>/dev/tcp/localhost/9092; do sleep 1; done'" | ||
| --health-interval=10s | ||
| --health-timeout=10s | ||
| --health-retries=10 | ||
|
|
||
| steps: | ||
| - uses: actions/checkout@v4 | ||
| with: | ||
| ref: ${{ github.head_ref }} | ||
|
|
||
| - name: Setting local-secret.yml | ||
| run: | | ||
| echo "${{ secrets.LOCAL_SECRET_YML }}" > ./src/main/resources/local-secret.yml | ||
|
|
||
| - name: Set up JDK 21 | ||
| uses: actions/setup-java@v4 | ||
| with: | ||
| java-version: '21' | ||
| distribution: 'temurin' | ||
|
|
||
| - name: Setup Gradle | ||
| uses: gradle/actions/setup-gradle@v4 | ||
|
|
||
| - name: Grant Permission | ||
| run: chmod +x ./gradlew | ||
|
|
||
| - name: Build With Gradle | ||
| run: ./gradlew clean build -x test | ||
|
|
||
| - name: Run Tests With Gradle | ||
| run: ./gradlew test | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -36,3 +36,4 @@ out/ | |
| ### VS Code ### | ||
| .vscode/ | ||
| /src/main/resources/local-secret.yml | ||
| .serena/ | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,40 @@ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| #!/bin/bash | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| PID=$(lsof -t -i:8080) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # 프로세스 종료 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if [ -z "$PID" ]; then | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| echo "No process is using port 8080." | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| else | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| echo "Killing process with PID: $PID" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| kill -15 "$PID" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # 직전 명령(프로세스 종료 명령)이 정상 동작했는지 확인 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if [ $? -eq 0 ]; then | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| echo "Process $PID terminated successfully." | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| else | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| echo "Failed to terminate process $PID." | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| fi | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| fi | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| JAR_FILE=$(ls /home/ubuntu/app/*.jar | head -n 1) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| echo "JAR 파일 실행: $JAR_FILE" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+20
to
+23
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. JAR 파일 존재 여부를 먼저 확인해야 합니다.
다음과 같이 방어 로직을 추가해 주세요: JAR_FILE=$(ls /home/ubuntu/app/*.jar | head -n 1)
echo "JAR 파일 실행: $JAR_FILE"
+
+if [ -z "$JAR_FILE" ]; then
+ echo "실행 가능한 JAR 파일을 찾을 수 없습니다."
+ exit 1
+fi🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| # 애플리케이션 로그 파일 설정 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| APP_LOG_DIR="/home/ubuntu/app/logs" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| APP_LOG_FILE="$APP_LOG_DIR/application-$(date +%Y%m%d-%H%M%S).log" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| echo "애플리케이션 로그 파일: $APP_LOG_FILE" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| sudo nohup java \ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| -Dspring.profiles.active=dev \ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| -Duser.timezone=Asia/Seoul \ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| -Dserver.port=8080 \ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| -Ddd.service=wellmeet-notification \ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| -Ddd.env=dev \ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| -jar "$JAR_FILE" > "$APP_LOG_FILE" 2>&1 & | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+30
to
+36
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Java 애플리케이션을 실행하기 위해
Suggested change
Comment on lines
+24
to
+36
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 로그 디렉터리를 먼저 생성하지 않으면 재실행이 실패합니다.
아래와 같이 APP_LOG_DIR="/home/ubuntu/app/logs"
APP_LOG_FILE="$APP_LOG_DIR/application-$(date +%Y%m%d-%H%M%S).log"
echo "애플리케이션 로그 파일: $APP_LOG_FILE"
+sudo mkdir -p "$APP_LOG_DIR"
+
sudo nohup java \📝 Committable suggestion
Suggested change
🧰 Tools🪛 Shellcheck (0.11.0)[warning] 36-36: sudo doesn't affect redirects. Use ..| sudo tee file (SC2024) 🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| echo "애플리케이션이 백그라운드에서 실행되었습니다." | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| echo "로그 확인: tail -f $APP_LOG_FILE" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| echo "=== 배포 완료 ===" | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,48 @@ | ||
| package com.wellmeet.config; | ||
|
|
||
| import com.wellmeet.notification.consumer.dto.NotificationMessage; | ||
| import java.util.HashMap; | ||
| import java.util.Map; | ||
| import org.apache.kafka.clients.consumer.ConsumerConfig; | ||
| import org.apache.kafka.common.serialization.StringDeserializer; | ||
| import org.springframework.beans.factory.annotation.Value; | ||
| import org.springframework.context.annotation.Bean; | ||
| import org.springframework.context.annotation.Configuration; | ||
| import org.springframework.kafka.annotation.EnableKafka; | ||
| import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory; | ||
| import org.springframework.kafka.core.ConsumerFactory; | ||
| import org.springframework.kafka.core.DefaultKafkaConsumerFactory; | ||
| import org.springframework.kafka.support.serializer.JsonDeserializer; | ||
|
|
||
| @EnableKafka | ||
| @Configuration | ||
| public class KafkaConfig { | ||
|
|
||
| @Value("${spring.kafka.bootstrap-servers}") | ||
| private String bootstrapServers; | ||
|
|
||
| @Value("${spring.kafka.consumer.group-id}") | ||
| private String groupId; | ||
|
|
||
| @Bean | ||
| public ConsumerFactory<String, NotificationMessage> consumerFactory() { | ||
| Map<String, Object> props = new HashMap<>(); | ||
| props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers); | ||
| props.put(ConsumerConfig.GROUP_ID_CONFIG, groupId); | ||
| props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class); | ||
| props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, JsonDeserializer.class); | ||
| props.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest"); | ||
|
|
||
| props.put(JsonDeserializer.USE_TYPE_INFO_HEADERS, false); | ||
| props.put(JsonDeserializer.VALUE_DEFAULT_TYPE, NotificationMessage.class); | ||
| return new DefaultKafkaConsumerFactory<>(props); | ||
| } | ||
|
|
||
| @Bean | ||
| public ConcurrentKafkaListenerContainerFactory<String, NotificationMessage> kafkaListenerContainerFactory() { | ||
| ConcurrentKafkaListenerContainerFactory<String, NotificationMessage> factory = | ||
| new ConcurrentKafkaListenerContainerFactory<>(); | ||
| factory.setConsumerFactory(consumerFactory()); | ||
| return factory; | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| package com.wellmeet.notification; | ||
|
|
||
| import com.wellmeet.notification.consumer.dto.NotificationMessage; | ||
| import com.wellmeet.notification.domain.NotificationChannel; | ||
|
|
||
| public interface Sender { | ||
|
|
||
| boolean isEnabled(NotificationChannel channel); | ||
|
|
||
| void send(NotificationMessage message); | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,28 @@ | ||||||
| package com.wellmeet.notification.consumer; | ||||||
|
|
||||||
| import com.wellmeet.notification.consumer.dto.NotificationMessage; | ||||||
| import com.wellmeet.notification.domain.NotificationEnabled; | ||||||
| import com.wellmeet.notification.repository.NotificationEnabledRepository; | ||||||
| import java.util.List; | ||||||
| import lombok.RequiredArgsConstructor; | ||||||
| import lombok.extern.slf4j.Slf4j; | ||||||
| import org.springframework.kafka.annotation.KafkaListener; | ||||||
| import org.springframework.stereotype.Service; | ||||||
|
|
||||||
| @Slf4j | ||||||
| @Service | ||||||
| @RequiredArgsConstructor | ||||||
| public class NotificationConsumer { | ||||||
|
|
||||||
| private final NotificationEnabledRepository notificationEnabledRepository; | ||||||
| private final NotificationSender notificationSender; | ||||||
|
|
||||||
| @KafkaListener(topics = "notification", groupId = "notification-group") | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Kafka
Suggested change
|
||||||
| public void consume(NotificationMessage message) { | ||||||
| List<NotificationEnabled> enables = notificationEnabledRepository.findByUserIdAndType( | ||||||
| message.getNotification().getRecipient(), | ||||||
| message.getNotification().getType() | ||||||
| ); | ||||||
| notificationSender.send(message, enables); | ||||||
| } | ||||||
| } | ||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,33 @@ | ||
| package com.wellmeet.notification.consumer; | ||
|
|
||
| import com.wellmeet.exception.ErrorCode; | ||
| import com.wellmeet.exception.WellMeetNotificationException; | ||
| import com.wellmeet.notification.Sender; | ||
| import com.wellmeet.notification.consumer.dto.NotificationMessage; | ||
| import com.wellmeet.notification.domain.NotificationEnabled; | ||
| import com.wellmeet.notification.domain.NotificationHistory; | ||
| import com.wellmeet.notification.repository.NotificationHistoryRepository; | ||
| import java.util.List; | ||
| import lombok.RequiredArgsConstructor; | ||
| import org.springframework.stereotype.Component; | ||
|
|
||
| @Component | ||
| @RequiredArgsConstructor | ||
| public class NotificationSender { | ||
|
|
||
| private final List<Sender> senders; | ||
| private final NotificationHistoryRepository notificationHistoryRepository; | ||
|
|
||
| public void send(NotificationMessage message, List<NotificationEnabled> enables) { | ||
| NotificationHistory history = new NotificationHistory(message.getRecipient(), | ||
| message.getPayload().toString()); | ||
| for (NotificationEnabled enabled : enables) { | ||
| notificationHistoryRepository.save(history); | ||
| Sender sender = senders.stream() | ||
| .filter(low -> low.isEnabled(enabled.getChannel())) | ||
| .findFirst() | ||
| .orElseThrow(() -> new WellMeetNotificationException(ErrorCode.SENDER_NOT_FOUND)); | ||
| sender.send(message); | ||
| } | ||
| } | ||
|
Comment on lines
21
to
32
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
public void send(NotificationMessage message, List<NotificationEnabled> enables) {
if (enables.isEmpty()) {
return;
}
notificationHistoryRepository.save(new NotificationHistory(message.getNotification().getRecipient()));
for (NotificationEnabled enabled : enables) {
senders.stream()
.filter(sender -> sender.isEnabled(enabled.getChannel()))
.findFirst()
.ifPresent(sender -> sender.send(message));
}
} |
||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| package com.wellmeet.notification.consumer.dto; | ||
|
|
||
| import java.time.LocalDateTime; | ||
| import lombok.Getter; | ||
| import lombok.NoArgsConstructor; | ||
|
|
||
| @Getter | ||
| @NoArgsConstructor | ||
| public class MessageHeader { | ||
|
|
||
| private String messageId; | ||
| private LocalDateTime timestamp; | ||
| private String source; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| package com.wellmeet.notification.consumer.dto; | ||
|
|
||
| import lombok.Getter; | ||
| import lombok.NoArgsConstructor; | ||
|
|
||
| @Getter | ||
| @NoArgsConstructor | ||
| public class NotificationInfo { | ||
|
|
||
| private NotificationType type; | ||
| private String recipient; | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
포크 PR에서 시크릿 미전달로 인한 CI 실패 가능성
pull_request이벤트로 실행될 때 외부 포크에서 올라온 PR에는 GitHub 시크릿이 전달되지 않습니다. 이 단계가 그대로 실행되면 빈local-secret.yml만 생성되고 이후 빌드/테스트가 비정상 종료되어 외부 기여자의 CI가 항상 실패하게 됩니다. 포크 PR을 고려해 시크릿이 비어 있을 때는 단계를 건너뛰거나, 대체 설정(예: 더미 설정·if조건으로 전체 잡 스킵)을 두는 식의 방어 로직을 추가해 주세요.🤖 Prompt for AI Agents