나만보는페이지

AI가 짜준 Spring 코드, 운영에서 터지는 5가지 패턴 본문

ai

AI가 짜준 Spring 코드, 운영에서 터지는 5가지 패턴

pplenty 2026. 5. 23. 21:03

 

컴파일 통과, 단위 테스트 통과, 코드 리뷰도 통과. 운영에 올린 뒤에야 알아챈다. 이 글은 AI 코딩 도구가 만든 Spring 코드가 production에서 잘 터지는 다섯 가지 패턴을 정리한다. 모두 AI만의 문제는 아니다. 사람도 만든다. 다만 AI는 같은 함정을 더 자신감 있게, 더 깔끔하게, 더 그럴듯하게 만든다.

공통 결부터 짚고 시작하자. AI는 한 메서드/한 파일 단위의 best practice는 충실히 따라간다. 변수명도 예쁘고, 어노테이션도 다 붙어 있고, 예외도 처리한다. 약한 곳은 정확히 그 바깥이다 — 호출 graph, 스레드 경계, 네트워크 실패, ORM identity lifecycle, 트랜잭션 프록시 같은 시스템 차원의 constraint. 다섯 패턴이 전부 이 결을 따른다.


1. N+1 쿼리 — '깔끔한 OOP'가 DB를 죽인다

AI한테 "주문 목록 요약을 만들어 달라"고 하면 십중팔구 이런 코드가 나온다.

@Service
@RequiredArgsConstructor
public class OrderReportService {
    private final OrderRepository orderRepository;

    public List<OrderSummary> recentSummaries(int days) {
        return orderRepository.findRecent(days).stream()
            .map(o -> new OrderSummary(
                o.getId(),
                o.getItems().size(),            // ← lazy load
                o.getCustomer().getName()       // ← lazy load
            ))
            .toList();
    }
}

보기엔 흠잡을 데가 없다. stream().map().toList()는 모범생 코드처럼 보이고, 엔티티 메서드 호출도 자연스럽다. 그런데 Hibernate가 실제로 날리는 쿼리를 보면 다른 풍경이 펼쳐진다. 주문 100건이면 orders 1회 + items 100회 + customer 100회 = 201개의 쿼리가 나간다. 트래픽이 붙으면 DB가 먼저 무너진다.

왜 AI는 이걸 잘 짜는가. AI는 @OneToMany의 기본 fetch가 LAZY인 것은 알지만, "이 메서드가 어떤 호출 패턴 안에서 실행되는지"는 모른다. 단일 메서드만 보고 짜기 때문에, list 안에서 게터가 호출될 때 lazy proxy가 폭발하는 그림이 시야 밖에 있다. 호출 graph 전체를 봐야 보이는 함정이다.

고치는 법. 필요한 연관을 한 번에 끌어오면 된다.

// JPQL fetch join
@Query("""
    SELECT DISTINCT o FROM Order o
    LEFT JOIN FETCH o.items
    LEFT JOIN FETCH o.customer
    WHERE o.createdAt > :since
""")
List<Order> findRecentWithDetails(@Param("since") LocalDateTime since);

// 또는 @EntityGraph
@EntityGraph(attributePaths = {"items", "customer"})
List<Order> findByCreatedAtAfter(LocalDateTime since);

탐지는 의외로 쉽다. hibernate.generate_statistics=true로 켜 두고 통합 테스트에서 query count를 assert하거나, datasource-proxy·p6spy로 실제 발생 SQL을 로깅하면 한 번에 잡힌다. CI에서 "이 엔드포인트는 쿼리 N개 이내"를 강제하면 가장 안전하다.

2. @Transactional 자기 호출 — 어노테이션은 있는데 트랜잭션은 없다

CSV 배치 임포트 같은 작업을 시키면 잘 나오는 코드다.

@Service
@RequiredArgsConstructor
public class UserImportService {
    private final UserRepository userRepository;

    public ImportResult importAll(List<UserCsvRow> rows) {
        int ok = 0, fail = 0;
        for (UserCsvRow row : rows) {
            try {
                saveOne(row);  // ← self-invocation
                ok++;
            } catch (Exception e) {
                fail++;
            }
        }
        return new ImportResult(ok, fail);
    }

    @Transactional
    public void saveOne(UserCsvRow row) {
        userRepository.save(row.toEntity());
        // ... 그 외 검증/연관 저장
    }
}

