개요
HTTP 410 Gone은 요청한 리소스가 서버에서 영구적으로 삭제되었고, 더 이상 사용할 수 없음을 나타내는 상태 코드입니다.
404(Not Found)와 비슷해 보이지만, 의도적으로 삭제되었다는 명확한 의미를 전달합니다.
410 vs 404 차이점
410 Gone
의미: 리소스가 과거에는 존재했지만, 영구적으로 제거됨
특징:
- 의도적인 삭제
- 다시 돌아오지 않음
- 전달 주소(Forwarding address) 없음404 Not Found
의미: 리소스를 찾을 수 없음
특징:
- 존재했는지 여부 불명확
- 일시적일 수 있음
- 나중에 다시 생길 수 있음비교표
| 구분 | 410 Gone | 404 Not Found |
|---|---|---|
| 의미 | 영구적으로 삭제됨 | 찾을 수 없음 |
| 과거 존재 | 확실히 존재했음 | 알 수 없음 |
| 재등장 가능성 | 없음 | 있을 수 있음 |
| SEO 영향 | 검색 엔진이 빠르게 인덱스에서 제거 | 일시적 오류로 간주될 수 있음 |
| 캐시 동작 | 캐시 가능 | 일반적으로 캐시하지 않음 |
사용 사례
1. 삭제된 사용자 계정
시나리오: 사용자가 계정을 영구 삭제GET /users/12345 HTTP/1.1
Host: api.example.com
HTTP/1.1 410 Gone
Content-Type: application/json
{
"error": "User account has been permanently deleted",
"deleted_at": "2024-12-01T10:30:00Z"
}2. 만료된 프로모션 페이지
시나리오: 기간 한정 프로모션 종료 후 페이지 제거GET /promotions/black-friday-2023 HTTP/1.1
Host: www.example.com
HTTP/1.1 410 Gone
Content-Type: text/html
<html>
<body>
<h1>This promotion has ended</h1>
<p>This offer expired on November 30, 2023.</p>
<a href="/current-promotions">View current promotions</a>
</body>
</html>3. API 버전 종료
시나리오: 구버전 API 엔드포인트 폐기GET /api/v1/products HTTP/1.1
Host: api.example.com
HTTP/1.1 410 Gone
Content-Type: application/json
{
"error": "API v1 has been discontinued",
"sunset_date": "2024-01-01",
"migration_guide": "https://docs.example.com/api/v2/migration"
}4. 삭제된 블로그 글
시나리오: 법적 이유 또는 저작권 문제로 콘텐츠 영구 삭제GET /blog/removed-article HTTP/1.1
Host: blog.example.com
HTTP/1.1 410 Gone
Content-Type: application/json
{
"message": "This article has been permanently removed",
"reason": "copyright_claim"
}구현 예제
Spring Boot
@RestController
@RequestMapping("/api/users")
public class UserController {
@GetMapping("/{userId}")
public ResponseEntity<UserResponse> getUser(@PathVariable Long userId) {
User user = userService.findById(userId);
if (user == null) {
// 일반적인 Not Found
return ResponseEntity.notFound().build();
}
if (user.isDeleted()) {
// 영구적으로 삭제된 경우
return ResponseEntity
.status(HttpStatus.GONE)
.body(new ErrorResponse(
"User account has been permanently deleted",
user.getDeletedAt()
));
}
return ResponseEntity.ok(new UserResponse(user));
}
}Express.js (Node.js)
app.get('/posts/:postId', async (req, res) => {
const post = await Post.findById(req.params.postId);
if (!post) {
return res.status(404).json({
error: 'Post not found'
});
}
if (post.deletedAt) {
return res.status(410).json({
error: 'This post has been permanently deleted',
deletedAt: post.deletedAt
});
}
res.json(post);
});Django REST Framework
from rest_framework import status
from rest_framework.response import Response
from rest_framework.views import APIView
class ProductDetailView(APIView):
def get(self, request, product_id):
try:
product = Product.objects.get(id=product_id)
except Product.DoesNotExist:
return Response(
{"error": "Product not found"},
status=status.HTTP_404_NOT_FOUND
)
if product.is_permanently_removed:
return Response(
{
"error": "This product has been discontinued",
"removed_at": product.removed_at
},
status=status.HTTP_410_GONE
)
serializer = ProductSerializer(product)
return Response(serializer.data)FastAPI
from fastapi import FastAPI, HTTPException, status
from datetime import datetime
app = FastAPI()
@app.get("/videos/{video_id}")
async def get_video(video_id: int):
video = await get_video_from_db(video_id)
if not video:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Video not found"
)
if video.deleted_at:
raise HTTPException(
status_code=status.HTTP_410_GONE,
detail={
"message": "This video has been permanently removed",
"deleted_at": video.deleted_at.isoformat(),
"reason": video.deletion_reason
}
)
return videoNestJS
@Controller('articles')
export class ArticlesController {
@Get(':id')
async getArticle(@Param('id') id: string): Promise<Article> {
const article = await this.articlesService.findById(id);
if (!article) {
throw new NotFoundException('Article not found');
}
if (article.isDeleted) {
throw new GoneException({
message: 'This article has been permanently deleted',
deletedAt: article.deletedAt,
});
}
return article;
}
}SEO 측면에서의 중요성
검색 엔진 크롤러 동작
410 Gone 응답 시
1. 검색 엔진이 페이지를 인덱스에서 신속하게 제거
2. 크롤링 예산(Crawl Budget)을 낭비하지 않음
3. 사이트맵에서도 제거 권장404 Not Found 응답 시
1. 일시적인 오류로 간주할 수 있음
2. 재크롤링 시도 가능
3. 인덱스 제거가 더 느림Google Search Console 예시
// sitemap.xml 업데이트
// 삭제된 URL은 사이트맵에서 제거
// 또는 명시적으로 표시
{
"url": "https://example.com/old-page",
"status": 410,
"lastmod": "2024-12-01"
}클라이언트 처리 방법
JavaScript Fetch API
async function fetchResource(url) {
try {
const response = await fetch(url);
if (response.status === 410) {
console.log('Resource permanently deleted');
// 캐시에서 제거
await caches.delete(url);
// UI에 영구 삭제 메시지 표시
showPermanentDeletionMessage();
return null;
}
if (response.status === 404) {
console.log('Resource not found');
// 재시도 로직 또는 대체 처리
return await fetchAlternative();
}
return await response.json();
} catch (error) {
console.error('Error fetching resource:', error);
}
}Axios (React 예시)
import axios from 'axios';
axios.get('/api/users/123')
.then(response => {
setUser(response.data);
})
.catch(error => {
if (error.response?.status === 410) {
// 영구 삭제됨 - 재시도 하지 않음
showError('This user account no longer exists');
redirectToHome();
} else if (error.response?.status === 404) {
// 찾을 수 없음 - 나중에 재시도 가능
showError('User not found');
}
});데이터베이스 설계 고려사항
Soft Delete 패턴
-- 사용자 테이블에 삭제 정보 추가
CREATE TABLE users (
id BIGINT PRIMARY KEY,
username VARCHAR(255),
email VARCHAR(255),
-- Soft delete 플래그
is_deleted BOOLEAN DEFAULT FALSE,
deleted_at TIMESTAMP NULL,
deletion_reason VARCHAR(255),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
-- 인덱스 추가
CREATE INDEX idx_users_deleted ON users(is_deleted, deleted_at);삭제 이력 테이블
-- 별도의 삭제 이력 관리
CREATE TABLE deletion_logs (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
resource_type VARCHAR(50), -- 'user', 'post', 'product' 등
resource_id BIGINT,
deleted_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
deleted_by BIGINT, -- 삭제를 실행한 사용자 ID
reason VARCHAR(255),
metadata JSON,
INDEX idx_resource (resource_type, resource_id)
);RFC 7231 명세
HTTP/1.1 RFC 7231에서 410 상태 코드 정의:
6.5.9. 410 Gone
The 410 (Gone) status code indicates that access to the target
resource is no longer available at the origin server and that this
condition is likely to be permanent.
If the origin server does not know, or has no facility to determine,
whether or not the condition is permanent, the status code 404
(Not Found) ought to be used instead.
The 410 response is primarily intended to assist the task of web
maintenance by notifying the recipient that the resource is
intentionally unavailable and that the server owners desire that
remote links to that resource be removed.모범 사례
1. 명확한 메시지 제공
{
"error": "Resource permanently deleted",
"message": "This user account was deleted on 2024-12-01",
"deleted_at": "2024-12-01T10:30:00Z",
"help_url": "https://help.example.com/account-deletion"
}2. 대안 제시
{
"error": "API v1 has been discontinued",
"sunset_date": "2024-01-01",
"alternatives": [
{
"version": "v2",
"endpoint": "https://api.example.com/v2/products",
"documentation": "https://docs.example.com/api/v2"
}
]
}3. 적절한 캐싱 헤더
HTTP/1.1 410 Gone
Cache-Control: max-age=604800, public
Content-Type: application/json
{
"error": "Resource permanently deleted"
}4. 로깅 및 모니터링
// 410 응답 로깅
logger.info({
status: 410,
resource: req.path,
deletedAt: resource.deletedAt,
requestedBy: req.user.id,
timestamp: new Date()
});
// 메트릭 수집
metrics.increment('http.response.410');정리
- 410 Gone: 리소스가 영구적으로 삭제되었음을 명확히 표현
- 404 차이: 404는 찾을 수 없음, 410은 의도적으로 제거됨
- SEO 효과: 검색 엔진이 빠르게 인덱스에서 제거
- 사용 사례: 삭제된 계정, 만료된 프로모션, API 버전 폐기, 삭제된 콘텐츠
- 구현: Soft delete 패턴 + 삭제 이력 관리
- 클라이언트: 410 수신 시 재시도하지 않고 캐시에서 제거