설계 문서

2026. 5. 22. 10:05·루퍼스/WIL

한 주 요약

이번 주에는 코드 구현을 하지 않았다. 대신 요구사항 정의서, 시퀀스 다이어그램, 클래스 다이어그램, ERD까지 총 4종의 설계 문서를 작성했다.

막상 시작해보니 결정해야 할 것들이 생각보다 많았다. 좋아요 API를 토글로 만들지 분리할지, 결제 실패를 롤백할지 실패 상태로 남길지, 재고 차감 트랜잭션 경계를 어디까지 둘지, FK를 실제 DB 제약으로 걸지 논리적 관계로만 둘지 등 구현 전에 정리하지 않으면 코드 작성 중 계속 흔들릴 만한 결정들이 많았다.

특히 빈 페이지에서 시작하는 게 가장 어려웠다. 요구사항을 문장으로 길게 풀어쓸지, 표 중심으로 정리할지, 다이어그램은 어느 수준까지 상세하게 그릴지부터 막혔다. 처음에는 내용을 너무 자세히 풀어썼다가 표 중심으로 다시 바꾸고, 라운드별로 나눴다가 통합 문서로 다시 합치는 시행착오도 있었다.

 


배운 것들

1. 설계 문서는 먼저 읽는 사람을 정해야 한다

이번에 가장 먼저 배운 건, 설계 문서는 내용을 쓰기 전에 누가 읽을 문서인지부터 정해야 한다는 점이었다.

라이브 세션에서 “실무 요구사항 정의서는 개발자만 보는 문서가 아니라 PM, 기획자, 비개발자와 소통하는 용도로 쓰이는 경우가 많다”는 피드백을 받았다. 처음에는 모든 내용을 하나의 흐름으로 자세히 설명하려고 했는데, 그렇게 작성하니 문서가 애매해졌다. 비개발자가 보기에는 너무 복잡하고, 개발자가 보기에는 중요한 구현 판단이 부족했다.

그래서 시퀀스 다이어그램을 두 가지 수준으로 나눴다.

  • 개요 다이어그램: 사용자, 서버, DB 중심의 큰 흐름
  • 상세 다이어그램: Controller, Facade, Service, Repository, 트랜잭션 범위, 예외 흐름까지 포함

같은 기능을 설명하더라도 추상화 수준이 달라지니 문서의 목적도 달라졌다. 개요 다이어그램은 “이 기능이 어떤 흐름으로 동작하는지”를 설명하는 데 좋았고, 상세 다이어그램은 “실제로 어느 레이어에서 어떤 책임을 가질지”를 정리하는 데 도움이 됐다.

처음에는 문서가 두 배로 늘어난 것 같아 부담스러웠지만, 결과적으로는 더 명확해졌다. 하나의 문서에 모든 사람을 만족시키려 하기보다, 읽는 사람에 따라 보여줄 깊이를 나누는 게 더 낫다는 걸 배웠다.


2. FK는 관계를 표현하는 방법 중 하나일 뿐이다

ERD를 처음 작성할 때는 관계가 있으면 당연히 FK를 걸어야 한다고 생각했다. RDB를 쓰고 있고, 테이블 간 관계가 있다면 FK 제약을 거는 게 자연스럽다고 봤다.

그런데 멘토님이 “실무에서는 물리 FK를 기능상 거의 걸지 않는 경우도 많다”고 말씀하셨다. 처음에는 조금 찜찜했다. FK를 안 걸면 정합성이 깨질 수 있는 것 아닌가 싶었다. 그래서 왜 그런 선택을 하는지 따로 찾아봤다.

물리 FK를 걸면 DB가 참조 무결성을 보장해준다는 장점이 있다. 하지만 그만큼 쓰기 작업마다 무결성 체크가 발생하고, 데이터 마이그레이션이나 대량 적재 시 제약을 풀고 다시 거는 부담이 생길 수 있다. 샤딩 구조에서는 다른 샤드의 row를 FK로 참조하기 어렵고, soft delete를 사용하는 도메인에서는 CASCADE나 RESTRICT 정책이 도메인 의도와 어긋나는 경우도 있다.

테스트 데이터를 만들 때도 부모-자식 데이터 순서를 신경 써야 하고, 테이블 간 참조가 복잡해지면 순환 참조 문제가 생길 수도 있다.

