커뮤니티 게시글

자유게시판

AI 분석 4번째 - 그 사이 1.1.0으로 업데이트 되었군요.

nike 2026-08-10 10:56 조회 89 댓글 1 수정됨

날씨가 워낙 덥다 보니, 
휴가는 집에 있는게 최고인 것 같네요. 
주말에 가족과 함께 서해 다녀왔는데 너무 힘들군요.

그 사이 벌써 1.1.0으로 업데이트 되었네요.
업데이트 기준으로 다시 분석해 봤습니다.

아 그런데 미리 살짝 고백하자면, 분석은 AI가 했고 전 무슨 말인지 모릅니다.

## 결론


회원 시스템은 로그인 폼을 제공하는 부속 기능이 아니라 **멀티도메인 플랫폼의 identity와

운영 권한을 담당하는 Core subsystem**으로 설계돼 있다. 도메인별 계정, 전역 레벨,

도메인 운영 범위, 확장 가능한 추가 필드, 불변 약관 개정본, 탈퇴와 파일 정리, 외부 인증

진입점까지 한 모델 안에 있다.


강점은 보안 장치를 하나씩 붙인 것이 아니라 가입·로그인·권한 재검증·탈퇴의 경계에 맞춰

배치했다는 점이다. 1.1.0에서는 회원 액션 Contract를 도입해 Board나 스킨이 프로필·쪽지·팔로우

같은 확장 기능의 구현과 URL을 직접 알지 않아도 되게 했다.


현재 가장 분명한 보완점은 **비밀번호 재설정 토큰 소비가 단일 원자 연산으로 묶이지 않은 점**이다.

새 회원 액션 기반도 노출 정책은 잘 통합했지만, 실제 액션 endpoint의 인증·권한·CSRF·대상 상태

검사는 각 제공자가 별도로 강제해야 한다.


## 1. 데이터 모델


### 전역 레벨과 도메인별 회원


`member_levels`는 도메인 컬럼이 없는 전역 테이블이다. 모든 도메인이 같은 레벨 숫자와 의미를

공유한다.


- `is_super`: 시스템 전체 관리자

- `is_admin`: 관리자 모드 접근 가능

- `can_operate_domain`: 사이트 소유·운영 가능

- `level_type`: SUPER, STAFF, PARTNER, SELLER, SUPPLIER, BASIC


반면 `members`는 `domain_id`에 속한다. 로그인 아이디와 닉네임도 `(domain_id, value)`로

유일하다. `domain_group`은 관리자가 접근할 수 있는 도메인 트리 범위를 표현한다.


이 구분은 멀티테넌트 SaaS에 맞는다. 등급 체계는 플랫폼이 통제하고, 계정은 사이트에 속한다.

대신 한 도메인만 독자적인 레벨 체계를 갖는 요구에는 맞지 않는다. 이것은 누락이라기보다

플랫폼 일관성을 택한 정책이다.


### 최초 가입 도메인


`origin_domain_id`는 가입 당시 도메인을 불변으로 보존한다. 현재 소속 도메인이 사이트 개설

등으로 바뀌어도 최초 도메인의 아이디·닉네임 슬롯을 예약한다.


```text

현재 소속 유일성: (domain_id, user_id), (domain_id, nickname)

최초 소속 예약:   (origin_domain_id, user_id), (origin_domain_id, nickname)

```


이 설계는 회원 이동 후 원래 사이트에서 다른 사람이 같은 식별자를 선점해 복귀가 꼬이는 문제를

막는다. 신규 가입에는 적용되지만 legacy row의 `origin_domain_id`는 NULL일 수 있으므로,

초기 배포 단계라면 이 값을 전수 보정하고 제약을 한 번에 확정하는 편이 장기 비용이 낮다.


### 상태와 탈퇴


회원 상태는 `active`, `inactive`, `dormant`, `blocked`, `pending`, `withdrawn`으로 구분된다.

탈퇴는 물리 삭제가 아니라 상태와 `withdrawn_at`, 사유를 남기는 soft-delete 성격이다. 추가 필드의

이메일·이름·전화번호와 파일, Core의 비밀번호·닉네임·최근 로그인 IP 등은 정리하지만 로그인

아이디는 보존한다. 따라서 완전 익명화가 아니라 일부 식별정보를 유지하는 탈퇴 모델이다.


