토비의 스프링

관심사

“모든 변경과 발전은 한가지 관심에 집중해서 일어난다. 문제는 다만 그에 따른 변경이 한가지 관심에 집중해 있지 않다는 것이다. 그래서 우리가 해야 할 일은, 한가지 관심이 한군데 집중되게 하는 것이다.”

관계

모델링 시점의 오브젝트 간 관계를 기반으로, 런타임 오브젝트 관계를 갖는 구조를 만들어주는것은 “클라이언트의 책임"이다. 클라이언트는 자기가 UserDao를 사용해야 할 입장이기에, UserDao의 세부 전략이라고도 볼 수 있는 구현클래스를 선택하고 선택한 클래스의 오브젝트를 생성해서 연결해줄 수 있다.

OCP

클래스나 모듈은 확장에는 열려있어야 하고, 변경에는 닫혀있어야 한다.

  • 구현체를 추가하는 방식의 확장을, 기존 로직의 수정 없이 가능한 구조
  • 조금 더 추상적인 것에 의존하며 책임을 분리함으로 달성

높은 응집도, 낮은 결합도

  • 관심사가 같은 것들이 응집해 있는것,
  • 책임과 관심사가 다른 오브젝트 또는 모듈과는 낮은 결합ㄴ도.

결합도랑 하나의 오브젝트가 변경이 일어날 때 관계를 맺고 있는 다른 오브젝트에게 ‘변화를 요구하는 정도’

Strategy Pattern

자신의 기능 context에서, 필요에 따라 변경이 필요한 알고리즘을 인터페이스를 통해 통째로 외부로 분리시키고, 이를 구현한 구체적인 알고리즘 클래스를 필요에 따라 바꿔서 사용할 수 있게 하는 디자인 패턴.

오브젝트 팩토리

  • 스프링이 제어권을 가지고 직접 만들고 관리하는 오브젝트 : bean
  • 빈의 생성과 관계설정 같은 제어를 담당하는 IoC오브젝트를 : beanFactory
  • 이를 확장한 application context (ioc 방식의 bean factory)

헷갈리는 IOC 용어 정리

  • bean : ioc방식으로 관리하는 오브젝트
  • bean factory : ioc를 담당하는 핵심 컨테이너, 빈 등록, 생성 조회, 반환 그 외 부가적인 빈 관리 기능, 보통 이걸 확장한 애플리케이션 컨텍스트 이용
  • application context : 빈 팩토리를 확장한 ioc 컨테이너, 스프링이 제공하는 각종 부가서비스 추가
  • configuration metadata : ioc를 적용하기 위해 사용하는 메타데이터
  • container, ioc container : 빈팩토리나 애플리케이션을 지칭

DL(Dependency Lookup)이 필요한 경우

  1. 프로토타입 스코프 빈 사용 시

    • 프로토타입 빈은 매번 새로운 인스턴스가 필요할 때 사용된다.
    • DI로는 한 번만 주입되므로, 여러 번 새 인스턴스가 필요하면 ObjectProvider나 Provider를 통한 DL이 필요.
  2. 선택적 의존성(Optional Dependencies) 처리

    • 특정 빈이 있을 수도, 없을 수도 있는 상황에서 유연하게 대응해야 할 때
  3. 순환 의존성 문제 해결

    • 두 빈이 서로를 참조하는 순환 의존성이 있을 때, DL을 사용하여 지연 로딩으로 해결할 수 있습니다.
  4. 스프링 컨테이너 외부에서 빈을 사용해야 할 때

    • 스프링 관리 객체가 아닌 일반 객체에서 스프링 빈을 사용해야 하는 경우
  5. 런타임에 빈 선택이 필요한 경우

    • 사용자 입력이나 설정에 따라 다른 빈을 사용해야 할 때
  6. 지연 초기화(Lazy Initialization)가 필요할 때

    • 특정 빈의 초기화 비용이 높고, 실제 사용 시점까지 초기화를 미루고 싶을 때

메소드를 이용한 의존관계 주입

  • setter 주입
  • 일반 메소드 이용한 주입
@Bean
public UserDao userDao() {
	UserDao userDao = new UserDao();
	userDao.setConnectionMaker(connectionMaker());
	return userDao;
}

템플릿

이 문제의 핵심은 변하지 않는, 그러나 많은 곳에서 중복되는 코드와 로직에 따라 자꾸 확장되고 자주 변하는 코드를 잘 분리해내는 작업이다.

결국 DI란 이러한 전략 패턴의 장점을 일반적으로 활용할 수 있도록 만든 구조라고 볼 수 있다.

스프링 DI는 넓게 보자면 객체의 생성과 관계 설정에 대한 제어권한을 오브젝트에서 제거하고 외부로 위임한다.

// 1. 콜백 인터페이스 정의
interface Callback {
    void execute();
}

// 2. 템플릿 클래스 정의
class Template {
    public void execute(Callback callback) {
        startOperation();        // 공통 시작 작업
        
        try {
            callback.execute();  // 콜백 실행 (변하는 부분)
        } catch (Exception e) {
            handleException(e);  // 예외 처리
        } finally {
            endOperation();      // 공통 종료 작업
        }
    }
    
    private void startOperation() {
        System.out.println("작업을 시작합니다.");
        // 리소스 초기화, 연결 설정 등
    }
    
    private void handleException(Exception e) {
        System.out.println("예외가 발생했습니다: " + e.getMessage());
        // 로깅, 롤백 등
    }
    
    private void endOperation() {
        System.out.println("작업을 종료합니다.");
        // 리소스 정리, 연결 종료 등
    }
}

// 3. 템플릿 콜백 패턴 사용 예시 (파일 작업)
class FileProcessor {
    private final Template template;
    
    public FileProcessor() {
        this.template = new Template();
    }
    
    public void processFile(final String filePath) {
        template.execute(new Callback() {
            @Override
            public void execute() {
                System.out.println("파일 " + filePath + "을 처리합니다.");
                // 실제 파일 처리 로직
            }
        });
    }
}