결국 FK를 걸지 말자는 이야기가 아니라, “관계가 있다”와 “DB가 그 관계를 강제한다”는 별개의 선택이라는 걸 알게 됐다.

이번 설계에서는 다음 기준으로 정리했다.

  • 같은 애그리거트 내부에서 생명주기를 같이하는 관계는 FK를 유지한다.
  • 서로 독립적으로 존재할 수 있는 도메인은 물리 FK 대신 ID 참조로 표현한다.
  • 정합성은 DB 제약뿐 아니라 응용 계층의 정책과 테스트로도 보장할 수 있어야 한다.

특히 ERD의 관계선과 FK 제약을 분리해서 볼 수 있다는 점이 새로웠다. ERD의 ||--o{ 같은 관계선은 논리적 연결을 표현할 수 있고, FK는 그 연결을 DB 레벨에서 강제할지에 대한 선택이다. 이전에는 두 개를 거의 같은 의미로 봤는데, 이번에 그 차이를 분명히 구분하게 됐다.


3. 애그리거트 경계는 “같이 살고 같이 죽는가?”로 판단할 수 있었다

DDD의 애그리거트 개념은 책이나 글로 볼 때는 꽤 추상적으로 느껴졌다. 그런데 이번에 FK 정책과 객체 참조 방식을 정리하면서 조금 손에 잡히기 시작했다.

이번에 기준으로 삼은 질문은 단순했다.

 

이 둘은 같이 생성되고, 같이 변경되고, 같이 삭제되어야 하는가?

 

Order와 OrderItem은 같이 움직인다. OrderItem은 Order 없이 독립적으로 의미를 가지기 어렵다. 주문이 삭제되거나 취소될 때 주문 항목도 같은 흐름 안에서 다뤄진다. 그래서 같은 애그리거트로 보고 객체 참조와 FK를 유지하는 쪽이 자연스러웠다.

반면 Product와 Brand는 서로 연결되어 있지만 생명주기가 다르다. Brand가 없어졌다고 Product row가 바로 같이 삭제되어야 하는 것도 아니고, Product가 수정될 때 Brand까지 같은 트랜잭션에서 항상 변경되어야 하는 것도 아니다. 이런 경우에는 객체로 강하게 묶기보다 ID 참조로 느슨하게 연결하는 편이 낫다고 판단했다.

Stock도 고민이 있었다. 처음에는 Product의 일부처럼 보였다. 하지만 재고는 주문 과정에서 자주 변경되고, 동시성 제어도 중요하다. Product의 일반 정보와 같은 변경 단위로 묶으면 불필요하게 락 경합 범위가 커질 수 있다. 그래서 Product와 분리된 모델로 보고, 재고 차감에 필요한 경계를 따로 잡았다.

이번 설계를 하면서 ERD, 객체 참조, FK, 트랜잭션 경계가 따로 떨어진 결정이 아니라는 걸 느꼈다. 결국 모두 “도메인의 변경 단위를 어디까지 볼 것인가”라는 질문에서 이어졌다.


4. 멱등성은 중복 방지가 아니라 재시도 안전성이다

좋아요 기능을 설계할 때 처음에는 POST /likes/toggle 하나로 처리하면 깔끔하다고 생각했다. 좋아요가 없으면 등록하고, 이미 있으면 취소하는 방식이다. API도 하나라 단순해 보였다.

그런데 멱등성 관점에서 다시 보니 문제가 있었다.

예를 들어 사용자가 좋아요 버튼을 눌렀고, 서버에서는 좋아요 등록이 성공했지만 응답이 유실됐다고 가정한다. 클라이언트는 실패로 판단하고 같은 요청을 재시도할 수 있다. 그런데 토글 API라면 두 번째 요청에서는 이미 좋아요가 있으니 취소가 된다.

결과적으로 사용자는 좋아요를 누른 의도였지만, 최종 상태는 좋아요 취소가 된다.

초기 상태: 좋아요 없음

1. POST /likes/toggle
   → 서버: 좋아요 등록

2. 응답 유실

3. 클라이언트 재시도
   → 서버: 이미 좋아요가 있으므로 취소

최종 상태: 좋아요 없음
 

같은 요청을 다시 보냈는데 최종 결과가 달라진다. 이건 재시도에 안전하지 않다.

그래서 좋아요 등록과 취소를 분리했다.

  • POST /likes : 좋아요 상태를 만들기
  • DELETE /likes : 좋아요 상태를 제거하기

이렇게 하면 클라이언트가 원하는 최종 상태가 API에 명확히 담긴다. 이미 좋아요가 있는 상태에서 POST /likes가 다시 들어와도 최종 상태는 여전히 “좋아요 있음”이다. 이미 좋아요가 없는 상태에서 DELETE /likes가 다시 들어와도 최종 상태는 “좋아요 없음”이다.

이번에 멱등성을 보면서 “중복 요청을 막는다”보다 “같은 요청이 다시 와도 의도한 최종 상태가 유지된다”는 관점이 더 중요하다는 걸 배웠다. 이 사고방식은 결제 요청, 외부 API 연동, 메시지 재처리 같은 상황에서도 그대로 이어질 것 같다.


5. 롤백과 보상 처리는 다르게 봐야 한다

결제 실패 처리를 설계하면서 처음에는 단순하게 생각했다. 결제 중 문제가 생기면 예외를 던지고 트랜잭션을 롤백하면 된다고 봤다.

그런데 그렇게 처리하면 결제 실패 기록까지 함께 사라진다. 시스템 입장에서는 아무 일도 없었던 것처럼 보인다. 하지만 실제 운영에서는 실패 이력도 중요한 데이터다. PG 대조, 사용자 문의 대응, 감사 추적을 하려면 “결제를 시도했고, 어떤 이유로 실패했는지”가 남아 있어야 한다.

이 지점에서 롤백과 보상 처리를 구분하게 됐다.

롤백은 시스템 오류처럼 해당 변경을 아예 무효화해야 할 때 적합하다. 데이터베이스에는 흔적이 남지 않는다.

반면 보상 처리는 비즈니스적으로 실패가 하나의 결과로 남아야 할 때 필요하다. 예를 들어 결제 실패 상태를 저장하고, 이미 차감한 재고가 있다면 복구하고, 주문 상태를 실패로 바꾸는 식이다. 중요한 건 이 보상 작업 자체는 커밋되어야 한다는 점이다.

그래서 결제 실패를 무조건 예외로 던져 전체 롤백시키기보다는, 실패 상태와 보상 결과를 저장한 뒤 사용자에게 실패 응답을 주는 흐름으로 설계했다.

이번 설계를 하면서 예외 처리가 항상 롤백을 의미하는 것은 아니라는 걸 배웠다. 실패도 도메인에서 의미 있는 결과라면, 사라지게 만들 것이 아니라 남겨야 한다.


회고

잘한 점

이번 주에 가장 잘한 점은 정책 결정을 그냥 머릿속에 두지 않고 문서에 남긴 것이다. 각 정책마다 “결정 / 검토한 대안 / 결정 이유”를 적어두니, 나중에 다시 봐도 왜 그렇게 설계했는지 따라갈 수 있었다.

특히 P-1부터 P-12까지 정책을 분리해둔 것이 좋았다. 구현 중에 판단이 흔들릴 때 다시 돌아볼 기준점이 생겼다.

시퀀스 다이어그램을 개요와 상세로 나눈 것도 좋았다. 처음에는 일이 늘어난다고 느꼈지만, 결과적으로 문서의 목적이 더 분명해졌다.

또 하나는 FK 정책처럼 멘토 피드백을 그대로 받아들이지 않고, 왜 그런 선택을 하는지 직접 찾아본 점이다. 단순히 “실무에서는 이렇게 한다더라”에서 끝났다면 크게 남는 게 없었을 텐데, 이유를 파고들면서 DB 제약, 도메인 모델링, 애그리거트 경계까지 같이 정리할 수 있었다.


아쉬운 점

가장 아쉬웠던 건 문서 간 정합성을 유지하는 일이 생각보다 어려웠다는 점이다.

정책 하나를 바꾸면 요구사항 정의서, 시퀀스 다이어그램, 클래스 다이어그램, ERD가 같이 바뀌어야 했다. 그런데 한 문서만 수정하고 다른 문서에 반영하지 못한 경우가 있었다. 나중에 다시 보면서 “여기는 예전 기준으로 남아 있네” 하고 발견한 부분도 있었다.

다음부터는 설계 결정을 바꾼 직후, 영향을 받는 문서를 바로 같이 수정해야겠다고 느꼈다. 몰아서 정리하려고 하면 놓치는 부분이 생긴다.

문서 형식을 처음에 제대로 잡지 못한 것도 아쉬웠다. 빈 페이지에서 바로 쓰기 시작하다 보니 중간에 구조를 여러 번 갈아엎었다. 처음부터 실무 샘플이나 좋은 예시 문서 1~2개를 참고했으면 시간을 줄일 수 있었을 것 같다.

또 모든 결정에 대해 같은 깊이로 고민하지 못했다. FK 정책은 왜 그런지 꽤 깊게 찾아봤지만, 일부 결정은 멘토 피드백을 듣고 그대로 반영한 것도 있었다. 모든 걸 과하게 파고들 필요는 없겠지만, 중요한 정책일수록 “왜 이 선택을 했는가”를 더 명확히 남겨야겠다고 느꼈다.


다음 주 계획

다음 주부터는 이번에 작성한 설계 문서를 기반으로 실제 구현을 시작한다.

구현하면서 문서와 코드가 어긋나는 부분이 분명히 나올 것 같다. 그때는 코드를 문서에 억지로 맞추기보다, 어느 쪽이 더 타당한지 다시 판단하고 바로 반영하려고 한다. 문서는 한 번 작성하고 끝나는 산출물이 아니라, 구현 과정에서 계속 업데이트되어야 하는 기준이라고 생각한다.

특히 결제 도메인은 처음 구현해보는 부분이라 신경 써서 볼 예정이다. 외부 PG 연동 흐름, 멱등키 처리, 결제 실패 시 보상 트랜잭션이 설계한 대로 동작하는지 확인해야 한다.

재고 차감도 중요하다. 동시 주문 상황에서 race condition이 발생할 수 있기 때문에 낙관 락으로 제어하는 방향을 잡았는데, 실제 통합 테스트에서 의도대로 동작하는지 검증할 계획이다.

이번 주가 설계의 기준을 세우는 시간이었다면, 다음 주는 그 기준이 실제 코드에서도 버틸 수 있는지 확인하는 시간이 될 것 같다.

저작자표시 (새창열림)

'루퍼스 > WIL' 카테고리의 다른 글

외부 PG 장애를 견디는 비동기 결제 설계  (0) 2026.06.26
인덱스/비정규화/캐시  (0) 2026.06.19
주문과 결제 사이에서 재고는 언제 차감해야 할까?  (0) 2026.06.12
DomainService는 Repository를 몰라도 될까?  (0) 2026.05.29
TDD  (0) 2026.05.15
'루퍼스/WIL' 카테고리의 다른 글
  • 인덱스/비정규화/캐시
  • 주문과 결제 사이에서 재고는 언제 차감해야 할까?
  • DomainService는 Repository를 몰라도 될까?
  • TDD
쿤쿤쿤
쿤쿤쿤
  • 쿤쿤쿤
    QQQ
    쿤쿤쿤
  • 전체
    오늘
    어제
    • 분류 전체보기 (70)
      • Spring (6)
      • JAVA (5)
      • System Design (0)
      • AI (0)
      • DB (1)
      • Linux (1)
      • Git (1)
      • 트러블 슈팅 (3)
      • 오픈소스 (0)
      • CS 기초 지식 (0)
        • 네트워크 (0)
        • 운영체제 (0)
        • 알고리즘 (0)
        • 개발상식 (0)
      • 알고리즘 (45)
        • 구현 (Implementation) (0)
        • DFS, BFS (0)
        • 완전탐색 (Bruteforce) (0)
        • 그리디 (Greedy) (0)
        • 투포인터 (Two Pointer) (0)
        • 이분탐색 (Binary Search) (0)
        • 스택, 큐 (Stack, Queue) (0)
        • DP (Dynamic Programming) (0)
        • 다익스트라 (Dijkstra) (0)
        • 구간합 (Prefix) (0)
        • 문제모음 (0)
        • 백준 (20)
        • 2020 KAKAO (7)
        • 2021 KAKAO (7)
        • 2022 KAKAO (7)
        • 프로그래머스 (0)
        • Softeer (3)
      • 기타 (0)
      • 루퍼스 (7)
        • WIL (6)
        • WRITING (1)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    di
    dipendency injection
    의존성 주입
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.6
쿤쿤쿤
설계 문서
상단으로

티스토리툴바