## 2. 가입 흐름


Front 가입은 세 단계다.


```text

GET /member/register

  약관 표시

POST /member/register/agree

  현재 revision의 동의 snapshot을 세션에 보관

GET /member/register/form

  도메인별 필드와 확장 폼 출력

POST /member/register/form

  Core + 확장 검증 -> MemberService::register()

GET /member/register/complete 또는 /pending

```


약관 동의 세션에는 도메인과 시간도 들어간다. 다른 도메인에서 받은 동의를 재사용하거나 오래된

단계 상태를 쓰지 못하게 한다. 승인형 가입 도메인은 신규 회원을 `pending`으로 만든다.


### `MemberService::register()`


가입 서비스의 핵심 순서는 다음과 같다.


1. Core 입력 검증

2. `MemberRegisterPreparingEvent` 발행

3. 구독자가 변경한 데이터까지 **전체 재검증**

4. 비밀번호 hash

5. DB transaction 시작

6. `members` 생성: 현재 도메인과 최초 도메인 기록

7. 추가 필드 저장

8. 동의한 policy revision/version/content hash/IP/user-agent snapshot 저장

9. commit

10. `MemberRegisteredByUserEvent` 발행


확장이 pre-event에서 값을 바꿀 수 있게 하면서도, 그 변경이 Core 검증을 우회하지 못하도록 다시

검증하는 것이 좋다. 완료 이벤트는 commit 뒤라 구독자가 환영 포인트나 알림을 처리할 때 아직

존재하지 않는 회원을 보지 않는다.


추가 필드의 unique race는 DB unique key가 최종 방어하고, 사용자 입력 충돌과 시스템 실패를

구분해 반환한다.


## 3. 로그인과 세션


### 로그인 시도


`AuthService::attempt()`는 다음 순서로 동작한다.


1. 이번 시도를 먼저 실패로 기록하고 rate-limit 판정

2. `(domain_id, user_id)`로 회원 조회

3. 회원이 없어도 dummy bcrypt hash 검증으로 timing 차이 완화

4. 비밀번호 검증

5. 비밀번호가 맞은 뒤에만 inactive/dormant/blocked/withdrawn 상태 공개

6. 성공 기록 및 실패 기록 정리

7. 필요 시 password hash 점진 rehash

8. session ID와 CSRF token 재발급

9. 민감정보를 뺀 user snapshot과 avatar URL을 세션에 저장

10. last login 갱신, `MemberLoggedInEvent` 발행


“조회 후 실패 기록”이 아니라 “이번 시도를 먼저 등록”하는 방식은 병렬 요청이 rate-limit 검사

사이로 함께 빠져나가는 틈을 줄인다. 없는 계정과 비밀번호 오류 메시지를 같게 하고, 계정 상태는

비밀번호가 확인된 소유자에게만 보여주는 것도 일관된 계정 열거 방어다.


SNS 로그인 같은 `loginByMember()`와 `loginByMemberId()`도 마지막에 active 상태를 다시 확인한다.

외부 인증 플러그인이 상태 검사를 잊어도 Core 경계에서 차단된다.


### 세션


`SessionManager`는 파일과 Redis driver를 지원한다.


- 도메인별 session name과 Redis prefix

- URL session ID 비활성화, cookie-only

- HttpOnly, SameSite=Lax

- HTTPS 자동 감지에 따른 Secure cookie

- idle timeout과 sliding 갱신

- regenerate 시 기존 server session 삭제

- flash data


로그인 시 session ID뿐 아니라 CSRF token도 같이 회전한다. 로그인 전 익명 권한에서 발급된

token을 관리자 권한 상승 후 그대로 쓰지 않는다.


## 4. 관리자 권한


관리자 진입은 `AdminMiddleware`가 처리한다.


1. 로그인 여부

2. admin 여부

3. 최대 60초 간격으로 DB의 회원 상태와 권한 재검증

4. super이면 통과

5. 현재 menu active code와 HTTP method/path로 action 결정

6. 도메인·레벨·메뉴·action·domain group 기준 negative ACL 검사


권한 모델은 “허용 목록”이 아니라 `member_level_denied_menus`의 차단 목록이다. 새 관리자 메뉴를

추가하면 기본적으로 접근 가능하고 필요한 레벨만 막는다. 확장 메뉴가 자주 늘어나는 플랫폼에는