// 4. 템플릿 콜백 패턴 사용 예시 (데이터베이스 작업)
class DatabaseProcessor {
    private final Template template;
    
    public DatabaseProcessor() {
        this.template = new Template();
    }
    
    public void executeQuery(final String query) {
        template.execute(new Callback() {
            @Override
            public void execute() {
                System.out.println("쿼리를 실행합니다: " + query);
                // 실제 쿼리 실행 로직
            }
        });
    }
}

// 5. 제네릭을 활용한 확장 예시
interface CallbackWithResult<T> {
    T execute();
}

class GenericTemplate {
    public <T> T executeWithResult(CallbackWithResult<T> callback) {
        startOperation();
        
        try {
            T result = callback.execute();  // 결과를 반환하는 콜백 실행
            return result;
        } catch (Exception e) {
            handleException(e);
            return null;
        } finally {
            endOperation();
        }
    }
    
    private void startOperation() {
        System.out.println("작업을 시작합니다.");
    }
    
    private void handleException(Exception e) {
        System.out.println("예외가 발생했습니다: " + e.getMessage());
    }
    
    private void endOperation() {
        System.out.println("작업을 종료합니다.");
    }
}

// 결과를 반환하는 제네릭 템플릿 사용 예시
class ResultExample {
    public static void main(String[] args) {
        GenericTemplate template = new GenericTemplate();
        
        // 문자열 결과를 반환하는 템플릿 콜백
        String result = template.executeWithResult(() -> {
            return "작업 결과입니다.";
        });
        
        System.out.println("반환된 결과: " + result);
    }
}

public class TemplateCallbackPatternExample {
    public static void main(String[] args) {
        FileProcessor fileProcessor = new FileProcessor();
        fileProcessor.processFile("example.txt");
        
        DatabaseProcessor dbProcessor = new DatabaseProcessor();
        dbProcessor.executeQuery("SELECT * FROM users");
        
        ModernUsage.main(args);
        
        ResultExample.main(args);
    }
}

예외

이전에는 복구할 가능성이 조금이라도 있다면 체크 예외로 만든다고 생각했는데, 지금은 항상 복구할 수 있는 예외가 아니라면 일단 언체크 예외로 만드는 경향이 있다.

Checked Exception :

  • 안잡을수 없음 Unchecked Exception :
  • 예외가 호출 스택을 따라 상위로 전파된다.
  • 어떤 메서드에서도 처리되지 않으면 JVM까지 전달된다.
  • JVM이 예외 정보를 표준 오류 스트림에 출력한다.
  • 해당 스레드가 종료된다.
  • 메인 스레드였다면 애플리케이션 전체가 종료된다.

비표준 SQL

다양한 db를 쓸 수 있도록 유연하게 하기위한 jdbc의 노력

  • 두가지 이슈가 있음
    • 비표준 sql -> db 고유의 문법과 방언
    • 각각 다른 에러코드
  • 에러코드 관련해서는 일단 구현체는 만들기 쉽고 거의 있음
    • 기본 -> 약간의 상태값을 가진 SQLException
      • SQLException은 사실상 복구 불능
      • checked로 던질 이유가 없어서 jdbc가 바꿔서 던져줌
    • DataAccessException : 엄청 상세한 서브클래스를 제공
      • BadSqlGrammarException, DataIntegrityViolationException,DuplicatedException
      • 근데 문제는 db마다 에러코드 자체가 다름

Transaction 동기화

  • jdbc의 예시에서 트랜잭션의 경계설정이 필요한 경우 :

    • 커넥션이 필요함
      • 커넥션에 AutoCommit을 false로 설정하는 설정을 진행
      • 이후 commit(), rollback()의 트랜잭션 종료를 명시적으로 수행해줘야함
    • 그 커넥션을 여기저기 계층에 전달시켜야함 (메서드 파라미터 지저분해짐, 커넥션을 서비스에서 여기저기 전달해줘야함, 그리고 dao가 전달받은 커넥션을 사용해야한다는것은 dao, service에서 특정 인터페이스에 대한 종속을 야기)
  • 당장 JTA (Java Transaction API), Hibernate, JDBC등 다양한 인터페이스가 있는데 특정 인터페이스에 의존성이 생기는 상황을 막기 위해 트랜잭션을 추상화한다.

  • 기본적으로

    • 커넥션을 저장하는 저장소를 통해서 동기화
    • 트랜잭션 경계 설정 등 관련한 공통 성질을 인터페이스로 추상화 해뒀음 : PlatformTransactionManager
    • 이 인터페이스의 적절한 구현체를 사용하면서 트랜잭션을 이용할 수 있음 예를들어 JTA가 필요하다면 JTATransactionManager, JPATransactionManager
    • 당연히 구현체를 직접 쓰는건 아쉬운 선택이니까 DI를 고려하게 되는데, PlatformTransactionManager는 싱글톤으로 사용해도 안전함
  • 이부분은 나도 예전에 rust로 프로젝트를 했을 때, 엄청 고민을 많이했던 부분인데 뭔가 확실히 스프링은 해주는게 많다는걸 다시 느꼈다.

  • 여기처럼 트랜잭션 매니저를 직접 구현해서

use sqlx::{Pool, Postgres, Transaction};
use std::sync::Arc;
use crate::error::db_error::DbError;

#[derive(Clone)]
pub struct TransactionManager {
    pool: Arc<Pool<Postgres>>,
}

impl TransactionManager {
    pub fn new(pool: Pool<Postgres>) -> Self {
        Self {
            pool: Arc::new(pool),
        }
    }

    pub async fn begin_tx(&self) -> Result<Transaction<'static, Postgres>, DbError> {
        self.pool
            .begin()
            .await
            .map_err(|e| DbError::SomethingWentWrong(e.to_string()))
    }
} 
  • 서비스에서 주입받고
