
일단 @OneToOne이란, JPA에서 사용되는 어노테이션으로 엔티티 간의 일대일 관계를 매핑할 때 사용함.
이때 매일메일에 나온 설명으론, @OneToOne일 때 연관관계의 주인이 아닌 엔티티를 조회할 경우 Lazy Loading이 동작하지 않음.
JPA는 연관된 엔티티가 없으면 null로 초기화,
있으면 Lazy Loading이 설정되어 있을 경우 프록시 객체로 초기화 함.
Lazy Loading은 사용자가 보지 않는 것들을 당장 로딩하지 않고 사용자가 필요로 하는 시점에 로딩하는 것을 의미한다고 한다.
프록시 객체란, 다른 객체를 대신하여 그 객체에 대한 접근을 제어하거나 기능을 확장하는 객체를 의미한다.
-> 즉, 가짜 객체를 의미한다.
그럼 지금 상황을 정리하면, JPA는 연관된 엔티티가 있고 Lazy Loading이 설정되어 있다면 가짜 객체로 초기화를 시켜놓는다.
그럼 우리가 이 상황에서 주의해야 할 점은 무엇일까?
DB 테이블 관점에선 연관관계의 주인이 아닌 엔티티는 연관관계를 참조할 FK가 없기 때문에 연관관계의 존재 여부를 알 수 없다.
그래서 JPA는 null 혹은 프록시 객체 중 무엇을 초기화 할 지 결정할 수 없게 되고 결과적으로 엔티티의 존재 여부를 확인하는 추가 쿼리를 실행하기 때문에 Lazy Loading이 동작하지 않는다. JPA의 한계이기 때문에 단방향으로 모델링하거나 Lazy Loading이 정말 필요한 것인지 확인해봐야 한다.
->
일단 좀 정리해서 생각하면, 어떠한 테이블이 외래키를 가지고 있다. (= 연관관계 주인이다)
주인이 아닌 엔티티는 연관관계를 참조할 FK가 없기 때문에 존재 여부 확인 불가하다. (= 이거 맞지)
JPA는 null 혹은 프록시 객체 중 무엇을 초기화 할 지 결정할 수 없게 되고 ( 연관 엔티티 여부에 따라 무엇으로 초기화 할 지 결정하므로 결정 불가함)
그러면 이때 추가 쿼리를 실행한다 ( 존재 여부를 확인한다 -> 지금 당장 로딩해서 접근하므로 Lazy Loading이 동작하지 않는다.)
-> 결국은 Lazy Loading의 발생시키고 싶지만 필연적으로 FK가 없기 때문에 해당 부분을 조회하면서 Lazy Loading에 실패한다는 의미다. 따라서 이 부분이 JPA의 한계점이고 이를 우회하여 모델링이 일어나거나 Lazy Loading을 남발한 것은 아닌지 확인해야 한다.
여기부터는 개인 공부 용도
그럼 항상 그렇듯이 파이썬에선?
파이썬에선 SQLAlchemy의 OneToOne & Lazy Loading이 있다.
파이썬에서는 relationship + unique 설정으로 OneToOne을 만든다.
class Parent(Base):
__tablename__ = 'parent'
id = Column(Integer, primary_key=True)
child = relationship('Child', back_populates='parent', uselist=False, lazy='select') # OneToOne
class Child(Base):
__tablename__ = 'child'
id = Column(Integer, primary_key=True)
parent_id = Column(Integer, ForeignKey('parent.id'), unique=True)
parent = relationship('Parent', back_populates='child')
여기서 uselist=False 가 1:1임을 의미한다.
unique = True, lazy="select"
Java JPA보다 SQLAlchemy가 Lazy Loading에 더 유연하게 작동한다.
하지만 세션이 닫힌 뒤에 lazy loading 접근 시 오류 발생한다. (DetachedInstanceError -> 에러코드에 대해서 어떤 로직? 추가 분석 필요)
-> 이왕 글 쓰기 시작한 김에 좀 더 찾아보자
class DetachedInstanceError(sa_exc.SQLAlchemyError):
"""An attempt to access unloaded attributes on a
mapped instance that is detached."""
code = "bhk3"
-> 일단 SQLAlchemyError라는 클래스를 상속받아서 일어난다.
여기서 code = bhk3는 SQLAlchemy의 내부적으로 예외를 분류하기 위한 에러코드임.
SQLAlchemy의 각 Exception 클래스에는 고유한 code 문자열이 할당되어 있다.
여기서 다음 단계는 LazyLoading 시 세션 체크 및 에러 발생 부분이다.
당연하게도 위에서 설명한 것과 같이 session 연결이 없는 경우는 바로 에러를 처리하는 부분으로 넘어가게 된다.
# github.com/sqlalchemy/sqlalchemy/blob/main/lib/sqlalchemy/orm/loading.py
from . import exc as orm_exc
def _load_for_state(state, ...):
session = state.session
if session is None:
raise orm_exc.DetachedInstanceError(
"Parent instance %s is not bound to a Session; "
"lazy load operation of attribute '%s' cannot proceed"
% (orm_util.instance_str(state.obj()), key)
)
# ... 이후 실제 로딩 로직
-> 챗 지피티가 알려준 코드는 이런 식이었다.
실제로 보고 싶어서 github에 들어가서 검색한 결과