운영 편의성이 높지만, 최고 보안 영역은 명시 deny 등록을 빠뜨리면 열린다는 뜻이기도 하다.

민감 기능은 menu ACL 하나에만 의존하지 않고 controller/service의 소유권 검사도 유지해야 한다.


세션 권한 snapshot을 매 요청 DB 조회하지 않고 최대 60초 간격으로 갱신하는 것은 성능과 즉시

강등 사이의 의도적 절충이다. 60초 이내의 권한 변경 반영 지연을 허용할 수 없는 기능은 별도

즉시 검사를 해야 한다.


## 5. 도메인별 추가 필드


회원 Core table을 계속 늘리는 대신 `member_fields`와 `member_field_values`를 둔다.


- text, email, tel, number, date, textarea, select, radio, checkbox, address, file, avatar

- 가입/프로필/목록 노출 여부

- 관리자 전용 여부

- 필수·고유·검색 가능 여부

- 필드별 검증 rule과 options/config


### 암호화와 검색


민감 필드는 AES-256-GCM으로 암호화한다. 검색 가능한 값은 별도 32-byte pepper로

HMAC-SHA256 blind index를 생성한다.


```text

field_value  = AES-256-GCM(plaintext)

search_index = HMAC-SHA256(normalize(search value), search pepper)

```


DB만 유출돼도 암호화 키와 pepper 없이는 원문 복호화와 단순 rainbow-table 역추적이 어렵다.

복호화 실패는 원문/암호문을 로그에 남기지 않고 요청당 최대 5회까지만 진단해 로그 폭주도 막는다.


`is_unique`는 애플리케이션 사전 검사만 믿지 않는다. `unique_key`에 평문 정규화 hash 또는 blind

index를 넣고 `(field_id, unique_key)` DB unique index로 경합을 최종 차단한다. EAV와 암호화

필드의 유일성을 함께 해결한 실용적인 설계다.


단, blind index는 동일 입력의 동일성은 드러낸다. 검색 가능성을 얻기 위한 불가피한 누출이며,

전화번호처럼 후보 공간이 작은 값은 pepper의 비밀 관리가 특히 중요하다.


## 6. 약관과 동의 증적


정책 본문은 `policies`의 현재 상태와 `policy_revisions`의 불변 개정본으로 나뉜다. 회원 동의는

단순히 “privacy 1.0” 문자열만 저장하지 않는다.


- `revision_id`

- 당시 `policy_version`

- `content_hash`

- 동의 시각

- IP

- user-agent

- domain


동의 이후 운영자가 정책 본문을 수정해도 회원이 실제로 본 내용을 개정본과 hash로 추적할 수

있다. 회원가입 단계에서도 선택한 policy ID를 현재 revision snapshot으로 바꾼 뒤 세션에 넣는다.


## 7. 비밀번호 재설정


현재 Front controller는 이메일 링크 기반 `PasswordResetService`를 사용한다.


- 회원 존재 여부와 무관하게 같은 성공 메시지

- 이메일당 5분, IP당 15분/5회 제한

- 기존 미사용 token 무효화

- 32-byte random token의 SHA-256 hash만 DB 저장

- 30분 만료

- 사용 후 `used_at` 기록 및 같은 회원의 나머지 token 무효화

- 회원 도메인의 password policy 적용


이 흐름은 과거의 identity 직접 확인형 `MemberService::resetPasswordByIdentity()`와 구분해야 한다.

공개 웹 경로의 주 흐름은 token 기반이다.


### 구체적인 보완점: token 소비 원자성


현재 reset은 다음 세 DB 호출로 분리돼 있다.


```text

findValidByTokenHash()

updatePassword()

markUsed() + invalidateByMember()

```


`markUsed()`도 `WHERE used_at IS NULL` 조건 없이 token ID만 갱신한다. 같은 token으로 거의 동시에

두 요청이 들어오면 둘 다 유효 token을 읽고 서로 다른 새 비밀번호를 순서대로 기록할 가능성이

있다. 마지막 요청의 비밀번호가 남는다.


개선 시에는 transaction 안에서 token row를 `SELECT ... FOR UPDATE`로 잠그거나,

`UPDATE ... SET used_at=NOW() WHERE token_id=? AND used_at IS NULL AND expires_at>NOW()`의