#[derive(Clone)]
pub struct UserTicketService {
    tx_manager: Arc<TransactionManager>,
    user_ticket_repo: Arc<dyn UserTicketRepositoryTrait>,
}
  • 아래처럼 사용했었다.
    pub async fn create_tickets_for_users(
        &self,
        user_ids: Vec<i32>,
    ) -> Result<Vec<TicketCreationResult>, ApiError> {
        let mut tx = self
            .tx_manager
            .begin_tx()
            .await
            .map_err(ApiError::Db)?;

        let mut results = Vec::new();

        for user_id in user_ids {
            let ticket_result = self
                .user_ticket_repo
                .create_ticket_in_tx(&mut tx, user_id)
                .await
                .map_err(|e| ApiError::Db(DbError::SomethingWentWrong(e.to_string())))?;

            results.push(ticket_result);
        }

        tx.commit()
            .await
            .map_err(|e| ApiError::Db(DbError::SomethingWentWrong(e.to_string())))?;

        Ok(results)
    }
  • 당시에도 이 책에서 읽은 내용이 생각나서 비슷하게 따라해본 것 도 있지만, 스프링 특유의 di집착이 지금생각하면 좋은 습관처럼 남아있게되어 이러한 부분을 인지시켜주나 싶긴 하다.
  • 실제로도 책을 다시 읽으면서 아래와 같은 문장을 다시 보니까 그냥 생각나서 주저리 한 이야기

객체지향 기술이나 패턴을 익히고 적용하는일이 어렵고 지루하게 느껴진다면, 스프링에서 DI가 어떻게 적용되고 있는지를 살펴보면서 이를 따라해보는것도 좋은 방법이다. 그러면서 좋은 코드의 특징이 무엇이고, 가치가 있는지 살펴보는 것이다. (중략) 스프링을 열심히 사용하다보면, 어느 날 자신이 만든 코드에 객체지향 원칙과 디자인 패턴의 장점이 잘 녹아있다는 사실을 발견하게 될 것 이다. 그것이 스프링을 사용함으로써 얻을 수 있는 가장 큰 장점이다.

일반적으로 서비스 추상화라고 하면 트랜잭션과 같이 기능은 유사하나 사용 방법이 다른 로우레벨의 다양한 기술에 대해 추상 인터페이스와 일관성 있는 접근 방법을 제공해주는 것을 말한다. 반면 MailService와 같이 테스트를 어렵게 만드는 건전하지 않은 방식으로 설계된 API를 사용할 때도 유용하게 쓰일 수 있다.

Dynamic Proxy와 Proxy Bean

프록시의 특징은 타깃과 같은 인터페이스를 구현했다는 것과 프록시가 타깃을 제어할 수 있는 위치에 있다는 것이다.

데코레이터 패턴은 타깃에 부가적인 기능을 런타임에 다이나믹하게 부여해주기 위해 프록시를 사용하는 패턴을 말한다.

프록시로서 동작하는 각 데코레이터는 위임하는 대상에도 인터페이스로 접근하기 때문에 자신이 최종 타깃으로 위임하는지, 아니면 다음 단계의 데코레이터 프록시로 위임하는지 알지 못한다. 그래서 데코레이터의 다음 위임 대상은 인터페이스로 선언하고 생성자나 수정자 메소드를 통해 위임 대상을 위부에서 런타임 시에 주입받을 수 있도록 만들어야 한다.

// 1. 컴포넌트 인터페이스 정의
interface DataSource {
    String read();
    void write(String data);
}

// 2. 구체적인 컴포넌트 - 기본 데이터 소스
class FileDataSource implements DataSource {
    private String fileName;
    
    public FileDataSource(String fileName) {
        this.fileName = fileName;
    }
    
    @Override
    public String read() {
        System.out.println("기본 데이터 읽기: " + fileName);
        return "원본 데이터";
    }
    
    @Override
    public void write(String data) {
        System.out.println("기본 데이터 쓰기: " + data + " to " + fileName);
    }
}

// 3. 암호화 데코레이터
class EncryptionDecorator implements DataSource {
    private DataSource wrappee;
    
    public EncryptionDecorator(DataSource source) {
        this.wrappee = source;
    }
    
    @Override
    public String read() {
        System.out.println("암호화 데코레이터: read() 호출 전");
        
        // wrappee가 기본 컴포넌트인지 다른 데코레이터인지 알 수 없음
        String data = wrappee.read();
        
        System.out.println("암호화 데코레이터: wrappee.read() 반환 값: " + data);
        String decrypted = decrypt(data);
        System.out.println("암호화 데코레이터: 복호화 결과: " + decrypted);
        
        return decrypted;
    }
    
    @Override
    public void write(String data) {
        System.out.println("암호화 데코레이터: write() 호출됨, 데이터: " + data);
        
        String encrypted = encrypt(data);
        System.out.println("암호화 데코레이터: 암호화 결과: " + encrypted);
        
        // wrappee가 기본 컴포넌트인지 다른 데코레이터인지 알 수 없음
        wrappee.write(encrypted);
        
        System.out.println("암호화 데코레이터: wrappee.write() 완료");
    }
    
    private String encrypt(String data) {
        return "암호화[" + data + "]";
    }
    
    private String decrypt(String data) {
        if (data.contains("암호화")) {
            return data.replace("암호화[", "").replace("]", "");
        }
        return "복호화[" + data + "]";
    }
}

// 4. 압축 데코레이터
class CompressionDecorator implements DataSource {
    private DataSource wrappee;
    
    public CompressionDecorator(DataSource source) {
        this.wrappee = source;
    }
    
    @Override
    public String read() {
        System.out.println("압축 데코레이터: read() 호출 전");
        
        // wrappee가 체인의 마지막인지 알 수 없음
        String data = wrappee.read();
        
        System.out.println("압축 데코레이터: wrappee.read() 반환 값: " + data);
        String decompressed = decompress(data);
        System.out.println("압축 데코레이터: 압축해제 결과: " + decompressed);
        
        return decompressed;
    }
    
    @Override
    public void write(String data) {
        System.out.println("압축 데코레이터: write() 호출됨, 데이터: " + data);
        
        String compressed = compress(data);
        System.out.println("압축 데코레이터: 압축 결과: " + compressed);
        
        // wrappee가 체인의 마지막인지 알 수 없음
        wrappee.write(compressed);
        
        System.out.println("압축 데코레이터: wrappee.write() 완료");
    }
    
