Skip to content

[FLYW-210] 번호인증 구현 - #258

Merged
kkhhmm3103 merged 3 commits into
devfrom
FLYW-210-번호인증-구현
Feb 4, 2026

Hidden character warning

The head ref may contain hidden characters: "FLYW-210-\ubc88\ud638\uc778\uc99d-\uad6c\ud604"
Merged

kkhhmm3103 merged 3 commits into
devfrom
FLYW-210-번호인증-구현

Conversation

@hjh79gw

@hjh79gw hjh79gw commented Feb 4, 2026 •

Copy link
Copy Markdown
Contributor

📌 PR 설명


번호인증 구현

✅ 완료한 기능 명세

  • 번호인증 구현

📸 스크린샷


💭 고민과 해결과정


🔗 관련 이슈

Closes #255


Summary by CodeRabbit

  • New Features

    • 회원가입에 휴대폰 인증 UI 및 흐름 추가(인증번호 전송·입력·검증·재전송) 및 가입 시 휴대폰 인증 필수화
    • 예약 관련 SMS 발송 대상이 각 승객이 아닌 예약자(소유자) 전화번호로 변경
  • Chores

    • SMS 인증용 DB 테이블 및 관련 매퍼·서비스·컨트롤러 추가
  • Tests

    • 휴대폰 인증 연동에 따른 단위테스트 업데이트

@hjh79gw hjh79gw added the FEAT 새로운 기능 추가 또는 기존 기능 확장 label Feb 4, 2026
@coderabbitai

coderabbitai Bot commented Feb 4, 2026 •

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

회원가입에 전화번호 SMS 인증을 도입합니다. 서버 사이드 SmsVerificationService·도메인·매퍼·마이그레이션과 SMS API 컨트롤러를 추가하고, SignUpService에서 전화번호 인증 여부를 확인하도록 변경하며 프론트엔드에 인증 UI/JS를 추가했습니다.

Changes

