byworld 님의 블로그
[자바단기심화 입문 TIL 총정리] 발표&프로젝트 내용, 다른 조 내용&피드백, 우리조 기술적 내용, KPT & 의견차이 토론, 정리 본문
[자바단기심화 입문 TIL 총정리] 발표&프로젝트 내용, 다른 조 내용&피드백, 우리조 기술적 내용, KPT & 의견차이 토론, 정리
byworld 님의 블로그 2026. 3. 15. 01:54서론
3-4일간 밤새느라 기력이 다 떨어졌다. 그래도 팀원분과 페어 프로그래밍식으로 같이 밤새고 공부한다! 이런 느낌이 들어서 열심히 하고 끝마칠 수 있게 되었다. 5명에서 3명이 되었다고 다른 팀들에서 고생했다고 하시는 게 어느정도 위로가 되었고, 어쨌든 해냈구나... 하는 뿌듯함이 있었다. 아쉬움(7): 만족(3) 이다. 그래도 이 아쉬운 점을 다음번에 개선해 가는 것 자체가 큰 자산이 될 것 같다. 팀원이 나 말고 2명이 있었는데 이 과정을 버텨준 것에 감사를 표한다. 편의상 팀원A, B로 칭하겠다. 그리고 튜터님이 정말 큰 도움이 되었다. 생각의 폭이 넓어진 느낌이다.
발표 및 프로젝트 내용
우리 조의 PPT이다. 그 외에도 우리가 한 내용을 정리하면 이러하다.
단기심화 6기 1차 팀프로젝트 발표
Pdelivery 14조 열정이 많조 안녕하세요. 저희는 14조 열정이 많조입니다. 지금부터 발표 시작하겠습니다.
docs.google.com
입문발표와피드백
8조 소개 페이지가 중요하다 (+ 개요가 중요) 왜했는지 목적이 뭔지 잘 전달 -> 호기심 이끌기 인프라(시스템) 아키텍처 부분 - 결정 이유 중요 S3 전략일때 도입 이유 -> 선택은 정답 없다, 60% 상황
docs.google.com
- 프로젝트 과정 - 목표설정, 설계, 개발, 협업, 테스트, 배포
- 코딩 컨벤션 - Java 코딩 포맷(네이버 핵데이), Git Flow, Conventional Commit, 코드래빗 반영 Resolve, 슬랙 깃허브 연동
- 아키텍처: 시스템(포트어댑터, Layered Arch, 헥사고널 변형(멀티 모듈 - MSA 확장 가능성)), 인프라(ECR, RDS, 도커, EC2 pull, Git Actions), 에러 처리
- API 설계 - 사용자 시나리오에 맞게 통합 관점에서 API를 테스트함, 권한 정책 엔트포인트 등, RESTful(자원 상태 변화), /me 패턴
- 트러블슈팅 - JPA 에서 DB 통신되지 않는 문제(1차캐시, flush, clear), CICD(Github Actions, EC2, ECR, RDS 등에서 이미지 이름 불일치, 포트 설정 오류, AWS 인증누락을 docker-compose 수정, Github Secrets 설정, ECR 인증 권한 설정 등),
- 향후 계획 - 추천 시스템 구현, 추가 메뉴 데코레이션 패턴 적용하여 구현, 전략 패턴 이용한 다양한 결제 방식 추가
특히 우리는 소프트웨어 아키텍처를 강조하면 좋겠다. 우리는 도메인 별로 패키지를 나누고, 그 안에서 Presentation, Application, Domain, Infrastructure 계층을 설정했다. 헥사고널 멀티 모듈 유사 구성이다. 웬만하면 impl과 인터페이스, 상위 클래스를 나누어 DI를 추구했다. Presentation에서는 Http 요청 처리, 검증, DTO 변환을 한다. 컨트롤러가 주요 파일이다. Application은 서비스 흐름제어, 트랜잭션 관리, 여러 도메인 조합이 있다. 서비스와 Provided(Port adapter에서 해당 도메인을 주는 쪽)가 주요 파일이다. Domain은 객체를 위한 패키지이다. Entity와 Repository가 있다. Infrastructure에는 JPA같은 DB접근, 외부 API를 받는 MSA와 유사하게 다른 객체를 정보를 받는 Required가 존재한다. PORT/ADAPTER 유사 패턴도 사용했다. 도메인 별 결합도를 줄이기 위한 용도이다. 우리는 Provider으로 객체를 보내고 Requirer로 객체를 받는다. 예를 들어 Store가 필요한 Menu같은 경우는 Store에서 StoreInfo라는 Store의 일부 객체를 StoreProvider으로 제공한다.(자세한 것은 Impl에서 구현한다.) 그리고 Menu는 MenuStoreRequirer로 StoreData라는 객체를 제공을 받는다. 사실 Provider도 여러개 만들 수 있지만, 그냥 하나를 통으로 하고 Requirer를 여러개 받는 정책을 택했다. 물론 도메인별 제공하는 정보가 다를 때는 정책을 수정해야겠다.
추가 구현으로는 발표 당일 새벽에 부랴부랴 수정했다. Paging 정책으로는 10, 30 ,50 이 아니면 10으로 고정하는, 무분별한 데이터 크롤링을 막는 페이징 정책을 사용했다. 컨트롤러 전에 Resolver으로 size를 자동 보정한다. 그리고 스케줄러는 팀원A가 작성하신 것을 바탕으로 수정했다. 주문 자동 취소 스케줄러로 @EnableScheduling, @Scheduled가 있어서 1분 주기로 작업을 실행한다. 그리고 CreatedAt이 5분 이상일 시 status를 UNPAID에서 CANCELLED로 바꾼다. 그리고 OrderStatusHistory이라는 추가 테이블에 기록한다.
인프라는 대부분 내가 담당했었다. AWS에서는 EC2, RDS, ECR, IAM 등의 수정을 했다. 깃허브에서는 Secret 키들을 넣고, 깃 액션으로 deploy.yml을 통해 머지가 된다면 이미지를 생성하고, 도커에서 pull하며, run 하고, EC2에서 도커를 통해 포트 8080으로 실행한다. 즉,개발자가 머지를 하면, CI프로세스가 진행되고 난 후 CD를 하고, 그 후에는 사용자가 IP 주소(DNS설정을 안해서)와 포트를 넣고 들어가면 엔드포인트에 접근이 가능해진다. 이걸 블루그린이나 로드밸런서, Redis, Bastion, DNS 연결 등을 더 도입했으면 좋지 않았을까 한다. 그래도 시간이 없어서 이정도면 괜찮지 않나? 자기 위안을 해본다.