    private String compress(String data) {
        return "압축[" + data + "]";
    }
    
    private String decompress(String data) {
        if (data.contains("압축")) {
            return data.replace("압축[", "").replace("]", "");
        }
        return "압축해제[" + data + "]";
    }
}

// 5. 로깅 데코레이터
class LoggingDecorator implements DataSource {
    private DataSource wrappee;
    
    public LoggingDecorator(DataSource source) {
        this.wrappee = source;
    }
    
    @Override
    public String read() {
        System.out.println("로깅 데코레이터: read() 호출 전");
        
        String result = wrappee.read();
        
        System.out.println("로깅 데코레이터: wrappee.read() 반환 값: " + result);
        return result;
    }
    
    @Override
    public void write(String data) {
        System.out.println("로깅 데코레이터: write() 호출됨, 데이터: " + data);
        
        wrappee.write(data);
        
        System.out.println("로깅 데코레이터: wrappee.write() 완료");
    }
}

// 6. 클라이언트 코드
public class DecoratorPatternDemo {
    public static void main(String[] args) {
        // 기본 컴포넌트
        System.out.println("=== 데코레이터 체인 생성 시작 ===");
        DataSource source = new FileDataSource("data.txt");
        System.out.println("기본 컴포넌트 생성: FileDataSource");
        
        // 암호화 데코레이터로 감싸기
        source = new EncryptionDecorator(source);
        System.out.println("1번째 데코레이터 추가: EncryptionDecorator");
        
        // 압축 데코레이터로 감싸기
        source = new CompressionDecorator(source);
        System.out.println("2번째 데코레이터 추가: CompressionDecorator");
        
        // 로깅 데코레이터로 감싸기
        source = new LoggingDecorator(source);
        System.out.println("3번째 데코레이터 추가: LoggingDecorator");
        
        // 데이터 쓰기 - 연쇄적 호출 시작
        System.out.println("\n=== 데이터 쓰기 연쇄 호출 시작 ===");
        source.write("테스트 데이터");
        System.out.println("=== 데이터 쓰기 연쇄 호출 완료 ===\n");
        
        // 데이터 읽기 - 연쇄적 호출 시작
        System.out.println("\n=== 데이터 읽기 연쇄 호출 시작 ===");
        String result = source.read();
        System.out.println("=== 데이터 읽기 연쇄 호출 완료 ===");
        System.out.println("최종 결과: " + result);
    }
}

일반적으로 사용하는 프록시라는 용어와 디자인 패턴에서 말하는 프록시 패턴은 구분할 필요가 있다. 전자는 클라이언트와 사용 대상 사이에 대리 역할을 맡은 오브젝트를 두는 방법을 총칭한다면,

후자는 프록시를 사용하는 방법중에서 타깃에 대한 접근방법을 제어하려는 목적을 가진 경우를 가리킨다.

프록시 패턴의 프록시는 타깃의 기능을 확장하거나 추가하지 않는다. 대신 클라이언트가 타깃에 접근하는 방식을 변경해준다.

리플렉션

리플렉션은 자바의 코드 자체를 추상화해서 접근하도록 만든 것이다.

다이나믹 프록시

  • 런타임시 프록시 팩토리에 의해 만들어지는 오브젝트
  • 타겟의 인터페이스와 같은 타입으로 만들어짐
  • 프록시로서 필요한 부가기능 제공 코드는 직접 작성 필요.
interface Hello {
    String sayHello(String name);
    String sayHi(String name);
    String sayThankyou(String name);
}

class HelloTarget implements Hello {
    @Override
    public String sayHello(String name) {
        return "Hello " + name;
    }

    @Override
    public String sayHi(String name) {
        return "Hi " + name;
    }

    @Override
    public String sayThankYou(String name) {
        return "Thank you " + name;
    }
}

public class UpperCaseHandler implement InvocationHandler {
    Hello target;

    public UpperCaseHandler(Hello target) {
        this.target = target;
    }

    public Object invoke(Object proxy, Method method, Objects[] args) throws Throwable {
        String ret = (String) method.invoke(target, args);
        return ret.toUpperCase();
    }


}

...

Hello proxiedHello = (Hello) Proxy.newProxyInstance(
	Hello.class.getClassLoader(), // 클래스 로더
	new Class[]{Hello.class}, // 구현할 인터페이스들
	new UpperCaseHandler(new HelloTarget()) // 프록시 인스턴스의 메서드 호출을 처리할 InvocationHandler
);
  • 문제는 이렇게 newProxyInstance()를 통해 만들어진 다이나믹 프록시를 빈등록해서 DI를 할수 없다는것
  • 그래서 팩토리 빈을 사용

public interface FactoryBean<T> {
	T getObject() throws Exception;
	Class<? extends T> getObjectType();
	boolean isSingleton();
}
  • 이 인터페이의 구현체를 스프링 빈으로 등록하면 팩토리 빈으로 동작하며 getObject()를 빈으로 관리해줌

