Files
eapim-portal/kjb-docs/invitation-code-input-implementation-plan.md
T
2025-11-24 22:23:04 +09:00

46 KiB

초대 코드 직접 입력 기능 구현 계획

작성일: 2025-11-24 목적: 폐쇄망 환경에서 이메일 링크를 사용할 수 없는 사용자를 위한 8자리 초대코드 직접 입력 기능 추가


📌 개요

배경

  • 현재 초대 시스템은 이메일 링크 방식만 지원
  • 폐쇄망 환경에서는 이메일 링크 클릭이 불가능
  • 8자리 토큰(예: K7MNPQ2R)을 생성하고 있지만 직접 입력 UI가 없음

목표

  1. 로그인 페이지에 "초대를 받으셨나요?" 링크 추가
  2. 초대코드 직접 입력 페이지 생성
  3. 기존 회원 로그인 시 자동으로 초대 확인 및 리다이렉트

🎯 구현 대상 사용자 플로우

신규 회원 (미가입자)

로그인 페이지 → "초대를 받으셨나요?" 클릭 → 초대코드 입력 → 검증 성공 → 회원가입 페이지

기존 회원 (ROLE_USER)

방법1: 로그인 페이지 → "초대를 받으셨나요?" 클릭 → 로그인 유도 → 로그인 → 자동 초대 확인 → 초대 수락 페이지
방법2: 로그인 페이지 → 로그인 → 자동 초대 확인 → 초대 수락 페이지

🛠 작업 목록

0. 마이페이지 초대 알림 (재방문 사용자 대응)

로그인 후 자동 리다이렉트를 놓친 사용자나, 페이지를 벗어났다가 다시 돌아온 사용자를 위해 마이페이지에서 초대 알림을 표시합니다.

0.1 AccountController 수정

파일: src/main/java/com/eactive/apim/portal/apps/user/controller/AccountController.java

GET /mypage 메서드 수정 (119-149라인):

@GetMapping("/mypage")
public ModelAndView mypage() {
    ModelAndView mav = new ModelAndView();

    try {
        PortalAuthenticatedUser currentUser = SecurityUtil.getPortalAuthenticatedUser();
        PortalUserDTO user = userFacade.findById(currentUser.getId());

        // ===== 새로 추가: 초대 확인 로직 =====
        // ROLE_USER만 초대 확인 (법인 회원은 제외)
        if (currentUser.getRoleCode() == RoleCode.ROLE_USER) {
            Optional<UserInvitation> pendingInvitation =
                userInvitationRepository.findFirstByInvitationEmailAndStatus(
                    currentUser.getLoginId(), InvitationStatus.PENDING);

            if (pendingInvitation.isPresent()) {
                // 초대 정보를 모델에 추가
                mav.addObject("hasPendingInvitation", true);

                // 초대한 법인명 조회
                String orgId = pendingInvitation.get().getOrgId();
                Optional<PortalOrg> org = portalOrgRepository.findById(orgId);
                if (org.isPresent()) {
                    mav.addObject("invitationOrgName", org.get().getOrgName());
                }
            }
        }
        // ================================

        // 기존 로직
        mav.addObject("loginId", user.getLoginId());
        mav.addObject("userName", user.getUserName());
        mav.addObject("mobileNumber", user.getMobileNumber());
        mav.addObject("user", user);

        // ... 기존 코드 계속
    }
}

추가 필드:

private final UserInvitationRepository userInvitationRepository;
private final PortalOrgRepository portalOrgRepository;

예상 작업량: 20-25줄 추가


0.2 마이페이지 레이아웃 수정 (옵션 1: 공통 알림)

파일: src/main/resources/templates/views/layout/kbank_mypage_layout.html

헤더 다음에 알림 배너 추가:

<header th:replace="fragment/kbank/header :: headerFragment"></header>

<!-- 초대 알림 배너 (모든 마이페이지에서 표시) -->
<div th:if="${hasPendingInvitation}" class="invitation-alert-banner">
    <div class="alert alert-info invitation-banner">
        <div class="invitation-content">
            <span class="icon">📩</span>
            <div class="message">
                <strong th:text="${invitationOrgName}">법인명</strong>에서 귀하를 법인회원으로 초대하였습니다.
            </div>
            <a th:href="@{/signup/decision_process}" class="btn btn-primary btn-sm">
                초대 확인하기
            </a>
        </div>
    </div>
</div>

<section layout:fragment="contentFragment">
</section>

CSS 스타일:

.invitation-alert-banner {
    background: #e3f2fd;
    border-bottom: 2px solid #2196F3;
    padding: 15px 0;
}

.invitation-banner {
    max-width: 1200px;
    margin: 0 auto;
    display: flex;
    align-items: center;
    padding: 12px 20px;
    background: white;
    border-radius: 8px;
    box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}

.invitation-content {
    display: flex;
    align-items: center;
    gap: 15px;
    width: 100%;
}

.invitation-content .icon {
    font-size: 24px;
}

.invitation-content .message {
    flex: 1;
    font-size: 14px;
    color: #333;
}

.invitation-content .btn {
    white-space: nowrap;
}

예상 작업량: 50-60줄 (HTML + CSS)


0.3 내 정보 관리 페이지 알림 (옵션 2: 페이지별 알림)

파일: src/main/resources/templates/views/apps/mypage/updatePersonalUser.html

페이지 상단에 알림 추가 (8-10라인 다음):

<div class="sub_title3">
    <h2 class="title">내 정보 관리</h2>
</div>

<!-- 초대 알림 -->
<div th:if="${hasPendingInvitation}" class="alert alert-info invitation-alert">
    <h4>📩 법인회원 초대</h4>
    <p>
        <strong th:text="${invitationOrgName}">법인명</strong>에서 귀하를 법인회원으로 초대하였습니다.
    </p>
    <div class="btn_wrap mt-3">
        <a th:href="@{/signup/decision_process}" class="common_btn_type_1">
            <span>초대 확인하기</span>
        </a>
    </div>
</div>

<div class="inner i_cs h_inner8">
    <!-- 기존 폼 내용 -->
</div>

CSS 스타일:

