게시판 하나 만드는 건 쉽다. boards, articles, comments 테이블 3개면 된다.
근데 프로덕션에 올리면 터진다. 좋아요가 꼬이고, 조회수가 밀리고, 대댓글 구조가 깨지고, 게시판 추가할 때마다 배포해야 한다.
이 글은 커뮤니티 DB 설계에서 자주 반복되는 5가지 실수와, 실무에서 실제로 쓰는 구조를 정리한다.
먼저, 기본 구조
올바른 커뮤니티 스키마의 뼈대:
users
id, nickname, email
boards
id, code, name, is_active
articles
id, board_id (FK), user_id (FK), title, body, view_count
comments
id, article_id (FK), user_id (FK), parent_id (FK, self), body
reactions
id, user_id (FK), reactable_type, reactable_id, type
UNIQUE(user_id, reactable_type, reactable_id)
이게 끝이다. 테이블 5개. 여기서 시작해서 실수 하나씩 짚어보자.
실수 1: 좋아요를 숫자 컬럼으로 저장
// ❌ articles 테이블에 like_count 하나만
Schema::create('articles', function (Blueprint $table) {
$table->id();
$table->unsignedInteger('like_count')->default(0);
// ...
});
// 좋아요 누를 때
Article::find($id)->increment('like_count');
3가지가 동시에 터진다:
- 한 유저가 100번 누를 수 있다 — 누가 눌렀는지 모르니 중복 방지 불가
- 좋아요 취소가 안 된다 — 누른 사람을 모르니 되돌릴 수 없다
- 동시 요청 시 카운트가 꼬인다 — 10명이 동시에 누르면 race condition
올바른 구조
// ✅ reactions 테이블 분리
Schema::create('reactions', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained();
$table->morphs('reactable'); // reactable_type + reactable_id
$table->string('type')->default('like');
$table->timestamps();
$table->unique(['user_id', 'reactable_type', 'reactable_id']);
});
// Eloquent에서 사용
class Article extends Model
{
public function reactions(): MorphMany
{
return $this->morphMany(Reaction::class, 'reactable');
}
public function likeCount(): int
{
return $this->reactions()->where('type', 'like')->count();
}
}
UNIQUE 제약이 중복을 DB 레벨에서 막는다. 애플리케이션 코드가 아니라 DB가 보장하니까 race condition에도 안전하다.
“그러면 like_count 컬럼은 아예 없애나요?”
아니다. 목록 조회 시 매번 COUNT를 치면 느리다. like_count를 캐시 컬럼으로 유지하되, 원천 데이터는 reactions 테이블이다:
// 좋아요 토글 시 캐시 컬럼 동기화
$article->reactions()->create([...]);
$article->update(['like_count' => $article->reactions()->count()]);
실수 2: 대댓글을 별도 테이블로 만들기
// ❌ 깊이마다 테이블 추가
Schema::create('comments', ...);
Schema::create('replies', ...); // 대댓글
Schema::create('reply_replies', ...); // 대대댓글??
3단 댓글이면 테이블 3개, 5단이면 5개. 쿼리도 JOIN이 깊이만큼 늘어난다. 구조적으로 확장 불가.
올바른 구조: 자기참조 FK
// ✅ parent_id로 자기참조
Schema::create('comments', function (Blueprint $table) {
$table->id();
$table->foreignId('article_id')->constrained();
$table->foreignId('user_id')->constrained();
$table->foreignId('parent_id')->nullable()->constrained('comments');
$table->text('body');
$table->timestamps();
});
class Comment extends Model
{
// 이 댓글의 대댓글들
public function children(): HasMany
{
return $this->hasMany(Comment::class, 'parent_id');
}
// 이 댓글의 부모
public function parent(): BelongsTo
{
return $this->belongsTo(Comment::class, 'parent_id');
}
// 최상위 댓글인가?
public function isRoot(): bool
{
return is_null($this->parent_id);
}
}
parent_id가 NULL이면 최상위 댓글, 값이 있으면 대댓글. 테이블 하나로 무한 깊이를 처리한다.
실무 팁: 대부분의 커뮤니티는 2단(댓글 → 대댓글)까지만 허용한다. UI가 복잡해지기 때문이다. 그래도 스키마는 자기참조로 만들어두고, 깊이 제한은 애플리케이션 레벨에서 건다:
// 대댓글까지만 허용 (depth = 1)
if ($parentComment->parent_id !== null) {
abort(422, '대댓글에는 답글을 달 수 없습니다.');
}
실수 3: 게시판을 코드에 하드코딩
// ❌ 게시판마다 라우트 등록
Route::get('/free', [BoardController::class, 'free']);
Route::get('/humor', [BoardController::class, 'humor']);
Route::get('/car', [BoardController::class, 'car']);
// 게시판 추가 = 코드 수정 + 배포
게시판이 3개면 괜찮다. 24개면? 게시판 하나 추가할 때마다 코드 수정하고 배포해야 한다. 운영자가 게시판을 직접 만들 수도 없다.
올바른 구조: DB 기반 동적 라우트
// boards 마이그레이션
Schema::create('boards', function (Blueprint $table) {
$table->id();
$table->string('code')->unique(); // URL용: "free", "humor"
$table->string('name'); // 표시용: "자유게시판", "유머"
$table->text('description')->nullable();
$table->boolean('is_active')->default(true);
$table->timestamps();
});
// ✅ 라우트 하나로 전체 처리
Route::get('/boards/{boardCode}', [ArticleController::class, 'index']);
Route::get('/boards/{boardCode}/{article}', [ArticleController::class, 'show']);
// 컨트롤러
class ArticleController extends Controller
{
public function index(string $boardCode)
{
$board = Board::where('code', $boardCode)
->where('is_active', true)
->firstOrFail();
$articles = $board->articles()
->latest()
->paginate(20);
return view('board.index', compact('board', 'articles'));
}
}
게시판 추가는 DB에 한 줄 INSERT면 끝이다. 배포 필요 없다.
실수 4: 조회수를 매 요청마다 UPDATE
// ❌ 글 조회할 때마다 DB 직접 UPDATE
public function show(Article $article)
{
$article->increment('view_count');
return view('article.show', compact('article'));
}
1,000명이 동시에 같은 글을 보면 1,000번의 UPDATE 쿼리가 날아간다. 행(row) 단위 쓰기 잠금이 걸리면서 응답이 밀린다. 게시글 하나 때문에 DB 전체가 느려질 수 있다.
올바른 구조: Redis 버퍼 + 배치 동기화
// ✅ 조회 시 Redis에 누적
public function show(Article $article)
{
Redis::hincrby('article_views', $article->id, 1);
return view('article.show', compact('article'));
}
// ✅ 스케줄러로 5분마다 DB 동기화
// app/Console/Kernel.php
$schedule->call(function () {
$views = Redis::hgetall('article_views');
foreach ($views as $articleId => $count) {
Article::where('id', $articleId)
->increment('view_count', (int) $count);
}
Redis::del('article_views');
})->everyFiveMinutes();
Redis HINCRBY는 메모리 연산이라 동시 1,000건이 와도 문제없다. DB에는 5분에 한 번만 쓴다.
트레이드오프: 조회수가 최대 5분 지연된다. 대부분의 커뮤니티에서 이건 문제가 안 된다 — 조회수가 실시간이어야 하는 서비스는 거의 없다.
실수 5: FK가 있는 데이터를 hard delete
// ❌ 유저 탈퇴 시 hard delete
User::find($id)->delete();
// → articles.user_id FK 제약 위반 → 에러
// 또는 CASCADE 설정 → 유저의 모든 글과 댓글이 삭제됨
유저가 탈퇴했다고 글이 사라지면 안 된다. 다른 사람이 단 댓글, 받은 좋아요 — 전부 연쇄 삭제된다.
올바른 구조: Soft Delete
// 마이그레이션에 softDeletes 추가
Schema::create('users', function (Blueprint $table) {
$table->id();
$table->string('nickname');
$table->softDeletes(); // deleted_at 컬럼
$table->timestamps();
});
// 모델에 SoftDeletes 트레이트
class User extends Model
{
use SoftDeletes;
}
// 탈퇴 처리
User::find($id)->delete();
// → deleted_at = 2026-04-13 14:30:00
// → 글, 댓글 전부 그대로 유지
// 뷰에서 탈퇴 유저 처리
{{ $article->user?->nickname ?? '탈퇴한 사용자' }}
모든 테이블에 soft delete가 필요한 건 아니다. 판단 기준:
| 테이블 | 삭제 전략 | 이유 |
|---|---|---|
| users | soft delete | 글, 댓글이 참조 중 |
| articles | soft delete | 댓글, 좋아요가 참조 중 |
| comments | soft delete | 대댓글이 참조 가능 |
| reactions | hard delete | 다른 테이블이 참조 안 함 |
| boards | soft delete | 게시판 글이 참조 중 |
“이 row가 사라지면 다른 row가 깨지는가?” — 깨지면 soft delete, 안 깨지면 hard delete.
보너스: 규모가 커지면 뭘 바꾸나
유저 10만 명, 하루 글 5,000건이 되면:
1. 인덱스 전략
// 게시판별 최신글 목록 — 가장 빈번한 쿼리
$table->index(['board_id', 'created_at']); // 복합 인덱스
WHERE board_id = ? ORDER BY created_at DESC — 이 쿼리를 인덱스만으로 해결한다.
2. 인기글 정렬
// ❌ 매 요청마다 계산
Article::withCount('reactions')->orderByDesc('reactions_count');
// → 10만 건 JOIN + COUNT + ORDER = 느림
// ✅ 스코어링 컬럼 + 스케줄러
$table->decimal('popularity_score')->default(0)->index();
// 1시간마다 계산
$schedule->call(function () {
Article::where('created_at', '>', now()->subDays(7))
->each(function ($article) {
$article->update([
'popularity_score' =>
$article->like_count * 3
+ $article->comments()->count() * 2
+ log1p($article->view_count)
]);
});
})->hourly();
사전 계산된 스코어로 정렬하면 ORDER BY popularity_score DESC가 인덱스를 타서 빠르다.
3. N+1 방지
// ❌ 글 목록에서 작성자, 댓글수, 좋아요수를 각각 조회
$articles = Article::all();
// 뷰에서 $article->user->name → N+1
// ✅ eager loading으로 한 번에
$articles = Article::with(['user', 'board'])
->withCount(['comments', 'reactions'])
->where('board_id', $board->id)
->latest()
->paginate(20);
정리
| 실수 | 올바른 구조 |
|---|---|
| 좋아요를 숫자 컬럼에 | reactions 테이블 분리 + UNIQUE 제약 |
| 대댓글을 별도 테이블로 | 자기참조 parent_id |
| 게시판을 코드에 하드코딩 | boards 테이블 + 동적 라우트 |
조회수를 매번 UPDATE | Redis 버퍼 + 배치 동기화 |
| FK 있는 데이터를 hard delete | soft delete + deleted_at |
5가지 다 “처음에는 됐는데 커지니까 터졌다” 유형이다. 처음부터 이렇게 설계하면 나중에 뜯어고칠 일이 없다.