리팩토링 되었나 생각이 들었다.

한번 하나하나 쫓아가보면,
class InstrumentedAttribute(QueryableAttribute[_T_co]):
@overload
def __get__(
self, instance: None, owner: Any
) -> InstrumentedAttribute[_T_co]: ...
@overload
def __get__(self, instance: object, owner: Any) -> _T_co: ...
def __get__(
self, instance: Optional[object], owner: Any
) -> Union[InstrumentedAttribute[_T_co], _T_co]:
if instance is None:
return self
dict_ = instance_dict(instance)
if self.impl.supports_population and self.key in dict_:
return dict_[self.key] # type: ignore[no-any-return]
else:
try:
state = instance_state(instance)
except AttributeError as err:
raise orm_exc.UnmappedInstanceError(instance) from err
return self.impl.get(state, dict_)
해당 클래스 내부에 __get__ 메서드가 이렇게 3가지가 있다.
1,2 get함수의 경우
1번 __get__ 함수의 경우 파이썬의 descriptor 프로토콜에서 나온 패턴임
-> 속성에 인스턴스가 아니라 클래스로 접근한 경우 (parent.child) 해당 디스크립터 객체(여기서는 인스턴스 자신)를 반환함.
2번 __get__의 경우 정적 타입 체크 차이로 나뉜 코드
-> instance가 none인지 Object인지 (none : 클래스(Parent.child), object : 인스턴스(parent.child))
이때 클래스 단위의 접근이 된 경우 Instrumented[_T_co], 인스턴스의 접근이 된 경우 -> _T_co (실제 관계 / 컬럼 값)
3번 get함수
dict_ = instance_dict(instance)
if self.impl.supports_population and self.key in dict_:
return dict_[self.key]
else:
try:
state = instance_state(instance)
except AttributeError as err:
raise orm_exc.UnmappedInstanceError(instance) from err
return self.impl.get(state, dict_)
마지막 self.impl.get(state, dict_) 이 부분이 진짜 Lazy Loading 실행되는 지점
일단 여기까지가 lazy loading 진입점이다.
이제 남은 확인 구간은 impl.get()과 strategies.py(세션 체크, DB 쿼리 실행)와 state.py(인스턴스의 세션 바인딩 상태 관리), loading.py (데이터 로딩 보조 함수)이다.
근데 impl.get함수를 확인하려 했으나, 이것도 리팩토링 되었다. 여기까지 확인해버리면 지금 볼륨이 너무 커질 거 같아서 일단 옛날 코드 기준으로 로직만 확인하면,
impl.get()함수는 속성마다 구현체가 다르다고 한다.
컬럼이면 ScalarAttributeImpl, 관계라면 RelationshipAttributeImpl이다.
class RelationshipAttributeImpl(AttributeImpl):
...
def get(self, state, dict_):
# 관계 전략(strategy, 보통 LazyLoader)을 선택해서 위임
return self.strategy(state, dict_, passive=PASSIVE_OFF)
관계라면 이 클래스의 get함수가 주로 호출된다.
이때 strategy (로딩 전략) 객체를 호출하게 된다.
state는 인스턴스의 상태정보이다.
이렇게 되면 self.strategy를 호출하게 되는데
해당 부분을 호출하게 되면, lazyLoader 객체를 불러오게 된다.
class LazyLoader(AbstractRelationshipLoader):
...
def __call__(self, state, passive):
session = state.session
if session is None:
raise orm_exc.DetachedInstanceError(...)
# 세션 있으면 실제 쿼리 날려서 값 로드
...
이렇게 LazyLoader객체를 생성해서 반환하게 되고, 해당 코드 내부에서 session 연결 여부를 확인하고 DetachedInstanceError를 호출하게 된다. (이것만 봐도 실제 코드는 생각보다 더욱 많이 각 기능에 따라 분할 되어 있고, 이 마저도 기능 중심으로 뭉쳐있는 형태라 지금의 SQLAlchemy 코드는 더욱 많이 세분화되고 리팩토링 되었다.) 코드 잘짜고 싶다.
| [매일메일] 자바에서 Object 타입인 value를 String으로 타입 캐스팅하는 것과 String.valueOf()를 사용하는 것의 차이점은 무엇인가요? (1) | 2025.07.11 |
|---|---|
| [매일메일] Infrastructure as Code (Iac)에 대해 설명해주세요 (0) | 2025.07.10 |
| [매일메일] String 객체는 가변일까요, 불변일까요? (0) | 2025.07.08 |
| [매일메일] 네트워크에서 회선 교환 방식과 패킷 교환 방식은 어떤 차이점이 있나요? (0) | 2025.07.07 |
| [매일메일] try-with-resources에 대해 설명해 주세요 (0) | 2025.07.04 |