affected row 1건을 token 소유권 획득으로 사용한 뒤 비밀번호를 갱신해야 한다. 이 항목은 실제

코드에서 확인되는 동시성 문제라 우선순위가 높다.


## 8. 수정과 탈퇴


본인 수정은 회원과 추가 필드를 한 transaction에서 갱신한 뒤

`MemberUpdatedBySelfEvent`를 발행한다.


탈퇴 순서는 더 신중하다.


1. 비밀번호 검증

2. 소유 도메인이 있으면 탈퇴 차단

3. `MemberWithdrawingEvent`로 확장의 차단 사유 수집

4. DB 변경 전에 파일 metadata 수집

5. transaction에서 추가 필드 삭제와 Core 민감값 정리/withdrawn 처리

6. commit 후 실제 파일 삭제

7. `MemberWithdrawnEvent` 발행


DB가 실패하면 파일은 남고, 파일 삭제가 실패해도 이미 성공한 탈퇴 transaction을 실패로

돌려 사용자에게 재시도를 유도하지 않는다. DB 진실을 먼저 확정하고 파일을 후처리하는 순서가

옳다. 남은 파일은 로그와 정리 작업으로 회수해야 한다.


탈퇴 후에도 로그인 아이디와 탈퇴 사유는 남는다. 식별자 재사용 방지와 운영 증적을 위한 정책일

수 있지만, 보존 목적·기간·열람 권한·파기 시점을 운영 문서에 명시해야 “개인정보 정리”라는 코드

주석과 실제 보존 범위가 어긋나지 않는다.


## 9. 확장 연동 경계


회원 내부 Entity/Repository를 확장이 직접 사용하는 대신 다음 계약과 이벤트가 있다.


- `MemberQueryInterface`, `MemberProfile`, `MemberIdentity`

- `MemberAccountGatewayInterface`

- `MemberCustomFieldQueryInterface`

- `MemberLevelCatalogInterface`

- `MemberAuthenticatorInterface`, `AuthContextInterface`, `AuthenticatedUser`

- `MemberActionQueryInterface`와 회원 액션 부속 DTO

- 가입 폼 렌더링/검증/준비 이벤트

- 가입·수정·탈퇴·로그인 완료 이벤트

- 회원 목록/상세 data enrichment 이벤트


개입형 이벤트와 완료 알림 이벤트를 나눈 것이 좋다. 가입 준비 이벤트는 데이터를 바꾸거나

거부할 수 있지만, 가입 완료 이벤트는 commit 뒤 부가 작업용이다. `pluginData`를 별도로 운반해

확장 입력을 Core 회원 컬럼에 섞지 않는다.


## 10. 회원 액션 기반


1.1.0은 프로필 보기·쪽지·팔로우처럼 “회원 문맥에서 확장이 제공하는 행동”을 Board나 스킨이

확장별 URL로 직접 조립하지 않도록 공통 액션 기반을 추가했다.


```text

활성 확장

  -> MemberActionBuildingEvent에서 Definition/StateResolver 등록

  -> MemberActionRegistry가 이름·endpoint·중복·variant 검증

  -> MemberActionQueryService가 viewer/placement/state 정책 적용

  -> ViewContext::memberActionMenu()가 검증된 대상과 CSRF로 렌더링

  -> 각 확장의 실제 endpoint가 최종 권한 검증과 업무 처리

```


### 등록과 검증


액션 ID는 `core`, `plugin:{name}`, `package:{name}` source와 local key의 조합이다. Registry는

다음을 중앙에서 검증하고 잘못된 정의를 제외한다.


- source·action key 형식과 중복

- label·icon·priority·placement 형식과 길이

- 상대경로 endpoint만 허용하고 `//`, query, fragment, 제어문자, path traversal 차단

- 정적 액션과 state variant 구성의 일관성

- stateful 액션의 resolver 존재 여부


거절 사유는 진단 로그와 관리자 시스템 화면의 도메인별 진단 패널에 노출된다. 확장 오동작을

메뉴 전체 장애로 만들지 않고 해당 정의만 격리하는 방식이다.


### 노출 정책과 상태 조회


`MemberActionScope`는 현재 도메인, viewer 회원, 대리 로그인 여부, viewer 레벨, 구체적 placement를

전달한다. Query service는 로그인 필요 여부, 자기 자신 허용, 대리 로그인 허용, 최소 레벨,