다른 조 내용 & 튜터 피드백
이번에 우리 팀에만 신경을 쓰다보니, 다른 조는 뭘 하고 있는지 잘 몰랐는데 발표에서야 알았다. 팀 간의 공유는 잘 안된 것 같아서 조금 아쉽다. 이걸 내가 공유의 장으로 트레이드오프, 면접, 트러블슈팅 등을 나누는 곳을 만들면 좋겠다.
이제 튜터님 다른조 피드백과 내가 새로 배운 점을 말하겠다.
소개 페이지에서 발표할 때는 소개 페이지가 가장 중요하다고 한다. 우리는 이 부분이 굉장히 부족했다. 간단한 이미지나 컨셉이라도 있어야하는데, 그런게 없었다. 사람 많으면 가능했을것이라는 핑계를 댄다... 왜 만들었는지를 먼저 말해야한다고 한다. 프로젝트 발표에서 가장 중요한 것은 기능 설명이 아니라 문제 정의다. 우리 팀은 개발자틱하게 되었다고 들었다. 사실 이건 나는 모욕으로 받아들였다. 난 공대생같다는 것을 싫어한다. 너드같다는 거니까. 원래 문과 경영학과였기도 하고. 전설의 스티브 잡스 발표를 누가 개발자틱하게 느끼겠는가? 그게 아니라도 듣는 이로하여금 사로잡게 해야한다. 난 그래서 라인바이라인으로 코드 보여주는 것을 굉장히 싫어한다. 근데 어느정도는 필요할 수 있다는 튜터님 얘기도 있기도 했다. 적절히 활용하는 것도 하나의 방법이겠다. 우리 청자는 다 코드를 볼 줄 아니까.
인프라 아키텍처에서는 기술 설명이 아니라 왜 그 기술을 선택했는지 설명이 필요하다고 하신다. 이 얘기는 우리 튜터님도 얘기를 했다. 왜 면접관들이 모르는 것이 아닌데 기술에 대해서 물어보냐 질문을 주셨다. 단순히 그 기술을 썼다에서 멈추지 않고 어떤 식으로 그것을 사용하기 위해 사고했는지, 다른 것과의 장단점이 뭔지를 보고싶고 그 의사결정에 대한 평가를 하기 위함이라고 하셨다. 예시로 S3 사용했다고 하면 단순히 쓴다 이 얘기가 아니라, 파일 저장 서버를 직접 운영하는 대신 무제한 확장, CDN 연동, 서버 부하 감소를 위해 선택했다. 대신 그 전에 비해서 단점은~ 이런식으로 장단점위주로 말해서 trade-off를 보여줘야한다. 선택에는 정답이 없다. 몇% 맞는 설명이다~는 될 수 있다.
더미 데이터 관리를 하는 조도 있었다. 대규모 서비스에서는 테스트용 데이터 관리 전략이 중요하다. 더미 데이터를 만듦으로써(PaymentHistory, OrderHistory 등) 테스트 데이터를 확보하고, 성능 테스트를하며 로직 검증을 한다. 실무에서는 Seeder, Test Fixture, Data Generator 등을 쓴다고 한다.
Spring Transaction Propagation에 대해 다루는 조도 있었다. REQUIRES_NEW같은 경우는 기존 트랜잭션에서 새 트랜잭션을 필수적으로 생성한다. 즉, 기존 트랜잭션과 완전히 분리한다. 주문 처리 -> REQUIRES_NEW -> 로그 저장 이런 식으로 롤백되어도 로그는 유지된다. 하지만 Connection Pool 고갈 문제가 발생 가능하다고 한다. 해당 방식은 새로운 DB 커넥션을 사용하는데, 밑의 예시처럼 트랜잭션 하나 당 커넥션 2개 이상 사용하기도 한다.
Thread1
Main Transaction (connection1)
REQUIRES_NEW (connection2)
Thread2
Main Transaction (connection3)
REQUIRES_NEW (connection4)
트래픽 증가 시 Connection pool exhaustion이 발생 가능하다. 그리고 메인 스레드가 트랜잭션 끝나기 전까지 커넥션을 반환하지 않는다. 따라서 pool 점유 증가한다. 따라서 해당 내용은 로그, 감사기록, 이벤트 저장 등 짧은 작업에만 사용해야한다는 얘기가 있다.
QueryDSL 페이징 최적화를 한 조도 있다. QueryDSL이 뭐냐면 타입 안전(type-safe) SQL/JPA 쿼리 빌더 라이브러리이다. 문자열로 JPQL/SQL 작성하지 않고 자바 코드로 쿼리를 작성한다. 쿼리는 문자열이라 컴파일 때 검증을 못하고 런타임 에러가 발생하기도 한다. QueryDSL은 엔티티 기반 클래스이다. QMember member = QMember.member; 이런 식으로 엔티티 기반 Q class를 자동 생성한다.
List<Member> members =
queryFactory
.selectFrom(member)
.where(member.age.gt(20))
.fetch();
다음과 같이 코드를 쓸 수 있다. 핵심 특징으로는 Type-safe, 동적 쿼리 편함, SQL 느낌으로 작성 등이 있다. Type-safe에서 member.age.gt(20)같은 내용은 age가 없으면 컴파일 에러가 발생한다. 즉, 런타임에서 컴파일 오류로 변경된다.
QueryDSL 이 내부적으로 하는 과정은 다음과 같다.
Enitity -> Q 클래스 생성(annotation processor) -> JPQL 생성 -> Hibernate
구성요소로는 QClass로 엔티티 메타 정보가 있다. QMember, QOrder, QMenu가 있고, JPAQueryFactory로 쿼리 실행 객체가 있다. Predicate(선행 조건) member.age.gt(20), member.name.eq("Kim")같은 것이 있고, fetch() 함수 종류로는 fetch(), fetchOne(), fetchFirst(), fetchCount() 등이 있다.
실무에서는 QueryDSL과 MyBetis가 양대산맥처럼 쓰인다고 한다. 기본 CRUD는 JpaRepository, 복잡 쿼리는 QueryDSL의 패턴이 많다.
대표적으로 QueryDSL이 필요한 상황은 동적 검색, 복잡한 Join, 통계 쿼리가 있다.
그리고 실무 패턴은 기본으로는 문자열 기반 JPQL로 하고, 타입안전 복잡한 것은 QueryDSL, DB 최적화를 하여 고성능을 누리려면 Native SQL을 쓴다.
이 방식의 단점은 Q class 생성해야해서 annotation processor, gradle 설정이 귀찮고, 빌드 의존성이 증가한다.(컴파일시 QClass 생성)
관련 문제로는 N+1 문제(연관 엔티티 조회시 1번쿼리+N번 추가 쿼리), fetch join(join으로 한번에 조회), pagination 최적화(페이징 조회 시 불필요 전체 count 쿼리 줄여 성능 개선), count 쿼리(SELECT COUNT(*)처리), BooleanBuilder(QueryDSL에서 조건을 동적을 추가할 때 사용하는 조건 빌더), JpaQueryFactory(QuerySQL쿼리를 생성하고 실행하는 핵심 객체), EntityGraph(fetch join 없이 연관 엔티티를 함께 조회하도록 JPA 제공 로딩 전략) 등이 있다.
그 QueryDSL 쓴 조에서는 content query, count query 2개의 쿼리 실행했는데 count query 비용이 크다(N+1 문제) 그래서 QueryDSL로 count query 분리하여 fetchResults() 대신 fetch() 후 countQuery로 별도로 실행한다. 그리고 Slice 사용하여 Page -> total count 필요하는데 Slice는 다음 페이지 존재 여부만 한다. Page가 비용이 더 크다는거다. 그래서 count query 제거 가능하다. 우리는 통계 테이블을 리뷰 도메인에 넣어서 해결했는데, 이런 방법이 있구나 싶다.
우리팀에 비슷한 상황(같지는 않음) 팀원 A님이 처한 상황 같은 경우는 중간(임시) 테이블을 만들어서 성능 향상이 있었다고 한다. 그 전에는 쿼리 이어붙이기를 하여 풀 탐색을 하여 5초정도로 검색 시간이 길었었는데, 중간(임시) 테이블에 비싼 결과를 세션별 저장해 0.x 초로 기존 반복 쿼리를 빠르게 만드는 방식을 택했다고 한다. 물론 DSL과는 차이가 있겠지만, 중간에 무언가를 두어 성능을 개선하고, 불필요한 연산을 방지한다는 점에서 유사한 것 같다. 이런 전략은 복잡한 join이나 통계 쿼리, 추천시스템, 대량 데이터 분석에 좋다고 한다. 하지만 더 좋은/ 혹은 대안의 방법도 있다.
인덱스를 활용하여 B+Tree의 logn 시간만에 검색이 가능한 방법이나, Composite index로 인덱스를 설정하는 법도 있고, Join을 EXISTS로, IN -> Join으로 쿼리를 재작성하는 방법도 있다. 그 외에도 Explain Analyze로 실행 계획 분석, Materialized View로 미리 계산된 결과 저장하는 법도 있다. 그것 말고도 캐시를 사용하는 Redis나 Query cache를 사용하는 방법도 존재한다. 찾아보니까 해당 방법은 레지스터나 CPU 캐시, L1,2,3 캐시가 아니라 RAM 기반 캐시 DB라고 한다. 쿼리 캐시는 DB 내부 캐시라고 한다. 과거 나간 팀원C가 잘못설명했던 것 같다. 대신 디스크IO보다는 훨씬 빠르다. 돛단배책, CMU 강의 자료로 배웠던 DB 시간에는 해당 내용을 다룰 때, 디스크만 따지고 메모리 연산은 O(f(n)) 연산에서 제외시켰는데, 그만큼 메모리가 상대적으로 빠른거겠지. 프로그래머가 알아야 하는 수도 배웠었는데, https://news.hada.io/topic?id=13749 여기에 관련 내용이 있다. 메모리는 1MB 순차 읽기하는데 걸리는 시간이 3ms, ssd는 49ms, hdd는 825ms 인데, 하드는 디스크 탐색까지 가고 arm 움직이고 등등 하면서 더 걸릴 것이다.
중간 테이블의 전형적인 패턴은 temp table로 1차 계산을 하고, 2차 조회로 빠른 select를 하는 것이다. 알고리즘 관점에서 더 좋은 방법은 Top-K 알고리즘(힙, Priority Queue 등 활용), Precomputation인 offline batch로 미리 계산하는 법, Column Store으로 ClickHouse, BigQuery하는 법 등이 있다. 하지만 실무적으로 빠르게 구현하려고 해당 방법을 사용해도 좋은 것 같다. 내가 정확히 그 사례는 모르겠으나, Materialized View, Temporary Table, Index tuning, Query rewrite 등에서 나온 개념같다.
실무 백엔드 성능 최적화에서 자주 나오는 사례에 대한 내용으로는 다음과 같다.
1 Index - 조회 조건에 맞는 인덱스를 추가해 Full Table Scan을 Index Scan으로 바꿔 DB 조회 속도를 크게 개선하는 방법
2 Query rewrite - 비효율적인 SQL을 더 효율적인 형태(JOIN ↔ EXISTS, 서브쿼리 제거 등)로 재작성해 실행 계획을 개선하는 최적화 방법
3 N+1 해결 - 연관 엔티티 조회 시 1번 조회 후 N번 추가 쿼리가 발생하는 문제를 fetch join, batch fetch 등으로 한 번의 조회로 줄이는 것
4 Pagination 최적화 - 페이징 조회 시 전체 count 쿼리를 줄이거나 분리하여 대용량 데이터 조회 성능을 개선하는 전략
5 Cache - 자주 조회되는 데이터를 Redis나 메모리에 저장해 DB 조회를 줄이고 응답 속도를 높이는 방법
6 Materialized view - 복잡한 집계나 join 결과를 미리 계산해 저장해 두고 조회 시 빠르게 가져오는 DB 성능 최적화 방식
7 Temporary table - 복잡한 계산 결과를 중간 테이블에 저장한 뒤 이후 쿼리에서 재사용해 쿼리 실행 시간을 줄이는 전략
8 Connection pool - DB 연결을 미리 생성해 재사용함으로써 매 요청마다 커넥션 생성 비용을 줄이는 방식(HikariCP 등)
9 Batch - 여러 INSERT/UPDATE를 묶어서 한 번에 처리하여 DB round trip 횟수를 줄이고 처리 속도를 높이는 방법
10 Async 처리 - 오래 걸리는 작업을 메시지 큐나 비동기 처리로 분리해 사용자 요청 처리 시간을 줄이는 구조(Kafka, MQ 등)
QueryDSL pagination 최적화 - 페이징 조회 시 데이터 조회 쿼리와 count 쿼리를 분리하거나 필요할 때만 실행해 불필요한 count 연산을 줄이는 전략
왜 fetch join + pagination 같이 못 쓰는지 - fetch join으로 1:N 관계를 가져오면 row가 중복되어 DB 레벨 pagination이 깨지기 때문에 정상적인 페이지 결과를 보장할 수 없다
QueryDSL count query 최적화 - 페이징 시 사용되는 count 쿼리에서 불필요한 join이나 fetch를 제거해 count 계산 비용을 줄이는 방식
Requires_New + connection pool 고갈 문제 - REQUIRES_NEW 트랜잭션은 기존 커넥션을 유지한 채 새 커넥션을 추가로 사용하므로 트래픽이 많으면 커넥션 풀이 고갈될 수 있다
QueryDSL total count N+1 문제 - 페이징 조회에서 각 요청마다 별도의 count 쿼리가 실행되면서 실제 데이터 조회보다 count 쿼리 비용이 커지는 성능 문제
다들 모놀리식이라는 조건이 있으니까 MSA 확장을 목표로 결합도를 떨어뜨리려고 한 것 같다. 그니까 모놀리식이 나쁘다는 전제로 간 것 같고, MVP 설계로 모놀리식을 쓴 것 같다. 사실 모놀리식도 장점이 있고, MSA도 단점이 있다. 장점으로는 단일 코드베이스(전체코드)로 초기 개발 속도가 매우 빠르다. 그리고 배포 구성이 단순하고(하나 파일만 배포), 하나 DB만 사용해서 데이터 정합성 보장이 쉽고, 단일 기술 스택으로 기술 스택으로 인한 호환 문제가 적다. 하지만 단점으로는 코드가 커질 수록 IDE 로딩, 빌드 시간이 기하급수적으로 증가, 작은 기능 하나를 변경해도 시스템 전체를 재배포해야하고, 배포 시 전체 서비스 일시적 다운타임이 발생할 위험이 있다. 하나의 모듈(결제 모듈이라고 하면) 메모리 누수나 트래픽 폭주가 전체 시스템에 영향을 줄 수 있다. 그리고 모듈 간 강 결합으로 스파게티 코드가 발생하기 쉬워 이해하고 수정하기 힘들 수 있다.
MSA는 조금 이 내용과는 반대로 좀 구현 출시 배포는 힘들어도 각각 도메인 별로 구별되어 그 다른 서버 내에서는 깔끔한 것 같다.
API 문서 자동화으로는 Swagger과 Spring REST Docs 테스트 기반 문서 생성방법이 나왔다. Swagger는 우리가 했던 방식이다. 근데 내용을 저장할 수 가 없어서 혹은 저장이 되어도 뭔가 postman보다 내용을 읽기가 힘들었다. RestDocs는 처음 들었다. Test 기반으로 문서를 생성하는 방법이다. 테스트 기반으로 문서를 생성하니까 문서의 정확도가 높지만 작성 비용이 증가한다는 단점이 있다. 실무에서는 섞어서 쓴다고 한다.
Liquibase 를 쓰는 조도 있었다. JPA ddl-auto라는 문제가 있엇다. 자동 생성을 하면서 DB 변경을 통제하지 못한다. 예로 컬럼이 변경되고, 데이터 손실이 발생하기도 한다. Liquibase는 DB migration 관리를 한다. V1_create_user.sql, V2_add_column.sql 이런 식으로 버전 기반 관리를 한다. 깃 방식과 비슷한가보다. DB 변경 이력 관리, 배포 안정성이라는 장점이 있다. 실무에서는 Flyway, Liquibase 둘 중 하나를 쓰는 것이 일반적이라 한다.
Event Driven Architecture를 사용하는 조도 있다. 서비스 간 직접 호출 대신 Event를 사용한다.
예를 들어 Order가 생성 -> Payment service -> Notification service 처럼 무언가 트리거가 되어 절차를 진행하는 방식이다. 예전 C# windows form 프로그램을 만들 때 버튼이나 컴포넌트에다가 더블클릭해서 함수 거는 방식으로 프로그램을 만들었는데, 그 방식과 매우 유사하다. 이렇게 하면 서비스 결합도가 감소하고, 확장성이 증가한다. MSA에서도 이벤트 기반으로 많이 된다고 하니 잘 알아봐야겠다.
Spring Batch를 쓰는 조도 있었다. 대량 데이터 처리 프레임워크이다. 정산, 통계, 데이터 마이그레이션의 기능이 있다. 특징으로는 chunk processing, retry, transaction 관리 등이 있다. 대용량 데이터 처리를 할 때 잘 써야겠다. 배달의 민족 책에서는 결제에다가 Batch를 쓴다고 하는데, 이것도 시간대(몰리는 시간대와 아닌 때)를 고려해야한다고 한다.
PostGIS라고 지리 데이터를 써서 PostgreSQL을 확장하는 것을 한 조도 있었다. 나도 위경도를 이용해서 맨하탄거리/유클리드 거리 등을 생각해보았는데, 이걸 튜터님한테 말하니까 지리 위경도 관련 함수 집합이 있다고 하셨다. 그게 이 내용인가보다. 위경도기반 거리 계산이 주 기능이다. geometry, geography 타입을 제공하고, 거리 계산/반경 검색/ 교차여부 판정/ 공간 인덱스같은 기능을 제공한다. 나와 가까운 가게 찾기를 할때 주로 쓴다. Spatial Index, distance calculation을 지원한다. 가게 위치를 점(point)으로 저장하고, 반경 2km, 가장 가까운 10개 가게, 배달 가능 범위 안에 드는지 등의 내용에는 두 공간 객체가 특정거리 이내인지 판정하는 ST_DWithin, 실제 거리를 계산하는 ST_Distance같은 함수가 자주 쓰인다.
평면 좌표계 기준인 geometry와 곡률이 존재하는 geography가 있다. 유클리드와 비유클리드 기하학(지구는 구형태니까 곡률이 1 보다 크다) 내용이다. 비유클리드에서 대표적으로 구형태와 프링글스 형태가 있는데, 구에서는 C<2 * pi *r, sum(triangle_angle_n) >180 이라는 고등학교 수학책에 나오는 내용과는 다른 내용이 나온다. 유클리드 5번째 평행선 공리를 바꾼 것이다. geometry는 유클리드로 변환한 것이니 실제보다 왜곡된 메르카토르 도법과 비슷하다 볼 수 있다. 한국 내에서는 별 차이가 없을 것 같은데 국가 간 혹은 큰 국가에서는 차이가 클 수 있다. 트레이드오프를 고려해야한다. 실제로는 그것보다 네비게이션처럼 경로에 따라서 거리를 계산하는 경우도 있을 수 있다. 그런것은 A* 알고리즘(f= g + h(휴리스틱함수(보통 유클리드 맨하탄))) 이나 Dijkstra(최적 경로, f=g, O(E log V)) 등으로 그래프 기반 최단 경로 알고리즘이 있다. Bellman-Ford 같은 방식도 있는데 보통 라우팅에서하지 실 거리에서는 잘 활용 안한다.
어쨌든 ST_DWithin을 쓰고 보통 ST_Distance를 계산하니까 후보찾는 것이 중요하다. 단순히 ORDER BY ST_Distance(...)만 쓰면 매 행마다 계산이 필요할 수 있다. 근데 반경 기준으로 좁히면 훨씬 효율적이다.
PostGIS는 PostgreSQL에 공간 데이터 타입과 공간 연산 추가해서 위치 기반 질의 거리 계산을 가능하게 한다. 주요 기능으로는 위경도 도형 저장, 거리 계산, 반경 검색, 교차/포함 판정, 공간 인덱스 지원 등이 있다. Spatial Index는 거리 계산 대상 후보를 줄여준다. 공간을 작은 영역으로 분할해서 저장한다. R-tree, GiST, Quad-Tree로 공간을 분할해서 각 영역(A,B,C,D)에 저장한다. Bounding Box으로 먼저 대충 사각형 범위로 걸러내고 인덱스는 먼저 □□□□ 이런 사각형과 겹치는 영역만 찾는다. 그래서 빠른 후보 필터링을 할 수 있다. Spatial index search -> Bounding box filter -> candidate rows -> real distance calculation 이런식으로 실제 실행 흐름이 된다. N distance calculation으로 Full Scan에서 log(N) 영역 검색 + candidate distance로 시간 복잡도를 줄인다. 배달/ 지도 앱은 보통 Spatial Index + Top-K search + A* routing을 주로 쓴다. 그 외에도 중요한 함수로써 ST_Contains, ST_Intersects, ST_Buffer 등을 쓴다.
Redis 캐시 전략 쓴 조도 있다. Redis는 빠른 조회용 메모리 데이터 저장소 + 캐시 계층이다. In-memory key-value database이다. RAM 기반 저장, 빠른 조회(µs 수준), 다양한 자료구조 제공 등이 있다. 제공되는 자료구조는 String, Hash, List, Set, Sorted Set, Bitmap(0,1 대량 Boolean, 흑백 사진, 출석체크, 유저 활성 여부, A/B테스트, feature flag 같은 경우), HyperLogLog(중복 제거된 데이터 개수를 매우 적은 메모리로 근사값 계산구조, 방문자 수 할 때 unique count로 근사 계산, DAU, Unique Visitor, 검색어 수, IP 수에 활용), Stream(메시지 로그 기반 데이터 스트림 구조(Kafka 비슷한 기능), 시간 순 쌓임, log 기반 consumer group, 메시지 큐 특징이 있고, Kafka와 달리 가벼운 메시지 큐이며 이벤트 처리, 로그 스트림, 비동기 작업, 실시간 파이프라인 등에 쓰임)이 있다. 그래서 단순 캐시 뿐 아니라 랭킹, 큐, 세션, 토큰 기능도 구현 가능하다. DISK 는 너무 느리다고 했다. DB 시간에 메모리는 디스크 할 때 시간 복잡도로 고려안할정도로 디스크에 비해 빠르다. 대표적인 캐시 대상으로 cart:userId → 상품 목록 이런 경우가 있다. 이런 상황은 조회 많고, 데이터가 일시적이다. 세션에 로그인 정보를 저장하고, 웹 서버가 여러개면 공통 세션 저장소(MSA 할 때 배웠다) 저장한다. 그리고 랭킹은 Redis Sorted Set ZEST(ex: ZADD ranking score user ZREVRANGE ranking 0 9) 로 실시간 랭킹 구현을 한다. 토큰 같은 경우 로그인 토큰 관리 token:userId로 한다. 이건 많이 쓰이니까 캐시에 저장해두고 인증인가하는 것이 좋다. TTL 기능으로 EXPIRE key seconds를 지원한다. 세션, OTP, 캐시 등 Time To Live 지난 후 자동 삭제 가능하다. 이 몇분도 잘 설정해야지 안그러면 메모리 터질 수 있다.
Redis 블랙리스트 관련 내용이다. 기본적으로 JWT는 stateless다. 상태 정보를 저장하지 않는다. 그래서 로그아웃 불가능하다. 이미 발급된 JWT는 만료 전까지 유효하다. 하지만 token을 redis에 저장하면 blacklist:jwt_token 이런 방식으로 검증 시 JWT 검증, Redis Blacklist 확인 후 Redis에 있으면 로그아웃된 토큰으로 Access Denied가 가능하다.
Redis는 직접 Pagination을 해결하지 않지만 cached list 형태로 활용 가능하다. feed cache, timeline cache 등이 있는데, SNS(Social Media)에서 자주 사용한다고 한다.
Redis Cache + 역정규화 문제이다. 캐시 데이터와 DB 데이터가 불일치하는 경우가 있다. 예를 들어 store, review가 있으면 AVG(review.rating) 이렇게 하면 join + aggregation을 하여 시간 비용이 비싸다. 그래서 컬럼을 따로 저장하여 store에 놓는 것(우리는 이 방식을 지양해서 rating에 하나 더 만들었다.)으로 join 없음으로 해결하기도 한다.
Redis 캐시 패턴으로 Cache Aside 방식이 있다. Redis 조회하고, 없으면 DB 조회하고, Redis 저장하는 방식이다. 캐시 히트, 미스 개념과 유사하다. Write Through면 DB + Redis 동시에 저장하고, Write Back은 Redis 먼저 DB 나중에 한다. 실 서비스에서는 아키 텍처로 Client -> Application -> Redis(cache) -> DB 이런 구조를 갖는다. 데이터베이스 앞단에 있다는 것이다. Cache invalidation으로 DB update 할 때 cache stale이 발생 가능하다. 그래서 TTL, Cache delete, Event driven update 등의 전략이 필요하다.Active Token 관리(중복 로그인) 으로 사용자 별 유효한 JWT를 Redis에 저장해 중복 로그인이나 이전 토큰을 무효화하는 방식도 있다. JWT가 Stateless라 동시 로그인이 가능 할 수 있다. active_token:123 → jwt_token_B 으로 userId 를 active token으로 저장하여 JWT 발급하고, Redis에 SET active_token:userId token 이렇게 저장 후 기존 token 덮어쓰기하여 무효화를 한다. 인증 시 Authorization: Bearer token 을 요청하고 JWT 검증, Redis 확인, token == active_token:userId 되면 인증 성공, 다르면 다른 곳에 로그인 됨이다. 거기다가 TTL도 EXPIRE active_token:userId 1h 처럼 설정할 수 있다. 정리하자면 Redis에 사용자별 현재 JWT를 저장해 기존 토큰을 자동으로 무효화할 수 있다. 스파르타 홈페이지도 이렇게 중복 로그인을 방지하는 것 같다. 실무에서는 JWT blacklist, Active Token, Refresh Token 저장 이 셋 패턴을 같이 쓴다고한다.
이 외에도 레디스 관련 내용이다.
Redis 캐시 전략 5가지
- Cache Aside – 먼저 캐시 조회 → 없으면 DB 조회 후 캐시에 저장
- Read Through – 캐시가 DB에서 자동으로 데이터를 읽어와 반환
- Write Through – 데이터 저장 시 DB와 캐시에 동시에 저장
- Write Back (Write Behind) – 캐시에 먼저 저장 후 나중에 DB에 비동기 저장
- Refresh Ahead – TTL 만료 전에 미리 캐시를 갱신해 캐시 미스 방지
Cache Aside vs Write Through
- Cache Aside – 애플리케이션이 캐시와 DB를 직접 관리 (가장 많이 사용)
- Write Through – 캐시가 DB 쓰기까지 함께 처리 (일관성 높지만 느림)
Redis 분산 락
→ 여러 서버가 동시에 같은 작업을 수행하지 않도록 Redis key를 이용해 락을 거는 방식
예
- NX : 키가 없을 때만 생성
- PX : TTL 설정
Redis Sorted Set 랭킹 시스템
→ 점수(score) 기준으로 자동 정렬되는 자료구조
예
ZADD ranking 200 user2
조회
→ 상위 10명 랭킹 조회
Redis가 싱글 스레드인데 빠른 이유
- 모든 데이터가 RAM에 있음
- lock / context switching 없음
- I/O multiplexing (epoll) 사용
- 명령이 매우 단순한 O(1) 연산 위주
- 네트워크 처리만 비동기적으로 처리
Redis = RAM 기반 캐시 + 단순 연산 + 싱글 스레드 이벤트 루프 여서 매우 빠른 응답이 가능하다는 얘기다.
Git Staging 서버를 둔 곳도 있다. Production과 거의 동일한 환경에서 배포 전 기능을 검증하는 테스트 서버이다. 일반적으로는 Local → Staging → Production 이 세 구조이고, local에서는 빠른 개발, 단위테스트, 디버깅을 하며, staging에서는 배포 직전 테스트 서버 Production과 동일한 환경에서 Docker, DB, Redis, API GW, CI/CD를 하여 통합 테스트, 배포 검증, 버그 발견을 하고 Production에서는 실 서비스 서버로 실제 트래픽, 데이터 보호가 중요한 곳, 다운타임 최소화 등을 주로 살핀다. 미리 검증한다 이게 주 목적같다. 나도 deploy.yml로 CI/CD를 하는데, 계속 오류나고 권한 없고 해서 몇번을 배포 당시 수정했다. 근데 이렇게 테스트 서버를 두면 그런 시행착오를 방지할 수 있겠다. 개발이 활발히 진행되는 데 만약 거기서 pull 해봐라. 배포 진행 실패하는동안 개발은 못하는거다. feature branch 개발 -> develop merge -> staging 서버 배포 -> 테스트 -> main → production 배포 이런 식으로 흐름을 가져간다. 일단 동일하게 환경을 구성하여 진행하니까 중복적으로 환경구성을 해야하므로 시간이 좀 걸릴 수 있겠다. 그래도 스테이징 테스트를 먼저 하면 주문 생성, 결제 api, redis 캐시, kafka 메시지 등을 테스트하고 production에 배포를 할 수 있어서 안정적이다.
꽤 많은 내용을 각 조에서 했다. 난 진짜 이번에 알았다. 핵심 키워드만 다시 정리하자.
Architecture
- MVP monolith
- Event Driven Architecture
Database
- Liquibase
- PostGIS
- QueryDSL paging
Transaction
- REQUIRES_NEW
- connection pool 문제
Caching
- Redis
- Cursor pagination
- Denormalization
Infrastructure
- Staging server
- CI/CD
Security
- JWT blacklist
- Active token
이렇게 있다. 물론 이게 내용 다는 아닌데, 내가 모르는 위주로 정리를 해봤다.
우리 조 기술적 내용
팀원 A의 정리 내용을 바탕으로 작성한다.
권한 관련해서 우리는 role 기반으로 url을 만드려고 했다. 왜냐하면 각각 역할이 다른데 응답하기 위해 url을 분리해야하지 않을까? 했었다. GET /customer/orders/{orderId} 다음과 같이 customer로 되는 것을 기획한거다. 하지만 실 프로젝트에서는 그렇게 하지 않았다. 리소스 기반 URL + @PreAuthorize의 hasRole 역할 분리로도 충분히 역할에 따른 응답을 달성할 수 있었다. 이 내용은 주위 개발자에게도 말해보았다. 리소스 기반이 원칙인데 역할 기반으로 하는 것은 REST 철학에 어긋나고, 지양해야하며, 필터 단(인증인가 처리)에서 처리하는 것이 좋다고 했다. 그런데 그 응답의 데이터 형식이 다르다면 백오피스로 퉁쳐서 분리하는 것이 좋다는 결론이 나왔다. 우리 조에서는 백오피스도 하지 않고 달성했다. 다행인 부분이지만, 앞으로 관리자의 권한에 따른 다른 데이터 응답 시에는 백오피스도 고려해보아야겠다.
For vs Stream에 대한 내용이다. 전자는 컴퓨터 전공때부터 많이 쓴 방식이다. 하지만 후자는 파이썬 람다, 랭체인, R언어 배울 때 좀 다루어 본 생소한 개념이었다. 후자는 함수형 스타일 데이터 처리를 하는 용도로 쓴다. 그리고 연산 체인의 형태로 Pipeline을 처리흐름으로 하고 Lazy Evalutation으로 중간에(filter, map 등) 바로 실행되는 것이 아니라 최종 연산이 호출될 때 한번에 실행된다. list.stream().filter(x -> x > 10) .map(x -> x * 2); 다음과 같은 코드는 바로 실행되지 않고, .toList().forEach().collect().count() 같은 terminal operation이 호출될 때 실행이 된다. 그리하여 원소 단위 pipeline 처리가 가능하다.
그렇게 stream으로 처리하면 선언형(함수형) 코드로 클린 아키텍처에서 나왔던 변수 불변성을 달성할 수 있고, 가독성이 좋으며, 병렬처리가 가능하다. 하지만 보통 for-loop > stream > parallel stream 순으로 속도 차이가 난다. 이유는 stream의 오버헤드 때문이다. 람다 호출 비용이 있다. 내부적으로 function 객체가 있고 함수 호출 오버헤드가 발생한다. 그리고 다음과 같은 Stream pipeline 각 단계가 있다고 치자. (list.stream().map(...).filter(...).collect(...) ;)Stream 객체, Iterator, Spliterator 등이 생성되면서 오버헤드가 발생한다. 그리고 Iterator 기반 처리로 array index로 직접 메모리 접근하는 것에 비해 느릴 수 있다. 또한 Pipeline 처리로 map, filter, reduce 같은 파이프라인 구조라서 하나 후 다음으로 넘어가야 해서 단계별 처리 비용이 있다. 그 외에도 JVM 최적화로 for-loop에서 JIT compiler, vectorization, loop unrolling 같은 최적화가 더 잘 작동한다. 대략 1천만 연산을 할 시 for-loop은 50ms, stream은 90ms, parallel stream은 70ms 정도 걸린다고 한다. 성능이 critical한 구간에서는 For 문을, 그게 아니면 함수형을 쓰는 전략이 좋아 보인다.
@Embedded annotation(JPA) 는 Value Object를 엔티티 안에 포함시켜 컬럼으로 매핑하는 JPA 기능이다.
@Embeddable
public class Address {
private String city;
private String street;
}
@Entity
public class User {
@Embedded
private Address address;
}
다음과 같은 코드가 있을 때 DB에서는 user 밑에 city와 street을 컬럼으로 가진다. 테이블 분리가 안되고, 같은 테이블 컬럼으로 저장하며, Value Object 모델링을 할 수 있다는게 특징이다.
이번에는 Custom Annotation이다. 기존 어노테이션을 묶어 새로운 의미 어노테이션을 만드는 방법이다.
@Target(ElementType.TYPE) // 어디에 붙일 수 있는지
@Retention(RetentionPolicy.RUNTIME) // 언제까지 유지되는지
@Documented // 문서포함 여부
@Component // 컴포넌트(빈 등록 가능)
public @interface Provider {
}
@Provider
public class PaymentProvider { }
다음과 같은 코드가 있다고 하자. Provider는 Component 중 하나이기 때문에 Spring이 자동으로 빈을 등록하고, Target, Retention, Documented 등의 효과도 가진다.
@RestControllerAdvice은 모든 REST Controller에서 발생하는 예외를 전역 처리하는 Spring 컴포넌트이다. 다음과 같은 코드가 있다.
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(RuntimeException.class)
public ResponseEntity<?> handle(RuntimeException e) {
return ResponseEntity.badRequest().body(e.getMessage());
}
}
Global Exception Handling의 역할을 하며 @ControllerAdvice + @ResponseBody 의 특징을 가진다.
JpaAuditConfig는 엔티티 생성/수정/삭제 시간 자동 기록 기능을 활성화한다. CreatedAt, UpdatedAt 등을 쓸 수 있다. 다음과 같은 예시 코드가 있다.
@Configuration
@EnableJpaAuditing
public class JpaAuditConfig {
}
@Entity
@EntityListeners(AuditingEntityListener.class)
public class BaseEntity {
@CreatedDate
private LocalDateTime createdAt;
@LastModifiedDate
private LocalDateTime updatedAt;
}
record vs class 이다. record는 불변 데이터 객체를 간단히 만들기 위한 Java 구조다. 다음과 같은 코드가 있다.
public record UserDto(
Long id,
String name
) {}
이 코드는 getter, equals, hashCode, toString, constructor 등을 자동 생성하고 immutable(불변), Dto에 적합하다는 특징이 있다.
@Transactional(readOnly = true) 이다. 읽기 전용 트랜잭션으로 DB 변경을 막고 성능을 최적화하는 옵션이다. dirty checking을 비활성하여 변경을 고려하지 않고, flush 최소화를 하며, DB read-only 힌트를 주어 조회 성능을 개선하고 변경 방지를 할 수 있다.
ReviewEntity와 RatingStatEntity는 같은 도메인에 존재하고 논리적으로는 1:n의 관계를 가지지만, JPA 연관관계를 하지 않았다. 모두 store 기준으로 관련이 있기 때문이다. 왜 걸지 않았냐면, RatingStat은 집계 테이블인데, 리뷰의 부모가 아니라 리뷰들의 통계 캐시이다. N:1로 걸면 의미가 맞지 않는다. 모듈 간 경계에서도 직접 참조 대신 MSA를 고려해 UUID 논리적 FK를 쓰는 패턴이다. 그리고 @Version으로 낙관적 락을 거는 것을 선택했는데, 비관적 락을 걸만큼 결제나 다른 민감한 사안에 비해 리뷰의 점수가 중요하지 않다고 판단했고, 그 안에서 동시성 제어를 하기 위함이다. 여기에서 JPA 연관관계를 걸면 리뷰 조회할 때 불필요하게 stat까지 로딩되거나 락 충돌이 생길 수 있다.
Order과 Menu의 엔티티 생성 방식도 다르다. 나같은 경우 Menu는 본인의 것만, MenuEntity는 그것에 더하여 FK를 보관하는 사실상 더 구체화된 테이블이라면 Order은 Cart에서 가져오는 OrderLine을 가지고 있다. 그리고 create()에서 팩토리 메서드로 생성할 때 builder를 활용하는 점은 같지만 Order에서는 OneToMany를 쓰면서 Many 입장인 OrderLine addOrderLine()으로 For문으로 돌면서 내용을 추가하고, calculateTotalPrice()를 계산하는 로직도 포함한다. 이렇게 주문, 메뉴를 만들려면 이 방법으로 강제한다.현재 코드에서 문제점은 OneToMany인데, JPA는 Many쪽이 FK를 가지는 구조가 자연스럽다. 현재 Order 저장하면 order 삽입, order_line 삽입, UPDATE order_line SET order_id 를 하여 insert 후 update가 발생한다. INSERT order_line(order_id)로 끝냈을 수 있는데 말이다. 성능적으로 OrderLine 100개라 치면, INSERT 100번 UPDATE 100번을 해야한다. 2배의 쿼리 효과가 난다. 이 것을 @ManyToOne(fetch = FetchType.LAZY) 으로 바꾸어 OrderLine에 놓고, Order에서는 @OneToMany(mappedBy="order") 으로 주인이 아닌 쪽에 mappedBy를 걸음으로써 양방향 설정을 하는 것을 권장한다고 한다. 단방향에서는 성능문제, 쿼리증가, JPA 내부 update 발생때문에 단방향은 웬만하면 권장하지 않는다.그리고 컬렉션 관리로 orderLines 리스트에서 add만 할 시에 orderLine.order = order; 가 안된다. 그래서 보통 helper 메서드를 만들어서(addOrderLine(OrderLine line)) 다음과 같은 것을 한번에 한다. orderLines.add(line)과 line.setOrder(this);를 말이다. MVP 수준에서는 이 방법이 더 간단하게 코드를 짤 수 잇다는 장점이 있다.PageResponse.of()도 create()와 비슷한 패턴이다. Slice<T> → PageResponse<T> 패턴을 of로 한줄로 처리한다. getContent()으로 데이터 리스트를 가져오고 hasNext()를 가져오는 것을 new로 매번 하기보다 of 한번으로 처리하는 팩토리 메서드이다. 디자인 패턴때 배웠는데 왜 기억이 잘 안나는지...
현재 Repository 구조 현황이다.
| 모듈 | Repository | Domain Interface | JpaRepository | JpaPersistence | 상태 |
| user | UserRepository | O | X | X | Spring Data 동적 구현 의존 |
| store | StoreRepository | O | O | O | 정상 |
| store | CategoryJpaRepository | X | O | X | JpaRepository 직접 사용 |
| menu | MenuRepository | O | O | O | 정상 |
| cart | CartRepository | O | O | O | 정상 |
| order | OrderRepository | O | O | O | 정상 |
| order | OrderStatusHistoryRepository | X | O | X | Domain이 JpaRepository 직접 extend |
| payment | PaymentRepository | O | O | O(부분) | search() 누락 |
| review | ReviewRepository | O | O | O | 정상 |
| review | RatingStatRepository | O | O | O | 정상 |
| shared | AiRequestJpaRepository | X | O | X | JpaRepository 직접 사용 |
현재 문제점은 Payment에서 보면 계층 우회하여 PaymentServiceImpl에서 PaymentJpaRepository를 직접 호출하는 것이다. Persistence 계층을 우회하고 Repository Abstraction이 붕괴되었다. Persistence를 중간에 거치게 해야한다. 그리고 OrderStatusHistoryRepository같은 경우에는 JpaRepository를 상속하고 있는데, Domain -> Spring Data 의존이 발생하고 계층 역전이 일어난다. Domain Repository -> JpaPersistence -> JpaRepository 방식으로 개선할 수 있다. Category와 AiRequest는 현재 Service에서 JpaRepository 직접 접근을 한다. Service -> Domain Repository -> Persistence Adapter -> JpaRepository 으로 구조를 개선할 수 있다. User Repository 에서도 Repository를 상속받아 Spring Data 동적 구현 의존을 하고, Persistence adapter가 없는 문제가 있다. MVP에서는 문제가 없지만 이것도 위 방법으로 개선할 수 있다.
Transactional 구조 점검의 표다.
| 모듈 | JpaPersistence | Service 클래스 레벨 | readOnly 분리 | 비고 |
| user | 없음 | @Transactional(readOnly=true) | O | 쓰기 메서드 개별 지정 |
| store | 없음 | @Transactional | O | 조회 readOnly |
| menu | 없음 | @Transactional | O | 조회 readOnly |
| cart | 없음 | @Transactional | O | 조회 readOnly |
| order | 없음 | 없음 | O | 메서드별 지정 |
| payment | 없음 | 없음 | O | 메서드별 지정 |
| review | 없음 | @Transactional | O | 조회 readOnly |
JpaPersistence에서 @Transactional이 없다. 그래도 Service에서 트랜잭션 경계 관리를 하기 때문에 트랜잭션 중복 문제가 없다. 하지만 클래스 레벨 @Transactional이 없어서 새 메서드 추가 시 누락이 가능하다. 현재 상태에서 대부분 Layered 패턴을 적용하였지만 Repository layer 일관성 확보, Service 트랜잭션 정책 통일, Persistence 계층 우회 제거 의 개선점이 존재한다.
다음과 같은 Provider/Requirer Transaction 상태가 있다.
| Provider | Transaction | 상태 |
| StoreProviderImpl | 없음 | 읽기 작업인데 트랜잭션 없음 |
| MenuProviderImpl | 없음 | 읽기 작업인데 트랜잭션 없음 |
| CartProviderImpl | readOnly=true | 정상 |
| OrderProviderImpl | 없음 | 읽기 작업인데 트랜잭션 없음 |
| PaymentProviderImpl | @Transactional(쓰기) | jakarta.transaction 사용 (잘못 import) |
Requirer에는 모두 @Transactional이 없다. Service 트랜잭션에 의존하고 Provider 호출만 수행한다. Service -> Requirer -> Provider -> Repository 구조에서 트랜잭션 경계가 Service인것이다. 대부분 Provider 트랜잭션이 없고, Requirer 트랜잭션이 없고 readOnly 설정 불일치가 있다. 현재는 문제가 없지만 Provider가 독립적으로 호출되면 트랜잭션 없이 실행 가능하다. 읽기 Provider, Transaction import를 org.springframework로 통일, Requirer는 단순 위임 역할 현재 구조 유지를 함으로써 개선이 가능하다.
Spring Pagination API 설계할 때 Pageable을 쓰면 request parameter 이름이 고정된다. Pagable 쓰는 경우에는 query parameter(엔드포인트 뒷부분 ?param=~)를 자동으로 매핑한다. page → pageable.pageNumber, size → pageable.pageSize, sort → pageable.sort 으로 ?page, ?size, ?sort 으로 Spring 규칙으로 고정되었다. 하지만 RequestParam 이름을 바꾸면? 예를 들어 size가 아니라 pageSize 같이 이름을 바꾸고 싶을 수도 있다. 하지만 Pagable은 기본적으로 인식을 못한다. 그래서 해결 방법으로 Spring 설정으로
resolver로 커스터마이징하는 방법이 있다.
@Configuration
public class PageableConfig {
@Bean
public PageableHandlerMethodArgumentResolverCustomizer customize() {
return resolver -> {
resolver.setPageParameterName("pageNumber");
resolver.setSizeParameterName("pageSize");
};
}
}
그 외에도 직접 파라미터를 받는 법도 있다.
@GetMapping("/stores")
public Page<StoreDto> getStores(
@RequestParam int pageNumber,
@RequestParam int pageSize
) {
Pageable pageable = PageRequest.of(pageNumber, pageSize);
}
Slice vs Page vs Cursor 에서는 Slice는 다음 페이지 여부만 확인, Page는 전체 count 쿼리 수행, Cursor는 offset 없이 id 기준 탐색을 한다. Slice에 비해 Page가 무겁다. Page는 관리자 페이지, Slice는 무한 스크롤이 필요할때 주로 쓴다. Cursor 방식은 WHERE id>lastId LIMIT 10 처럼 아이디가 기존보다 큰지 비교하고 일부만 가져온다. 정리하자면 Slice, Pagination, Cursor 구현 시 Pageable 사용을 하고 파라미터 이름을 고정한다. 이름 커스텀 방법으로는 resolver, 직접 파라미터 방법이 있다.
JPA 통합 테스트에서 em.flush()를 쓰는 이유는 JPA의 쓰기 지연 설정때문에 SQL 이 바로 실행되지 않을 수 있기 때문에 강제실행을 하기 위함이다. 실제 실행 시점은 flush, transaction commit, query 실행 직전이다. 그래서 예시로 보자면 repository.save(user) 후 repository.findById(user.getId()) 하기 전에 insert가 안되는 문제가 생긴다. 그것을 해결하는 것이다. 그래서 보통 flush 후 clear 하여 SQL 실행 후 1차 캐시를 제거한다. 하지만 여기서 문제는 EntityManager에 flush가 직접 의존하여 JPA 내부에 묶인다. 그래서 대안으로 repository.saveAndFlush(entity);으로 EntityManager 직접 접근을 없애기도 하고, @DataJpaTest 으로 Spring이 트랜잭션을 관리하기도 한다. 보통 테스트는 트랜잭션 시작, 테스트 실행, 롤백으로 flush가 필요없는 경우도 많다.
AOP(Aspect Oriented Programming) 는 우리 팀에서 별로 다루지 않았다. 핵심 비즈니스 로직과 공통 관심사를 분리하자는 것이다. 대표적인 공통 관심사는 Logging, Transaction, 권한 체크, Validation, Monitoring, Retry 같은 공통 관심사가 있다. 권한 체크같은 경우 throw로 Exception을 매번 주는 중복코드를 방지하기 위해 Controller에서 AOP로 권한 검사 후 Service를 한다.
else를 웬만하면 쓰지 말자 하는 것도 있다. 이것은 클린 코드 + 객체 지향 설계 원칙이다. 밑에는 안티 패턴이다.
if (role == CUSTOMER) {
orderCustomer();
} else if (role == OWNER) {
orderOwner();
} else if (role == MASTER) {
orderMaster();
}
이렇게 하면 역할이 추가될 때마다 코드 수정해야하고, else 조건이 점점 늘어난다. 또, OCP를 위반한다.
대신 이렇게 패키지 구조를 수정할 수 있다.
OrderStrategy (interface)
├ CustomerStrategy
├ OwnerStrategy
└ MasterStrategy
그리고 선택한 후 strategy.execude()로 인터페이스를 실행할 수 있다. 즉, 조건문에서 객체 위임으로 바뀐 것이다.
권한에 위임은 Role 객체가 스스로 행동하게 만든다. 다음과 같은 안티 패턴이 있다.
if (user.getRole() == OWNER) {
storeService.create();
}
이렇게 if로 짜는 것이 아니라 user.canCreateStore(), roleStrategy.checkPermission() 이런 식으로 권한 판단 로직을 권한 객체로 이동해서 사용해야한다. 즉, Service -> RoleStrategy -> 실제 구현으로 객체 사용한다. 이렇게 코드 수정 지점 감소, 버그 감소, 테스트 쉬움을 위해 Strategy, Polymorphism, Delegation 을 사용한다. 디자인 패턴 학교 수업 때 if else 쭉 쓰는 것은 당구 50짜리라고 하고 전략 패턴 적용하면 200 이상이라고 확장 가능하게 코드를 짜서 팔아 먹을 수준으로 만들라고 한 것이 기억난다. 어째서 나는 그걸 적용을 못한건지.. 내가 당구 50짜리 수준이었다라고 반성한다.
스케줄러에서 현재 구현한 방식은 @Schedulded 로 1분 단위로 하고, UNPAID 주문 조회하며, CreatedAt 5분 이상 이면 CANCELLED로 status를 바꾼다. 그 후 OrderStatusHistory에 기록한다. SELECT * FROM orders WHERE status = 'UNPAID' AND created_at < now() - interval '5 minutes' 방식이다. 그 다음 UPDATE orders SET status = 'CANCELLED' 후에 OrderStatusHistory insert 하는 일반적인 스케줄러 방식이다.
하지만 스케줄러가 1분마다 실행하면 SELECT * FROM orders WHERE status='UNPAID' 를 매번 대량 조회해야한다. 이로 인해 CPU, 네트워크, 메모리 문제가 있다. 그래서 스케줄러가 DB를 계속 가져온다는 문제가 있다. 그래서 인메모리 큐 방식으로 메모리 큐에 예약하고(5분 후 실행 예약) 그러면 5분 뒤 자동 취소가 되어 DB polling이 없다. 하지만 문제는 서버 재시작 시에 큐 데이터가 사라진다. 그래서 UNPAID 주문이 영원히 남아 데이터 정합성이 깨질 수 있다. 그래서 백업 스케줄러도 만들어 누락 복구를 하여 10분같이 더 긴 시간을 통해 오래된 주문 검사를 한다. 이것은 safety net 방식이라 불린다.
다른 방식으로는 DB 기반 방식이 있다. order table에 created_at 뿐 아니라 cancelled_at 도 추가하여 cancel_at = created_at + 5min 이렇게 계산한다. 다음 쿼리 처럼 대량 처리를 할 수 있다.
UPDATE orders
SET status='CANCELLED'
WHERE cancel_at < now()
AND status='UNPAID'
하지만 bulk update 문제가 발생하여 많은 row lock이 발생 가능하다. 10000개의 주문이 있다면 lock 10000 rows를 하게 되어 그동안 결제 요청, 주문 수정이 대기할 수 있다. 이를 위해서 실무에서는 limit batch를 다음과 같이 사용하기도 한다.
UPDATE orders
SET status='CANCELLED'
WHERE id IN (
SELECT id
FROM orders
WHERE status='UNPAID'
AND created_at < now()-interval '5 min'
LIMIT 100
)
즉, Limit으로 100개씩만 처리하는 것이다.
다른 방법으로는 event queue를 쓰는 것이다. Kafka/Redis 에서 order created -> delayed event -> cancel event 으로 해결할 수 있다. 이 외에도 delay queue로 Redis ZEST를 한다. 현재 수준은 MVP 트래픽 적은 서비스에서 정상적인 설계지만 트래픽 커질 시 해당 방법을 도입에 대한 고려를 해야한다.
KPT & 의견 차이
그래도 우리는 어쨌든 프로젝트를 우당탕탕 완성했다! 하지만 뭔가 아쉬움이 남는다. 다음은 우리의 KPT이다. 팀원 B만 작성해서 팀의 KPT 의도와 좀 다르다.