한 행이 실패하면 그 행만 롤백되고 나머지는 살아남길 기대한 코드다. 그런데 실제로는 @Transactional전혀 동작하지 않는다. 모든 row가 같은 (혹은 트랜잭션 없는) 상태로 흘러가서, 검증을 통과한 일부 데이터만 부분 저장되거나, 예외 시점에 따라 전체가 통째로 날아가는 일관성 없는 결과가 나온다.

왜 동작하지 않나. Spring AOP는 프록시 기반이다. 빈으로 주입받은 UserImportService는 실제 객체가 아니라 프록시이고, 프록시는 메서드 진입 시점에 트랜잭션 어드바이스를 끼워 넣는다. 그런데 importAll 안에서 saveOne(row)을 호출하는 순간, 이 호출은 this를 거치지 ‒ 프록시를 우회하기 때문에 어드바이스가 끼어들 자리가 없다. @Transactional은 그저 코드에 적힌 종이쪽지가 된다.

왜 AI는 이걸 잘 짜는가. AI는 @Transactional이라는 어노테이션의 존재와 의미는 알지만, 그것이 작동하는 메커니즘(프록시-기반 AOP)을 추상적으로만 이해한다. 어노테이션만 붙이면 마법처럼 동작한다고 가정한다. 같은 이유로 @Async, @Cacheable, @PreAuthorize도 자기 호출에서 똑같이 무너진다.

고치는 법. 가장 깔끔한 답은 빈을 분리하는 것이다.

@Service
@RequiredArgsConstructor
public class UserImportService {
    private final UserRowSaver rowSaver;   // 다른 빈으로 분리

    public ImportResult importAll(List<UserCsvRow> rows) {
        for (UserCsvRow row : rows) {
            try { rowSaver.saveOne(row); ok++; }
            catch (Exception e) { fail++; }
        }
        // ...
    }
}

@Component
@RequiredArgsConstructor
class UserRowSaver {
    private final UserRepository userRepository;

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void saveOne(UserCsvRow row) { ... }
}

REQUIRES_NEW까지 명시한 이유는, 한 행의 실패가 다른 행의 트랜잭션에 영향을 주지 않도록 각 row를 독립 트랜잭션으로 격리하기 위해서다. 정확한 격리 의도를 어노테이션에 새겨 두면, 다음 사람(또는 다음 AI)이 같은 함정에 다시 빠지지 않는다.

3. ThreadLocal 컨텍스트 손실 — traceId가 사라지고 인증이 비어 있다

알림 발송이나 외부 API 호출처럼 시간이 오래 걸리는 작업은 @Async를 붙이라고 한다.

@Service
@RequiredArgsConstructor
public class NotificationService {
    private final EmailClient emailClient;

    @Async
    public CompletableFuture<Void> notifyAll(List<User> users) {
        // 이 안에서 MDC.get("traceId") → null
        // SecurityContextHolder.getContext().getAuthentication() → null
        users.forEach(emailClient::send);
        return CompletableFuture.completedFuture(null);
    }
}

동기 코드 시절엔 잘 돌던 로깅·인증·테넌트 컨텍스트가 비동기 경계를 넘는 순간 사라진다. production 증상은 잔잔하다 — 에러가 나는 게 아니라, 로그에 traceId가 안 찍히고, 멀티테넌트 시스템에서 다른 테넌트의 데이터를 건드린다. "왜 어떤 요청만 로그가 끊겨 있죠?"가 흔한 첫 신호다.

왜 이런 일이 생기나. MDC, SecurityContextHolder, RequestContextHolder는 전부 ThreadLocal 위에 얹혀 있다. 이름이 말하듯 스레드에 묶인 저장소다. @Async는 새 스레드에서 실행되고, ThreadLocal은 자동으로 전파되지 않는다. 부모 스레드의 컨텍스트는 자식 스레드 입장에서 보이지 않는다.

왜 AI는 이걸 잘 짜는가. AI는 ThreadLocal도 알고 @Async도 안다. 다만 둘이 만나는 boundary에서 컨텍스트가 끊긴다는 사실은 한 메서드만 보고 있어선 보이지 않는다. 호출 graph 전체와 런타임 스레드 모델을 함께 그려야 보이는 문제다.

고치는 법. 컨텍스트를 명시적으로 propagation 해 줘야 한다. 가장 손쉬운 길은 micrometer-context의 ContextSnapshot이다. Spring Boot 3.x에서 표준 솔루션으로 자리 잡았고, MDC·Security·Reactor 컨텍스트를 한 번에 묶어 전파한다.