.invitation-alert {
    background: #e3f2fd;
    border: 2px solid #2196F3;
    border-radius: 8px;
    padding: 20px;
    margin: 20px 0;
    text-align: center;
}

.invitation-alert h4 {
    color: #1976D2;
    margin-bottom: 10px;
    font-size: 18px;
}

.invitation-alert p {
    color: #333;
    margin-bottom: 15px;
    font-size: 14px;
}

예상 작업량: 40-50줄 (HTML + CSS)


0.4 선택 기준

옵션 1 (레이아웃 수정) 추천:

  • 모든 마이페이지에서 초대 알림 표시
  • 일관된 UX
  • 수정 파일 적음 (1개 레이아웃 파일)

옵션 2 (개별 페이지 수정):

  • 페이지별 커스터마이징 가능
  • 여러 파일 수정 필요 (updatePersonalUser, updateCorporateUser, updateCorporateManager 등)
  • 일부 페이지에서 놓칠 가능성

권장: 옵션 1 (레이아웃 수정)


0.5 테스트 시나리오

시나리오 1: 로그인 후 초대 자동 확인 놓침

  1. 로그인 시 초대 자동 리다이렉트 발생
  2. 사용자가 뒤로가기나 다른 페이지 이동
  3. 마이페이지 접속
  4. 상단에 초대 알림 배너 표시
  5. "초대 확인하기" 클릭 → /signup/decision_process 이동

시나리오 2: 세션 만료 후 재로그인

  1. 로그인 세션 만료
  2. 재로그인
  3. 자동 리다이렉트 없음 (세션 초기화)
  4. 마이페이지 접속
  5. 초대 알림 표시

시나리오 3: 초대 수락 후 알림 제거

  1. 마이페이지에서 초대 알림 확인
  2. "초대 확인하기" 클릭 → 초대 수락
  3. 마이페이지 재방문
  4. 초대 알림 없음 (PENDING 상태 아님)

시나리오 4: 법인 회원은 알림 표시 안 됨

  1. ROLE_CORP_USER로 로그인
  2. 마이페이지 접속
  3. 초대 알림 없음 (ROLE_USER만 표시)

1. 프론트엔드 (Thymeleaf Templates)

1.1 로그인 페이지 수정

파일: src/main/resources/templates/views/apps/login/login.html

수정 위치: 53-73라인 login_list 영역

작업 내용:

<li class="icon_invite">
    <img th:src="@{/img/icon/icon_login_invite.png}" alt="">
    <a th:href="@{/signup/invitation-code}">초대를 받으셨나요?</a>
</li>
<li>
    <span>|</span>
</li>

예상 작업량: 10줄 추가


1.2 초대코드 입력 페이지 생성 (신규)

파일: src/main/resources/templates/views/apps/register/invitationCodeInput.html

작업 내용:

  • 8자리 초대코드 입력 폼
  • 대문자 자동 변환 (JavaScript)
  • CSRF 토큰 포함
  • 검증 실패 시 에러 메시지 표시

UI 요구사항:

  • 입력 필드: 8자리 영문+숫자 (예: K7MNPQ2R)
  • 실시간 대문자 변환
  • 제출 버튼: "확인"
  • 취소 버튼: "돌아가기" (로그인 페이지로)

예상 작업량: 100-120줄

예시 구조:

<form th:action="@{/signup/invitation-code}" method="post">
    <input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"/>

    <div class="form_type_box">
        <div class="info1">
            <p class="title">초대코드</p>
            <div class="info_line">
                <input type="text" name="invitationCode"
                       maxlength="8"
                       placeholder="8자리 초대코드 입력 (예: K7MNPQ2R)"
                       pattern="[A-Z0-9]{8}"
                       required>
            </div>
        </div>
    </div>

    <div class="btn_login">
        <button type="submit">확인</button>
    </div>
</form>

JavaScript 기능:

  • 자동 대문자 변환
  • 8자리 제한
  • 영문+숫자만 입력 허용

2. 백엔드 (Java Controller)

2.1 UserRegisterController 수정

파일: src/main/java/com/eactive/apim/portal/apps/user/controller/UserRegisterController.java

추가 엔드포인트:

2.1.1 GET /signup/invitation-code
@GetMapping("/signup/invitation-code")
public String showInvitationCodeInput(HttpSession session, Model model) {
    // 초대코드 입력 페이지 표시
    return "apps/register/invitationCodeInput";
}
2.1.2 POST /signup/invitation-code
@PostMapping("/signup/invitation-code")
public String processInvitationCode(
        @RequestParam("invitationCode") String invitationCode,
        HttpSession session,
        RedirectAttributes redirectAttributes,
        Model model) {

    // 1. 입력값 검증 (8자리, 영문+숫자)
    if (!invitationCode.matches("[A-Z0-9]{8}")) {
        model.addAttribute("error", "올바른 형식의 초대코드를 입력해주세요.");
        return "apps/register/invitationCodeInput";
    }

    // 2. DB에서 토큰 조회
    Optional<UserInvitation> invitation =
        userInvitationRepository.findByToken(invitationCode);

    // 3. 유효성 검증
    if (!invitation.isPresent() ||
        invitation.get().getStatus() != UserInvitationEnums.InvitationStatus.PENDING) {
        model.addAttribute("error", "유효하지 않은 초대코드입니다.");
        return "apps/register/invitationCodeInput";
    }

    // 4. 만료 확인
    if (invitation.get().getExpiresOn().isBefore(LocalDateTime.now())) {
        model.addAttribute("error", "만료된 초대코드입니다.");
        return "apps/register/invitationCodeInput";
    }

    // 5. 사용자 존재 확인
    String email = invitation.get().getInvitationEmail();
    Optional<PortalUser> existingUser =
        portalUserRepository.findPortalUserByEmailAddr(email);

    if (existingUser.isPresent()) {
        // 기존 회원 → 로그인 유도
        session.setAttribute("invitationCodeForLogin", invitationCode);
        redirectAttributes.addFlashAttribute("message",
            "로그인하여 초대를 수락해주세요.");
        return "redirect:/login";
    } else {
        // 신규 회원 → 가입 페이지
        return "redirect:/signup/portalUser?invitation=" + invitationCode;
    }
}

