[LIVErary] 프로젝트 회고
LIVErary 프로젝트의 핵심인 '방(Room)' 도메인을 풀스택으로 개발하며 겪은 기술적 경험을 정리했습니다. JPA Bulk Update의 데이터 정합성 문제, React의 렌더링 최적화(Derived State), 그리고 DDD 기반의 엔티티 설계 과정을 상세히 회고합니다.
프로젝트를 마무리하며…
주요 개발 내용
LIVErary의 메인 기능인 방(Room) 도메인의 풀스택 개발을 전담했습니다. 사용자가 소통하는 공간의 생명주기(생성, 예약, 참여, 종료)를 관리하고, 이를 뒷받침하는 DB 설계, 스케줄러 구현, 그리고 프론트엔드 최적화까지 수행했습니다.
Backend & Architecture (Java, Spring Boot, JPA)
- 도메인 주도 설계(DDD) 기반 Entity 및 DB 설계
- ERD 설계를 통해 정규화된 데이터베이스 구조를 수립했으며, 특히 방(Room)과 참여 이력(RoomHistory)을 분리하여 데이터의 성격에 맞는 관리를 도모했습니다.
Room,RoomHistory,RoomReservation엔티티를 설계하고, 비즈니스 로직(초대 코드 생성, 입장/퇴장 처리, 정보 수정)을 엔티티 내부 메서드(updateInfo,join,leave)로 캡슐화하여 객체지향적인 설계를 지향했습니다.
- 방 상태 관리 스케줄러 구현
- 예약된 방이 시작 시간이 되었을 때 자동으로 상태를 변경하거나, 종료 시간이 지난 방을 닫는 로직을
@Scheduled를 활용해 구현했습니다. - 배치 작업 도중 발생할 수 있는 데이터 정합성 문제를 고려하여 트랜잭션 범위를 세밀하게 조정했습니다.
- 예약된 방이 시작 시간이 되었을 때 자동으로 상태를 변경하거나, 종료 시간이 지난 방을 닫는 로직을
Frontend (React, React Query)
- 모달 컴포넌트 전략적 분리:
CreateRoomModal: 생성 및 예약에 필요한 모든 필드(날짜, 시간, 도서 검색)를 포함하는 종합 모달 구현.UpdateLiveRoomModal: 라이브 중인 방의 특성상 시간 변경은 불가하고 메타데이터(제목, 인원)만 즉시 반영되도록 경량화된 별도 모달로 분리하여 UX를 개선했습니다.
- 통합 폼 핸들링 및 최적화
useCreateRoomForm훅을 설계하여 방 생성과 수정 로직을 통합 관리하되,isEditMode를 통해 생성(Create)과 수정(Update) 로직을 분기 처리하여 코드 재사용성을 높였습니다.- 방 수정 시 초기 렌더링 속도 개선을 위해
useEffect대신 Derived State(파생 상태) 패턴을 적용하여 불필요한 리렌더링을 제거했습니다.
- 부분 수정(Partial Update)을 위한 데이터 처리
- 수정 요청 시 모든 데이터를 덮어쓰지 않고, 변경된 필드(Dirty Checking)만 서버로 전송하여 데이터 무결성을 보장했습니다. 특히 책 정보(ISBN)와 같이 연관 관계가 있는 데이터가 의도치 않게 삭제되는 것을 방지했습니다.
트러블슈팅 (Troubleshooting)
1. JPA Bulk Update와 영속성 컨텍스트 불일치로 인한 데이터 정합성 문제
문제 상황:
예약된 방의 상태를 ‘시작 전’에서 ‘진행 중’으로, 또는 ‘진행 중’에서 ‘종료’로 일괄 변경하는 스케줄러 로직을 구현했습니다. 성능을 고려하여 JPA의 Dirty Checking(변경 감지) 대신,
@Modifying어노테이션을 활용한 Bulk Update 쿼리를 사용했습니다. 그런데 스케줄러가 실행되어 DB상의 상태 값은 변경되었음에도 불구하고, 애플리케이션에서 해당 방을 조회했을 때 여전히 변경 전의 상태(예: ‘시작 전’)로 조회되는 현상이 발생했습니다.방 상태를 일괄 변경하기 위해 JPA의
@Modifying쿼리를 사용하여 Bulk Update를 수행했습니다. 하지만 업데이트 이후 조회한 엔티티에서 변경된 값이 반영되지 않고 과거 데이터가 조회되는 현상이 발생했습니다. 이는 Bulk 연산이 영속성 컨텍스트를 거치지 않고 DB에 직접 쿼리를 날리기 때문에, 영속성 컨텍스트에 남아있던 1차 캐시 데이터와 DB 데이터 간의 불일치로 인한 것이었습니다.고민의 과정:
로그를 확인해보니
UPDATE쿼리는 정상적으로 나갔지만, 이후SELECT쿼리로 조회한 엔티티의 필드 값은 옛날 그대로였습니다. 이는 JPA의 영속성 컨텍스트(Persistence Context) 동작 원리와 관련이 있음을 깨달았습니다.Bulk 연산(
@Modifying)은 영속성 컨텍스트를 거치지 않고 바로 DB에 쿼리를 날립니다. 하지만 애플리케이션의 영속성 컨텍스트(1차 캐시)에는 이미 해당 엔티티가 ‘변경 전’ 상태로 로딩되어 있었을 가능성이 큽니다. JPA는 조회 시 1차 캐시에 엔티티가 있다면 DB 조회 결과(변경된 값)를 무시하고 1차 캐시의 값(변경 전 값)을 반환하기 때문에, 데이터 불일치가 발생한 것이었습니다.해결 방법:Java
@Modifying(clearAutomatically = true)옵션을 적용하여 Bulk Update 실행 직후 영속성 컨텍스트를 초기화하도록 설정했습니다. 이를 통해 다음 조회 시 강제로 DB에서 최신 데이터를 가져오게 하여 데이터 정합성 문제를 해결했습니다.@Modifying어노테이션에clearAutomatically = true옵션을 추가했습니다. 이 옵션은 Bulk Update 쿼리가 실행된 직후 영속성 컨텍스트를 강제로 초기화(clear)합니다.1 2 3
@Modifying(clearAutomatically = true) @Query("UPDATE Room r SET r.status = :status WHERE ...") void updateStatusBulk(...);
이렇게 설정하면, 이후 로직에서 해당 방을 조회할 때 영속성 컨텍스트가 비어 있으므로 DB에서 최신 데이터(상태가 변경된 데이터)를 새로 조회해오게 되어 정합성 문제가 해결되었습니다.
2. 방 수정 시 프론트엔드 리렌더링 최적화 (Derived State 적용)
문제 상황:
방 수정 모달 진입 시 비동기 데이터(카테고리 목록)와 기존 방 정보를 매칭하는 과정에서
useEffect내부에setState를 사용하여 무한 렌더링 루프가 발생하거나 초기값이 비어 보이는 UI 결함이 있었습니다.‘라이브 방 정보 수정 모달’을 구현할 때, 모달 진입 시 기존 방의 카테고리 정보(이름)를 바탕으로 현재 카테고리 목록에서 일치하는 ID를 찾아 초기값으로 세팅해야 했습니다. 처음에는
useEffect를 사용하여 비동기로 받아온categories데이터와room데이터가 변경될 때마다setCategoryId를 호출하는 방식을 사용했습니다.하지만 이 방식은 두 가지 문제를 일으켰습니다. 첫째, 데이터가 로드되는 시차로 인해
setCategoryId가 여러 번 호출되면서 불필요한 리렌더링이 발생했습니다. 둘째, 렌더링 타이밍에 따라 초기값이 잠시 비어 보이는 UI 깜빡임이 발생하여 사용자 경험을 저해했습니다. 심지어react-hooks/set-state-in-effect경고까지 발생했습니다.1 2 3 4 5
// [Before] useEffect(() => { const found = categories.find(c => c.name === room.categoryName); if (found) setCategoryId(found.categoryId); // 💥 리렌더링 유발 & 경고 발생 }, [categories, room]);
고민의 과정:
“굳이 상태(
state)로 관리해서 동기화해야 할까?”라는 의문이 들었습니다.categoryId는 사용자가 선택한 값이 없을 때는 ‘기존 방 정보와 일치하는 카테고리 ID’여야 하고, 사용자가 선택하면 ‘그 선택한 값’이어야 합니다. 즉, 렌더링 시점에 props(room,categories)와 state(categoryId)를 조합하여 계산할 수 있는 값이었습니다. React 공식 문서에서도 props나 state로 계산 가능한 값은 별도의 state로 두지 말고 렌더링 중에 계산(Derived State)하라고 권장하고 있습니다.해결 방법:
useEffect를 제거하고 렌더링 과정에서 값을 즉시 계산하는 파생 상태(Derived State) 방식을 도입했습니다. 이를 통해 상태 동기화 타이밍 이슈를 해결하고 모달 진입 속도를 획기적으로 개선했습니다.1 2 3 4
// [After] const matchedCategory = categories.find((c) => c.name === room?.categoryName); // 상태(categoryId)가 없으면 계산된 값(matchedCategory)을 사용 (Fallback) const finalCategoryId = categoryId || matchedCategory?.categoryId || '';
이렇게 변경하자마자 복잡한
useEffect의존성 배열 관리와 리렌더링 루프 문제가 사라졌고, 모달이 열리는 즉시 올바른 값이 선택된 상태로 렌더링되어 UI 반응 속도도 빨라졌습니다.성과: 코드가 간결해지고 리렌더링 횟수가 감소하여 모달 진입 속도가 향상되었습니다.
3. 부분 수정(Partial Update) 시 연관 데이터(ISBN) 유실 방지
문제 상황:
PATCH메서드로 방 정보를 수정할 때, 클라이언트에서 보내지 않은 필드(null)가 기존 데이터를 덮어쓰는(Overwrite) 문제가 발생할 위험이 있었습니다.방 정보를 수정하는
PATCHAPI는 클라이언트가 보낸 필드만 수정하고 나머지는 유지해야 합니다. 그런데 프론트엔드에서 방 제목만 수정하고 책 정보는 건드리지 않은 채 ‘수정 완료’를 눌렀을 때, 백엔드에서 해당 방의 책 정보가 삭제되는(연관 관계가 끊어지는) 사고가 발생했습니다.원인을 분석해보니, 프론트엔드의
selectedBook상태가 초기화되어 있어 수정 요청 시isbn: null(또는undefined)이 전송되었고, 백엔드는 이를 “책 정보를 삭제하라(Null로 업데이트하라)”는 요청으로 해석했기 때문이었습니다.고민의 과정:
백엔드에서
null을 “변경 없음”으로 처리할지 “삭제”로 처리할지 명확한 정책이 필요했습니다. 하지만PATCH메서드 특성상null을 보내면 해당 필드를null로 만드는 것이 표준에 가깝기도 하고, 실제로 책 연동을 해제하고 싶을 때도 있을 것이라 백엔드 로직만으로는 한계가 있었습니다.따라서 프론트엔드에서 “사용자가 책을 명시적으로 수정했는가?”를 판단하는 로직이 필요했습니다. 사용자가 검색창을 열고 새 책을 선택한 행위(Dirty)가 없다면, 아예 ISBN 필드를 전송하지 않아야(undefined) 백엔드가 기존 값을 유지할 수 있다고 판단했습니다.
해결 방법:
Entity 내부에
updateInfo메서드를 구현하여,null이 아닌 값만 업데이트하도록 제어했습니다. 또한, ‘책을 변경하면 카테고리도 변경된다’, ‘카테고리만 변경하면 책 연동은 해제된다’와 같은 도메인 규칙을 엔티티 메서드 내에 강제하여 데이터 무결성을 보장했습니다.1 2 3 4 5 6 7 8 9 10 11
public void updateInfo(..., Book book, Category category, ...) { if (title != null) this.title = title; // Dirty Checking if (book != null) { this.book = book; this.category = book.getCategory(); // 연관 관계 자동 설정 } else if (category != null) { this.category = category; this.book = null; // 비즈니스 규칙 적용 } }
프론트엔드에서 Dirty Checking 로직을 강화했습니다.
- 초기 렌더링 시 기존 책 정보가 있다면
selectedBook에 세팅하되,isbn필드는 비워두거나 별도의 플래그(isBookChanged)를 두어 관리했습니다. - 사용자가 책을 검색하고 클릭했을 때만
selectedBook에 유효한 ISBN이 들어가게 했습니다. - 최종 수정 요청(
updateRoom) 시,isbn필드에 값이 있는지 확인하여, 값이 있을 때만 Payload에 포함시키고, 없으면undefined로 보내 아예 요청 본문에서 제외시켰습니다.
1 2 3 4 5
const isBookChanged = !!selectedBook?.isbn; updateRoom({ ... isbn: isBookChanged ? selectedBook?.isbn : undefined, // 변경 시에만 전송 });
이로써 의도치 않은 데이터 유실을 완벽하게 방지했습니다.
- 초기 렌더링 시 기존 책 정보가 있다면
4. 도메인 주도 설계를 위한 Entity 내부 비즈니스 로직 캡슐화
문제 상황:
초기 개발 단계에서는 서비스(Service) 계층에 비즈니스 로직이 집중되어 있었습니다. 예를 들어, 방에 참여할 때 “현재 인원이 최대 인원보다 적은지 확인”하고, “현재 인원을 1 증가”시키고, “상태를 변경”하는 로직들이 모두 서비스 메서드에 절차지향적으로 나열되어 있었습니다.
이로 인해 다른 서비스에서 방 참여 로직이 필요할 때 코드가 중복되거나, 실수로 검증 로직을 누락하여 최대 인원을 초과하는 버그가 발생할 위험이 컸습니다. 엔티티는 그저 데이터 덩어리(Getter/Setter만 있는)에 불과했습니다.
고민의 과정:
객체지향적인 설계를 위해 데이터와 그 데이터를 조작하는 로직을 한곳에 묶어야 한다고 생각했습니다.
Room엔티티가 자신의 상태(인원, 상태 등)를 가장 잘 알고 있으므로, “참여 가능 여부 확인”이나 “정보 수정” 같은 로직은 엔티티가 직접 수행하는 것이 응집도를 높이는 길이라 판단했습니다. 이를 통해 서비스 계층은 비즈니스 흐름(트랜잭션 관리, 리포지토리 호출 등)에만 집중하고, 핵심 규칙은 도메인 객체가 책임지도록 리팩토링하기로 했습니다.해결 방법:
Room엔티티 내부에 핵심 비즈니스 메서드를 구현했습니다.join(): 현재 인원을 체크하고 증가시키는 메서드. 인원이 꽉 찼으면 예외를 던집니다.updateInfo(): 제목, 인원, 책 등의 정보를 수정하는 메서드. 특히 “책이 변경되면 카테고리도 변경된다”는 연관 관계 규칙을 이 메서드 안에 캡슐화했습니다.
1 2 3 4 5 6 7
// Room Entity 내부 public void join() { if (this.currentUser >= this.maxUser) { throw new RoomFullException(); } this.currentUser++; }
이제 서비스 계층에서는
room.join()만 호출하면 되었고, 어디서 호출하든 동일한 검증 로직과 상태 변경 규칙이 적용되어 데이터 무결성과 코드 재사용성이 크게 향상되었습니다.
5. Infra: Context Path 및 설정 파일 이슈
문제 상황:
배포 환경에서 프록시 서버(Nginx)와 Spring Boot 애플리케이션 간의 경로 매핑 문제로 인해 API 호출 시 404 에러가 발생했습니다. 또한, YAML 설정 파일의 들여쓰기 오류로 인해 애플리케이션 구동 실패가 빈번했습니다.
해결 방법:
Spring Boot의
server.servlet.context-path설정을 명시적으로 지정하여 리버스 프록시 설정과 일치시켰으며, 설정 파일 관리를 엄격하게 하여 배포 안정성을 확보했습니다.
성과 및 배운 점
- 사용자 경험(UX) 고도화: 모달 닫힘 → 데이터 갱신 → 성공 알림(Alert)으로 이어지는 비동기 로직의 순서를 정교하게 제어하여 끊김 없는 UX를 제공했습니다. 라이브 방 수정 시 캐시 무효화(
invalidateQueries) 전략을 통해 새로고침 없이도 리스트와 상세 정보가 즉시 갱신되도록 구현했습니다. - 코드 품질 향상:
useCreateRoomForm훅 하나로 생성/수정/예약 로직을 통합 관리하면서도, UI 컴포넌트는 분리하여 유지보수성과 재사용성을 동시에 확보했습니다. 데이터 무결성을 고려한 설계:
프론트엔드의 Dirty Checking 전송 방식부터 백엔드의 Entity 캡슐화, 그리고 JPA 영속성 컨텍스트 관리까지 데이터가 흐르는 전 구간에서 무결성을 유지하는 방법을 깊이 있게 고민하고 적용했습니다.
풀스택 아키텍처 이해도 향상:
단순한 API 구현을 넘어, Docker를 통한 인프라 구성, DB 설계, 스케줄러를 통한 백그라운드 프로세스 관리, 그리고 프론트엔드 상태 관리 최적화까지 웹 서비스의 전체적인 아키텍처를 조망하고 구축하는 역량을 길렀습니다.
트러블슈팅 기록의 생활화:
개발 과정에서 마주친 JPA 동기화 문제나 환경 설정 이슈 등을 블로그 포스트로 상세히 기록하며, 문제를 해결하는 논리적 사고 과정을 정립했습니다.
아쉬운 점 및 향후 개선 방향
컴포넌트 의존성 분리:
UpdateLiveRoomModal에서useCreateRoomForm의 로직을 일부 차용했는데, 공통 비즈니스 로직을 순수 함수나 전용 훅(useRoomFormLogic)으로 완전히 분리하여 의존성을 낮추는 리팩토링이 필요합니다.낙관적 업데이트(Optimistic Updates) 도입:
현재는 수정 완료 후 서버 데이터를 재조회합니다. 추후
setQueryData를 활용해 서버 응답 대기 없이 UI를 먼저 갱신하는 낙관적 업데이트를 적용하여 UX를 더욱 개선하고 싶습니다.테스트 코드 커버리지 확대:
기능 구현과 트러블슈팅에 집중하다 보니 유닛 테스트와 통합 테스트 작성이 다소 부족했습니다. 향후에는 견고한 리팩토링을 위해 테스트 코드를 보강할 계획입니다.
마치며
이번 프로젝트는 Java의 러닝 커브를 극복했다는 점에 의의가 있습니다. 처음 사용하는 언어였지만, 배우고 팀원들에게 질문하고 귀찮게 하면서 데이터의 흐름을 익히기 위해 노력했습니다. 백엔드 DTO 설계부터 프론트엔드 UI 반영까지 Full Cycle 개발을 경험하며, 데이터 흐름의 전체적인 구조를 설계하고 문제를 해결하는 풀스택 역량을 크게 성장시킬 수 있었습니다. 특히 React의 렌더링 원리(Effect vs Derived State)를 깊이 이해하고 적용하는 계기가 되었습니다.

