오랜만에 프로젝트를 진행하며, 내린 설계 결정과 막혔던 문제들
이 글은 AI 기반 식단·운동 습관 플랫폼 얌얌키우기를 개발하며 남긴 개발 노트다. 어떤 설계 결정을 왜 내렸는지, 그리고 개발 도중 실제로 막혔던 문제를 어떻게 풀었는지를 기록으로 남긴다. 프로젝트 전반의 회고는 별도의 글에서 다뤘고, 여기서는 코드와 설계에 대한 이야기에 집중한다.
설계 결정과 Trade off
AI 관련 기능을 FastAPI로 분리하다
AI 호출과 전/후처리, 결과 결합을 모두 FastAPI에 위임했다. FastAPI는 단순한 Claude 프록시가 아니라 AI와 관련된 모든 기능을 다루는 역할을 맡는다. 덕분에 Spring 서버의 역할이 한결 가벼워졌고, AI 관련 기능이나 파이프라인을 수정해도 Spring 서버가 영향을 받지 않게 되었다.
물론 Spring AI를 사용해 AI 코드까지 Java로 통일하면 운영·관리의 복잡도를 줄일 수 있었다. 그럼에도 FastAPI를 선택한 이유는 두 가지다. 하나는 파이썬 진영의 AI 생태계 성숙도이고, 다른 하나는 관심사의 분리다. 빠르게 변화하는 AI를 별도의 서버로 떼어 두는 편이 장기적으로 유리하다고 판단했다.
갱신 데이터는 @Scheduled로, 다만 확장 여지를 남겨두고
매일 새롭게 갱신되어야 하는 데이터는 @Scheduled를 통해 처리했다. 현재는 사용자 규모가 충분히 작고 대량으로 처리해야 할 데이터도 없기 때문에 @Scheduled만으로 충분하다고 판단했다.
다만 사용자 수와 서비스 운영 시간에 따라 급격하게 증가할 수 있는 데이터(Streak)가 있다. 이 부분은 추후 Spring Batch를 활용해 더 안전하게 처리할 예정이다.
도메인 모델에 비즈니스 규칙을 담다
도메인 모델을 얇은 데이터 홀더가 아니라 비즈니스 규칙을 담는 곳으로 설계했다. 도메인 객체는 능동적으로 행동하고, application 레이어에서 domain 객체를 호출해 용도에 맞게 사용한다. 이렇게 하면 유지보수가 쉬워지고 확장성도 커진다.
기술적으로 막혔던 문제와 해결
1. 도메인 간 순환 의존성
여태까지 프로젝트를 몇 번 진행하면서 들었던 아쉬움은 패키지 간에 존재하는 순환 의존성이었다. 주로 도메인별로 패키지를 나누는데, A 도메인에서 B 도메인을 호출하고 B 도메인에서 다시 A 도메인을 호출한다. 심지어 AService에서 BService를 호출하고 BService에서 다시 AService를 호출하는 경우도 종종 있었다. 이러면 나중에 코드를 읽기도 어렵고, 기능을 추가하거나 확장하기도 어렵다. 그리고 높은 확률로 이런 코드는 하나의 객체가 여러 책임을 떠안고 있다.
그래서 이번 프로젝트에서는 순환 의존이 발생하지 않도록 노력했다. 초반 설계에서는 AI를 적극 활용했다. 2명이서 한 달 동안 진행하기엔 규모가 너무 커서 도메인을 분리하기가 어려웠는데, Claude Skills를 통해 질의응답으로 요구사항을 구체적으로 작성하고 도메인을 나눴다. 그 결과 인증 / 성장 / 회원 / 영양 / 캐릭터 / 식단프로그램 / 운동루틴의 7개 도메인으로 나뉘었다.
다시 생각해 봐도 식단과 음식을 '영양'으로, 뱃지와 스트릭과 목표 진행률을 '성장'으로 묶은 것은 혼자서는 어려웠으리라 생각한다. 물론 완벽하게 달성됐냐고 물으면 확답할 순 없지만, 여러 엔티티를 하나의 추상적인 개념으로 묶었다는 것 자체에 만족한다. 혼자 진행했다면 스트릭을 streak 패키지로 분리하고, 나중에 추가된 '뱃지'는 또 새로운 패키지를 만들어 작업했을 것이다.
순환 의존과 도메인 분리는 왜 연관이 있을까
너무 작은 단위로 나눠 버리면 하나의 기능이 여러 도메인에 영향을 끼치고, 의존성 그래프가 복잡해져 관리하기 어려워진다. 반대로 너무 큰 단위로 나누면 도메인 간 순환 의존은 없겠지만 클래스 단위에서 순환 의존이 발생할 가능성이 높다. 결국 '적당한' 단위를 찾는 문제다.
여기에 더해, DDD를 접하고 도입하면서 Layered Architecture로 설계했다. Presentation·Application·Domain·Infrastructure로 레이어를 나누고, 세부 구현(저수준)이 비즈니스 규칙(고수준)을 의존하도록 방향을 고정해 레이어 간 순환을 원천적으로 막았다.
그래도 순환 의존은 발생했다
명시적으로 의존 규칙을 정하고 도메인을 '적당히' 나눠도 순환 의존성이 발생하지 않는다는 보장은 없다. 내 경우 다음 두 가지 유스케이스가 맞물리며 순환 의존성을 만들어냈다.
- 하나의 체중 관리 프로그램이 종료되면 사용자의 상태가 변경된다.
- 온보딩이 끝나면 캐릭터와 스트릭, 체중 관리 프로그램이 초기화된다.
이를 의존성 방향으로 그려 보면 다음과 같다.
1번: program → member (프로그램 종료 → 사용자 상태 변경)
┌────────────────────────────────┐
│ ▼
program member ──┬──▶ character
▲ │ └──▶ growth
│ │
└────────────────────────────────┘
2번: member → program (온보딩 종료 → 체중 관리 프로그램 생성)
각각의 기능을 개발할 때는 문제가 되지 않았지만, 두 기능을 모두 구현하고 나니 순환 의존성이 드러났다. 1번에서 program이 member를 의존하고(프로그램이 종료되면 사용자 상태를 변경), 2번에서 다시 member가 program을 의존하면서(온보딩이 끝나면 체중 관리 프로그램을 생성) program과 member가 서로를 물고 도는 고리가 생긴 것이다.
SDP, 그리고 이벤트 패턴으로의 해결
의존성을 어떻게 풀어야 할지 AI에게 질의하며 SDP(Stable Dependencies Principle) 를 배웠다. 의존성은 더 안정적인 방향을 향해야 한다는 원칙이다. member는 다른 도메인이 두루 참조하는, 서비스에서 가장 안정적인 축이다. 따라서 순환을 이루던 두 간선(1번 program→member, 2번 member→program) 중 SDP를 어기는 쪽은 안정적인 member가 바깥으로 나가는 2번이다. 그래서 2번 의존성을 이벤트 패턴으로 제거하기로 했다. 온보딩이 끝나면 member가 이벤트를 발행하고, 이를 구독하는 캐릭터·스트릭·체중 관리 프로그램이 각자의 생성 로직을 실행한다. 의존성을 프레임워크로 옮긴 셈이다.
코드를 디버깅하기 어렵고 기능의 영향 범위가 직관적으로 파악되지 않는다는 단점이 있지만, 이보다 더 큰 문제라고 판단했던 순환 의존을 없앨 수 있었기에 이 방법을 채택했다.
사실 이 순환 의존성을 발견한 것은 AI 리뷰 덕분이었다. 여태껏 하나의 기능을 개발하면서 그 기능의 의존성만 생각했지, 도메인 간 의존성이 계속 유지되는지는 인지하지 못하고 있었다. 그건 개발이 어느 정도 진행된 뒤 리팩토링 과정에서만 신경 쓰는 것인 줄 알았다. 이제는 기능 하나를 개발할 때도 레이어별·도메인별·클래스별 의존성을 확인해야겠다고 다짐했다.
2. 스트릭 생성 방식 — 조회 시 생성 vs 스케줄러
스트릭 생성을 어떻게 구현할지 고민한 적이 있다. 두 가지 방법을 떠올렸다.
첫 번째는 조회 시 생성이다. 스트릭이라는 개념(혹은 객체)은 사용자가 웹에 진입해 확인하는 시점부터 유효하다. 그래서 스트릭 데이터를 조회하는 시점에 '없으면 생성'하는 방법을 생각했다. 서비스에 적극적으로 활동 중인 사용자에 한해서만 데이터를 생성한다는 점에서 효율적이다.
두 번째는 스케줄 작업을 통한 생성이다. 고정된 시각에 사용자의 스트릭을 갱신하는 것이다. 적극적으로 활동 중인 사용자가 아니어도 데이터를 생성해야 한다는 단점이 있다.
나는 스케줄러를 이용하는 두 번째 방법을 선택했다. 스트릭은 '어제' 기록에 따라 바뀐다는 점에서, 하루가 지나는 시점에 수행되는 것이 make sense라고 판단했다. 또한 '조회 시 생성'은 API가 REST하지 않고 멱등성이 보장되지 않는다는 점을 문제로 봤다. GET 메서드를 수행했는데 서버에 데이터가 생성되고 요청의 멱등성도 보장되지 않으니 옳지 않다.
3. '하루'의 경계를 어디에 둘 것인가
기술적으로 어려웠다고 하기엔 애매하지만, '하루'의 개념이 새벽 4시에 초기화된다는 점이 문제였다. 운동 도메인과 식단 도메인 사이에서 '하루'의 의미를 어떻게 정의할지 고민스러웠다.
LocalDate는 그 자체로 비즈니스적인 의미를 가지지 않는다. 따라서 의미를 가지는 시간이라면 객체로 감싸는 편이 더 좋은 코드를 작성하는 길이라는 것을 배웠다.
4. 외부 API 이중 인코딩 문제
외부 API를 호출하는 객체(RestClient를 다룸)에서 이미 인코딩된 값을 한 번 더 인코딩하는 문제가 발생했다. URI를 다루는 프레임워크 내부 객체가 쿼리 파라미터에 인코딩을 자동으로 적용하고 있었다. 결국 디코딩 키로 변경해서 해결했다.
- 증상: 공공 데이터 API를 가져올 때 401 예외가 발생함.
- 원인: 프레임워크가 쿼리 파라미터를 자동으로 인코딩하고 있었음.
- 시도
- 처음엔 키가 틀렸다고 의심해 공공데이터 OpenAPI Swagger로 확인해 봤지만 문제 없었음.
- 실제 전송되는 요청 URL을 로깅해 보니, 이미 인코딩된 서비스 키가 한 번 더 인코딩되고 있음을 확인.
- 최종적으로 디코딩 키를 환경변수로 주입하는 방식으로 해결.
- 해결: 환경변수에 디코딩 키를 넣어 해결.