// 1) Executor를 컨텍스트-aware로 감싸기
@Bean
public TaskExecutor taskExecutor() {
    ThreadPoolTaskExecutor base = new ThreadPoolTaskExecutor();
    base.setCorePoolSize(8);
    base.setMaxPoolSize(32);
    base.setQueueCapacity(200);
    base.initialize();
    return ContextExecutorService.wrap(base, ContextSnapshot::captureAll);
}

// 2) 또는 Spring Security 전용: DelegatingSecurityContextExecutor
@Bean
public Executor secureExecutor(TaskExecutor base) {
    return new DelegatingSecurityContextExecutor(base);
}
Virtual Threads도 예외가 아니다. Spring Boot 3.2+에서 가상 스레드를 켜면 스레드 자체는 가벼워지지만, ThreadLocal 전파 문제는 그대로다. 오히려 비동기 코드가 늘면서 같은 함정에 빠지는 빈도가 더 늘 가능성이 크다. 컨텍스트 전파 기반을 미리 깔아 두자.

4. HTTP 클라이언트 기본값 — 타임아웃 없는 RestTemplate은 시한폭탄

외부 API를 부르려고 RestTemplate을 만들어 달라고 하면 보통 이게 나온다.

@Configuration
public class HttpConfig {
    @Bean
    public RestTemplate restTemplate() {
        return new RestTemplate();
    }
}

짧다. 깔끔하다. 무난해 보인다. 함정은 보이지 않는 곳에 있다 — 기본 connect/read timeout이 무한대이고 커넥션 풀이 없다는 사실. 평소엔 문제없이 잘 돈다. downstream이 느려지는 순간 그림이 바뀐다.

① 외부 결제 API 응답이 30초로 지연됨
② /api/checkout 처리 스레드들이 응답을 무한정 기다림
③ Tomcat의 기본 200개 worker 스레드가 모두 점유됨
④ /health, /api/products 등 멀쩡한 엔드포인트도 거부됨
⑤ 한 downstream의 느림이 → 전체 서비스 장애로 cascading

한 의존성의 지연이 우리 서비스 전체의 장애로 번지는, 이른바 cascading failure의 교과서적 시나리오다. 정작 우리는 잘못한 게 없는데 "우리 서버가 다운됐다"는 알림을 받게 된다.

왜 AI는 이걸 잘 짜는가. 튜토리얼·블로그 글의 절대다수는 new RestTemplate()로 시작한다. 타임아웃·커넥션 풀은 production constraint라 입문서에서 거의 다루지 않는다. AI는 학습 데이터의 다수파를 따라가고, "이건 데모용이지 production용이 아니다"라는 컨텍스트는 따라오지 않는다.

고치는 법. 명시적 타임아웃과 커넥션 풀, 그리고 가능하면 회복성 패턴까지.

@Bean
public RestTemplate restTemplate(RestTemplateBuilder builder) {
    PoolingHttpClientConnectionManager pool = new PoolingHttpClientConnectionManager();
    pool.setMaxTotal(50);
    pool.setDefaultMaxPerRoute(20);

    CloseableHttpClient httpClient = HttpClients.custom()
        .setConnectionManager(pool)
        .build();

    return builder
        .connectTimeout(Duration.ofSeconds(2))
        .readTimeout(Duration.ofSeconds(5))
        .requestFactory(() -> new HttpComponentsClientHttpRequestFactory(httpClient))
        .build();
}

Spring 6.1+의 RestClientWebClient도 같은 함정이 있다 — 기본값은 production용이 아니다. 추가로 Resilience4j의 circuit breaker·bulkhead를 얹어 두면, 한 downstream의 장애가 전체로 번지는 길을 끊을 수 있다. "외부 호출은 반드시 timeout, pool, circuit breaker 세 개"를 팀 규칙으로 박아 두자.

5. @Data + JPA Entity — equals/hashCode가 진실을 배신한다

가장 단골 함정. Lombok에 익숙한 AI는 엔티티에도 거리낌 없이 @Data를 붙인다.

@Entity
@Data                       // ← @Getter, @Setter, @EqualsAndHashCode, @ToString
@NoArgsConstructor
public class Order {

    @Id @GeneratedValue
    private Long id;

    private String orderNo;

    @ManyToOne(fetch = FetchType.LAZY)
    private Customer customer;

    @OneToMany(mappedBy = "order", cascade = CascadeType.ALL)
    private List<OrderItem> items = new ArrayList<>();
}

