금요일

Spring @Transactional self-invocation 완벽 해결 — 내부 메서드 호출 시 트랜잭션이 무시되는 이유

🔍 검색 키워드: Spring Transactional self-invocation, 스프링 자기 호출 트랜잭션 문제, @Transactional 내부 메서드 호출 무시, Spring AOP self-call, 스프링 트랜잭션 프록시 우회

증상: @Transactional인데 트랜잭션이 적용 안 된다

같은 클래스 내부에서 다른 메서드를 호출할 때 @Transactional이 무시되고, DB 에러가 발생해도 롤백이 되지 않는 현상이 발생한다.

@Service
public class OrderService {

    public void processOrder(Long orderId) {
        saveOrder(orderId);   // 트랜잭션이 적용되지 않음!
    }

    @Transactional
    public void saveOrder(Long orderId) {
        orderRepository.save(new Order(orderId));
        throw new RuntimeException("에러 발생");  // 롤백 안 됨!
    }
}

원인: Spring AOP 프록시 메커니즘

Spring의 @Transactional은 AOP 프록시 기반으로 동작한다. 외부에서 빈을 호출하면 프록시 객체를 거쳐 트랜잭션 처리가 이루어지지만, 같은 클래스 내에서 this.saveOrder()로 직접 호출하면 프록시를 우회하여 실제 객체를 직접 호출하게 된다. 결과적으로 트랜잭션이 시작되지 않는다.

// 외부 호출: 프록시 → 실제 메서드 (트랜잭션 O)
orderService.saveOrder(id);

// 내부 self-invocation: 실제 객체.saveOrder() (트랜잭션 X)
this.saveOrder(id);

해결 방법 1 (권장): 클래스 분리

가장 깔끔한 해결책이다. 트랜잭션이 필요한 메서드를 별도 클래스로 분리하면 외부 호출이 되어 프록시가 정상 동작한다.

@Service
public class OrderService {
    private final OrderSaveService orderSaveService;

    public OrderService(OrderSaveService orderSaveService) {
        this.orderSaveService = orderSaveService;
    }

    public void processOrder(Long orderId) {
        orderSaveService.saveOrder(orderId);  // 프록시 통과 → 트랜잭션 O
    }
}

@Service
public class OrderSaveService {
    @Transactional
    public void saveOrder(Long orderId) {
        orderRepository.save(new Order(orderId));
    }
}

해결 방법 2: ApplicationContext에서 자기 자신 빈 주입

@Service
public class OrderService {
    @Autowired
    private ApplicationContext applicationContext;

    public void processOrder(Long orderId) {
        OrderService self = applicationContext.getBean(OrderService.class);
        self.saveOrder(orderId);  // 프록시 통과
    }

    @Transactional
    public void saveOrder(Long orderId) {
        orderRepository.save(new Order(orderId));
    }
}

해결 방법 3: @Lazy Self-injection

@Service
public class OrderService {
    @Autowired
    @Lazy
    private OrderService self;

    public void processOrder(Long orderId) {
        self.saveOrder(orderId);  // 프록시 통과
    }

    @Transactional
    public void saveOrder(Long orderId) {
        orderRepository.save(new Order(orderId));
    }
}

Spring Boot 2.6 이상에서는 순환 참조가 기본으로 금지되므로 spring.main.allow-circular-references=true 설정이 필요하다. 권장하지 않는다.

해결 방법 비교표

방법권장도비고
클래스 분리★★★★★가장 깔끔, 단일 책임 원칙 준수
ApplicationContext 주입★★★코드는 다소 복잡해짐
@Lazy Self-injection★★순환 참조 이슈, 비권장
AspectJ 컴파일 타임 위빙★★★빌드 설정 복잡

Spring @Transactional self-invocation 문제는 AOP 프록시 원리를 이해하면 자연스럽게 해결 방향이 보인다. 가장 좋은 방법은 처음부터 단일 책임 원칙을 지켜 클래스를 적절히 분리하는 것이다. 이 문제는 Spring을 사용하는 개발자라면 반드시 숙지해야 할 AOP의 핵심 동작 방식이다.

