Maple Talk SNS 서버의 REST API 명세서입니다. 모든 API는 일관된 응답 형식을 따릅니다.
{
"code": 200,
"message": "성공 메시지",
"data": {
// 실제 응답 데이터
}
}{
"code": 400,
"message": "에러 메시지",
"errors": [
{
"field": "필드명",
"message": "상세 에러 메시지"
}
]
}- 토큰 저장: httpOnly 쿠키로 자동 저장
- 토큰 전송: 쿠키로 자동 전송
- 보안: XSS, CSRF 공격 방지
- 사용법: 별도 설정 없이 자동 작동
- 토큰 저장: AsyncStorage에 수동 저장
- 토큰 전송: Authorization 헤더에 Bearer 토큰
- 플랫폼 구분:
x-platform: mobile헤더 필수 - 사용법:
// 로그인 후 await AsyncStorage.setItem('accessToken', response.data.tokens.accessToken); await AsyncStorage.setItem('refreshToken', response.data.tokens.refreshToken); // API 요청 시 headers: { 'x-platform': 'mobile', 'Authorization': `Bearer ${accessToken}` }
Endpoint: POST /auth/signup
Description: 새로운 사용자 계정을 생성합니다.
Request Headers:
Content-Type: application/json
Request Body:
{
"email": "user@example.com",
"password": "password123",
"nickname": "사용자닉네임"
}Request Parameters:
| 필드 | 타입 | 필수 | 설명 | 제약사항 |
|---|---|---|---|---|
| string | ✅ | 사용자 이메일 | 이메일 형식, 100자 이하 | |
| password | string | ✅ | 사용자 비밀번호 | 6자 이상 |
| nickname | string | ✅ | 사용자 닉네임 | 2-20자 |
Response (201 Created):
{
"code": 201,
"message": "회원가입이 완료되었습니다.",
"data": {
"userId": 1,
"email": "user@example.com",
"nickname": "사용자닉네임"
}
}Error Responses:
400 Bad Request - 필수 필드 누락:
{
"code": 400,
"message": "이메일, 비밀번호, 닉네임을 모두 입력해주세요.",
"errors": []
}400 Bad Request - 이메일 형식 오류:
{
"code": 400,
"message": "올바른 이메일 형식을 입력해주세요.",
"errors": []
}400 Bad Request - 비밀번호 길이 부족:
{
"code": 400,
"message": "비밀번호는 6자 이상이어야 합니다.",
"errors": []
}400 Bad Request - 닉네임 길이 오류:
{
"code": 400,
"message": "닉네임은 2자 이상 20자 이하여야 합니다.",
"errors": []
}400 Bad Request - 이메일 중복:
{
"code": 400,
"message": "이미 존재하는 이메일입니다.",
"errors": []
}400 Bad Request - 닉네임 중복:
{
"code": 400,
"message": "이미 존재하는 닉네임입니다.",
"errors": []
}Endpoint: POST /auth/login
Description: 사용자 인증 후 AccessToken과 RefreshToken을 발급합니다.
Request Headers:
Content-Type: application/json
Request Body:
{
"email": "user@example.com",
"password": "password123"
}Request Parameters:
| 필드 | 타입 | 필수 | 설명 |
|---|---|---|---|
| string | ✅ | 사용자 이메일 | |
| password | string | ✅ | 사용자 비밀번호 |
Response (200 OK):
웹 브라우저 응답:
{
"code": 200,
"message": "로그인이 완료되었습니다.",
"data": {
"user": {
"id": 1,
"email": "user@example.com",
"nickname": "사용자닉네임",
"profile_img": null
}
}
}모바일 앱 응답 (Headers에 x-platform: mobile 추가):
{
"code": 200,
"message": "로그인이 완료되었습니다.",
"data": {
"user": {
"id": 1,
"email": "user@example.com",
"nickname": "사용자닉네임",
"profile_img": null
},
"tokens": {
"accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}
}
}웹 쿠키 설정:
accessToken: httpOnly 쿠키 (15분 유효)refreshToken: httpOnly 쿠키 (7일 유효)
모바일 앱 토큰 저장:
accessToken: AsyncStorage에 저장 (15분 유효)refreshToken: AsyncStorage에 저장 (7일 유효)
Error Responses:
400 Bad Request - 필수 필드 누락:
{
"code": 400,
"message": "이메일과 비밀번호를 입력해주세요.",
"errors": []
}401 Unauthorized - 인증 실패:
{
"code": 401,
"message": "이메일 또는 비밀번호가 올바르지 않습니다.",
"errors": []
}Endpoint: POST /auth/refresh
Description: RefreshToken을 사용하여 새로운 AccessToken을 발급합니다.
Request Headers:
웹 브라우저:
Cookie: refreshToken={refreshToken}
모바일 앱:
x-platform: mobile
Authorization: Bearer {refreshToken}
참고: 토큰 재발급 API는 이제 refreshTokenMiddleware를 통해 RefreshToken 유효성을 검증합니다.
Request Body: 없음
Response (200 OK):
{
"code": 200,
"message": "토큰이 재발급되었습니다.",
"data": {
"accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}
}Error Responses:
400 Bad Request - 토큰 누락:
{
"code": 400,
"message": "RefreshToken이 필요합니다.",
"errors": []
}401 Unauthorized - RefreshToken 만료:
{
"code": 401,
"message": "RefreshToken이 만료되었습니다.",
"errors": [
{
"field": "refreshToken",
"message": "TOKEN_EXPIRED"
}
]
}401 Unauthorized - RefreshToken 형식 오류:
{
"code": 401,
"message": "RefreshToken 형식이 올바르지 않습니다.",
"errors": [
{
"field": "refreshToken",
"message": "TOKEN_INVALID"
}
]
}Endpoint: POST /auth/logout
Description: 향상된 로그아웃 처리 - AccessToken과 RefreshToken을 모두 활용하여 안전한 로그아웃
Request Headers:
웹 브라우저:
Cookie: accessToken={accessToken}
Content-Type: application/json
모바일 앱:
x-platform: mobile
Authorization: Bearer {accessToken} // AccessToken (Header)
Content-Type: application/json
Request Body:
{
"refreshToken": "eyJhbGciOi...refreshToken...", // 필수: RefreshToken
"deviceId": "iPhone123" // 옵셔널: 로그아웃할 디바이스 식별자
}Request Parameters:
| 필드 | 타입 | 필수 | 설명 | 제약사항 |
|---|---|---|---|---|
| refreshToken | string | ✅ | RefreshToken (body로 전송) | 없으면 userId 추출 시도 |
| deviceId | string | ❌ | 현재 로그아웃할 디바이스 식별자 | 없으면 UserDevice 정리 생략 |
동작 방식:
시나리오 1: AccessToken 유효
1. 헤더 AccessToken 검증 ✅
2. userId 추출
3. DB에서 해당 userId의 모든 RefreshToken 삭제
4. UserDevice 정리 (deviceId 있으면)
5. 200 OK 응답
시나리오 2: AccessToken 만료, RefreshToken 유효
1. 헤더 AccessToken 검증 ❌ (만료)
2. body RefreshToken 검증 ✅
3. userId 추출 (ignoreExpiration 적용)
4. DB에서 해당 userId의 모든 RefreshToken 삭제
5. UserDevice 정리 (deviceId 있으면)
6. 200 OK 응답
시나리오 3: 둘 다 유효하지 않음
1. AccessToken 검증 ❌
2. RefreshToken 검증 ❌
3. userId 없음 → DB 작업 없음
4. 200 OK 응답 (클라이언트 정리만 진행)
장점:
- AccessToken 만료되어도 로그아웃 가능
- RefreshToken이 body로 전송되어 안전
- token 없이 요청시 graceful degradation
Response (200 OK):
모든 플랫폼 공통 응답:
{
"code": 200,
"message": "로그아웃이 완료되었습니다.",
"data": null
}특별 케이스 응답 (refreshToken 없이 요청):
{
"code": 200,
"message": "Logout processed (client-side cleanup required)",
"data": null
}프론트엔드 구현 예시:
// 모바일 앱에서 - 개선된 방식
const deviceId = await getDeviceId(); // 디바이스 식별자 획득
const refreshToken = await AsyncStorage.getItem('refreshToken');
const response = await fetch('/auth/logout', {
method: 'POST',
headers: {
'Authorization': `Bearer ${accessToken}`,
'x-platform': 'mobile',
'Content-Type': 'application/json'
},
body: JSON.stringify({
refreshToken: refreshToken, // 필수 추가
deviceId: deviceId
})
});
// 토큰 정리 (항상 실행)
await AsyncStorage.removeItem('accessToken');
await AsyncStorage.removeItem('refreshToken');Error Responses:
401 Unauthorized - 토큰 누락:
{
"code": 401,
"message": "인증 토큰이 필요합니다.",
"errors": []
}500 Internal Server Error - RefreshToken 삭제 실패:
{
"code": 500,
"message": "로그아웃 처리 중 오류가 발생했습니다.",
"errors": []
}참고: UserDevice 삭제 실패는 로그에 기록되지만 로그아웃은 성공으로 처리됩니다.
Endpoint: GET /auth/check-email
Description: 이메일 중복 여부를 확인합니다.
Request Headers: 없음
Query Parameters:
| 필드 | 타입 | 필수 | 설명 |
|---|---|---|---|
| string | ✅ | 확인할 이메일 |
Request Example:
GET /auth/check-email?email=user@example.com
Response (200 OK) - 사용 가능:
{
"code": 200,
"message": "사용 가능한 이메일입니다.",
"data": {
"isAvailable": true
}
}Response (200 OK) - 사용 불가:
{
"code": 200,
"message": "이미 사용 중인 이메일입니다.",
"data": {
"isAvailable": false
}
}Error Responses:
400 Bad Request - 이메일 누락:
{
"code": 400,
"message": "이메일을 입력해주세요.",
"errors": []
}500 Internal Server Error:
{
"code": 500,
"message": "이메일 중복 체크 중 오류가 발생했습니다.",
"errors": []
}Endpoint: GET /auth/check-nickname
Description: 닉네임 중복 여부를 확인합니다.
Request Headers: 없음
Query Parameters:
| 필드 | 타입 | 필수 | 설명 |
|---|---|---|---|
| nickname | string | ✅ | 확인할 닉네임 |
Request Example:
GET /auth/check-nickname?nickname=사용자닉네임
Response (200 OK) - 사용 가능:
{
"code": 200,
"message": "사용 가능한 닉네임입니다.",
"data": {
"isAvailable": true
}
}Response (200 OK) - 사용 불가:
{
"code": 200,
"message": "이미 사용 중인 닉네임입니다.",
"data": {
"isAvailable": false
}
}Error Responses:
400 Bad Request - 닉네임 누락:
{
"code": 400,
"message": "닉네임을 입력해주세요.",
"errors": []
}500 Internal Server Error:
{
"code": 500,
"message": "닉네임 중복 체크 중 오류가 발생했습니다.",
"errors": []
}Endpoint: POST /auth/kakao-login
Description: 클라이언트에서 받은 카카오 액세스 토큰으로 카카오 API를 호출하여 사용자 정보를 조회하고 console.log로 출력합니다.
Request Headers:
Content-Type: application/json
Request Body:
{
"accessToken": "카카오_액세스_토큰_문자열"
}Request Parameters:
| 필드 | 타입 | 필수 | 설명 | 제약사항 |
|---|---|---|---|---|
| accessToken | string | ✅ | 카카오 액세스 토큰 | Bearer 토큰 형식 |
Response (200 OK):
{
"code": 200,
"message": "로그인이 완료되었습니다.",
"data": {
"user": {
"id": 1,
"email": "user@kakao.com",
"nickname": "신규철",
"profile_img": null
},
"tokens": {
"accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}
}
}참고: 신규 사용자의 경우 자동으로 회원가입 후 로그인 처리되며, 기존 사용자의 경우 기존 계정으로 로그인됩니다.
Error Responses:
400 Bad Request - 필수 필드 누락:
{
"code": 400,
"message": "accessToken을(를) 입력해주세요.",
"errors": []
}400 Bad Request - 유효하지 않은 액세스 토큰:
{
"code": 400,
"message": "유효하지 않은 액세스 토큰입니다.",
"errors": []
}400 Bad Request - 잘못된 요청:
{
"code": 400,
"message": "잘못된 요청입니다.",
"errors": []
}400 Bad Request - 서버 오류:
{
"code": 400,
"message": "카카오 API 호출 중 서버 오류가 발생했습니다.",
"errors": []
}Endpoint: POST /auth/naver-login
Description: 클라이언트에서 받은 네이버 액세스 토큰으로 네이버 API를 호출하여 사용자 정보를 조회하고 회원가입 또는 로그인을 처리합니다.
Request Headers:
Content-Type: application/json
Request Body:
{
"accessToken": "네이버_액세스_토큰_문자열"
}Request Parameters:
| 필드 | 타입 | 필수 | 설명 | 제약사항 |
|---|---|---|---|---|
| accessToken | string | ✅ | 네이버 액세스 토큰 | Bearer 토큰 형식 |
Response (200 OK):
{
"code": 200,
"message": "로그인이 완료되었습니다.",
"data": {
"user": {
"id": 1,
"email": "user@naver.com",
"nickname": "규초리",
"profile_img": null
},
"tokens": {
"accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}
}
}참고:
- 신규 사용자의 경우 자동으로 회원가입 후 로그인 처리되며, 기존 사용자의 경우 기존 계정으로 로그인됩니다.
- 닉네임은 네이버에서 제공하는
nickname을 우선 사용하며, 없으면name을 사용합니다. - 네이버는 프로필 이미지를 제공하지 않으므로
profile_image는 null로 설정됩니다.
Error Responses:
400 Bad Request - 필수 필드 누락:
{
"code": 400,
"message": "accessToken을(를) 입력해주세요.",
"errors": []
}400 Bad Request - 유효하지 않은 액세스 토큰:
{
"code": 400,
"message": "유효하지 않은 액세스 토큰입니다.",
"errors": []
}400 Bad Request - 이메일 동의 필요:
{
"code": 400,
"message": "네이버 계정에서 이메일 정보 제공에 동의해주세요.",
"errors": []
}400 Bad Request - 서버 오류:
{
"code": 400,
"message": "네이버 로그인 중 서버 오류가 발생했습니다.",
"errors": []
}Endpoint: DELETE /auth/delete-account
Description: 사용자 계정을 영구적으로 삭제합니다. 모든 게시물, 피드, 숏츠, 소셜 계정 연동 정보가 함께 삭제되며, 프로필 이미지를 포함한 모든 미디어 파일이 정리됩니다. 삭제된 계정은 복구할 수 없습니다.
Request Headers:
웹 브라우저:
Cookie: accessToken={accessToken}
모바일 앱:
x-platform: mobile
Authorization: Bearer {accessToken}
Content-Type: application/json
Request Body: 없음 (토큰으로 사용자 식별)
삭제 대상:
- 게시물 및 관련 콘텐츠 (Post, PostContent)
- 피드 및 관련 콘텐츠 (FeedPost, FeedPostContent)
- 숏츠 및 미디어 파일 (Short)
- 소셜 계정 연동 정보 (SocialAccount)
- 프로필 이미지 파일
- 사용자 정보 (소프트 삭제: 닉네임/이메일 변경, is_deleted=1)
동작 방식:
1. 트랜잭션 시작
2. 사용자 검증 (is_deleted=0 확인)
3. 파일 경로 수집 (게시물/피드/숏츠/프로필 이미지)
4. 콘텐츠 삭제 (Post, FeedPost, Short - CASCADE 활용)
5. SocialAccount 삭제 (하드 삭제)
6. 사용자 정보 변경 (소프트 삭제)
7. 트랜잭션 커밋
8. 파일 정리 (비트랜잭션)
9. 토큰 정리 (쿠키/헤더 클리어)
Response (200 OK):
웹 브라우저 응답:
{
"code": 200,
"message": "계정이 성공적으로 삭제되었습니다. 클라이언트에서 저장된 토큰을 삭제해주세요.",
"data": null
}모바일 앱 응답:
{
"code": 200,
"message": "계정이 성공적으로 삭제되었습니다. 클라이언트에서 저장된 토큰을 삭제해주세요.",
"data": null
}클라이언트 처리:
// 계정 삭제 후 토큰 정리 (필수)
await AsyncStorage.removeItem('accessToken');
await AsyncStorage.removeItem('refreshToken');
// 로그인 페이지로 리다이렉트Error Responses:
400 Bad Request - 이미 삭제된 계정:
{
"code": 400,
"message": "이미 삭제된 계정입니다.",
"errors": []
}401 Unauthorized - 인증 실패:
{
"code": 401,
"message": "인증에 실패했습니다.",
"errors": [
{
"field": "token",
"message": "TOKEN_EXPIRED"
}
]
}500 Internal Server Error - 삭제 실패:
{
"code": 500,
"message": "계정 삭제 처리 중 오류가 발생했습니다.",
"errors": []
}특징:
- 트랜잭션 보장: 모든 DB 작업이 원자적으로 처리되거나 롤백
- CASCADE 삭제: FK 관계를 통한 연쇄 삭제 자동화
- 파일 정리: DB 작업 성공 후 파일 시스템 정리 (실패해도 계정 삭제 성공으로 간주)
- 소프트 삭제: 사용자 정보는 보존하되 식별 불가능하게 변경
- 토큰 정리: 계정 삭제 후 클라이언트에서 토큰 즉시 삭제 권장
참고: 파일 삭제 실패는 로그에 기록되지만 계정 삭제는 성공으로 처리됩니다. 이는 외부 저장소(NCP/S3)의 일시적 장애를 고려한 설계입니다.
- 유효기간: 15분
- 용도: API 요청 인증
- 포함 정보: userId, type: 'access'
- 유효기간: 7일
- 용도: AccessToken 재발급
- 포함 정보: userId, type: 'refresh'
- 저장 위치: 데이터베이스 (RefreshToken 테이블)
- 회원가입
curl -X POST http://localhost:3001/auth/signup \
-H "Content-Type: application/json" \
-d '{
"email": "test@example.com",
"password": "password123",
"nickname": "테스트유저"
}'- 로그인
curl -X POST http://localhost:3001/auth/login \
-H "Content-Type: application/json" \
-d '{
"email": "test@example.com",
"password": "password123"
}'- 토큰 재발급
curl -X POST http://localhost:3001/auth/refresh \
-H "Authorization: Bearer {refreshToken}"- 로그아웃
curl -X POST http://localhost:3001/auth/logout \
-H "Authorization: Bearer {accessToken}"| HTTP 상태 코드 | 설명 | 사용 예시 |
|---|---|---|
| 200 | OK | 성공적인 요청 처리 |
| 201 | Created | 리소스 생성 성공 (회원가입) |
| 400 | Bad Request | 잘못된 요청 (입력값 오류) |
| 401 | Unauthorized | 인증 실패 (로그인 실패, 토큰 무효) |
| 500 | Internal Server Error | 서버 내부 오류 |
토큰 만료:
{
"code": 401,
"message": "인증에 실패했습니다.",
"errors": [
{
"field": "token",
"message": "TOKEN_EXPIRED"
}
]
}토큰 형식 오류:
{
"code": 401,
"message": "인증에 실패했습니다.",
"errors": [
{
"field": "token",
"message": "TOKEN_INVALID"
}
]
}토큰 미사용 가능:
{
"code": 401,
"message": "인증에 실패했습니다.",
"errors": [
{
"field": "token",
"message": "TOKEN_NOT_BEFORE"
}
]
}토큰 만료:
{
"code": 401,
"message": "RefreshToken이 만료되었습니다.",
"errors": [
{
"field": "refreshToken",
"message": "TOKEN_EXPIRED"
}
]
}토큰 형식 오류:
{
"code": 401,
"message": "RefreshToken 형식이 올바르지 않습니다.",
"errors": [
{
"field": "refreshToken",
"message": "TOKEN_INVALID"
}
]
}// 토큰 에러 처리 예시
if (errorData.code === 401) {
const errorType = errorData.errors[0]?.message;
if (errorType === 'TOKEN_EXPIRED') {
// 🔄 토큰 재발급 시도
await refreshToken();
} else if (errorType === 'TOKEN_INVALID') {
// 🚫 로그인 페이지로 이동 (토큰이 손상됨)
window.location.href = '/login';
} else if (errorType === 'TOKEN_NOT_BEFORE') {
// ⏰ 토큰이 아직 유효하지 않음 (시계 동기화 문제)
console.log('토큰이 아직 유효하지 않습니다.');
}
}- 용도: 일반 API 인증 (AccessToken 검증)
- 사용 API: 로그아웃, 보호된 API
- 토큰 위치:
- 웹: 쿠키의
accessToken - 모바일:
Authorization: Bearer {accessToken}헤더
- 웹: 쿠키의
- 용도: 토큰 재발급 API 전용 (RefreshToken 검증)
- 사용 API: 토큰 재발급
- 토큰 위치:
- 웹: 쿠키의
refreshToken - 모바일:
Authorization: Bearer {refreshToken}헤더
- 웹: 쿠키의
- 용도: 선택적 인증 (토큰이 있어도 없어도 통과)
- 사용 API: 선택적 인증이 필요한 API
- 동작: 토큰이 있으면
req.user설정, 없으면 통과
- 용도: 일반 API 인증 (AccessToken 검증)
- 사용 API: 보호된 API
- 토큰 위치:
- 웹: 쿠키의
accessToken - 모바일:
Authorization: Bearer {accessToken}헤더
- 웹: 쿠키의
- 용도: 토큰 재발급 API 전용 (RefreshToken 검증)
- 사용 API: 토큰 재발급
- 토큰 위치:
- 웹: 쿠키의
refreshToken - 모바일:
Authorization: Bearer {refreshToken}헤더
- 웹: 쿠키의
- 용도: 향상된 로그아웃 처리 (AccessToken + RefreshToken 활용)
- 사용 API: 로그아웃
- 특징:
- AccessToken이 만료되어도 RefreshToken으로 userId 추출
- 둘 다 실패시 graceful degradation
- 토큰이 없어도 200 OK 반환
- 용도: 선택적 인증 (토큰이 있어도 없어도 통과)
- 사용 API: 선택적 인증이 필요한 API
- 동작: 토큰이 있으면
req.user설정, 없으면 통과
| API | 미들웨어 | 설명 |
|---|---|---|
/auth/signup |
없음 | 인증 불필요 |
/auth/login |
없음 | 인증 불필요 |
/auth/refresh |
refreshTokenMiddleware |
RefreshToken 검증 |
/auth/logout |
logoutMiddleware ⭐ |
향상된 로그아웃 처리 |
/auth/check-email |
없음 | 인증 불필요 |
/auth/check-nickname |
없음 | 인증 불필요 |
- 이전: 토큰 검증 없이 재발급 가능
- 현재:
refreshTokenMiddleware로 RefreshToken 유효성 검증 - 효과: 만료된 토큰, 무효한 토큰으로 재발급 시도 차단
- 웹: httpOnly 쿠키로 XSS, CSRF 공격 방지
- 모바일: Authorization 헤더로 안전한 토큰 전송
- 공통: JWT 서명 검증, 토큰 만료 시간 관리
- 데이터베이스: MySQL (maple_talk_sns)
- ORM: Sequelize
- 인증 방식: JWT (JSON Web Token)
- 비밀번호 암호화: bcrypt (salt rounds: 10)
- CORS: 활성화 (localhost:3000, localhost:19006)
User.id(Primary Key)User.email(Unique)User.nickname(Unique)RefreshToken.user_id+RefreshToken.tokenRefreshToken.token_hash(토큰 검색용)
- 사용자 기본 정보: 10분 캐시 (Redis)
- 인증 상태: 5분 캐시 (Redis)
- 이메일/닉네임 중복 체크: 1시간 캐시 (Redis)
- JWT 토큰 블랙리스트 캐싱 (Redis)
- 로그인 시도 횟수 제한 (Rate Limiting)
- IP 기반 접근 제한 (향후 구현)
- RefreshToken 자동 정리 (7일 만료)
- 동시 로그인 세션 관리
- 토큰 재발급 시 이전 토큰 무효화
- Redis Cluster 지원 (향후 구현)
- 마이크로서비스 아키텍처 준비
- OAuth 2.0 연동 준비 (향후 구현)