예상 작업량: 50-60줄


2.2 PortalAuthenticationSuccessHandler 수정

파일: src/main/java/com/eactive/apim/portal/config/PortalAuthenticationSuccessHandler.java

수정 위치: onAuthenticationSuccess() 메서드 (38-83라인)

추가 로직:

@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
                                    Authentication authentication) throws IOException, ServletException {
    String username = request.getParameter("id");
    PortalUser user = portalUserRepository.findPortalUserByEmailAddr(username).orElse(null);

    // ... 기존 로직 (이메일 인증, 휴면 계정, 비밀번호 만료 체크)

    // ===== 새로 추가 =====
    // 초대 코드 자동 확인 (ROLE_USER 회원만)
    if (user.getRoleCode() == RoleCode.ROLE_USER) {
        Optional<UserInvitation> pendingInvitation =
            userInvitationRepository.findFirstByInvitationEmailAndStatus(
                username, InvitationStatus.PENDING);

        if (pendingInvitation.isPresent()) {
            // 초대가 있으면 자동으로 초대 수락 페이지로 리다이렉트
            response.sendRedirect(contextPath + "/signup/decision_process");
            return;
        }
    }
    // ==================

    // 기존 decisionToken 체크 로직
    String decisionToken = (String) request.getSession().getAttribute("decisionToken");
    if (decisionToken != null) {
        response.sendRedirect(contextPath + "/signup/decision_process");
    } else {
        response.sendRedirect(contextPath + "/");
    }
}

추가 필드:

private final UserInvitationRepository userInvitationRepository;

예상 작업량: 15줄 추가


3. 보안 설정 (선택사항)

3.1 PortalConfigSecurity 확인

파일: src/main/java/com/eactive/apim/portal/config/PortalConfigSecurity.java

확인 사항:

  • /signup/invitation-code 경로가 인증 없이 접근 가능한지 확인
  • CSRF 토큰이 적용되는지 확인