다이나믹 프록시를 적용한 TransactionInterceptor의 동작 방식 정리

  1. 인터셉터 구조:

    • MethodInterceptor 인터페이스를 구현한다.
    • AOP 프록시 체인에서 메서드 호출을 가로채 트랜잭션 로직을 추가한다.
    public class TransactionInterceptor extends TransactionAspectSupport implements MethodInterceptor, Serializable {
        @Override
        public Object invoke(MethodInvocation invocation) throws Throwable {
            // 트랜잭션 처리 로직
            return invokeWithinTransaction(invocation.getMethod(), invocation.getTargetClass(), invocation::proceed);
        }
    }
    
  2. invoke() 메서드:

    • invoke(MethodInvocation invocation) 메서드가 핵심.
    • 타겟 메서드 호출을 가로채 트랜잭션 처리 로직을 적용한다.
  3. 트랜잭션 속성 결정:

    • TransactionAttributeSource를 사용해 메서드에 적용할 트랜잭션 속성을 결정한다.
    • @Transactional 애노테이션 설정에서 속성을 가져온다.
    TransactionAttributeSource tas = getTransactionAttributeSource();
    TransactionAttribute txAttr = tas.getTransactionAttribute(method, targetClass);
    
  4. 트랜잭션 처리 과정:

    // invokeWithinTransaction 메서드의 핵심 로직
    protected Object invokeWithinTransaction(Method method, Class<?> targetClass, InvocationCallback invocation) throws Throwable {
        // 1. 트랜잭션 속성 및 트랜잭션 매니저 조회
        TransactionAttributeSource tas = getTransactionAttributeSource();
        TransactionAttribute txAttr = tas.getTransactionAttribute(method, targetClass);
        PlatformTransactionManager tm = determineTransactionManager(txAttr);
    
        // 2. 트랜잭션 시작
        TransactionInfo txInfo = createTransactionIfNecessary(tm, txAttr, methodName);
    
        Object retVal;
        try {
            // 3. 실제 타겟 메서드 호출
            retVal = invocation.proceedWithInvocation();
        }
        catch (Throwable ex) {
            // 4. 예외 발생 시 롤백 처리
            completeTransactionAfterThrowing(txInfo, ex);
            throw ex;
        }
        finally {
            // 5. 트랜잭션 리소스 정리
            cleanupTransactionInfo(txInfo);
        }
    
        // 6. 트랜잭션 커밋
        commitTransactionAfterReturning(txInfo);
        return retVal;
    }
    
  5. 트랜잭션 생성 로직:

    • createTransactionIfNecessary 메서드는 속성과 현재 상태에 따라 트랜잭션을 시작하거나 참여한다.
    • 트랜잭션 전파 속성에 따라 다른 동작을 수행한다.
    protected TransactionInfo createTransactionIfNecessary(PlatformTransactionManager tm, 
                                                          TransactionAttribute txAttr, 
                                                          String joinpointIdentification) {
        // 트랜잭션 상태 조회
        TransactionStatus status = tm.getTransaction(txAttr);
        return prepareTransactionInfo(tm, txAttr, joinpointIdentification, status);
    }
    
  6. 예외 처리 및 롤백 결정:

    • completeTransactionAfterThrowing 메서드로 예외 유형에 따라 롤백 여부를 결정한다.
    protected void completeTransactionAfterThrowing(TransactionInfo txInfo, Throwable ex) {
        if (txInfo != null && txInfo.hasTransaction()) {
            if (txInfo.transactionAttribute.rollbackOn(ex)) {
                // 롤백 조건 만족 시 롤백 수행
                txInfo.getTransactionManager().rollback(txInfo.getTransactionStatus());
            } else {
                // 롤백 조건 미만족 시 커밋 수행
                txInfo.getTransactionManager().commit(txInfo.getTransactionStatus());
            }
        }
    }
    
  7. 트랜잭션 연결:

    • TransactionSynchronizationManager를 통해 트랜잭션 리소스를 현재 실행 스레드와 연결한다.
    // DataSourceTransactionManager의 예
    protected void doBegin(Object transaction, TransactionDefinition definition) {
        // 커넥션 획득 및 설정
        Connection con = DataSourceUtils.getConnection(dataSource);
        // 트랜잭션 속성 설정
        con.setAutoCommit(false);
        // 현재 스레드에 리소스 바인딩
        TransactionSynchronizationManager.bindResource(dataSource, new ConnectionHolder(con));
        // 트랜잭션 동기화 활성화
        TransactionSynchronizationManager.initSynchronization();
    }
    

참고 : [[스프링-트랜잭션-정리]]

TLDR

  1. 컨텍스트에서 트랜잭션 관리 기능이 중복됨
  2. 프록시를 통해 트랜잭션 관리를 해주는 데코레이터를 추가
  3. 해당 데코레이터를 자동으로 만들어주는 다이나믹 프록시 기능이 있음
  4. 다이나믹 프록시는 타겟의 인터페이스 정보를 바탕으로 리플렉션을 이용해서 인터페이스의 메소드와 아규먼트를 가로채서 전달해줌
  5. 다이나믹 프록시를 생성하려면 데코레이터에 InvocationHandler를 구현해놔야하는데, 여기서 override하는 invoke()메소드는 프록시가 호출되었을때, method(리플렉션의)와 arguments들을 전달받음
  6. 즉 구현한 invoke()메서드 안에서 데코레이팅 로직을 수행하고, 타겟의 메서드를 호출해주는식
  7. 그리고 이 다이나믹 프록시를 빈등록해야하는데, 그게 생성자통해서 오브젝트가 생성되는 구조가 아니기 때문에 FactoryBean을 구현해서 빈을 등록 즉 아래처럼 등록된 서비스에 트랜잭션 기능을 일괄 적용한다면
<bean id="someServiceTarget" class="complex.module.SomeServiceImpl">
	<property name="someDao" ref="someDao" />
</bean>

이처럼 구현해서 풀 수 있다.

<bean id="someService" class="springprjt.service.TxProxyFactoryBean">
	<property name="target" ref="someServiceTarget" />
	<property name="transactionManager" ref="transactionManager" />
	<property name="serviceInterface" ref="complex.module.CoreService" />
</bean>

프록시가 좋음에도 프록시를 구현하지 않았던 두가지 이유

  1. 프록시를 적용할 대상이 구현하고 있는 인터페이스를 구현하는 프록시 클래스를 일일히 만들어야함
  2. 부가적인 기능이 여러 메소드에 반복되어 나타나게됨 을 해결한것
  • 1번은 리플렉션 기반의 동적 프록시를 이용해서 인터페이스 없이 Method의 추상화와 argument전달등을 통해
  • 2번은 1번이 되니까 (reflection을 통해 타겟 인터페이스를 모르는 상태로 호출하는게 되니까) 한번만 invoke()해도 되니까 해결됨

근데 또 프록시 팩토리 빈의 한계

  • 하나의 클래스 안에 존재하는 여러 개의 메소드에 부가기능을 한 번에 제공하는건 어렵지 않지만, 한번에 여러개의 클래서에 공통적인 부가기능을 제공해야 한다면?
  • 하나의 타겟에 여러개 제공도 xml설정이 엄청 늘어난다면?
  • 그리고 TransactionHandler가 엄청나게 많아지는 이슈 (오브젝트 수가)

