본문 바로가기
개발/개발

[스크랩 기능]폴더 하나 만들면서 UX와 설계를 같이 고민해본 이야기_1

by 글쓰는 개발자 2026. 2. 11.

요구사항 : 유저는 스크랩폴더를 만들 수 있고, 폴더 안에 원하는 게시물들을 추가할 수 있다.

폴더 하나 만드는 기능이 이렇게 많은 고민을 하게 될 줄은 몰랐다.

처음에는 단순했다.
“유저가 스크랩 폴더를 만든다 → 폴더의 이름을 저장한다”

그런데 막상 구현을 시작하니 질문이 하나씩 생겼다.

  • 같은 유저가 같은 이름의 폴더를 만들면 어떻게 해야 하지?
  • 중복을 허용해야 할까, 막아야 할까?
  • 자동으로 (2)를 붙여주면 UX는 좋아질까?
  • 그럼 이 로직은 DB에서? 백엔드에서? 프론트에서?

이 글은 ‘폴더 이름 중복 처리’라는 작은 기능을 구현하면서 배운 설계 포인트를 정리한 기록이다.


1. 중복을 허용할 것인가, 막을 것인가?

가장 먼저 고민한 건 이거였다.

같은 유저가 같은 이름의 폴더를 여러 개 가져도 괜찮을까?

 

기술적으로는 가능하다.

실제 식별은 `folder_id`로 하고, 이름은 단순히 보여주기용으로 쓰면 된다.


하지만 UX 관점에서 생각해 보면 애매해진다.

  • 삭제할 때 “어느 폴더를 삭제하는 건지”를 다시 확인해야 하고
  • 이동/선택 UI에서는 동일한 이름의 폴더가 여러 개 노출되며
  • 사용자는 결국 이름이 아닌 다른 단서를 찾아야 한다

즉, ID로 구분하는 방식은 시스템 입장에서는 편하지만,  
사용자 입장에서는 매번 판단 비용을 요구한다. 그래서 결론은 다음과 같았다.

같은 유저 내에서는 폴더 이름은 논리적으로 유니크해야 한다

 

이건 단순히 데이터베이스 제약을 거는 문제가 아니라,  
*사용자가 폴더를 인지하고 조작하는 방식에 대한 도메인 규칙에 가깝다.


2. 그렇다면 중복일 때 UX는 어떻게 할까?

선택지는 두 가지였다.

  1. 중복이면 에러를 내고 이름을 다시 입력하게 한다
  2. 자동으로 (2), (3)을 붙여서 만들어준다

여기서 운영체제(Windows, macOS)의 폴더 생성 시의 동작을 떠올려 보면,
두 번째가 사용자 기대에 더 가깝다.

그래서 정책을 이렇게 정했다.

같은 이름이 있으면 서버가 자동으로 (2), (3)… 을 붙여서 생성한다

 

중요한 포인트는 이 동작을 프론트가 아니라 서버가 책임진다는 점이다.

프론트에서는 중복 여부를 미리 판단하지 않고,
단순히 “이 이름으로 폴더를 만들어 달라”라고 요청한다.

서버는 이 요청을 받아
- 중복 여부를 판단하고
- 필요한 경우 자동으로 이름을 변경한 뒤
- 도메인 규칙을 만족하는 최종 결과를 확정한다.

즉, 중복 처리 정책의 시작과 끝은 모두 서버에 있다.

그리고 좀 더 찾아보니

  • 폴더 이름 중복
  • 포인트 차감
  • 재고 감소
  • 결제 상태 변경

전부에 공통으로 적용되는 원칙이라고 한다. 생각해 보니 그렇다.

 

잠깐, 이런 생각을 할 수도 있다.

ajax로 중복여부 확인해서 넘길 수도 있지 않나?

 

여기서 생각할 수 있는 건, ajax로 중복 확인하는 건 사용자 편의(미리 알려주기) 용도로는 좋다는 것이다.

하지만, 'ajax로 중복확인한다 = 최종 결과를 보장한다'는 맞지 않다.

왜냐면, 

AJAX로 확인한 순간부터 서버에 저장되는 순간까지 사이에

  • 다른 탭에서 생성할 수도 있고
  • 다른 기기에서 생성할 수도 있고
  • 다른 사용 흐름(앱/관리자/배치)에서 생성할 수도 있고
  • 아주 짧은 순간에 동시에 요청이 들어올 수도 있다

즉, “확인 당시엔 중복이 아니었는데 저장 시점엔 중복”이 될 수 있다.
이걸 TOCTOU(Time Of Check To Time Of Use, 시간차 공격/경쟁 조건) 문제라고 한다.

그래서 원칙은

클라이언트는 “미리 확인”할 수 있지만,
서버/DB가 “최종으로 강제”해야 한다.

 

 

마침, 이와 비슷한 내용을 주제로 한 글을 스터디원 중 한 명이 작성했다.

[orvit] 왜 서버는 검증 없이 클라이언트가 보낸 값을 그대로 신뢰하면 안 될까?

https://ek12mv2.notion.site/orvit-300f2a2bb68a80c19581f1a757e5b643?source=copy_link

 

우연히 읽은 테스트 관련 아티클에서도 클라이언트의 값을 그대로 서버로 받아서는 안된다고 한다.

https://medium.com/@dev_90291/test-case-design-techniques-in-software-testing-d59a3e0dbc0e

 