우리가 5명 중 2명 이탈로 위로를 많이 받았긴 했지만 근본적인 문제는 팀원 이탈때문이 아니었다! 사실 의견 차이와 의사결정 내용 지연이 가장 문제라고 생각한다. 사실 나는 팀원 B와 굉장히 다른 마인드를 가지고 있다. 나는 혼돈은 당연하고 그것을 견뎌야하며, 비정립된 이론이나 방식이더라도 목적에 맞으면 하자는 입장이다. 하지만 팀원 B는 체계적이고 개인적인 생각보다 학파의 정립된 의견이나 방식을 따라야하고, 철저한 계획 방식에 맞추자는 입장이었다. 그 의견차이로 1시간 정도 토론을 하였다.
난 솔직히 대부분의 의견에는 반대한다. 사실 그렇게 정립된 의견을 많이 알아보자는 것을 제외하고는 그 분의 의견을 안티패턴으로 삼을 것이다. 나는 철학이나 정립된 방식이 좋은 길잡이가 되긴 한다고 생각한다. 하지만 그것이 유클리드 공리나 뉴턴 3법칙이 아니지 않는가? 아파트 18층에서 뛰어내리는 것과 같이 물리 법칙처럼 무시하고 팀 의사결정 한다고 죽을 일이 있을까? 하지만 그 분은 우리만의 개인적인 생각, 공유는 망상으로 생각하더라. 잘 정립된 방식이라도 방식일 뿐이다. 그것이 경제, 경영같은 이론을 무시해서 나오는 말이 아니다.(본인도 경영 출신이다) 우리 팀과 비슷한 스타트업에서는 상황이 매번 바뀌고 프로그램을 만들기 위해 몇년씩이나 할 수 없다. 그 방식을 배우는 시간도 비용이다. 그리고 그 방식이나 본인이 생각했을 때 이해를 못했다고 해서 못 넘어가면 혼자 프로젝트하는 것에 비해 생산성이 나올까? 골든 서클 이론에서도 보면 why인 목적을 우선해야하는 것이 맞는 것 같다. how나 what은 다음 문제다. 나는 팀 프로젝트에서 n 명이라고 하면 각자 이해하는 방식이 달라서 n/2 인분 정도 효율이 나오는 것이 일반적이라고 생각한다. 하지만 진행자, 주도자가 이해한 방식대로만 혹은 정립된 방식으로만 팀을 이끌어간다면 유연성에 대한 대응이 부족하고 1인분 이상의 효과를 내기가 힘들다. 나는 n명이 있다면 n인분 이상하는 것이 목표다. 각자의 지식을 허브처럼 매끄럽게 연결하는 것이 팀장의 목적이라 생각한다. 한명의 방식대로 가야지 하는 것은 스티브 잡스, 제프 베조스 급의 출중한 관리자가 아니고서야 전체 조직을 이끌기 힘들다. 나는 그렇게 수직적 체계보다 '관리가능한' 수평적인 확장을 원하는 것이다. 집단 지성 효과가 나오게 말이다. 그 관리 가능한이라는 것도 범위가 중요한데, 나는 그것의 이해를 60%이상 하면 관리가 된다고 보았다.
이런 이런 지식이 있고 그걸 써봤다는 것을 포트폴리오에 넣을 것이 목적이면 그 분 말이 맞다. 하지만 정말 팀이 더 잘 프로젝트를 원활히 하고싶다면 부딛히고 시행착오도 하면서 수정을 상황에 맞게 해야한다고 생각한다. 과거의 내용을 그대로 적용하는 것은 역사를 많이 배웠다와 다를 바가 없다. 물론 그런 지식도 중요하지만 잘 활용하고 만족도 높은 결과를 내도록 유도하는 것이 지혜다. 비트겐슈타인의 논리철학논고(Tractatus)를 보면 6.54절에 사다리를 딛고 올라간 후에는 그 사다리를 던져버려야한다는 말이 있다. 그 기존 철학을 무참히 파괴한 논리로 무장한 내용을 던져버려야한다니. 그 당시에는 굉장히 충격적이었지만 이제는 조금 공감이 된다. 말할 수 있는 부분에 대해 말해오고 그게 아니면 침묵해야지만 실상은 말할 수 없는 것이 더 중요하다는 신선한 내용인거다. 실제로 비트겐슈타인은 자기 생각을 철학적 탐구라는 책으로 바꾸기도 했고. 나도 지금껏 부족하지만 배워온 것이 있다. 하지만 거기에 얽매이면 안된다고 생각한다. 언제든 사다리를 찰 준비를 해야한다는 것이다. 어쨌든 내 생각이 정답은 아니지만 그러한 유연성이 필요하다는 입장을 역설하고 싶다.
그러면 나와 이렇게 다른 사람은 어떻게 대할까? 분명 나나 다른 사람의 입장을 바꾸기는 쉽지 않다. 적어도 그 합의점을 잘 말해서 고충을 잘 들어줘야한다고 생각한다. 그리고 일방적으로 주장 후 받아들이는 이 상황을 피해야한다. 그러려면 상대방이 기분나쁘지 않게 비판을 하고 그것이 반영되어 더 좋은 결과를 내야하는데. 다음 조에서는 이것을 시도해봐야겠다. 잘 될지는 모르겠다. 확실히 난 팀원보다 팀장이 맘에 편한 것 같다. 아니면 부팀장으로 이런 저런 제안을 하거나.그리고 우리가 특히 설계할 때 굉장히 오래 시간이 걸렸지만 별로 뭐가 나오지 못했다. 나는 우선순위를 정하고 해야한다고 생각한다. 일단 발제가 있다면 뭐가 있는지 큰 그림을 본 다음에 추가 기능이나 기능 구현 방법같은 나무같은 부분은 티켓이나 댓글로 공유해야한다고 생각한다. 왜냐면 5시간동안 사소한 부분까지 이게 맞느니 틀리니 하니 시간도 많이 걸리고 생각해볼 시간이 적었다. 각자 공부해왔으면 좋겠다. 그니까 개괄이 잡히고 나서 합의를 해야지 합의만 하려고 하면 안된다는 것이다. 지식이 있냐 처음듣냐는 또 받아들이는 것이 다르기 때문이다. REST에서 role 을 엔드포인트에 넣냐 빼냐 할 때 따로 고민을 해오는 부분은 좋았다. 사람들이 고민을 안해서 그렇지... 그것을 잘 해오게 하는 것도 역량인데 다음에 일정이나 템플릿을 잘 설계해봐야겠다.MVP 범위에 대해 고민도 했다. 추가 기능을 제시하면 "이것은 MVP 범위에 벗어난 것 같습니다."란 팀원의 지속적 의견이 있었다. 예를 들어 회원 테이블에 전화번호 컬럼을 넣냐 마냐 하는 문제도 있었다. 그 문제로 1시간 씩 떠들었었는데, 이게 MVP인가 아닌가에 대해서 얘기하는 것 자체가 MVP 에 어긋난 것 같다. 그냥 담당자가 엔티티에 두세줄이면 끝날 문제인데 말이다. 난 그 MVP 라는 용어가 뭔지 보기 위해 린 스타트업 책을 읽었다. 그 MVP의 목표는 제품을 출시했을 때 최소한의 기능만이라도 넣어 시장에 먹히냐 수요조사를 하기 위함이다. 그니까 빨리 내놓기 위함이지 진짜 최소만 해라 이게 아닌것이다. 그리고 그런 추가기능 아이디어가 있을 때 그것을 잘 공유하기 위한 다른 파일을 만들어 거기서 의견을 공유했으면 좋겠다. 그건 회의로 끌고오기보다 텍스트로 혹은 그림으로 전달하는 것이 더 시간 효과적이라 생각한다.그리고 내용을 모두가 다 알아야한다는 강박을 좀 버렸으면 좋겠다. 팀원C가 주로 그런 입장이어서 이해시키려고 노력하는 모습이 보였는데, 그것도 텍스트나 자료를 주면서 각자 이해시키고 담당자만 확실히 알면 좋지 않을까 싶다.그리고 진행 상황 공유자 잘 안됐다. 다음 팀에서는 뭐를 했고, 뭐를 하고, 컨디션은 어떻고, 작업시간은 어떻게 되는지를 잘 공유해서 팀원간 트래킹을 하고 싶다. 그리고 혼자 하는 것에 비해 서로 이끌고 가주는 그런 구조를 원한다.
결론
이것저것 많이 적긴 했지만 결론은 그 전보다 나았으면 좋겠다는 것이다. 지식적으로도 팀워크적으로도 성장을 원한다. 그게 아니면 부트캠프 왜 오겠는가? 뭐라도 얻어내야지. 욕심이 생긴다. 그 열정이 더 커졌으면 좋겠다!
'TIL' 카테고리의 다른 글
| 20260323 내일배움캠프 TIL - GW 설계, 스터디 삽질 (0) | 2026.03.23 |
|---|---|
| [자바단기심화 숙련 TIL 중간 정리] MSA 특강 정리 (0) | 2026.03.18 |
| [자바단기심화 입문 TIL 16일차] 메뉴 (반)완성, 코드 리뷰/요청, 요즘 우아한 개발 (0) | 2026.03.06 |
| [자바단기심화 입문 TIL 10일차] ERD 명세서, 인프라 그림 (0) | 2026.02.25 |
| [자바단기심화 입문 TIL 9일차] 입문 프로젝트 발제 (0) | 2026.02.24 |