placement를 일관되게 적용한다.


상태형 액션은 source별 resolver를 회원 ID 묶음으로 한 번 호출해 N+1을 피하고, 요청 중 결과를

cache한다. resolver는 `default`, 등록된 variant, `@hidden`을 반환할 수 있다. 일반 예외·누락·알 수

없는 variant에는 액션별 `default` 또는 `hidden` 실패 정책을 적용하고 민감한 대상 ID를 진단

로그에서 제거한다.


### 안전한 렌더링


대상 전달 방식은 의도에 따라 세 종류로 고정된다.


- `PrivateBody`: CSRF token을 포함한 POST body

- `PublicQuery`: 공개 읽기용 query

- `PublicPath`: 공개 읽기용 path


View helper는 대상값이 유효하지 않으면 아무것도 렌더링하지 않는다. CSRF token이 없으면 공개

링크는 유지하되 private POST action은 숨긴다. endpoint, label, icon, 대상값은 component에서

escape한다. Board는 글·댓글 작성자의 액션을 service에서 묶어 조회한 뒤 view data에 검증된

액션만 전달한다.


### 책임 경계와 남은 주의점


액션 메뉴가 보인다는 사실은 업무 권한을 부여하지 않는다. 실제 endpoint는 요청마다 로그인,

CSRF, 현재 도메인의 대상 조회, 자기 자신 허용 여부, 차단·탈퇴 상태, 관계 소유권을 다시 검증해야

한다. Registry와 Query service는 “어떤 메뉴를 보여줄지”를 통합한 것이지 controller의

authorization을 대신하지 않는다.


또한 Query service는 현재 요청 도메인과 `MemberActionScope::domainId`가 같은지는 확인하지만,

전달받은 `targetMemberId`가 그 도메인 소속인지는 직접 조회하지 않는다. Core Board 소비자는

같은 도메인의 글·댓글 ID만 전달하므로 안전하다. 신규 소비자와 state resolver에는 “같은

도메인에서 이미 검증된 대상 ID만 전달한다”는 전제의 문서화와 contract test가 필요하며,

장기적으로는 Query service에서 대상 scope를 중앙 검증하는 방안도 가치가 있다.


runtime resolver가 던진 `RuntimeException` 같은 일반 예외는 실패 정책으로 격리하지만,

`TypeError`를 포함한 `Error`는 다시 던진다. 개발 단계의 프로그래밍 오류를 숨기지 않는 장점은

있지만, 운영 환경에서는 확장 하나의 타입 오류가 작성자 화면 전체를 실패시킬 수 있다. debug

환경은 fail-fast, 운영 환경은 진단 후 해당 source만 숨기는 식으로 정책을 구분할지 검토할 만하다.


## 설계 평가


### 잘된 점


- 회원 조회와 인증이 도메인 scope를 기본값으로 갖는다.

- 가입 데이터가 확장 이벤트 이후 다시 검증된다.

- 가입·수정·탈퇴의 Core 데이터가 transaction 경계 안에 있다.

- 로그인 rate-limit, timing 평준화, 상태 열거 방어가 같은 흐름에 정렬돼 있다.

- 로그인 권한 상승 시 session ID와 CSRF token을 함께 회전한다.

- 관리자 권한 snapshot을 DB와 주기적으로 재대조한다.

- 추가 필드 암호화·검색·유일성 요구를 DB 제약까지 연결했다.

- 약관 동의를 수정 가능한 현재 문서가 아닌 불변 revision에 연결한다.

- 탈퇴의 DB 변경과 파일 삭제 순서가 장애 복구 관점에서 합리적이다.

- 확장용 query/auth 계약과 lifecycle event가 이미 존재한다.

- 회원 액션의 등록·노출·상태 조회·URL 조립을 공통 계약으로 묶고, 잘못된 확장 정의를 진단하며

  격리한다.

- 민감 액션은 POST body와 CSRF token을 사용하고, token이 없으면 렌더링 단계에서 숨긴다.


### 남은 부담과 개선 가치


1. 비밀번호 reset token 소비를 원자화해야 한다.

2. 회원 액션의 표시 여부는 authorization이 아니다. 제공 endpoint가 로그인·CSRF·도메인·대상

   상태·소유권을 다시 검증한다는 규칙을 확장 개발 체크리스트와 contract test로 고정해야 한다.

