Today's Codekata
# The Number of Employees Which Report to Each Employee
SELECT
e.employee_id,
e.name,
COUNT(r.employee_id) AS reports_count,
ROUND(AVG(r.age)) AS average_age
FROM Employees e
INNER JOIN Employees r ON r.reports_to = e.employee_id
GROUP BY e.employee_id
ORDER BY e.employee_id;
# Primary Department for Each Employee
SELECT e1.employee_id,
COALESCE(e2.department_id, e1.department_id) AS department_id
FROM Employee e1
LEFT JOIN (
SELECT employee_id, department_id
FROM Employee
WHERE primary_flag = 'Y'
) e2 ON e1.employee_id = e2.employee_id
GROUP BY e1.employee_id;
# Triangle Judgement
SELECT x, y, z,
CASE WHEN x + y > z AND x + z > y AND y + z > x THEN 'Yes'
ELSE 'No' END AS triangle
FROM Triangle;
// 피보나치 수
public int solution(int n) {
int a = 0, b = 1;
for (int i = 2; i <= n; i++) {
int temp = (a + b) % 1234567;
a = b;
b = temp;
}
return b;
}
처음엔 외부에서 % 1234567로 나눈 나머지를 구했었는데, 계속 테스트에서 실패가 떠서 long 타입도 사용해봤으나 temp 값이 너무 커져서 실패로 나왔다는 것을 알게 되었다. 알고 보니 지금 구하는 값은 F(n) 자체가 아니라 F(n) mod 1234567이었기 때문에, 중간 계산마다 나머지를 구하면서 수열을 만들어야 정확한 결과가 나온다. 피보나치 수는 지수적으로 커지기 때문에, long 타입으로도 감당할 수 없는 순간이 온다. 하지만 모듈로 연산의 성질 덕분에 (a + b) \mod m = ((a \mod m) + (b \mod m)) \mod m 이 성질을 활용하면 중간 계산마다 % 1234567을 적용해도 수열의 흐름은 정확히 유지된다.
Today I Learned
#JPA 연관관계
연관관계 매핑이란?
연관관계 매핑은 JPA(Java Persistence API)에서 객체 간의 관계를 데이터베이스 테이블의 외래 키(FK) 관계로 연결해주는 작업을 말한다.
외래 키를 관리하는 객체가 연관관계의 주인이 된다.
| 연관관계 | 사용 어노테이션 | 특징 | 외래 키 위치 |
| 일대다 | `@OneToMany`, `@ManyToOne` | 한 엔티티가 여러 엔티티 참조 | N쪽 |
| 일대일 | `@OneToOne` | 서로 하나씩만 참조 | 한쪽 (유니크 제약) |
| 다대다 | `@ManyToMany` | 서로 다수의 관계 | 중간 테이블 |
1:N 단방향 매핑
- `@OneToMany`과 `@JoinColumn`을 통해 중간 테이블 없이 직접 매핑이 가능하다.
- 외래 키가 N쪽에 있어 DB 설계상 부자연스럽고, N:1 양방향 매핑으로 전환하는 것이 일반적이다.
1:N 양방향 매핑
- `Company`가 `Tutor`리스트를 관리 (`@OneToMany`)
- `Tutor`는 `Company`를 참조 (`@ManyToOne`)
- 객체 간 참조가 양방향으로 가능해지고, 외래 키 관리가 명확 + 유지보수에 용이하다.
1:1 단방향
- 한쪽만 상대 엔티티를 참조하고, 외래 키는 참조하는 쪽에 위치한다.
- `@JoinColumn(name = "address_id", unique = true)`로 설정해준다.
1:1 양방향
- 양쪽 모두 서로 참조 가능하고, 외래 키가 있는 쪽이 연관관계의 주인이 된다.
- `mappedBy`를 사용해 읽기 전용 설정이 가능하고, 객체 지향적으로 더 자연스러운 설계 방식이다.
N:M 다대다
- `@JoinTable`로 중간 테이블 자동 생성하고, 단방향 / 양방향 모두 가능하다.
- 주의사항: 중간 테이블에 추가 정보 저장이 불가능하고, SQL 쿼리 예측이 어렵다는 단점이 있다.
- 해결방법: 중간 테이블을 엔티티로 분리하고, `@ManyToOne`으로 양쪽을 연결해주는 방식을 사용한다. (실무에서 많이 사용됨)
#상속관계 매핑
상속관계 매핑이란
JPA에서 엔티티 간 상속 구조를 데이터베이스 테이블에 매핑하는 방법이다.
관계형 DB는 상속 개념이 없기 때문에 JPA가 이를 처리하기 위한 3가지 전략을 제공한다.
| 전략 | 설명 | 장점 | 단점 |
| JOINED | 부모/자식 각각 테이블 생성 후 JOIN | 정규화, 무결성, 저장공간 효율 | 조회 시 JOIN 많음, INSERT 2회, SQL 복잡 |
| SINGLE_TABLE | 하나의 테이블에 모든 필드 저장 | SQL 단순, JOIN 없음 | NULL 허용 많음, 테이블 비대, 조회 성능 저하 가능 |
| TABLE_PER_CLASS | 자식 클래스마다 테이블 생성 | 명확한 분리, NOT NULL 가능 | 조회 성능 저하, 부모 타입 조회 시 모든 테이블 탐색 |
주요 어노테이션
- `@Inheritance(strategy = ...)`: `JOINED`, `SINGLE_TABLE`(기본값), `TABLE_PER_CLASS`
- `@DiscriminatorColumn(name = "dtype")`: 테이블 구분용 컬럼 생성
- `@DiscriminatorValue("값")`: dtype 값 지정 (기본은 클래스명)
선택 기준
- `JOINED`: 비즈니스 로직이 복잡할 때 사용 (객체 지향 개발에 어울리는 방법)
- `SINGLE_TABLE`: 구조가 단순하고, 확장성이 낮을 때
- `TABLE_PER_CLASS`: 사용하지 않는 것을 권장, 공통 필드만 필요 시 `@MappedSuperclass`사용을 권장
#Proxy
프록시란
JPA에서 지연 로딩을 구현하기 위해 사용하는 대리 객체로, 실제 엔티티를 즉시 조회하지 않고, 필요할 때(DB 접근 시점에) 데이터를 가져온다.
| 메서드 | 설명 | 특징 |
| `em.find()` | 실제 엔티티 즉시 조회 | SQL 즉시 실행됨 |
| `em.getReference()` | 프록시 객체 반환 | SQL은 실제 속성 접근 시점에 실행됨 |
Proxy 객체 특징
- Hibernate가 실제 클래스를 상속하여 프록시 객체 생성
- `==`가 아닌 `instanceof`로 확인해야 함
- 내부적으로 `target`에 실제 엔티티 참조를 보관
- 최초 접근 시점에만 초기화 (한 번만 DB 조회)
- 준영속 상태에서 프록시 접근 시 `LazyInitializationException` 발생
스프링의 프록시 생성 방식
- JDK 동적 프록시: 타깃이 인터페이스일 때 사용, 인터페이스 기반 프록시 생성
- CGLIB 프록시: 타깃이 클래스일 때 사용, 클래스 상속 기반 프록시 생성, `final` 클래스/메서드 적용 불가
#지연로딩과 즉시로딩
Lazy Loading (지연로딩)
- `@ManyToOne(fetch = FetchType.LAZY)`로 설정, 연관 객체는 Proxy 객체로 반환된다.
- 실제 속성 접근 시점에 DB 조회가 발생, 불필요한 연관 객체 조히를 방지하여 성능 최적화에 유리하다.
- 기본적으로 모든 연관관계는 LAZY 설정이 권장된다.
Eager Loading (즉시로딩)
- `@ManyToOne(fetch = FetchType.EAGER)`로 설정, Proxy 객체가 아닌 실제 객체를 반환한다.
- 엔티티 조회 시 연관 객체도 즉시 JOIN하여 함께 조회한다.
- 즉시 로딩은 성능 이슈와 예측 불가능한 SQL을 유발할 수 있으므로 신중하게 사용해야 한다.
N+1 문제와 해결방법
- 연관관계가 있는 엔티티를 조회할 때 조회된 개수 N개 만큼의 쿼리가 추가로 발생하는 문제이다.
- 이로 인해 불필요한 쿼리가 반복되어 성능 저하와 시스템 리소스 낭비가 발생한다.
| 해결 방법 | 설명 |
| `fetch join` | JPQL에서 명시적으로 JOIN |
| `@EntityGraph` | 조회 시 연관 엔티티 그래프 설정 |
| `@BatchSize` | 일괄 조회로 SQL 수 줄이기 |
| Native Query | 직접 SQL 작성하여 최적화 |
#영속성 전이
영속성 전이(Cascade)란
부모 엔티티에 대한 작업을 자식 엔티티에도 자동으로 전이시키는 기능으로, 연관관계 매핑과 별개로 생명주기 관리에 초점을 둔다.
| 속성 | 설명 |
| `ALL` | 모든 작업 전이 (`PERSIST`, `REMOVE` 등) |
| `PERSIST` | 저장 전이 |
| `REMOVE` | 삭제 전이 |
| `MERGE`, `REFRESH`, `DETACH` | 잘 사용되지 않음 |
@OneToMany(mappedBy = "category", cascade = CascadeType.ALL)
private List<Product> productList = new ArrayList<>();
- 자식 엔티티가 부모에 완전히 종속되어 있을 때만 사용한다. (ex: 블로그 글과 댓글, 상품과 상품 이미지)
- 자식이 독립적으로 존재할 수 있다면 사용하지 않음 (ex: 작가와 책)
고아 객체 (Orphan Removal)
부모 엔티티와의 연관관계가 끊어진 자식 엔티티를 자동으로 삭제하는 기능이다.
@OneToMany(mappedBy = "category", orphanRemoval = true)
private List<Product> productList = new ArrayList<>();
- 참조하는 곳이 하나뿐인 경우에만 사용하고, `@OneToOne`, `@OneToMany` 관계에서만 사용이 가능하다.
- `CascadeType.REMOVE`와 유사하지만, 컬렉션에서 제거된 객체도 삭제된다.
#트랜잭션 전파
트랜잭션 전파란
하나의 트랜잭션이 다른 트랜잭션 내에서 어떻게 동작할지를 정의하는 규칙이다.
여러 계층 / 메서드 간 트랜잭션 흐름을 유연하게 제어할 수 있다.
`@Transactional(propagation = ...)`로 설정한다.
| 전파 유형 | 설명 | 특징 |
| `REQUIRED`(기본값) | 기존 트랜잭션이 있으면 참여, 없으면 새로 생성 | 가장 일반적, 상위 트랜잭션에 종속 |
| `REQUIRES_NEW` | 항상 새로운 트랜잭션 생성, 기존 트랜잭션은 일시 중단 | 독립적 실행, 상위 트랜잭션과 분리 |
| `SUPPORTS` | 트랜잭션이 있으면 참여, 없으면 트랜잭션 없이 실행 | 유연하지만 명확한 제어 어려움 |
| `NOT_SUPPORTED` | 트랜잭션이 있어도 중단하고 트랜잭션 없이 실행 | 트랜잭션을 명시적으로 배제 |
| `MANDATORY` | 반드시 기존 트랜잭션이 있어야 함 | 없으면 예외 발생 |
| `NEVER` | 트랜잭션 없이 실행되어야 함 | 트랜잭션이 있으면 예외 발생 |
| `NESTED` | 현재 트랜잭션 내에서 중첩 트랜잭션 생성 | 독립적 롤백 가능, 부모 커밋 시 함께 커밋됨 |
#마치며
오늘은 JPA의 연관관계 매핑부터 상속 전략, 프록시, 로딩 방식, 트랜잭션 전파까지 다양한 개념을 정리하면서, 단순히 어노테이션 몇 개 붙이는 수준이 아니라는 걸 느꼈다. 각각의 설정이 왜 필요한지, 어떤 상황에서 어떤 방식이 더 적절한지를 고민해야 하고, 실무에서는 성능이나 유지보수까지 고려해서 설계해야 함을 느낄 수 있었다. 특히 연관관계 매핑이나 지연 로딩 같은 부분은 단순한 기능이 아니라, 전체 흐름과 구조에 영향을 주는 요소라서 더 신중하게 접근해야겠다는 생각이 들었다. 앞으로는 단순히 기능을 쓰는 데 그치지 않고, 그 배경과 의도를 이해하면서 설계하는 습관을 가져야겠다.