예상 설정 (이미 /signup/**이 허용되어 있을 가능성 높음):

.antMatchers("/signup/**").permitAll()

🔍 테스트 계획

단위 테스트

3.1 Controller 테스트

@SpringBootTest
@AutoConfigureMockMvc
class InvitationCodeInputTest extends BaseWebTest {

    @Test
    void testValidInvitationCode_NewUser() {
        // 신규 회원용 유효한 코드 입력 → 가입 페이지로 리다이렉트
    }

    @Test
    void testValidInvitationCode_ExistingUser() {
        // 기존 회원용 유효한 코드 입력 → 로그인 페이지로 리다이렉트
    }

    @Test
    void testInvalidInvitationCode() {
        // 존재하지 않는 코드 → 에러 메시지
    }

    @Test
    void testExpiredInvitationCode() {
        // 만료된 코드 → 에러 메시지
    }

    @Test
    void testAlreadyProcessedCode() {
        // 이미 처리된 코드(COMPLETED) → 에러 메시지
    }
}

3.2 AuthenticationSuccessHandler 테스트

@Test
void testAutoRedirectWithPendingInvitation() {
    // ROLE_USER 로그인 시 초대가 있으면 자동 리다이렉트
}

@Test
void testNoAutoRedirectForCorpUser() {
    // ROLE_CORP_USER는 자동 리다이렉트 안됨
}

통합 테스트 시나리오

시나리오 1: 신규 회원 초대코드 입력

  1. 로그인 페이지 접속
  2. "초대를 받으셨나요?" 클릭
  3. 초대코드 입력 (예: K7MNPQ2R)
  4. 회원가입 페이지로 이동
  5. 가입 완료

예상 결과: ROLE_CORP_USER로 가입 완료


시나리오 2: 기존 회원 초대코드 입력 후 로그인

  1. 로그인 페이지 접속
  2. "초대를 받으셨나요?" 클릭
  3. 초대코드 입력 (예: K7MNPQ2R)
  4. "로그인하여 초대를 수락해주세요" 메시지
  5. 로그인 페이지로 리다이렉트
  6. 로그인
  7. 자동으로 초대 수락 페이지로 이동
  8. 약관 동의 후 수락
  9. 로그아웃 후 재로그인

예상 결과: ROLE_USER → ROLE_CORP_USER 전환 완료


시나리오 3: 기존 회원 직접 로그인 (초대 자동 확인)

  1. 로그인 페이지 접속
  2. 로그인 (초대받은 이메일로)
  3. 자동으로 초대 수락 페이지로 리다이렉트
  4. 약관 동의 후 수락

예상 결과: 초대 수락 완료


시나리오 4: 유효하지 않은 초대코드

  1. 초대코드 입력 페이지 접속
  2. 존재하지 않는 코드 입력 (예: INVALID1)
  3. "유효하지 않은 초대코드입니다" 에러 메시지
  4. 입력 페이지 유지

예상 결과: 에러 메시지 표시, 재입력 가능


시나리오 5: 만료된 초대코드

  1. 초대코드 입력 페이지 접속
  2. 7일 이상 경과한 코드 입력
  3. "만료된 초대코드입니다" 에러 메시지

예상 결과: 에러 메시지 표시


⚠️ 주의사항 및 고려사항

보안

  • CSRF 토큰 적용 확인
  • SQL Injection 방지 (JPA 사용으로 기본 방어)
  • 초대코드 Brute Force 공격 방지 (세션 기반 실패 횟수 제한) - 아래 상세 구현 방법 참조
  • 세션 하이재킹 방지 (기존 Spring Security로 대응)
  • 이메일 형식 검증 (프론트엔드 + 백엔드 이중 검증) 구현 완료

사용성

  • 입력 필드 자동 대문자 변환
  • 8자리 제한 및 유효성 검증 (클라이언트 + 서버)
  • 명확한 에러 메시지
  • 모바일 반응형 디자인

우선순위 충돌 처리

PortalAuthenticationSuccessHandler에서 여러 조건 체크 순서:

  1. 이메일 인증 필요 (READY 상태)
  2. 휴면 계정 (DORMANT 상태)
  3. 임시 비밀번호 로그인
  4. 비밀번호 만료 (90일)
  5. → [새로 추가] 초대 코드 확인 ← ROLE_USER만 해당
  6. decisionToken 세션 체크
  7. 일반 홈페이지

결정: 비밀번호 관련 체크 이후, 일반 홈 이동 전에 초대 확인


📊 작업 규모 요약

항목 파일 수 예상 코드량 난이도 예상 시간
마이페이지 초대 알림 2 70-85줄 보통 1시간
HTML 수정 (로그인) 1 10줄 쉬움 15분
HTML 신규 (초대코드 입력) 1 100-120줄 보통 1-1.5시간
Controller 수정 (UserRegister) 1 50-60줄 보통 1시간
Handler 수정 1 15줄 보통 30분
단위 테스트 2 100줄 보통 1시간
통합 테스트 - 수동 중요 2-2.5시간

총 예상 시간: 6.5-8시간


📝 체크리스트

개발 전

  • 기존 초대 플로우 완전 이해
  • DB 스키마 확인 (UserInvitation 테이블)
  • 기존 테스트 케이스 리뷰

개발 중

  • 초대 만료일 동적 설정 🔧 개선
    • DurationParser 유틸리티 클래스 생성
    • UserManFacade에 PortalPropertyService 의존성 추가
    • createInvitation() 메서드 수정 (동적 만료일)
    • PTL_PROPERTY 테이블에 설정 추가
  • 중복 초대 방지 추가 ⚠️ 필수
    • UserInvitationRepository 메서드 추가
    • UserManFacade.sendInvitation()에 중복 체크 로직 추가
    • 같은 법인 중복 초대 차단
    • 만료된 초대 자동 취소 후 재생성
    • 다른 법인 중복 초대 차단 (비즈니스 정책 결정 필요)
  • 마이페이지 초대 알림 추가 (재방문 사용자 대응)
    • AccountController에 초대 확인 로직 추가
    • kbank_mypage_layout.html에 알림 배너 추가
    • CSS 스타일 작성
  • 로그인 페이지 "초대를 받으셨나요?" 링크 추가
  • 초대코드 입력 페이지 생성
  • UserRegisterController 엔드포인트 구현
    • GET /signup/invitation-code
    • POST /signup/invitation-code (Brute Force 방지 포함)
  • AuthenticationSuccessHandler 수정 (자동 초대 확인)
  • CSRF 토큰 적용 확인
  • 에러 처리 구현

테스트

  • 단위 테스트 작성 및 통과
  • 초대 만료일 동적 설정 테스트 🔧
    • 기본값 7일 테스트
    • 2시간 설정 테스트
    • 30분 설정 테스트
    • 14일 설정 테스트
    • 잘못된 형식 → 기본값 fallback 테스트
  • 중복 초대 방지 테스트 ⚠️ 필수
    • 같은 법인 중복 초대 차단
    • 만료된 초대 재발송
    • 다른 법인 중복 초대 차단
    • 초대 수락 후 중복 차단
  • 마이페이지 초대 알림 테스트
    • 로그인 후 자동 확인 놓친 경우
    • 세션 만료 후 재로그인
    • 초대 수락 후 알림 제거 확인
    • 법인 회원은 알림 표시 안 됨
  • 신규 회원 플로우 테스트
  • 기존 회원 플로우 테스트
  • 에러 케이스 테스트
  • 만료된 코드 테스트
  • Brute Force 방지 테스트 (5회 실패 시나리오)
  • 우선순위 충돌 테스트

배포 전

  • 코드 리뷰
  • 통합 테스트 완료
  • 로그 확인 (Hibernate SQL, 애플리케이션 로그)
  • 스테이징 환경 배포 및 검증
  • 운영 배포 계획 수립

🔗 관련 파일

주요 소스

  • UserRegisterController.java:149-191 - 기존 초대 처리 로직
  • UserManFacade.java:124-163 - 초대 생성 및 토큰 생성
  • PortalAuthenticationSuccessHandler.java:38-83 - 로그인 성공 처리
  • userDecisionProcess.html - 초대 수락 페이지 (약관 포함)

참고 문서

  • kjb-docs/mermaid/invite-flow-integrated.mermaid - 통합 플로우차트
  • kjb-docs/mermaid/invite-sequence.mermaid - 시퀀스 다이어그램
  • CLAUDE.md - 프로젝트 아키텍처

📅 일정 (예상)

단계 작업 내용 소요 시간 담당자
0단계 마이페이지 초대 알림 구현 1시간 -
1단계 HTML 작업 (로그인, 초대코드 입력) 1.5시간 -
2단계 Controller 구현 (Brute Force 방지 포함) 1.5시간 -
3단계 AuthenticationSuccessHandler 수정 0.5시간 -
4단계 단위 테스트 작성 1시간 -
5단계 통합 테스트 (수동) 2.5시간 -
8시간

📌 Brute Force 방지 구현 (세션 기반)

개요

초대코드 입력 실패 시 세션 기반으로 실패 횟수를 추적하여, 일정 횟수 이상 실패 시 일시적으로 입력을 차단합니다.

구현 방법

1. 세션 속성 정의

// 세션에 저장할 키
public static final String INVITATION_CODE_FAILURE_COUNT = "invitationCodeFailureCount";
public static final String INVITATION_CODE_LOCKED_UNTIL = "invitationCodeLockedUntil";

// 설정값
public static final int MAX_FAILURE_COUNT = 5;  // 최대 실패 허용 횟수
public static final int LOCK_DURATION_MINUTES = 15;  // 차단 시간 (분)

2. UserRegisterController 수정

파일: UserRegisterController.java

POST /signup/invitation-code 메서드 수정:

@PostMapping("/signup/invitation-code")
public String processInvitationCode(
        @RequestParam("invitationCode") String invitationCode,
        HttpSession session,
        RedirectAttributes redirectAttributes,
        Model model) {

    // 1. 차단 상태 확인
    LocalDateTime lockedUntil = (LocalDateTime) session.getAttribute(INVITATION_CODE_LOCKED_UNTIL);
    if (lockedUntil != null && LocalDateTime.now().isBefore(lockedUntil)) {
        long minutesLeft = ChronoUnit.MINUTES.between(LocalDateTime.now(), lockedUntil);
        model.addAttribute("error",
            String.format("너무 많은 시도로 인해 %d분간 초대코드 입력이 차단되었습니다.", minutesLeft + 1));
        return "apps/register/invitationCodeInput";
    }

    // 2. 입력값 검증
    if (!invitationCode.matches("[A-Z0-9]{8}")) {
        incrementFailureCount(session);
        model.addAttribute("error", "올바른 형식의 초대코드를 입력해주세요.");
        return "apps/register/invitationCodeInput";
    }

    // 3. DB에서 토큰 조회 및 검증
    Optional<UserInvitation> invitation =
        userInvitationRepository.findByToken(invitationCode);

    if (!invitation.isPresent() ||
        invitation.get().getStatus() != UserInvitationEnums.InvitationStatus.PENDING ||
        invitation.get().getExpiresOn().isBefore(LocalDateTime.now())) {

        // 실패 처리
        int failureCount = incrementFailureCount(session);

        if (failureCount >= MAX_FAILURE_COUNT) {
            // 차단 처리
            LocalDateTime lockTime = LocalDateTime.now().plusMinutes(LOCK_DURATION_MINUTES);
            session.setAttribute(INVITATION_CODE_LOCKED_UNTIL, lockTime);
            model.addAttribute("error",
                "너무 많은 시도로 인해 15분간 초대코드 입력이 차단되었습니다.");
        } else {
            int remainingAttempts = MAX_FAILURE_COUNT - failureCount;
            model.addAttribute("error",
                String.format("유효하지 않은 초대코드입니다. (남은 시도: %d회)", remainingAttempts));
        }

        return "apps/register/invitationCodeInput";
    }

    // 4. 성공 시 실패 카운트 초기화
    session.removeAttribute(INVITATION_CODE_FAILURE_COUNT);
    session.removeAttribute(INVITATION_CODE_LOCKED_UNTIL);

    // 5. 기존 로직 (사용자 확인 및 리다이렉트)
    String email = invitation.get().getInvitationEmail();
    Optional<PortalUser> existingUser =
        portalUserRepository.findPortalUserByEmailAddr(email);

    if (existingUser.isPresent()) {
        session.setAttribute("invitationCodeForLogin", invitationCode);
        redirectAttributes.addFlashAttribute("message", "로그인하여 초대를 수락해주세요.");
        return "redirect:/login";
    } else {
        return "redirect:/signup/portalUser?invitation=" + invitationCode;
    }
}

// Helper 메서드
private int incrementFailureCount(HttpSession session) {
    Integer failureCount = (Integer) session.getAttribute(INVITATION_CODE_FAILURE_COUNT);
    if (failureCount == null) {
        failureCount = 0;
    }
    failureCount++;
    session.setAttribute(INVITATION_CODE_FAILURE_COUNT, failureCount);
    return failureCount;
}

3. 프론트엔드 UI 개선

invitationCodeInput.html에 추가:

<!-- 차단 상태 표시 -->
<div th:if="${error != null and error.contains('차단')}" class="alert alert-danger">
    <strong>⚠️ 보안 경고</strong>
    <p th:text="${error}"></p>
    <p>잠시 후 다시 시도해주세요.</p>
</div>

<!-- 일반 에러 표시 (남은 시도 횟수 포함) -->
<div th:if="${error != null and !error.contains('차단')}" class="alert alert-warning">
    <p th:text="${error}"></p>
</div>

4. 장점

  • DB 사용 불필요: 세션 메모리만 사용하여 간단하고 빠름
  • 자동 만료: 세션 타임아웃(15분) 시 자동으로 초기화
  • 사용자별 독립적: 각 세션마다 독립적으로 카운트
  • 성능 영향 최소: 추가 DB 쿼리 없음

5. 주의사항

  • 세션이 만료되면 실패 카운트도 초기화됨 (긍정적 효과)
  • 다른 브라우저/디바이스에서는 별도 카운트 (IP 기반 차단 아님)
  • 로그아웃 시 세션 초기화로 카운트 리셋 가능 (큰 문제 아님)

6. 테스트 시나리오

시나리오 1: 5회 실패 후 차단
1. 잘못된 코드 입력 (1회) → "남은 시도: 4회"
2. 잘못된 코드 입력 (2회) → "남은 시도: 3회"
3. 잘못된 코드 입력 (3회) → "남은 시도: 2회"
4. 잘못된 코드 입력 (4회) → "남은 시도: 1회"
5. 잘못된 코드 입력 (5회) → "15분간 차단되었습니다"
6. 올바른 코드 입력 → 차단 메시지 표시, 입력 불가

시나리오 2: 차단 후 시간 경과
1. 5회 실패로 차단 상태
2. 15분 대기
3. 올바른 코드 입력 → 정상 처리

시나리오 3: 실패 후 성공
1. 잘못된 코드 입력 (2회) → "남은 시도: 3회"
2. 올바른 코드 입력 → 성공, 카운트 초기화
3. 다시 잘못된 코드 입력 → "남은 시도: 4회" (초기화됨)

📌 초대 만료일 동적 설정 (PortalProperty) 🔧 개선

현재 상황

UserManFacade.createInvitation() (149-150라인):

// Set expiration date (e.g., 7 days from now)
newInvitation.setExpiresOn(LocalDateTime.now().plusDays(7));

문제점:

  • 만료일이 하드코딩되어 있음 (7일 고정)
  • 운영 중 변경 시 코드 수정 및 재배포 필요
  • 환경별 다른 설정 불가

개선 방안: PTL_PROPERTY 테이블 활용

PTL_PROPERTY 테이블에서 초대 만료 기간을 읽어와 동적으로 설정합니다.

1. 시간 표현 유틸리티 클래스 생성 (신규)

파일: src/main/java/com/eactive/apim/portal/common/util/DurationParser.java

package com.eactive.apim.portal.common.util;

import java.time.Duration;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

/**
 * 시간 표현 문자열을 Duration으로 파싱하는 유틸리티
 * 지원 형식: 5m (5분), 2h (2시간), 7d (7일)
 */
public class DurationParser {

    private static final Pattern DURATION_PATTERN = Pattern.compile("^(\\d+)([mhd])$");

    /**
     * 시간 표현 문자열을 Duration으로 변환
     *
     * @param durationStr 시간 문자열 (예: "5m", "2h", "7d")
     * @return Duration 객체
     * @throws IllegalArgumentException 형식이 올바르지 않을 경우
     */
    public static Duration parse(String durationStr) {
        if (durationStr == null || durationStr.trim().isEmpty()) {
            throw new IllegalArgumentException("Duration string cannot be null or empty");
        }

        String normalized = durationStr.trim().toLowerCase();
        Matcher matcher = DURATION_PATTERN.matcher(normalized);

        if (!matcher.matches()) {
            throw new IllegalArgumentException(
                "Invalid duration format: " + durationStr +
                ". Expected format: <number><unit> (e.g., 5m, 2h, 7d)"
            );
        }

        long value = Long.parseLong(matcher.group(1));
        String unit = matcher.group(2);

        switch (unit) {
            case "m":
                return Duration.ofMinutes(value);
            case "h":
                return Duration.ofHours(value);
            case "d":
                return Duration.ofDays(value);
            default:
                throw new IllegalArgumentException("Unsupported time unit: " + unit);
        }
    }

    /**
     * 시간 표현 문자열을 일(day) 단위로 변환
     *
     * @param durationStr 시간 문자열 (예: "5m", "2h", "7d")
     * @return 일(day) 단위 long 값
     */
    public static long parseToDays(String durationStr) {
        return parse(durationStr).toDays();
    }

    /**
     * 기본값을 제공하는 안전한 파싱
     *
     * @param durationStr 시간 문자열
     * @param defaultValue 파싱 실패 시 기본값
     * @return Duration 객체
     */
    public static Duration parseOrDefault(String durationStr, Duration defaultValue) {
        try {
            return parse(durationStr);
        } catch (Exception e) {
            return defaultValue;
        }
    }
}

예상 작업량: 70줄


2. UserManFacade 수정

파일: UserManFacade.java

변경 전 (149-150라인):

// Set expiration date (e.g., 7 days from now)
newInvitation.setExpiresOn(LocalDateTime.now().plusDays(7));

변경 후:

// ===== 새로 추가 =====
private final PortalPropertyService portalPropertyService;

public String createInvitation(PortalUser sender, String emailAddr) {
    UserInvitation newInvitation = new UserInvitation();
    newInvitation.setAdminId(sender.getId());
    newInvitation.setOrgId(sender.getPortalOrg().getId());
    newInvitation.setInvitationEmail(emailAddr);

    // Set additional required fields
    newInvitation.setInvitationDate(LocalDateTime.now());
    newInvitation.setStatus(InvitationStatus.PENDING);

    // Generate 8-character token
    String token = generateEightCharToken();
    newInvitation.setToken(token);

    // ===== 변경: 동적 만료일 설정 =====
    // PTL_PROPERTY에서 초대 만료 기간 조회
    Map<String, String> properties = portalPropertyService.getPortalPropertiesAsMap("Portal");
    String expirationDuration = properties.getOrDefault("invitation.expiration", "7d");

    // 시간 문자열 파싱 (예: "7d" → 7일, "2h" → 2시간, "30m" → 30분)
    Duration duration = DurationParser.parseOrDefault(expirationDuration, Duration.ofDays(7));

    // 만료일 설정
    newInvitation.setExpiresOn(LocalDateTime.now().plus(duration));
    // ================================

    // Save the invitation
    userInvitationRepository.save(newInvitation);
    return token;
}

추가 필드:

private final PortalPropertyService portalPropertyService;

예상 작업량: 10줄 추가


PTL_PROPERTY 테이블 설정

데이터 삽입 SQL

-- 초대 만료 기간 설정 (기본값: 7일)
INSERT INTO PTL_PROPERTY (PROP_GROUP, PROP_KEY, PROP_VALUE, DESCRIPTION)
VALUES ('Portal', 'invitation.expiration', '7d', '초대 만료 기간 (형식: 숫자+단위, 예: 5m, 2h, 7d)');

-- 다른 예시들
-- 30분: '30m'
-- 2시간: '2h'
-- 14일: '14d'

설정 예시

PROP_GROUP PROP_KEY PROP_VALUE 설명
Portal invitation.expiration 7d 7일 후 만료 (기본값)
Portal invitation.expiration 2h 2시간 후 만료 (테스트용)
Portal invitation.expiration 30m 30분 후 만료 (데모용)
Portal invitation.expiration 14d 14일 후 만료 (운영용)

지원하는 시간 단위

단위 설명 예시 실제 기간
m 분 (minutes) 5m 5분
m 분 (minutes) 30m 30분
h 시간 (hours) 2h 2시간
h 시간 (hours) 24h 24시간 (1일)
d 일 (days) 7d 7일
d 일 (days) 30d 30일

형식: <숫자><단위> (예: 5m, 2h, 7d)


에러 처리

잘못된 형식 입력 시

// PTL_PROPERTY에 잘못된 값이 있는 경우
// 예: "invalid", "7", "d7", "7dd"

Duration duration = DurationParser.parseOrDefault(expirationDuration, Duration.ofDays(7));
// → 파싱 실패 시 기본값 7일 사용

로그 출력 (선택사항):

try {
    duration = DurationParser.parse(expirationDuration);
} catch (IllegalArgumentException e) {
    log.warn("Invalid invitation expiration duration '{}', using default 7 days",
             expirationDuration, e);
    duration = Duration.ofDays(7);
}

테스트 시나리오

테스트 1: 기본값 7일

PTL_PROPERTY: 설정 없음 또는 "7d"
초대 생성: 2025-11-24 10:00
만료일: 2025-12-01 10:00 (7일 후) ✅

테스트 2: 2시간 (테스트용)

PTL_PROPERTY: "2h"
초대 생성: 2025-11-24 10:00
만료일: 2025-11-24 12:00 (2시간 후) ✅

테스트 3: 30분 (데모용)

PTL_PROPERTY: "30m"
초대 생성: 2025-11-24 10:00
만료일: 2025-11-24 10:30 (30분 후) ✅

테스트 4: 14일 (운영용)

PTL_PROPERTY: "14d"
초대 생성: 2025-11-24 10:00
만료일: 2025-12-08 10:00 (14일 후) ✅

테스트 5: 잘못된 형식 → 기본값

PTL_PROPERTY: "invalid" 또는 빈 값
초대 생성: 2025-11-24 10:00
만료일: 2025-12-01 10:00 (기본 7일) ✅
로그: "Invalid invitation expiration duration 'invalid', using default 7 days"

장점

  • 재배포 불필요: DB 설정만 변경하면 즉시 적용
  • 환경별 설정: 개발/스테이징/운영 환경마다 다른 만료일 설정 가능
  • 유연성: 분/시간/일 단위 모두 지원
  • 안전성: 잘못된 설정 시 기본값(7일) 사용
  • 가독성: "7d"가 "7"보다 의미 명확

📌 중복 초대 방지 구현 ⚠️ 필수

현재 문제점

UserManFacade.sendInvitation() (91-132라인)에서:

  • 이미 법인 회원인지 체크함 (108-110라인)
  • PENDING 상태의 초대가 이미 있는지 체크 안 함

결과: 같은 이메일에 여러 번 초대 가능 → 중복 초대 생성


구현 방법

1. UserManFacade.sendInvitation() 수정

파일: src/main/java/com/eactive/apim/portal/apps/user/facade/UserManFacade.java

수정 위치: 103-111라인 (기존 사용자 확인 로직 다음)

public void sendInvitation(PortalUser sender, String emailAddr) {
    // 이메일 형식 검증
    if (emailAddr == null || emailAddr.trim().isEmpty()) {
        throw new IllegalArgumentException("이메일 주소를 입력해주세요.");
    }

    // 이메일 정규식 검증
    String emailPattern = "^[a-zA-Z0-9._%+\\-]+@[a-zA-Z0-9.\\-]+\\.[a-zA-Z]{2,}$";
    if (!emailAddr.matches(emailPattern)) {
        throw new IllegalArgumentException("올바른 이메일 형식을 입력해주세요.");
    }

    // 기존 사용자인지 확인
    Optional<PortalUser> existingUser = portalUserRepository.findPortalUserByEmailAddr(emailAddr);

    if (existingUser.isPresent()) {
        PortalUser user = existingUser.get();
        if (user.getPortalOrg() != null && !user.getRoleCode().equals(RoleCode.ROLE_USER)) {
            throw new IllegalArgumentException("기관에 등록된 이용자 입니다.");
        }
    }

    // ===== 새로 추가: 중복 초대 확인 =====
    // 1. 현재 법인의 PENDING 초대 확인
    Optional<UserInvitation> existingInvitation =
        userInvitationRepository.findFirstByInvitationEmailAndOrgIdAndStatus(
            emailAddr,
            sender.getPortalOrg().getId(),
            InvitationStatus.PENDING
        );

    if (existingInvitation.isPresent()) {
        UserInvitation invitation = existingInvitation.get();

        // 만료된 초대인 경우 → 기존 초대 취소 후 새로 생성
        if (invitation.getExpiresOn().isBefore(LocalDateTime.now())) {
            invitation.setStatus(InvitationStatus.CANCELED);
            userInvitationRepository.save(invitation);
            // 계속 진행하여 새 초대 생성
        } else {
            // 아직 유효한 초대가 있는 경우 → 에러
            throw new IllegalArgumentException(
                "이미 초대 중인 사용자입니다. 초대 만료일: " +
                invitation.getExpiresOn().format(DateTimeFormatter.ofPattern("yyyy-MM-dd"))
            );
        }
    }

    // 2. 다른 법인의 PENDING 초대 확인 (선택사항)
    Optional<UserInvitation> otherOrgInvitation =
        userInvitationRepository.findFirstByInvitationEmailAndStatus(
            emailAddr,
            InvitationStatus.PENDING
        );

    if (otherOrgInvitation.isPresent() &&
        !otherOrgInvitation.get().getOrgId().equals(sender.getPortalOrg().getId())) {

        // 다른 법인에서 초대 중 → 경고 메시지 (선택사항)
        // 비즈니스 정책에 따라 차단 또는 허용
        throw new IllegalArgumentException(
            "해당 사용자는 다른 법인에서 초대 중입니다."
        );
    }
    // ====================================

    String token = createInvitation(sender, emailAddr);

    // ... 나머지 기존 로직
}

예상 작업량: 30-35줄 추가


2. UserInvitationRepository 메서드 추가 (필요 시)

현재 findFirstByInvitationEmailAndStatus 메서드는 있지만, 같은 법인 내 중복 체크를 위해 새 메서드가 필요할 수 있습니다.

파일: UserInvitationRepository.java

public interface UserInvitationRepository extends JpaRepository<UserInvitation, String> {

    // 기존 메서드
    Optional<UserInvitation> findFirstByInvitationEmailAndStatus(
        String invitationEmail,
        InvitationStatus status
    );

    // 새로 추가: 같은 법인 내 중복 체크
    Optional<UserInvitation> findFirstByInvitationEmailAndOrgIdAndStatus(
        String invitationEmail,
        String orgId,
        InvitationStatus status
    );
}

예상 작업량: 5줄 추가 (메서드 시그니처만)


중복 초대 시나리오 및 처리 방침

시나리오 1: 같은 법인에서 중복 초대

상황: A법인 관리자가 user@example.com에게 초대
상태: PENDING (만료일: 2025-12-01)
재시도: A법인 관리자가 동일 이메일에 다시 초대 시도
결과: ❌ "이미 초대 중인 사용자입니다. 초대 만료일: 2025-12-01"

정책: 차단 (같은 법인 내 중복 초대 불가)


시나리오 2: 같은 법인, 만료된 초대

상황: A법인 관리자가 user@example.com에게 초대
상태: PENDING (만료일: 2025-11-20, 이미 만료됨)
재시도: A법인 관리자가 동일 이메일에 다시 초대 시도
처리:
  1. 기존 초대 상태 → CANCELED
  2. 새로운 PENDING 초대 생성
  3. 새 토큰 발급
결과: ✅ 새 초대 이메일 발송

정책: 허용 (만료된 초대는 자동 취소 후 재생성)


시나리오 3: 다른 법인에서 중복 초대 (비즈니스 정책 필요)

옵션 A: 차단 (추천)

상황: A법인에서 user@example.com에게 초대 (PENDING)
재시도: B법인 관리자가 동일 이메일에 초대 시도
결과: ❌ "해당 사용자는 다른 법인에서 초대 중입니다."

옵션 B: 허용

상황: A법인에서 user@example.com에게 초대 (PENDING)
재시도: B법인 관리자가 동일 이메일에 초대 시도
결과: ✅ 두 초대 모두 PENDING 상태 유지
선택: 사용자가 먼저 수락한 초대로 가입

권장: 옵션 A (차단) - 사용자 혼란 방지


시나리오 4: 초대 수락 후 다른 법인 초대

상황: user@example.com이 A법인 초대 수락 (ROLE_CORP_USER)
재시도: B법인 관리자가 동일 이메일에 초대 시도
결과: ❌ "기관에 등록된 이용자 입니다." (기존 로직)

정책: 차단 (이미 구현됨, 108-110라인)


프론트엔드 에러 메시지 처리

파일: userList.html (sendInvitation 함수, 223-235라인)

현재 에러 처리 코드:

.fail(function (jqXHR, textStatus, errorThrown) {
    let errorMessage;

    try {
        const errorResponse = JSON.parse(jqXHR.responseText);
        errorMessage = errorResponse.msg || '이용자 초대 중 오류가 발생했습니다';
    } catch (e) {
        errorMessage = '이용자 초대 중 오류가 발생했습니다: ' + errorThrown;
    }
    customPopups.showAlert(errorMessage);
});

변경 필요 없음: 이미 서버 에러 메시지를 그대로 표시하므로 중복 초대 에러도 자동으로 표시됨


테스트 시나리오

테스트 1: 같은 법인 중복 초대 차단

1. A법인 관리자 로그인
2. user@example.com에게 초대 발송 → 성공
3. 다시 user@example.com에게 초대 시도
4. 에러: "이미 초대 중인 사용자입니다. 초대 만료일: 2025-12-01"
5. 초대 목록에서 1개만 표시 ✅

테스트 2: 만료된 초대 재발송

1. A법인 관리자 로그인
2. 만료된 초대 존재 (만료일: 2025-11-20)
3. user@example.com에게 다시 초대 시도
4. 성공: 기존 초대 CANCELED, 새 초대 PENDING
5. 새 토큰으로 이메일 발송 ✅

테스트 3: 다른 법인 중복 초대 차단

1. A법인 관리자가 user@example.com에게 초대 (PENDING)
2. B법인 관리자 로그인
3. user@example.com에게 초대 시도
4. 에러: "해당 사용자는 다른 법인에서 초대 중입니다."
5. B법인 초대 목록에 표시 안 됨 ✅

테스트 4: 초대 수락 후 중복 차단

1. user@example.com이 A법인 초대 수락 (ROLE_CORP_USER)
2. B법인 관리자가 user@example.com에게 초대 시도
3. 에러: "기관에 등록된 이용자 입니다." (기존 로직)
4. 초대 생성 안 됨 ✅

구현 우선순위

시나리오 우선순위 이유
같은 법인 중복 초대 차단 🔴 필수 데이터 정합성 문제, 사용자 혼란
만료된 초대 재생성 🟡 권장 UX 개선 (수동 취소 불필요)
다른 법인 중복 초대 차단 🟡 권장 사용자 혼란 방지

📌 이메일 검증 구현 완료

구현 내역

1. 프론트엔드 (userList.html)

  • <input type="email"> 적용
  • HTML5 pattern 속성 추가: [a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}
  • JavaScript 정규식 검증 추가

2. 백엔드 (UserManRestController.java)

  • 이메일 null/empty 체크
  • 정규식 검증: ^[a-zA-Z0-9._%+\\-]+@[a-zA-Z0-9.\\-]+\\.[a-zA-Z]{2,}$
  • 검증 실패 시 400 에러 반환

3. Facade (UserManFacade.java)

  • 이메일 null/empty 체크
  • 정규식 검증
  • IllegalArgumentException 발생

적용된 파일

  • src/main/resources/templates/views/apps/users/userList.html
  • src/main/java/com/eactive/apim/portal/apps/user/controller/UserManRestController.java
  • src/main/java/com/eactive/apim/portal/apps/user/facade/UserManFacade.java

📌 후속 작업 (선택사항)

  1. Brute Force 방지: 초대코드 입력 실패 횟수 제한 상세 구현 계획 추가 (세션 기반)
  2. 이메일 검증: 이메일 형식 검증 구현 완료
  3. 중복 초대 방지: PENDING 초대 중복 체크 상세 구현 계획 추가
  4. 초대 만료일 동적 설정: PTL_PROPERTY 기반 만료일 설정 상세 구현 계획 추가
  5. 관리자 알림: 초대코드 입력 시도 로그 기록
  6. 통계: 초대코드 직접 입력 vs 이메일 링크 사용 비율 추적
  7. QR 코드: 초대코드를 QR 코드로 제공 (모바일 스캔)
  8. 초대 재발송: 만료 전 초대 재발송 기능 (UI 버튼)
  9. 만료일 연장: 주 단위(w), 월 단위(M), 년 단위(y) 지원

문서 버전: 1.4 최종 수정: 2025-11-24 작성자: Claude Code 변경 이력:

  • v1.4 (2025-11-24): 초대 만료일 동적 설정 (PTL_PROPERTY 기반, 시간 표현 파싱 유틸리티)
  • v1.3 (2025-11-24): 중복 초대 방지 기능 추가 (4가지 시나리오 및 구현 방법)
  • v1.2 (2025-11-24): 마이페이지 초대 알림 기능 추가 (재방문 사용자 대응), 작업 규모 재산정 (6→8시간)
  • v1.1 (2025-11-24): Brute Force 방지 세션 기반 구현 방법 추가, 이메일 검증 구현 완료 표시
  • v1.0 (2025-11-24): 초기 작성