ProxyFactoryBean

자바에는 jdk에서 제공하는 다이나믹 프록시 외에도 편리하게 프록시를 만들 수 있도록 지원해주는 다양한 기술이 존재한다. 따라서 스프링은 일관된 방법으로 프록시를 만들 수 있게 도와주는 추상 레이어를 제공한다.

  • 스프링의 ProxyFactoryBean은 프록시를 생성해서 빈 오브젝트로 등록하게 해주는 팩토리 빈이다.
  • 기존 FactoryBean과는 달리 ProxyFactoryBean은 순수하게 프록시를 생성하는 작업만을 담당하고 프록시를 통해 제공해줄 부가기능은 별도의 빈에 둘 수 있다.
  • 그리고 여기서 생성하는 프록시에서 사용할 부가기능은 MethodInterceptor인터페이스를 통해서 만든다.
  • MethodInterceptor는 InvocationHanlder와 비슷하지만 한가지 다른점이 있는데, invoke()메소드는 타깃 오브젝트에 대한 정보를 제공하지 않는다는 것이다.
  • 그래서 타겟에 대한 정보는 InvocationHanlder를 구현한 클래스가 직접 알고 있어야 했다.
  • 반면에 MethodInterceptor는 타겟 오브젝트의 정보도 제공받는다.
  • 그래서 타깃 오브젝트와 상관 없이 독립적으로 만들어 질 수 있고, 타겟이 다른 여러 프록시에서 함께 사용할 수있다.

어드바이스: 타깃이 없는 순수한 부가기능

MethodInvocation은 일종의 콜백 오브젝트로, proceed() 메소드를 실행하면 타깃 오브젝트의 메소드를 내부적으로 실행해주는 기능이 있다. “그렇다면 MethodInvocation 구현 클래스는 일종의 공유 가능한 탬플릿처럼 동작하는 것이다.” ProxyFactoryBean은 작인 단위의 탬플릿/콜백 구조를 응용해서 적용했기 때문에 탬플릿 역할을 하는 MethodInvocation을 싱글톤으로 두고 공유할 수 있다. 마치 SQL 파라미터 정보에 종속되지 않는 JdbcTemplate이기 때문에 수많은 DAO메서드가 하나의 jdbcTemplate 오브젝트를 공유할 수 있는 것과 마찬가지이다.

MethodInterceptor처럼 타겟 오브젝트에 적용하는 부가기능을 담은 오브젝트를 스프링에서는 어드바이스라고 부른다.

타겟의 인터페이스 타입을 제공받지 않고도 ProxyFactoryBean은 어떻게 인터페이스를 구현한 프록시를 만들어 낼 수 있을까?

그냥 자동 검출 기능이 있음.. 뭐 리플렉션에서 받아오겠지?

포인트컷: 부가기능 적용 대상 메서드 선정 방법

  • 템플릿/콜백 구조를 응용했고, 싱글톤 빈으로 해둔 덕분에 여러 프록시가 공유해서 사용할 수 있지만, 대신 어떤 메소드가 호출될지와 같은 구현과 관련한 부가정보를 담을수는 없다.
  • MethodInterceptor는 InvocationHandler와는 다르게 프록시가 클라언트로부터 받는 요청을 일일이 전달받을 필요는 없다. MethodInterceptor에는 재사용 가능한 순수한 부가기능 제공 코드만 남겨주는 것이다.
  • 대신 프록시에 부가기능 적용 메소드를 선택하는 기능을 넣는다.
  • 물론 프록시의 핵심 가치는 티겟을 대신해서 클라이언트의 요청을 받아 처리하는 오브젝트로서의 존재 자체이므로 메소드를 선별하는 기능은 프록시로부터 다시 분리하는 편이 낫다.

기존 InvocationHandler 오브젝트는, 오브젝트 차원에서 특정 타겟을 위한 프록시에 제한된다는 뜻이다. 그래서 InvocationHandler는 굳이 빈으로 등록하는 대신 팩토리 내부에서 매번 생성하도록 만들었던 것이다.

스프링은 부가기능을 제공하는 오브젝트를 어드바이스라고 부르고, 메소드 선정 알고리즘을 담은 오브젝트를 포인트컷이라고 부른다. 어드바이스와 포인트컷은 모두 프록시에 DI로 주입돼서 사용된다. 두가지 모두 여러 프록시에서 공유가 가능하도록 만들어지기 때문에 싱글톤 빈으로 등록이 가능하다.

프록시는 클라이언트로부터 요청을 받으면 먼저 포인트컷에게 부가기능을 부여할 메소드인지를 확인해달라고 요청한다. 포인트컷은 Pointcut 인터페이스를 구현해서 만들면 된다. 프록시는 포인트컷으로부터 부가기능을 적용할 대상 메소드인지 확인 받으면, MethodInterceptor 타입의 어드바이스를 호출한다. 어드바이스는 jdk의 다이나믹 프록시처럼 직접 타겟을 호출하지 않는다. 자신이 공유돼야 하므로 타깃 정보라는 상태를 가질 수 없다. 따라서 타겟에 직접 의존하지 않도록 일종의 템플릿 구조로 설계되어 있다. 어드바이스가 부가기능을 부여하는 중에 타깃 메소드의 호출이 필요하면 프록시로부터 전달받은 MethodInvocation 타입 콜백 오브젝를 실행하기 때문이다.

실제 위임 대상인 타겟 오브젝트의 레퍼런스를 갖고 있고, 이를 이용해 타겟 메소드를 직접 호출하는 것은 프록시가 메소드 호출에 따라 만드는 Invocation 콜백의 역할이다.