3. `MemberActionQueryService`는 target member의 도메인 소속을 직접 확인하지 않는다. 현재 Board

   경로는 안전하지만 신규 소비자·resolver의 전제에 맡기지 않도록 중앙 scope 검증을 검토할

   가치가 있다.

4. 회원 액션 resolver의 `Error`는 격리되지 않는다. 운영 환경에서 확장 오류가 전체 화면 장애로

   번지는 것을 허용할지 명시적인 failure policy가 필요하다.

5. 탈퇴 후 로그인 아이디와 사유를 보존한다. 재사용 방지·분쟁 대응 목적과 보존 기간을 개인정보

   정책에 명시해야 한다.

6. 전역 레벨·negative ACL은 제품 정책과 잘 맞지만, 보안 민감 controller는 별도 service-level

   authorization을 계속 가져야 한다.

7. 관리자 강등 반영에는 기본 최대 60초 지연이 있다. 즉시 차단 대상 기능의 정책을 문서화해야 한다.

8. 암호화 key와 search pepper의 rotation 절차가 코드 사용법만큼 중요하다. 복호화 실패 로그는

   있지만 dual-key rotation/backfill 운영 절차는 별도 문서로 고정할 가치가 있다.

9. 파일 정리 실패는 의도적으로 commit을 되돌리지 않는다. orphan file 정리 job과 지표가 있어야

   이 선택이 장기적으로 안전하다.


## 최종 판단


회원 시스템은 **좋은 편을 넘어 Core의 신뢰 기반 역할을 할 만큼 설계 의도가 분명하다**.

특히 멀티도메인 소유권, 추가 필드 보안, 약관 증적, 탈퇴 후처리는 일반적인 초기 CMS가 뒤늦게

붙이는 문제를 처음부터 모델에 넣었다.


1.1.0의 회원 액션 기반은 단순히 메뉴 항목을 추가한 변경이 아니다. 확장이 행동을 등록하는 경계,

소비 화면이 현재 viewer와 위치에 맞는 행동을 조회하는 경계, 실제 endpoint가 업무 권한을

검증하는 경계를 분리했다. 등록값 검증, 상태 일괄 조회, 실패 정책, 진단 화면, CSRF 렌더링까지

한 흐름으로 연결한 점은 완성도가 높다.


다만 보안 시스템은 전체가 좋아도 한 개의 경쟁 조건이 중요하다. 현재 가장 실질적인 개선은

추상적 “보안 강화”가 아니라 **reset token의 원자 소비**다. 그 다음은 새 회원 액션 endpoint의

권한 검증 규칙과 target domain 전제를 계약 수준으로 강화하는 일이다.


## 주요 근거 파일


- `database/migrations/002_create_member_tables.sql`

- `database/migrations/007_create_password_reset_tokens.sql`

- `database/migrations/009_create_login_attempts.sql`

- `database/migrations/018_add_member_field_unique_key.sql`

- `database/migrations/020_add_policy_revisions.sql`

- `src/Service/Member/MemberService.php`

- `src/Service/Member/PasswordResetService.php`

- `src/Service/Member/PolicyService.php`

- `src/Service/Member/FieldEncryptionService.php`

- `src/Service/Member/MemberQueryService.php`

- `src/Service/Member/MemberActionRegistry.php`

- `src/Service/Member/MemberActionQueryService.php`

- `src/Repository/Member/MemberRepository.php`

- `src/Service/Auth/AuthService.php`

- `src/Service/Auth/LoginAttemptService.php`

- `src/Infrastructure/Session/SessionManager.php`

- `src/Core/Middleware/AdminMiddleware.php`

- `src/Core/Event/Member/MemberActionBuildingEvent.php`

- `src/Controller/Front/MemberController.php`

- `src/Controller/Front/AuthController.php`

- `packages/Board/Controller/Front/BoardController.php`

- `views/Components/member_action_menu.php`

- `src/Contract/Member/*`

- `src/Contract/Auth/*`

- `tests/Unit/Service/Member/MemberActionQueryServiceTest.php`

- `tests/Unit/Core/Rendering/MemberActionMenuTest.php`

- `tests/Unit/Service/Member*Test.php`

- `tests/Unit/Service/Auth*Test.php`

글쓰기

댓글 1

우아한삽질 2026-08-14 13:34
👍👍👍 항상 고생하십니다 !