이 코드에 숨어 있는 함정은 최소 세 개다.

함정 1 — 양방향 관계 + toString → StackOverflowError. @Data가 생성한 toString()은 모든 필드를 찍는다. order.toString()items를 찍으면 각 item.toString()이 다시 order를 찍고… 무한 재귀. 로깅 한 번에 서버가 멈춘다.

함정 2 — id 기반 equals/hashCode의 lifecycle 함정. 영속화 전에는 id == null, 영속화 후에는 id가 채워진다. 즉 동일한 자바 객체의 hashCode가 시점에 따라 바뀐다. 이 엔티티를 HashSet에 넣어 두면, persist 한 뒤에는 같은 객체가 Set 안에서 사라진다.

함정 3 — 양방향 관계 + equals → 무한 재귀. Order.equalsitems를 비교하면 각 OrderItem.equalsorder를 비교하고… 다시 무한 재귀.

왜 AI는 이걸 잘 짜는가. Lombok 튜토리얼의 디폴트 예시가 거의 항상 @Data다. 그리고 @Data의 의미("그냥 다 만들어 줘")는 JPA의 identity lifecycle("id는 persist 시점에 채워진다")이라는 깊은 도메인 지식과 충돌한다는 사실은 입문 자료에 거의 적혀 있지 않다. AI는 가장 흔한 예시를 따라간다.

고치는 법. 엔티티에서 @Data는 금지. 필요한 만큼만 명시적으로.

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@ToString(of = {"id", "orderNo"})            // 연관 필드 제외
public class Order {

    @Id @GeneratedValue
    private Long id;

    private String orderNo;     // business key

    // ... 연관 관계

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof Order other)) return false;
        // id가 둘 다 채워진 경우에만 id로 비교, 아니면 동일성으로
        return id != null && id.equals(other.id);
    }

    @Override
    public int hashCode() {
        // 시점에 따라 바뀌지 않는 상수, 또는 클래스 기반
        return getClass().hashCode();
    }
}

원칙은 간단하다. equals/hashCode는 lifecycle을 가로질러 변하지 않아야 한다. id를 쓰되 null 시점을 명시적으로 처리하거나, 변하지 않는 business key(주문번호, 사번 등)가 있다면 그걸 쓴다. toString은 절대 연관 필드를 포함하지 않는다. 이 세 줄 짜리 규칙을 팀에 박아 두는 것만으로 production 알람이 한참 줄어든다.

마치며: AI 코드를 리뷰하지 말고, 우리의 production constraint를 가르치자

다섯 패턴을 늘어놓고 보니 결이 하나로 모인다.

AI는 한 메서드 단위의 syntax-level best practice는 흉내 낸다. 약한 곳은 호출 graph, 스레드 경계, 네트워크 실패, ORM identity lifecycle, 프록시-기반 AOP 같은 시스템 차원의 constraint다. 이 다섯 가지는 한 파일을 들여다봐선 보이지 않고, 우리 시스템 전체와 production에서의 운영 경험에서만 보인다.

그래서 진짜 일은 매 PR마다 AI 코드를 손으로 리뷰하는 게 아니라, 우리 production constraint를 AI에게 가르치는 것이다. 지난 글에서 만든 spring-review Skill의 references/anti-patterns.md에 이 다섯 패턴을 그대로 박아 두면, AI가 다음번엔 같은 자리에서 같은 실수를 덜 한다.

.claude/skills/spring-review/
├── SKILL.md
└── references/
    ├── conventions.md
    ├── security.md
    └── anti-patterns.md   ← 이 글의 다섯 패턴을 여기에
                              (lazy loading, self-invocation,
                               ThreadLocal, HTTP defaults, @Data on entity)

한 가지 더 — 이 다섯 패턴은 사람이 짠 코드에서도 똑같이 잘 나온다. AI를 의심하기 전에, 우리 팀 코드베이스부터 한번 grep 해 보자. new RestTemplate(), @Data가 붙은 @Entity, @Async 메서드 안의 MDC.get… 의외로 많이 나온다. 거기서부터 시작하면 된다.

참고: Spring Framework reference (Transaction proxy, AOP), Hibernate User Guide (N+1 / fetch strategies), micrometer-context 문서, Resilience4j 가이드. 버전에 따라 세부 동작이 다를 수 있으니 현재 사용하는 Spring Boot 버전의 문서를 함께 확인하자.

Comments