@Test
public void pointcutAdvisor() {
    ProxyFactoryBean pfBean = new ProxyFactoryBean();
    pfBean.setTarget(new HelloTarget());
    
    NameMatchPoincut poincut = new NameMatchPointCut();
    poincut.setMappedName("sayH*");

	// Pointcut Advice 같이 전달
	// 여러 포인트컷과 어드바이스를 매핑할 수 있기에 이렇게 두개 전다.
	// 어드바이저 = 포인트컷 + 어드바이스
    pfBean.addAdvisor(new DefaultPointcutAdvisor(pointcut, new UppercaseAdvice()));

    Hello proxiedHello = (Hello) pfBean.getObject();

    assertThat(proxiedHello.sayHello("Toby"), is("HELLO TOBY"));
    assertThat(proxiedHello.sayHello("Toby"), is("HI TOBY"));
    assertThat(proxiedHello.sayHello("Toby"), is("Thank You TOBY"));
}

지금까지 해왔던 작업의 목표는 비즈니스 로직에 반복적으로 등장해야만 했던 트랜잭션 코드를 깔끔하고 효과적으로 분리해내는 것이다. 이렇게 분리해낸 트랜잭션 코드는 투명한 부가기능 형태로 제공돼야 한다. 투명하다는건 부가기능을 적용한 후에도 기존 설계와 코드에는 영향을 주지 않는다는 뜻이다/

빈 후처리기를 이용한 자동 프록시 생성기

  • 스프링 빈 오브젝트로 만들어지고 난 이후에 오브젝트를 다시 가공하는 인터페이스
  • DafaultAdvisorAutoProxyCreator : 자동 프록시 생성기 -> 빈을 만들때마다 후처리기로 보내고, 여기서 모든 어드자이저 내의 포인트컷을 뒤지고 대상이라면 그때 프록시를 만들어 어드바이저를 연결해준다.
  • 이건 아래의 인터페이스를 보면 알 수 있듯, Pointcut이 클래스필터와 메소드 매처 두가지를 돌려주는 메소드를 가지고 있기 때문에 가능하다.
public interface Pointcut {
	ClassFilter getClassFilter(); // 프록시를 적용할 클래스인지 확인
	MethodMatcher getMethodMatcher(); // 어드바이스를 적용할 메소드인지 확인
}

Aspect란 그 자체로 애플리케이션의 핵심 기능을 담고 있지는 않지만, 애플리케이션을 구성하는 중요한 한 가지 요소이고, 핵심 기능에 부가되어 의미를 갖는 특별한 모듈

AOP 용어 정리

  • 타겟 : 부가기능을 부여할 대상, 핵심기능을 담은 클래스일 수 있지만 경우에 따라서는 다른 부가기능을 제공하는 또다른 프록시 일 수 있음
  • 어드바이스 : 타겟에 제공할 부가기능을 담은 모듈, 메소드 레벨의 정의도 가능함, 호출 과정 전반에 참여할수도있지만 예외가 동작할때만 동작하는 어드바이스처럼 호출 과정의 일부에만 동작할수도 있음
  • 조인 포인트 : 어드바이스가 적용될 수 있는 위치
  • 포인트컷 : 어드바이스를 적용할 조인 포인트를 선별하는 작업 또는 그 기능을 정의한 모듈
  • 프록시 : 클라이언트와 타겟 사이에 투명하게 존재하면서 부가기능을 제공하는 오브젝트
  • 어드바이저: 포인트컷과 어드바이스를 하나씩 가지고 있는 오브젝트, 어떤 부가기능을 어디에 전달할 것인가를 알고있는 가장 기본 모듈

프록시 방식 aop는 같은 타겟 오브잭트 내의 메소드를 호출할 때는 적용되지 않는다

  • 클라이언트가 호출하면 프록시가 호출되고 트랜잭션 프록시를 타게된다,
  • 다만 타겟 오브젝트 내에서 다른 메소드를 호출하는 경우에는 직접 호출된다.

기껏해야 get으로 시작하는 메서드에 읽기전용 속성을 부여하고 REQUIRED전파 속성을 사용하는 정도에는 무시해도 된다. 다만 복잡한 트랜잭션 전파속성을 적용해야하는 경우는 주의가 필요하다.

  • 읽기전용 속성은 데이터베이스 최적화를 위한 힌트일 뿐이다.
  • REQUIRED 전파 속성은 이미 트랜잭션이 있으면 그것을 사용하므로 내부 호출에도 트랜잭션 컨텍스트가 유지된다.
  • 해결방법은 두가지이다.
    • Aspectj 사용
    • 스프링 API를 이용해 프록시 오브젝트에 대한 레퍼런스를 가져온 뒤에 같은 오브젝트의 메소드 호출도 프록시를 타게 강제하는 방법 -> 권장하지 않는다.

Transactional 어노테이션

@Target({ElementType.METHOD, ElementType.TYPE}) // 어노테이션 사용 대상
@Retention(RetentionPolicy.RUNTIME) // 어노테이션 정보 유지 기한
@Inherited // 상속해도 가능하도록
@Documented
public @interface Transactional {
    String value() default "";
    Propagation propagation() default Propagation.REQUIRED;
    Isolation isolation() default Isolation.Default;
    ...
}

자바 ORM 표준 JPA 프로그래밍

영속성 컨텍스트가 엔티티관리하는 장점

  • 1차 캐시 : 영속성 컨텍스트의 캐시는 인스턴스를 캐시하므로 동일성이 보장됨 + 어플리케이션 레벨의 REPEATABLE READ
  • 동일성 보장
  • 트랜잭션을 지원하는 쓰기 지연
  • 변경 감지
  • 지연 로딩

결론적으로 그냥 db io를 줄여줌, jpa, hibernate 크게는 orm의 독자적인 장점으로 보기는 어렵지만 생태계가 이쪽으로 발전했기 때문에 이만큼 해주는게 없지않나 싶음, 난이도나 공수가 올라가거나

jpa 수정

  • @org.hibernate.annotations.DynamicUpdate : 특정 칼럼만 업데이트 하는 update쿼리를 작성해줌, 오버헤드를 따져봤을때 칼럼 30개이상은 되어야 유의미한것같음

[[양방향-순환참조]]

프록시와 지연로딩