Time Of Check To Time Of Use, TOCTOU race condition로 검색해도 위키피디아 이외의 글도 많이 나온다.

https://en.wikipedia.org/wiki/Time-of-check_to_time-of-use

https://cwe.mitre.org/data/definitions/367.html


3. 중복 방지는 반드시 DB가 한다

처음에는 이런 방식도 떠올렸다.

if ScrapFolder.objects.filter(user_num=user_num, folder_name=name).exists():
    # 폴더 존재 하면 폴더명 + (2) 붙이기

 

하지만 이 방식에는 치명적인 문제가 있다.

  • 동시 요청이 오면?
  • 거의 동시에 두 요청이 들어오면 둘 다 exists()를 통과한다

그럼 exists()를 transaction.atomic()으로 감싸면 동시성 문제가 해결되지 않을까?

with transaction.atomic():
    if ScrapFolder.objects.filter(
        user_num=user_num,
        folder_name=name
    ).exists():
        # 중복 처리

 

겉보기에는 트랜잭션 안에서 진행되니까 안전해 보이는데, 경쟁 조건(race condition)을 막지 못한다.

 

거의 동시에 두 요청이 들어온 경우, 둘 다 insert 성공 -> 중복 데이터 생성

 

<거의 동시에 두 요청이 들어온 경우>

  1. 둘 다 exists()에서는 False
  2. 둘 다 insert 성공
  3. 중복 데이터 생성

transaction.atomic()은

“이 블록 안에서 실행된 쿼리들을 하나의 트랜잭션으로 묶는다”
는 의미지,
“다른 트랜잭션이 동시에 읽지 못하게 막는다”
는 의미가 아님

 

그럼 atomic은 언제 의미가 있나?

atomic()은

  • 여러 개의 쿼리를 하나로 묶어야 할 때
  • 중간에 실패하면 전체 롤백해야 할 때
예)
with transaction.atomic():
    order.save()
    inventory.decrease()
    payment.update()

 

그럼 동시성을 막으려면 어떤 것이 필요할까?

 

방법 1

DB 유니크 인덱스

UniqueConstraint(fields=["user_num", "folder_name"])

그리고

try:
    create()
except IntegrityError:
    # suffix 처리

 

 

방법 2

select_for_update() + 트랜잭션

 

하지만 이건:

  • 해당 row가 이미 있어야 잠글 수 있음
  • 없는 row는 못 잠금
  • 폴더 생성 같은 케이스에는 적합하지 않음

장고문서에서 'select_for_update'를 검색해서 요약해 보면

https://docs.djangoproject.com/en/6.0/ref/models/querysets/#django.db.models.query.QuerySet.select_for_update

Returns a queryset that will lock rows until the end of the transaction.

 

이미 존재하는 row들을 락 거는 것. 그렇다면 '아직 생성되지 않은 폴더 이름'은 잠글 수 없으니, 방법 2는 맞지 않다. 

 

이 내용을 확인하면서 알게 된 이 케이스에서 나온 비슷한 DB이론은 'Phantom Read', ' Check-then-Insert race condition'.

 

 

여기서는 'Check Then Insert Race Condition'이라는 이론이 현 상황의 문제와 동일하다 생각하여 이 이론의 해결방법과 아까 나왔던 방법 1로 해결하기로 하였다

ScrapFolder 테이블에 아래의 제약조건을 추가.
constraints = [
            models.UniqueConstraint(
                fields=["user_num", "folder_name"],
                name="uniq_scrapfolders_usernum_foldername",
            )
        ]
 
 

그럼 서버 로직은 

  1. 먼저 그대로 저장 시도
  2. DB에서 IntegrityError 발생
  3. (2), (3)… 을 붙여서 재시도

이 방식을 찾아보니 

중복을 “검사”하지 말고, 중복을 “허용한 뒤 실패로 판단”한다

 

라는 실무자의 답변이 있는 글도 찾아볼 수 있었다.

 

SQLAlchemy 공식 Google Groups 토론

Checking for a unique constraint violation before inserting new records, is it recommended?

https://groups.google.com/g/sqlalchemy/c/G6eb_1gpn1s

 

LBYL vs EAFP: Preventing or Handling Errors in Python

https://realpython.com/python-lbyl-vs-eafp/

 

 

 

1탄은 여기까지!


참고 

검색 키워드

- TOCTOU(Time Of Check To Time Of Use) race condition
- Martin Fowler, Domain Logic
- Django Documentation – ValidationError / IntegrityError
- Stripe API Docs – Idempotent Requests

- select_for_update()

 

 

동시성제어 관련

[Django] select_for_update를 사용해 안전하게 데이터 수정하기

https://brownbears.tistory.com/553

[DB] Dirty Read, Non-Repeatable Read, Phantom Read 예시 및 Snapshot Isolation Level | LIM

https://amazelimi.tistory.com/entry/DB-Dirty-Read-Non-Repeatable-Read-Phantom-Read-%EC%98%88%EC%8B%9C-%EB%B0%8F-Snapshot-Isolation-Level-LIM#google_vignette

[MySQL] InnoDB에서 PHANTOM READ를 방지하는 방법

https://didcheck.tistory.com/33 

Phantom Read 부정합문제 해결방안 In PostgreSQL, MSSQL Server

https://coding-review.tistory.com/304

 

반응형