Cohort / File(s) Summary
SMS 서비스 · 도메인 · 매퍼 · 마이그레이션
src/main/java/com/flyway/sender/service/SmsVerificationService.java, src/main/java/com/flyway/sender/domain/SmsVerification.java, src/main/java/com/flyway/sender/mapper/SmsVerificationMapper.java, src/main/resources/mapper/sender/SmsVerificationMapper.xml, src/main/resources/db/migration/V21__create_sms_verification_table.sql
새로운 SmsVerification 서비스 추가: 코드 생성·전송·만료·검증·검증표시 및 전송률 제한 로직, 도메인 모델·MyBatis 매퍼·DB 마이그레이션 추가.
SMS API · 보안 · 필터 변경
src/main/java/com/flyway/sender/controller/SmsVerificationApiController.java, src/main/java/com/flyway/security/config/SecurityConfigApi.java, src/main/java/com/flyway/security/config/SecurityConfigWeb.java, src/main/java/com/flyway/security/filter/OnboardingAccessFilter.java, src/main/java/com/flyway/security/jwt/JwtApiAuthFilter.java, src/main/java/com/flyway/security/jwt/JwtWebAuthFilter.java
/api/sms/** 공개 엔드포인트로 추가 및 SMS 컨트롤러 도입. 관련 보안 설정·필터의 예외 경로 확장.
회원가입 통합 변경
src/main/java/com/flyway/auth/service/SignUpServiceImpl.java, src/test/java/com/flyway/auth/service/SignUpServiceImplTest.java, src/test/java/com/flyway/auth/service/UserAuthServiceImplTest.java
SignUpServiceImpl에 SmsVerificationService 주입 및 가입/완료 OAuth 흐름에서 전화번호 인증 검사 추가(미인증 시 USER_PHONE_NOT_VERIFIED 예외); 단위테스트에 목 주입 반영 및 생성자 시그니처 확장.
프론트엔드 · 리소스
src/main/webapp/WEB-INF/views/signup.jsp, src/main/webapp/resources/signup/js/signup.js, src/main/webapp/resources/auth/signup.css
회원가입 폼에 SMS 전송/코드 입력/검증 UI 추가, JS로 전송·재전송·검증 흐름 및 제출 조건에 phoneVerified 검사 추가, .is-hidden CSS 유틸 추가.
기존 SMS 발송·매퍼·DTO 변경
src/main/java/com/flyway/sender/service/SmsService.java, src/main/java/com/flyway/sender/dto/PassengerTicketInfo.java, src/main/resources/mapper/sender/SmsMapper.xml, src/main/resources/mapper/user/UserProfileMapper.xml
SmsService가 예약자(발신자) 번호로 발송하도록 변경; PassengerTicketInfo.phoneNumber 제거 및 매퍼에서 phone_number 선택/조건 제거; UserProfile insert에서 COALESCE 제거(널 허용).
오류 코드 추가
src/main/java/com/flyway/template/exception/ErrorCode.java
USER_PHONE_NOT_VERIFIED(400, "U012", "전화번호 인증이 필요합니다.") 오류 코드 추가.

Sequence Diagram

sequenceDiagram
    participant Client as Client (Browser)
    participant Controller as SmsVerificationApiController
    participant Service as SmsVerificationService
    participant DB as SmsVerificationMapper (DB)
    participant Gateway as SMS Gateway

    Client->>Controller: POST /api/sms/send (phoneNumber)
    Controller->>Service: sendCode(phoneNumber)
    Service->>DB: countRecentByPhone(phoneNumber, since)
    DB-->>Service: count
    alt rate limit exceeded
        Service-->>Controller: throw Exception
        Controller-->>Client: 400 Bad Request
    else within limit
        Service->>Service: generate code & id, set expiresAt
        Service->>DB: insert(SmsVerification)
        DB-->>Service: OK
        Service->>Gateway: send SMS(code)
        Gateway-->>Service: Sent
        Service-->>Controller: success
        Controller-->>Client: ApiResponse<Void>
    end

    Client->>Controller: POST /api/sms/verify (phoneNumber, code)
    Controller->>Service: verify(phoneNumber, code)
    Service->>DB: findLatestByPhone(phoneNumber)
    DB-->>Service: SmsVerification
    alt expired or mismatch
        Service-->>Controller: false
        Controller-->>Client: ApiResponse<Boolean> false
    else valid
        Service->>DB: markVerified(id, timestamp)
        DB-->>Service: OK
        Service-->>Controller: true
        Controller-->>Client: ApiResponse<Boolean> true
    end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • #210: SignUpServiceImpl에 전화번호 처리/인증 흐름을 추가하는 PR로 본 변경과 직접적인 중복/연속성이 있음.
  • #117: SignUpServiceImpl에 새 의존성 및 사전 가입 검증을 추가한 PR로 생성자·검증 흐름 변경과 연관됨.
  • #83: Kakao OAuth 가입 흐름 관련 변경과 본 PR의 SignUpServiceImpl 수정이 교차하는 부분이 있어 연관됨.

Suggested labels

ready-for-review

Suggested reviewers

  • gaeunnlee
  • ochanhyeok

Poem

🐰 깡총깡총 코드밭을 달려
여섯 자리 숫자별이 반짝이고,
문자 한 통에 문이 열리고,
가입 길목에 빛이 비치네.
토끼가 폴짝 축하해요! 🎉

🚥 Pre-merge checks | ✅ 2 | ❌ 3
❌ Failed checks (1 warning, 2 inconclusive)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 11.11% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Linked Issues check ❓ Inconclusive The linked issue #255 (FLYW-210) has no specific technical requirements or acceptance criteria documented. The PR implements comprehensive phone verification features (SMS sending, verification, persistence, API endpoints, UI integration, security configuration), but without documented requirements, full compliance cannot be definitively validated. Add specific technical requirements and acceptance criteria to issue #255 to enable proper validation of the implementation scope and completeness.
Out of Scope Changes check ❓ Inconclusive All changes are directly related to SMS-based phone verification implementation. However, some modifications (e.g., JWT filter changes affecting all '/api/' paths, PassengerTicketInfo phone field removal, UserProfileMapper COALESCE change) extend beyond core SMS verification and may have broader system impacts. Clarify whether security filter changes (JwtWebAuthFilter), DTO modifications (PassengerTicketInfo), and data handling changes (UserProfileMapper) are intentionally scoped within FLYW-210 or represent separate concerns.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title 'FLYW-210 번호인증 구현' clearly and concisely summarizes the main change: implementing phone number verification functionality. The title is directly related to the substantial changes across the codebase for SMS verification features.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch FLYW-210-번호인증-구현

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 6

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
src/main/resources/mapper/user/UserProfileMapper.xml (1)

10-17: ⚠️ Potential issue | 🟠 Major

phone_number 컬럼 NOT NULL 제약으로 인한 INSERT 실패 위험

phone_number 컬럼이 스키마상 NOT NULL 제약이 있습니다 (V1, V19 마이그레이션 확인). COALESCE 제거로 인해 #{phoneNumber}가 NULL일 경우, DEFAULT 값이 있어도 명시적 NULL 삽입은 INSERT 실패를 초래합니다. phone_number가 선택사항이라면 COALESCE를 복원하거나 Java 단에서 null 체크를 추가해야 합니다.

src/test/java/com/flyway/auth/service/SignUpServiceImplTest.java (1)

61-94: ⚠️ Potential issue | 🔴 Critical

🚨 테스트 실패: 기존 테스트들이 SMS 인증 검증으로 인해 실패합니다

SignUpServiceImpl.signUp() 메서드가 이제 SMS 인증을 요구하지만, 기존 테스트들이 업데이트되지 않았습니다:

  1. EmailSignUpRequest에 phoneNumber가 설정되지 않음 → validateRequest()에서 USER_INVALID_INPUT 발생
  2. smsVerificationService.isVerified()에 대한 mock 설정이 없음 → USER_PHONE_NOT_VERIFIED 발생
🐛 테스트 수정 예시
 `@Test`
 `@DisplayName`("회원가입 성공 시 사용자/아이덴티티/프로필이 생성된다")
 void signUp_success_createsUserAndIdentity() {
     EmailSignUpRequest request = EmailSignUpRequest.builder()
             .name("Tester")
             .email("test@example.com")
             .rawPassword("password")
             .attemptId("attempt-1")
+            .phoneNumber("01012345678")
             .build();

+    when(smsVerificationService.isVerified("01012345678")).thenReturn(true);
     when(signUpAttemptRepository.consumeIfVerified(eq("attempt-1"), eq("test@example.com"), any()))
             .thenReturn(1);
     when(userIdentityRepository.existsEmailIdentity("test@example.com")).thenReturn(false);
     when(passwordEncoder.encode("password")).thenReturn("encoded");

     signUpService.signUp(request);

다른 테스트들도 동일하게 phoneNumber 설정과 isVerified() mock이 필요합니다. 또한 SMS 인증 실패 시나리오에 대한 테스트 케이스 추가를 권장합니다.

src/test/java/com/flyway/auth/service/UserAuthServiceImplTest.java (1)

60-92: ⚠️ Potential issue | 🔴 Critical

🚨 테스트 실패: SignUpServiceImplTest와 동일한 문제

SignUpServiceImplTest와 마찬가지로, 이 테스트 클래스의 모든 signUp() 호출 테스트들이 실패합니다:

  1. EmailSignUpRequest에 phoneNumber 미설정
  2. smsVerificationService.isVerified() mock 미설정

예를 들어 signUp_duplicateEmail_throwBusinessException 테스트는 USER_EMAIL_ALREADY_EXISTS를 기대하지만, 실제로는 SMS 인증 검증 단계에서 먼저 실패합니다.

🐛 수정이 필요한 테스트 목록

다음 테스트들에 phoneNumber 설정과 smsVerificationService.isVerified() mock 추가가 필요합니다:

  • signUp_duplicateEmail_throwBusinessException
  • signUp_success_saveAll
  • signUp_passwordEncodingMissing_throwBusinessException
  • signUp_always_checks_duplicate_first
 `@Test`
 `@DisplayName`("중복 이메일이면 BusinessException(USER_EMAIL_ALREADY_EXISTS) 발생하고 저장 로직은 수행되지 않는다")
 void signUp_duplicateEmail_throwBusinessException() {
     // given
     EmailSignUpRequest req = new EmailSignUpRequest();
     req.setName("홍길동");
     req.setEmail("dup@example.com");
     req.setRawPassword("password1234");
     req.setAttemptId("AttemptId");
+    req.setPhoneNumber("01012345678");

+    when(smsVerificationService.isVerified("01012345678")).thenReturn(true);
     when(userIdentityRepository.existsEmailIdentity("dup@example.com"))
             .thenReturn(true);
🤖 Fix all issues with AI agents
In
`@src/main/java/com/flyway/sender/controller/SmsVerificationApiController.java`:
- Around line 17-25: SmsVerificationApiController.send currently accepts any
phoneNumber and catches Exception broadly; add input validation for phoneNumber
(null/blank and regex format check) at the start of send and throw or return a
meaningful bad-request ApiResponse when invalid, referencing the method send and
ApiResponse; replace the single catch(Exception e) with specific handling: catch
validation/format errors (e.g., IllegalArgumentException or a custom
ValidationException) to return ResponseEntity.badRequest() with a clear error
code/message, and keep a separate generic catch(Exception e) that logs the full
exception and returns ResponseEntity.status(500) with
ApiResponse.error("SERVER_ERROR", "Internal server error") so unexpected errors
aren't masked; ensure service.sendCode(phoneNumber) is only called after
validation.

In `@src/main/java/com/flyway/sender/mapper/SmsVerificationMapper.java`:
- Around line 20-22: The comment for SmsVerificationMapper.existsVerifiedPhone
incorrectly states "5분 내" while SmsVerificationService.isVerified uses a
10-minute window; update the comment to match the actual behavior or remove the
hardcoded time note since the time limit is handled dynamically in
SmsVerificationService.isVerified, and ensure both
SmsVerificationMapper.existsVerifiedPhone and SmsVerificationService.isVerified
remain consistent about which timeframe is enforced or documented.

In `@src/main/java/com/flyway/sender/service/SmsService.java`:
- Around line 114-118: The log at the end of the ticket-sending block currently
prints userPhone in cleartext; change it to log a masked version of the phone
number to avoid PII exposure by adding/using a phone-masking helper (e.g.,
maskPhoneNumber) or inline masking logic and call that when logging; update the
log.info line in SmsService (the block that calls buildTicketSmsContent(...) and
sendSms(...)) to pass the masked phone value instead of userPhone, and also
ensure sendSms and any internal SMS-related logs use the same mask helper for
consistency.

In `@src/main/java/com/flyway/sender/service/SmsVerificationService.java`:
- Line 49: The line using v.getCode().equals(code) in SmsVerificationService
should be replaced with a constant-time comparison to prevent timing attacks:
convert both strings to byte[] with a fixed charset (e.g., UTF-8), guard against
nulls, and use MessageDigest.isEqual(byte[], byte[]) (or another constant-time
utility) to compare the stored code (v.getCode()) and the supplied code
parameter, returning the result of that comparison instead of String.equals().
- Line 30: The code in SmsVerificationService generates the OTP using
java.util.Random (String code = String.format("%06d", new
Random().nextInt(1000000))); which is predictable; replace it with
java.security.SecureRandom (e.g., a static final SecureRandom instance used to
call nextInt(1_000_000)) and update the import; ensure the replacement occurs
where the code variable is constructed so the formatted 6-digit string is
produced from SecureRandom.nextInt(1_000_000).

In `@src/main/webapp/resources/signup/js/signup.js`:
- Around line 366-386: Fix the syntax error and unreachable SMS-check: remove
the stray characters causing the invalid token ("}cd") and correct the closing
brace so the block compiles; then move or merge the phone verification logic
(phoneVerifiedHidden check) into the same pre-submit validation flow inside the
signupForm.submit listener (before any unconditional return) so both
emailVerifiedHidden and phoneVerifiedHidden are validated, calling
e.preventDefault() and showToast/alert for each failing check in sequence within
the handler (refer to signupForm submit listener, emailVerifiedHidden,
phoneVerifiedHidden, showToast).
🧹 Nitpick comments (9)
src/main/resources/mapper/sender/SmsMapper.xml (1)

33-35: 중복 주석 한 줄 정리 제안
동일 주석이 연속으로 들어가 있어 하나만 남기는 편이 깔끔합니다.

🧹 제안 변경안
-    <!-- 탑승객별 티켓 정보 (좌석/기내식/수하물 ) -->
-    <!-- 탑승객별 티켓 정보 (좌석/기내식/수하물 ) -->
+    <!-- 탑승객별 티켓 정보 (좌석/기내식/수하물 ) -->
src/main/java/com/flyway/security/config/SecurityConfigApi.java (1)

73-77: /api/sms/ 공개 범위를 실제 사용하는 HTTP 메서드(POST)로 제한 권장**
현재 모든 HTTP 메서드가 공개되어 있으나, SmsVerificationApiController에서 실제 사용하는 메서드는 POST(/send, /verify)뿐입니다. 불필요한 메서드(GET, PUT, DELETE 등)를 허용하지 않도록 좁혀 공격 면을 줄일 것을 권장합니다.

🔧 제안 변경안
                .antMatchers(
                        "/api/auth/**",
                        "/api/public/**",
-                       "/api/sms/**"
-               ).permitAll()
+               ).permitAll()
+               .antMatchers(HttpMethod.POST, "/api/sms/**").permitAll()
src/main/resources/db/migration/V21__create_sms_verification_table.sql (1)

1-10: 스키마 설계는 적절하나, 몇 가지 고려사항이 있습니다.

  1. 동일 전화번호에 대한 중복 인증 요청 처리: 현재 스키마에서는 동일 전화번호로 여러 인증 요청이 쌓일 수 있습니다. 서비스 레이어에서 처리하거나, 인덱스 전략을 조정하는 것을 고려해 주세요.

  2. 만료된 레코드 정리: expires_at을 활용한 주기적 정리 작업(batch job 또는 scheduled task)이 필요합니다.

  3. 레코드 조회 최적화: phone_number 단독 조회가 빈번할 경우 별도 인덱스 추가를 고려해 주세요.

♻️ 선택적 개선안
 CREATE TABLE sms_verification (
                                   sms_verification_id CHAR(36) NOT NULL,
                                   phone_number VARCHAR(20) NOT NULL,
                                   code VARCHAR(6) NOT NULL,
                                   expires_at DATETIME NOT NULL,
                                   verified_at DATETIME NULL,
                                   created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
                                   PRIMARY KEY (sms_verification_id),
-                                  INDEX idx_sms_phone (phone_number, created_at)
+                                  INDEX idx_sms_phone (phone_number, created_at),
+                                  INDEX idx_sms_expires (expires_at)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
src/main/webapp/resources/signup/js/signup.js (1)

135-144: 들여쓰기 스타일 불일치

새로 추가된 SMS 관련 DOM 요소 선언들이 기존 코드와 다른 들여쓰기 스타일(4칸 추가 들여쓰기)을 사용하고 있습니다. 기존 코드 스타일과 일관성을 유지해 주세요.

src/main/java/com/flyway/auth/service/SignUpServiceImpl.java (1)

80-80: 불필요한 빈 줄

userRepository.save(user); 이후에 불필요한 빈 줄이 추가되었습니다.

src/main/resources/mapper/sender/SmsVerificationMapper.xml (1)

13-20: findLatestByPhone에서 created_at 컬럼 누락

SmsVerification 도메인 객체에 createdAt 필드가 있다면, SELECT 절에도 created_at AS createdAt을 포함해야 합니다. 현재는 조회 시 해당 필드가 null로 반환됩니다.

♻️ 수정 제안
     <select id="findLatestByPhone" resultType="com.flyway.sender.domain.SmsVerification">
         SELECT sms_verification_id AS smsVerificationId,
                phone_number AS phoneNumber, code,
-               expires_at AS expiresAt, verified_at AS verifiedAt
+               expires_at AS expiresAt, verified_at AS verifiedAt,
+               created_at AS createdAt
         FROM sms_verification
         WHERE phone_number = #{phoneNumber} AND verified_at IS NULL
         ORDER BY created_at DESC LIMIT 1
     </select>
src/main/java/com/flyway/sender/controller/SmsVerificationApiController.java (1)

27-34: /verify 엔드포인트 입력값 검증 필요

phoneNumber와 code 파라미터에 대한 null/빈 문자열 검증이 없습니다. 잘못된 입력이 서비스 레이어로 전달되어 예기치 않은 동작을 유발할 수 있습니다.

🛡️ 수정 제안
 `@PostMapping`("/verify")
 public ResponseEntity<ApiResponse<Boolean>> verify(
         `@RequestParam` String phoneNumber,
         `@RequestParam` String code) {
+    if (phoneNumber == null || phoneNumber.isBlank() || code == null || code.isBlank()) {
+        return ResponseEntity.badRequest()
+                .body(ApiResponse.error("INVALID_INPUT", "전화번호와 인증번호를 입력해주세요."));
+    }
     boolean ok = service.verify(phoneNumber, code);
     String msg = ok ? "인증되었습니다." : "인증번호가 일치하지 않습니다.";
     return ResponseEntity.ok(ApiResponse.success(ok, msg));
 }
src/main/java/com/flyway/sender/service/SmsVerificationService.java (2)

27-28: 커스텀 비즈니스 예외 사용 권장

RuntimeException은 너무 일반적이며 클라이언트에서 적절한 에러 핸들링이 어렵습니다. 프로젝트의 BusinessException과 ErrorCode를 사용하여 일관된 예외 처리를 하세요.

♻️ 수정 제안
+import com.flyway.common.exception.BusinessException;
+import com.flyway.common.exception.ErrorCode;

         if (count >= 3) {
-            throw new RuntimeException("잠시 후 다시 시도해주세요.");
+            throw new BusinessException(ErrorCode.SMS_RATE_LIMIT_EXCEEDED);
         }

44-53: 인증 실패 시 로깅 추가 권장

인증 실패 사유(코드 없음, 만료, 불일치)에 대한 로깅이 없어 문제 발생 시 디버깅이 어렵습니다. 보안 감사를 위해 실패 케이스도 로깅하는 것이 좋습니다.

📝 수정 제안
     `@Transactional`
     public boolean verify(String phoneNumber, String code) {
         SmsVerification v = mapper.findLatestByPhone(phoneNumber);
-        if (v == null) return false;
-        if (v.getExpiresAt().isBefore(LocalDateTime.now())) return false;
-        if (!v.getCode().equals(code)) return false;
+        String masked = phoneNumber.length() >= 7 
+            ? phoneNumber.substring(0, 3) + "****" + phoneNumber.substring(7) 
+            : phoneNumber;
+        if (v == null) {
+            log.warn("[SMS] 인증 실패 - 요청 없음: {}", masked);
+            return false;
+        }
+        if (v.getExpiresAt().isBefore(LocalDateTime.now())) {
+            log.warn("[SMS] 인증 실패 - 만료됨: {}", masked);
+            return false;
+        }
+        if (!constantTimeEquals(v.getCode(), code)) {
+            log.warn("[SMS] 인증 실패 - 코드 불일치: {}", masked);
+            return false;
+        }

         mapper.markVerified(v.getSmsVerificationId(), LocalDateTime.now());
+        log.info("[SMS] 인증 성공: {}", masked);
         return true;
     }

Comment on lines +17 to +25
public ResponseEntity<ApiResponse<Void>> send(@RequestParam String phoneNumber) {
try {
service.sendCode(phoneNumber);
return ResponseEntity.ok(ApiResponse.success(null, "인증번호가 발송되었습니다."));
} catch (Exception e) {
return ResponseEntity.badRequest()
.body(ApiResponse.error("FAIL", e.getMessage()));
}
}

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 | 🟡 Minor

입력값 검증 및 예외 처리 개선 필요

  1. phoneNumber에 대한 유효성 검증이 없습니다. null, 빈 문자열, 또는 잘못된 형식의 전화번호가 서비스 레이어까지 전달될 수 있습니다.
  2. Exception을 포괄적으로 catch하면 예상치 못한 오류(예: NullPointerException, DB 연결 오류)가 일반적인 "FAIL" 응답으로 처리되어 디버깅이 어려워집니다.
🛡️ 수정 제안
 `@PostMapping`("/send")
-public ResponseEntity<ApiResponse<Void>> send(`@RequestParam` String phoneNumber) {
+public ResponseEntity<ApiResponse<Void>> send(`@RequestParam` String phoneNumber) {
+    if (phoneNumber == null || phoneNumber.isBlank()) {
+        return ResponseEntity.badRequest()
+                .body(ApiResponse.error("INVALID_INPUT", "전화번호를 입력해주세요."));
+    }
     try {
         service.sendCode(phoneNumber);
         return ResponseEntity.ok(ApiResponse.success(null, "인증번호가 발송되었습니다."));
-    } catch (Exception e) {
+    } catch (IllegalArgumentException e) {
         return ResponseEntity.badRequest()
                 .body(ApiResponse.error("FAIL", e.getMessage()));
     }
 }
🤖 Prompt for AI Agents
In `@src/main/java/com/flyway/sender/controller/SmsVerificationApiController.java`
around lines 17 - 25, SmsVerificationApiController.send currently accepts any
phoneNumber and catches Exception broadly; add input validation for phoneNumber
(null/blank and regex format check) at the start of send and throw or return a
meaningful bad-request ApiResponse when invalid, referencing the method send and
ApiResponse; replace the single catch(Exception e) with specific handling: catch
validation/format errors (e.g., IllegalArgumentException or a custom
ValidationException) to return ResponseEntity.badRequest() with a clear error
code/message, and keep a separate generic catch(Exception e) that logs the full
exception and returns ResponseEntity.status(500) with
ApiResponse.error("SERVER_ERROR", "Internal server error") so unexpected errors
aren't masked; ensure service.sendCode(phoneNumber) is only called after
validation.

Comment on lines +20 to +22
// 인증 완료 여부 확인 (5분 내)
int existsVerifiedPhone(@Param("phoneNumber") String phoneNumber,
@Param("since") LocalDateTime since);

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 | 🟡 Minor

주석과 실제 사용이 불일치

주석에는 "5분 내"라고 되어 있지만, SmsVerificationService.isVerified()에서는 10분을 사용합니다. 주석을 정확하게 수정하거나, 시간 제한이 동적이므로 주석을 제거하세요.

📝 수정 제안
-    // 인증 완료 여부 확인 (5분 내)
+    // 인증 완료 여부 확인 (since 시간 이후)
     int existsVerifiedPhone(`@Param`("phoneNumber") String phoneNumber,
                             `@Param`("since") LocalDateTime since);
🤖 Prompt for AI Agents
In `@src/main/java/com/flyway/sender/mapper/SmsVerificationMapper.java` around
lines 20 - 22, The comment for SmsVerificationMapper.existsVerifiedPhone
incorrectly states "5분 내" while SmsVerificationService.isVerified uses a
10-minute window; update the comment to match the actual behavior or remove the
hardcoded time note since the time limit is handled dynamically in
SmsVerificationService.isVerified, and ensure both
SmsVerificationMapper.existsVerifiedPhone and SmsVerificationService.isVerified
remain consistent about which timeframe is enforced or documented.

Comment on lines 114 to +118
try {
String content = buildTicketSmsContent(firstInfo, segments, paxInfoList);
sendSms(firstInfo.getPhoneNumber(), content);
log.info("[SMS] 티켓 발송 - passenger: {} {}, phone: {}",
firstInfo.getFirstName(), firstInfo.getLastName(), firstInfo.getPhoneNumber());
sendSms(userPhone, content); // 회원 번호로 발송
log.info("[SMS] 티켓 발송 - passenger: {} {}, to userPhone: {}",
firstInfo.getFirstName(), firstInfo.getLastName(), userPhone);

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

전화번호 로그 마스킹 필요(PII 노출 위험)
새 로그가 사용자 전화번호를 그대로 노출합니다. 운영 로그 접근 범위를 고려하면 마스킹이 필요합니다. (가능하면 sendSms 내부 로그도 동일하게 마스킹 권장)

🔒 제안 변경안
-                log.info("[SMS] 티켓 발송 - passenger: {} {}, to userPhone: {}",
-                        firstInfo.getFirstName(), firstInfo.getLastName(), userPhone);
+                log.info("[SMS] 티켓 발송 - passenger: {} {}, to userPhone: {}",
+                        firstInfo.getFirstName(), firstInfo.getLastName(), maskPhone(userPhone));
+    private String maskPhone(String phone) {
+        if (phone == null) return null;
+        int len = phone.length();
+        if (len <= 4) return "****";
+        return phone.substring(0, Math.min(3, len)) + "****" + phone.substring(len - 4);
+    }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
try {
String content = buildTicketSmsContent(firstInfo, segments, paxInfoList);
sendSms(firstInfo.getPhoneNumber(), content);
log.info("[SMS] 티켓 발송 - passenger: {} {}, phone: {}",
firstInfo.getFirstName(), firstInfo.getLastName(), firstInfo.getPhoneNumber());
sendSms(userPhone, content); // 회원 번호로 발송
log.info("[SMS] 티켓 발송 - passenger: {} {}, to userPhone: {}",
firstInfo.getFirstName(), firstInfo.getLastName(), userPhone);
try {
String content = buildTicketSmsContent(firstInfo, segments, paxInfoList);
sendSms(userPhone, content); // 회원 번호로 발송
log.info("[SMS] 티켓 발송 - passenger: {} {}, to userPhone: {}",
firstInfo.getFirstName(), firstInfo.getLastName(), maskPhone(userPhone));
}
private String maskPhone(String phone) {
if (phone == null) return null;
int len = phone.length();
if (len <= 4) return "****";
return phone.substring(0, Math.min(3, len)) + "****" + phone.substring(len - 4);
}
🤖 Prompt for AI Agents
In `@src/main/java/com/flyway/sender/service/SmsService.java` around lines 114 -
118, The log at the end of the ticket-sending block currently prints userPhone
in cleartext; change it to log a masked version of the phone number to avoid PII
exposure by adding/using a phone-masking helper (e.g., maskPhoneNumber) or
inline masking logic and call that when logging; update the log.info line in
SmsService (the block that calls buildTicketSmsContent(...) and sendSms(...)) to
pass the masked phone value instead of userPhone, and also ensure sendSms and
any internal SMS-related logs use the same mask helper for consistency.

throw new RuntimeException("잠시 후 다시 시도해주세요.");
}

String code = String.format("%06d", new Random().nextInt(1000000));

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

java.util.Random 대신 SecureRandom 사용 필요

인증 코드 생성에 java.util.Random을 사용하고 있습니다. 이는 예측 가능한 시드를 사용하므로 공격자가 다음 인증 코드를 예측할 수 있습니다. 보안에 민감한 코드 생성에는 SecureRandom을 사용해야 합니다.

🔒 수정 제안
 import java.util.Random;
+import java.security.SecureRandom;
 import java.util.UUID;

 `@Slf4j`
 `@Service`
 `@RequiredArgsConstructor`
 public class SmsVerificationService {

     private final SmsVerificationMapper mapper;
     private final SmsService smsService;
+    private final SecureRandom secureRandom = new SecureRandom();

     `@Transactional`
     public void sendCode(String phoneNumber) {
         // 5분 내 3회 제한
         int count = mapper.countRecentByPhone(phoneNumber, LocalDateTime.now().minusMinutes(5));
         if (count >= 3) {
             throw new RuntimeException("잠시 후 다시 시도해주세요.");
         }

-        String code = String.format("%06d", new Random().nextInt(1000000));
+        String code = String.format("%06d", secureRandom.nextInt(1000000));
🤖 Prompt for AI Agents
In `@src/main/java/com/flyway/sender/service/SmsVerificationService.java` at line
30, The code in SmsVerificationService generates the OTP using java.util.Random
(String code = String.format("%06d", new Random().nextInt(1000000))); which is
predictable; replace it with java.security.SecureRandom (e.g., a static final
SecureRandom instance used to call nextInt(1_000_000)) and update the import;
ensure the replacement occurs where the code variable is constructed so the
formatted 6-digit string is produced from SecureRandom.nextInt(1_000_000).

SmsVerification v = mapper.findLatestByPhone(phoneNumber);
if (v == null) return false;
if (v.getExpiresAt().isBefore(LocalDateTime.now())) return false;
if (!v.getCode().equals(code)) return false;

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

타이밍 공격 방지를 위한 상수 시간 비교 필요

String.equals()는 첫 번째 불일치 문자에서 즉시 반환하므로 응답 시간 차이를 통해 인증 코드를 유추할 수 있는 타이밍 공격에 취약합니다. MessageDigest.isEqual() 또는 상수 시간 비교를 사용하세요.

🔒 수정 제안
+import java.security.MessageDigest;
+import java.nio.charset.StandardCharsets;

     `@Transactional`
     public boolean verify(String phoneNumber, String code) {
         SmsVerification v = mapper.findLatestByPhone(phoneNumber);
         if (v == null) return false;
         if (v.getExpiresAt().isBefore(LocalDateTime.now())) return false;
-        if (!v.getCode().equals(code)) return false;
+        if (!constantTimeEquals(v.getCode(), code)) return false;

         mapper.markVerified(v.getSmsVerificationId(), LocalDateTime.now());
         return true;
     }

+    private boolean constantTimeEquals(String a, String b) {
+        if (a == null || b == null) return false;
+        return MessageDigest.isEqual(
+            a.getBytes(StandardCharsets.UTF_8),
+            b.getBytes(StandardCharsets.UTF_8)
+        );
+    }
🤖 Prompt for AI Agents
In `@src/main/java/com/flyway/sender/service/SmsVerificationService.java` at line
49, The line using v.getCode().equals(code) in SmsVerificationService should be
replaced with a constant-time comparison to prevent timing attacks: convert both
strings to byte[] with a fixed charset (e.g., UTF-8), guard against nulls, and
use MessageDigest.isEqual(byte[], byte[]) (or another constant-time utility) to
compare the stored code (v.getCode()) and the supplied code parameter, returning
the result of that comparison instead of String.equals().

Comment thread src/main/webapp/resources/signup/js/signup.js Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@src/main/webapp/resources/signup/js/signup.js`:
- Around line 509-521: The SMS send flow uses await res.json() without handling
parse errors; update the send SMS handler so you only attempt JSON parsing when
response content-type is JSON or wrap res.json() in a try-catch (in the same
async function that calls fetch) and handle failures by showing
smsErrorStatus.textContent with a fallback message (e.g., data?.message if
parsed, otherwise a generic "인증번호 발송에 실패했습니다." or the raw res.text()) and ensure
smsErrorStatus.classList.remove("hidden") is called on parse or network errors;
reference the fetch call and the smsErrorStatus usage in the SMS send handler to
locate where to add the defensive parsing and error display.
🧹 Nitpick comments (4)
src/main/java/com/flyway/auth/service/SignUpServiceImpl.java (1)

164-167: SMS 인증 확인 순서가 일관성 없습니다.

signUp() 메서드에서는 SMS 인증을 DB 작업 전에 먼저 확인하지만, 이 메서드에서는 이메일 업데이트(line 159)와 상태 업데이트(line 162) 이후에 확인합니다. 트랜잭션이 롤백되긴 하지만, 검증 실패 시 불필요한 DB 작업이 발생하고 코드 일관성이 떨어집니다.

♻️ SMS 인증을 먼저 수행하도록 순서 변경 제안
     `@Override`
     `@Transactional`
     public void completeOauthSignUp(String userId, EmailSignUpRequest request) {
         validateOauthRequest(request);
         if (userId == null || userId.isBlank()) {
             throw new BusinessException(ErrorCode.USER_INVALID_INPUT);
         }

         User user = userRepository.findById(userId);
         if (user == null || user.getStatus() != AuthStatus.ONBOARDING) {
             throw new BusinessException(ErrorCode.USER_INVALID_INPUT);
         }

+        // SMS 인증 확인
+        if (!smsVerificationService.isVerified(request.getPhoneNumber())) {
+            throw new BusinessException(ErrorCode.USER_PHONE_NOT_VERIFIED);
+        }
+
         String email = request.getEmail();
         if (email != null && !email.isBlank()) {
             userRepository.updateEmail(userId, email);
         }

         userRepository.updateStatus(userId, AuthStatus.ACTIVE);

-        if (!smsVerificationService.isVerified(request.getPhoneNumber())) {
-            throw new BusinessException(ErrorCode.USER_PHONE_NOT_VERIFIED);
-        }
-
         UserProfile profile = UserProfile.builder()
                 .userId(userId)
                 .name(request.getName())
                 .phoneNumber(request.getPhoneNumber())
                 .build();
         userProfileRepository.updateProfile(profile);
     }
src/main/java/com/flyway/security/jwt/JwtApiAuthFilter.java (1)

37-46: /api/sms(슬래시 없음) 경로 누락 가능성

startsWith("/api/sms/")만으로는 /api/sms 요청이 제외되지 않아 필터가 적용될 수 있습니다. 의도적으로 공개해야 한다면 정확 매치도 포함하는 편이 안전합니다.

✅ 제안 수정안
-        return path.startsWith("/api/public/")
-                || path.startsWith("/api/auth/")
-                || path.startsWith("/api/sms/");
+        return path.startsWith("/api/public/")
+                || path.startsWith("/api/auth/")
+                || path.equals("/api/sms")
+                || path.startsWith("/api/sms/");
src/main/java/com/flyway/security/filter/OnboardingAccessFilter.java (1)

17-29: /api/sms(슬래시 없음) 경로가 허용 목록에서 누락될 수 있음

"/api/sms/"만 허용하면 /api/sms 호출은 차단됩니다. 공개 경로라면 정확 매치도 포함하는 것을 권장합니다.

✅ 제안 수정안
     private static final String[] ALLOWED_PREFIXES = {
         "/signup",
         "/auth/",
         "/resources/",
         "/css/",
         "/js/",
         "/images/",
         "/favicon.ico",
         "/error",
         "/api/auth/",
         "/api/public/",
-        "/api/sms/"
+        "/api/sms",
+        "/api/sms/"
     };
src/main/webapp/resources/signup/js/signup.js (1)

135-144: 들여쓰기가 파일의 나머지 부분과 일치하지 않습니다.

새로 추가된 SMS 관련 변수 선언(lines 135-144)은 4칸 들여쓰기를 사용하지만, 기존 코드(lines 113-133)는 2칸 들여쓰기를 사용합니다. 이 파일 전체에서 일관된 들여쓰기 스타일(2칸)을 유지해 주세요.

♻️ 들여쓰기 수정 제안
-    const sendSmsBtn = document.getElementById("sendSmsBtn");
-    const resendSmsBtn = document.getElementById("resendSmsBtn");
-    const smsStatus = document.getElementById("smsStatus");
-    const smsErrorStatus = document.getElementById("smsErrorStatus");
-    const smsSentBox = document.getElementById("smsSentBox");
-    const smsSuccessBox = document.getElementById("smsSuccessBox");
-    const smsCodeInput = document.getElementById("smsCode");
-    const verifySmsBtn = document.getElementById("verifySmsBtn");
-    const smsVerifyStatus = document.getElementById("smsVerifyStatus");
-    const phoneVerifiedHidden = document.getElementById("phoneVerified");
+  const sendSmsBtn = document.getElementById("sendSmsBtn");
+  const resendSmsBtn = document.getElementById("resendSmsBtn");
+  const smsStatus = document.getElementById("smsStatus");
+  const smsErrorStatus = document.getElementById("smsErrorStatus");
+  const smsSentBox = document.getElementById("smsSentBox");
+  const smsSuccessBox = document.getElementById("smsSuccessBox");
+  const smsCodeInput = document.getElementById("smsCode");
+  const verifySmsBtn = document.getElementById("verifySmsBtn");
+  const smsVerifyStatus = document.getElementById("smsVerifyStatus");
+  const phoneVerifiedHidden = document.getElementById("phoneVerified");

Comment on lines +509 to +521
try {
const res = await fetch(`${contextPath}/api/sms/send?phoneNumber=${encodeURIComponent(phone)}`, {
method: "POST"
});
const data = await res.json();

if (!res.ok) {
if (smsErrorStatus) {
smsErrorStatus.textContent = data?.message || "인증번호 발송에 실패했습니다.";
smsErrorStatus.classList.remove("hidden");
}
return;
}

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 | 🟡 Minor

JSON 파싱 오류 처리가 누락되었습니다.

res.json() 호출이 try-catch 없이 직접 사용됩니다. 서버가 JSON이 아닌 응답(예: 500 에러 HTML 페이지)을 반환하면 파싱 오류가 발생하여 사용자에게 적절한 피드백을 제공하지 못할 수 있습니다.

이메일 인증 코드(lines 254-263)와 같은 방어적 패턴을 적용하는 것을 권장합니다.

🛡️ 수정 제안
         try {
             const res = await fetch(`${contextPath}/api/sms/send?phoneNumber=${encodeURIComponent(phone)}`, {
                 method: "POST"
             });
-            const data = await res.json();
+
+            let data = null;
+            try {
+                data = await res.json();
+            } catch (e) {
+                data = {};
+            }

             if (!res.ok) {
🤖 Prompt for AI Agents
In `@src/main/webapp/resources/signup/js/signup.js` around lines 509 - 521, The
SMS send flow uses await res.json() without handling parse errors; update the
send SMS handler so you only attempt JSON parsing when response content-type is
JSON or wrap res.json() in a try-catch (in the same async function that calls
fetch) and handle failures by showing smsErrorStatus.textContent with a fallback
message (e.g., data?.message if parsed, otherwise a generic "인증번호 발송에 실패했습니다."
or the raw res.text()) and ensure smsErrorStatus.classList.remove("hidden") is
called on parse or network errors; reference the fetch call and the
smsErrorStatus usage in the SMS send handler to locate where to add the
defensive parsing and error display.

@hjh79gw hjh79gw added the ready-for-review PR 리뷰 요청 label Feb 4, 2026

@cl-o-lc cl-o-lc left a comment

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.

수고하셨습니다!

@kkhhmm3103 kkhhmm3103 left a comment

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.

수고했습니다~

@kkhhmm3103
kkhhmm3103 merged commit 3082a6a into dev Feb 4, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

FEAT 새로운 기능 추가 또는 기존 기능 확장 ready-for-review PR 리뷰 요청

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[FLYW-210] 번호인증 구현

3 participants