// 내부적으로 생성되는 프록시 클래스의 이론적 구조
public class MemberProxy extends Member {
    private Member target = null;
    
    @Override
    public String getName() {
        if (target == null) {
            // 초기화 요청
            initializeTarget();
        }
        return target.getName();
    }
    
    private void initializeTarget() {
        // 영속성 컨텍스트를 통해 실제 엔티티 로딩
        // target 필드에 할당
    }
}

위는 이론적 구조이고, 실제는 자바 바이트코드 조작 라이브러리를 이용해서 처리됨

  • 컬렉션을 하나 이상 즉시 로딩하는것은 권장하지 않음, 컬렉션과 조인한다는것은 일대다 조인으로 이뤄지는데 n x m 조인이 이루어지는경우가 생김
  • 그리고 외부조인으로 사용되기때문에 더더욱 안됨

컬렉션 즉시 로딩(EAGER)의 문제점

  1. 카테시안 곱(Cartesian Product) 문제
    • 여러 컬렉션을 즉시 로딩하면 SQL JOIN에서 N × M 개의 결과 행이 발생함
    • 이는 애플리케이션에 불필요한 데이터가 로드되어 성능과 메모리에 심각한 영향을 미침
  2. 외부 조인(OUTER JOIN) 사용
    • JPA는 일대다 관계에서 기본적으로 외부 조인을 사용함
    • 여러 외부 조인은 쿼리 성능을 더욱 저하시킴

엔티티 클래스

@Entity
public class User {
    @Id @GeneratedValue
    private Long id;
    private String name;
    
    @OneToMany(mappedBy = "user", fetch = FetchType.EAGER)
    private List<Order> orders = new ArrayList<>();
    
    @OneToMany(mappedBy = "user", fetch = FetchType.EAGER)
    private List<Post> posts = new ArrayList<>();
}

@Entity
public class Order {
    @Id @GeneratedValue
    private Long id;
    private String orderNumber;
    
    @ManyToOne
    private User user;
}

@Entity
public class Post {
    @Id @GeneratedValue
    private Long id;
    private String title;
    
    @ManyToOne
    private User user;
}

샘플 데이터

User 테이블:

id name
1 Alice
2 Bob

Order 테이블:

id order_number user_id
1 ORD-001 1
2 ORD-002 1
3 ORD-003 2

Post 테이블:

id title user_id
1 First Post 1
2 Second Post 1
3 Hello World 2
4 My Story 2

생성되는 SQL 예시

SELECT u.*, o.*, p.*
FROM User u
LEFT OUTER JOIN Order o ON u.id = o.user_id
LEFT OUTER JOIN Post p ON u.id = p.user_id

결과 (N × M 문제)

u.id u.name o.id o.order_number o.user_id p.id p.title p.user_id
1 Alice 1 ORD-001 1 1 First Post 1
1 Alice 1 ORD-001 1 2 Second Post 1
1 Alice 2 ORD-002 1 1 First Post 1
1 Alice 2 ORD-002 1 2 Second Post 1
2 Bob 3 ORD-003 2 3 Hello World 2
2 Bob 3 ORD-003 2 4 My Story 2

문제점 설명:

  • User ‘Alice’가 2개의 주문과 2개의 게시물을 가지고 있어 4개의 행이 생성됨(2×2=4)
  • User ‘Bob’이 1개의 주문과 2개의 게시물을 가지고 있어 2개의 행이 생성됨(1×2=2)
  • 원래 User는 2명이지만, 결과 행은 총 6개로 증가함
  • 데이터가 불필요하게 중복되어 메모리 사용량 증가 및 성능 저하

대안

  • 지연 로딩(LAZY) 사용
    • 컬렉션은 항상 지연 로딩으로 설정하고 필요할 때만 로딩
  • 페치 조인(Fetch Join) 사용
// 필요한 컬렉션만 선택적으로 로딩
String jpql = "SELECT u FROM User u JOIN FETCH u.orders WHERE u.id = :id";
User user = em.createQuery(jpql, User.class)
              .setParameter("id", userId)
              .getSingleResult();
  • @EntityGraph 사용
@EntityGraph(attributePaths = {"orders"})
@Query("SELECT u FROM User u WHERE u.id = :id")
User findUserWithOrders(@Param("id") Long id);
  • DTO 사용
@Query("SELECT new com.example.UserOrderDTO(u.id, u.name, o.id, o.orderNumber) " + "FROM User u JOIN u.orders o WHERE u.id = :id") List<UserOrderDTO> findUserOrderDTOs(@Param("id") Long id);
  • 배치 사이즈 조정 (@BatchSize)
@OneToMany(mappedBy = "user", fetch = FetchType.LAZY)
@BatchSize(size = 100)
private List<Order> orders = new ArrayList<>();
  • N+1 문제는 여전히 발생하지만, IN 쿼리를 사용해 한 번에 여러 컬렉션을 로딩하도록 최적화

영속성전이와 고아객체

영속성 전이

  • 부모 엔티티의 영속성 상태 변화가 자식 엔티티에게도 전이되는 기능
  • 예를 들어 부모를 persist(), remove() 하면 자식도 같이 persist(), remove() 됨
  • 주로 @OneToMany, @ManyToOne, @OneToOne 관계에서 사용
옵션 설명
PERSIST 부모 저장 시 자식도 저장
REMOVE 부모 삭제 시 자식도 삭제
MERGE 부모 갱신 시 자식도 갱신
DETACH 부모 detach 시 자식도 detach
REFRESH 부모 refresh 시 자식도 refresh
ALL 위의 모든 기능 포함

고아객체

  • 부모와의 관계가 끊어진(연관관계가 제거된) 자식 엔티티를 자동으로 remove() 처리하는 기능
  • 물리적으로 DB에서 자식 row를 삭제함
  • orphanRemoval = true 로 설정

동작 조건

  • 자식이 부모 컬렉션에서 제거되거나, 부모 엔티티와의 연관관계가 끊어질 때
  • 보통 정말 명확하면 : CascadeType.ALL + orphanRemoval = true
  • 애매하면 : CascadeType.PERSIST