수요일

Docker 멀티스테이지 빌드 캐시 최적화 — 레이어 순서와 캐시 전략

🔍 검색 키워드: Docker 멀티스테이지, 빌드 캐시, Docker layer, Dockerfile 최적화, 빌드 성능, Docker 이미지 크기

Docker 빌드가 매번 10분씩 걸린다면 문제는 멀티스테이지 캐시 전략이다. 각 stage마다 캐시가 독립적으로 작동하는데, 우리는 보통 이걸 무시하고 작성한다.

■ 증상

docker build --no-cache -t myapp .
매번 node_modules 다시 설치 → 5분 소요
또는 코드 변경 → 전체 이미지 재빌드 → 10분 대기

■ 원인

1. 레이어 순서 잘못됨 - 변경 빈도가 높은 코드를 먼저 놓음
2. stage 간 캐시 미사용 - FROM base 후 다시 의존성 설치
3. .dockerignore 미설정 - 불필요한 파일까지 ADD → 캐시 무효화

■ 해결방법

❌ 나쁜 예:

FROM node:18
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build

✅ 좋은 예:

FROM node:18 AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production

FROM node:18 AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM node:18 AS runtime
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY package.json .
EXPOSE 3000
CMD ["npm", "start"]

■ .dockerignore 설정 필수:

node_modules
dist
build
.git
.env
.next

■ 최적화 체크리스트

1. 레이어 변경 빈도순 정렬 (거의 변경 안 됨→자주 변경)
2. RUN 명령어 합치기
3. npm ci 사용 (install보다 빠름)
4. 멀티스테이지로 최종 이미지 크기 50% 감소
5. BuildKit 활용으로 빌드 속도 60% 향상 가능

React useEffect 무한루프 — 의존성 배열 검증과 최적화 전략

🔍 검색 키워드: React useEffect 무한루프, useEffect 의존성 배열, React hooks 성능, useEffect 무한 재렌더링, React 성능 최적화

useEffect를 사용하다 보면 컴포넌트가 계속 리렌더링되는 악몽을 경험한다. 브라우저 개발자 도구를 열어보면 콘솔에 로그가 끝없이 출력되고, 네트워크 요청이 수십 개씩 날아간다. 이게 바로 useEffect 무한루프다.

■ 증상

Warning: An effect caused a component to suspend while rendering
또는
컴포넌트가 재렌더링 → useEffect 실행 → state 업데이트 → 다시 재렌더링

■ 원인

1. 의존성 배열 누락 - 의존성 배열을 쓰지 않으면 매 렌더링마다 실행
2. 배열 참조 문제 - 객체나 배열을 의존성에 넣으면 매번 새로운 참조 생성
3. state 업데이트 - useEffect 내에서 의존성에 포함된 state를 업데이트

■ 해결방법

❌ 잘못된 코드:

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    fetch(`/api/users/${userId}`)
      .then(r => r.json())
      .then(data => setUser(data));
    // 의존성 배열이 없다!
  });
}

✅ 올바른 코드:

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);

  useEffect(() => {
    fetch(`/api/users/${userId}`)
      .then(r => r.json())
      .then(data => setUser(data));
  }, [userId]); // userId가 변할 때만 실행
}

참조 문제 해결:

function SearchResults({ filters }) {
  const [results, setResults] = useState([]);

  useEffect(() => {
    searchAPI(filters);
  }, [JSON.stringify(filters)]);
}

■ 정리표

의존성 배열 없음 → 매 렌더링마다 실행 → [] 추가하거나 필요한 의존성 명시
배열/객체 의존성 → 참조가 매번 변함 → JSON.stringify() 또는 useMemo()
state 순환 업데이트 → useEffect가 의존성 state 업데이트 → 의존성에서 제외하거나 조건 분기
클로저 문제 → 예전 state 값 참조 → useCallback, useRef 활용