Gün 3 / Production Basics / Modül 09
Modül 09 Gün 3 Production Basics ~100 dk

Data Access & Transaction Management

Veritabanı tarafını “repository yaz, save çağır” seviyesinde bırakmıyoruz; production’da gerçekten hata çıkaran noktalara iniyoruz. JPA’nın bize ne kazandırdığını, lazy/eager loading davranışını, N+1 problemini, transaction boundary kararını ve propagation seviyelerini MiniBank üzerinden adım adım çalışacağız.

Anlatım
65 dk
MiniBank Case
50 dk
Odak
JPA + Transaction
Checkpoint
lab-09

Bu modülde sık geçecek kavramlar

Data access konularında kelimeler birbirine çok yakın görünebilir: entity, repository, persistence context, transaction, propagation… Bu bölümde önce dili sadeleştiriyoruz; sonra derinliğe geçiyoruz.

01

Entity

Veritabanındaki bir tabloyu temsil eden Java sınıfıdır. Spring/JPA dünyasında entity, API response modeli değil; persistence modelidir.

02

Repository

Entity üzerinde veritabanı işlemleri yapmak için kullandığımız erişim katmanıdır. Service katmanı SQL veya EntityManager detayına doğrudan gömülmemelidir.

03

Persistence Context

JPA’nın takip ettiği managed entity alanıdır. Bir entity burada managed hale gelirse, değişiklikler transaction sonunda flush edilebilir.

04

EntityManager

JPA’nın entity yaşam döngüsünü yöneten ana bileşenidir. Spring Data JPA repository’leri çoğu zaman bu detayı bizim yerimize kullanır.

05

Transaction

Bir işin ya tamamen başarılı olması ya da geri alınması kuralıdır. Para transferi gibi işlerde transaction boundary business kararının parçasıdır.

06

Transaction Boundary

Transaction’ın nerede başlayıp nerede biteceğidir. Genelde controller’da değil, service katmanında belirlenir.

07

Lazy Loading

İlişkili verinin hemen değil, ihtiyaç olduğunda yüklenmesidir. Performans için faydalıdır ama yanlış yerde LazyInitializationException veya N+1 doğurabilir.

08

Eager Loading

İlişkili verinin ana entity ile birlikte yüklenmesidir. Basit görünür ama gereksiz veri çekmeye ve büyük query’lere sebep olabilir.

09

N+1 Problem

Bir liste için 1 ana query, listedeki her kayıt için ayrıca N query atılmasıdır. Production performans problemlerinin klasik JPA sebeplerindendir.

10

Propagation

Mevcut bir transaction varken çağrılan method’un bu transaction’a katılıp katılmayacağını belirler.

11

Rollback-only

Bir transaction’ın artık commit edilemeyecek şekilde işaretlenmesidir. Exception yakalansa bile transaction commit aşamasında geri dönebilir.

12

Fetch Join / EntityGraph

İlişkili veriyi kontrollü şekilde beraber çekmek için kullanılan yaklaşımlardır. N+1 problemini çözmede sık kullanılır.

13

Optimistic Lock (@Version)

Satırı kilitlemeden çakışmayı tespit eden yaklaşımdır. Entity’ye eklenen @Version alanı sayesinde commit anında versiyon değişmişse OptimisticLockException fırlatılır.

14

Pessimistic Lock

İlgili satırı en baştan kilitleyen yaklaşımdır. Arka planda SELECT ... FOR UPDATE çalışır; ikinci işlem kilit bırakılana kadar bekler. Lost update’i imkansız kılar ama bekleme/deadlock riski getirir.

15

Race Condition / Lost Update

Aynı veriyi eş zamanlı güncelleyen iki işlemden birinin değişikliğinin kaybolmasıdır. Sonuç, işlemlerin iç içe geçme sırasına bağlı hale gelir ve yanlış bakiye gibi tutarsızlıklar doğar.

JPA ne işe yarar?

JPA, veritabanındaki satırlarla Java objeleri arasında köprü kurmamızı sağlar. Ama asıl değer sadece “SQL yazmadan veri çekmek” değildir. Asıl değer; entity yaşam döngüsü, değişiklik takibi, repository modeli ve transaction ile birlikte çalışan tutarlı bir veri erişim katmanı kurmaktır.

Çok basit tanım

JPA, veritabanındaki kayıtları Java objeleri gibi yönetmemizi sağlar. Spring Data JPA ise repository altyapısıyla bu modeli Spring Boot uygulamasında daha hızlı ve standart kullanmamızı sağlar.

JPA hangi problemi çözer?

JDBC ile çalışırken bağlantı açma, SQL yazma, ResultSet okuma, alanları Java objesine map etme, hata yönetimi ve transaction kontrolü çoğu zaman tekrar eden kod üretir. JPA bu tekrarların önemli kısmını soyutlar. Sen entity ve repository modelini kurarsın; framework kayıt bulma, saklama, değişiklik takip etme ve transaction sonunda veritabanına yansıtma süreçlerini yönetir.

JPA olmadan düşünürsek

SQL string’leri, connection yönetimi, result mapping, try/catch/finally blokları ve transaction sınırları daha fazla manuel kontrol gerektirir.

JPA ile düşünürsek

Entity modelini, repository methodlarını ve transaction boundary’yi doğru tasarlarsın. Framework detayların çoğunu yönetir ama performans etkilerini yine sen bilmek zorundasın.

MiniBank’ta nerede karşımıza çıkacak?

MiniBank’ta Account, Customer, TransferRecord ve AuditLog gibi entity’ler olacak. Repository’ler bu entity’leri okuyacak/yazacak. Service katmanı ise “para transferi bir bütün olarak başarılı mı başarısız mı?” gibi transaction kararlarını yönetecek.

Basit entity ve repository fikri
@Entity
public class Account {
    @Id
    private Long id;

    private String iban;
    private BigDecimal balance;

    // Entity davranışı: bakiye düş, bakiye artır vb.
}

public interface AccountRepository extends JpaRepository<Account, Long> {
    Optional<Account> findByIban(String iban);
}

Persistence Context ve managed entity mantığı

JPA’yı senior seviyede anlamanın kilit noktalarından biri şudur: Repository’den dönen entity sadece sıradan bir Java objesi gibi görünür ama transaction içinde persistence context tarafından takip ediliyor olabilir.

Managed entity ne demek?

Bir entity persistence context içinde managed hale geldiyse, JPA bu objenin değişikliklerini takip eder. Transaction içinde entity üzerinde alan değiştirdiğinde her zaman açıkça save() çağırman gerekmez; transaction sonunda flush sırasında değişiklik veritabanına yansıyabilir. Bu güçlüdür ama davranışın ne zaman gerçekleştiğini bilmezsen şaşırtıcı olabilir.

01
Repository çağrılır
Account veritabanından okunur.
02
Entity managed olur
Persistence context entity’yi takip eder.
03
Service değiştirir
balance alanı güncellenir.
04
Flush olur
Değişiklik SQL’e çevrilir.
05
Commit
Transaction başarıyla kapanır.
Üretim notu

Entity’yi API response olarak dışarı vermek genellikle iyi fikir değildir. Entity persistence modelidir; API contract değildir. Lazy alanlar, gereksiz ilişki yüklemeleri ve serialization problemleri doğabilir. Bu yüzden REST modülünde öğrendiğimiz DTO ayrımı burada da kritik hale gelir.

Lazy loading vs eager loading

Entity ilişkilerinde en sık yapılan hata, “ilişkiyi ne zaman yüklemek istiyorum?” sorusunu bilinçli sormamaktır. Lazy ve eager sadece annotation seçimi değildir; query davranışı, performans ve API response tasarımını etkiler.

Lazy loading

İlişkili veri hemen çekilmez. İhtiyaç olduğunda yüklenir. Çoğu ilişkide daha güvenli başlangıçtır çünkü gereksiz veri çekimini azaltır.

Eager loading

İlişkili veri ana entity ile birlikte yüklenir. Bazı küçük ve her zaman gereken ilişkilerde mantıklı olabilir ama kontrolsüz kullanılırsa pahalı hale gelir.

MiniBank örneği

Bir hesabın son transferleri her endpoint’te gerekmeyebilir. GET /accounts/{iban} sadece hesap özetini dönebilir. Ama GET /accounts/{iban}/history transfer geçmişini de isteyebilir. Bu iki endpoint aynı veri ihtiyacına sahip değildir; bu yüzden loading stratejisi endpoint ihtiyacına göre düşünülmelidir.

Account.java — ilişki örneği
@Entity
public class Account {
    @Id
    private Long id;

    private String iban;

    @OneToMany(mappedBy = "account", fetch = FetchType.LAZY)
    private List<TransferRecord> transfers = new ArrayList<>();
}
Doğru soru

“Bu ilişki lazy mi eager mı olsun?” yerine önce şunu sor: “Bu use case bu ilişkiye gerçekten ihtiyaç duyuyor mu?” İhtiyaç varsa kontrollü query ile beraber çek; ihtiyaç yoksa hiç yükleme.

N+1 problemi

N+1 problemi, JPA kullanan ekiplerin production’da en sık karşılaştığı performans problemlerinden biridir. Kod küçük görünür, test datasında hızlıdır, ama gerçek veride yüzlerce SQL üretir.

Çok basit anlatım

Diyelim ki 100 hesabı listeledin. İlk query 100 hesabı getirir. Sonra her hesabın transferlerini ayrı ayrı okumaya başlarsan 100 query daha atılır. Toplamda 1 + 100 query oluşur. Buna N+1 diyoruz.

01
Account listesi
1 query ile 100 account gelir.
02
Loop başlar
Her account için transfers alanına erişilir.
03
Ek query’ler
Her account için ayrı select çalışır.
04
Production etkisi
Sayfa yavaşlar, DB yükü artar.

Nasıl fark ederiz?

SQL loglarını açtığında aynı pattern’de tekrar eden select sorguları görürsün. Bu yüzden JPA performansını konuşurken sadece Java koduna bakmak yetmez; üretilen SQL’i de okumak gerekir.

application.yml — eğitimde SQL’i görünür yapma
spring:
  jpa:
    show-sql: true
    properties:
      hibernate:
        format_sql: true
logging:
  level:
    org.hibernate.SQL: DEBUG
    org.hibernate.orm.jdbc.bind: TRACE

Çözüm yaklaşımları

Fetch join

İhtiyaç duyulan ilişkiyi query içinde bilinçli şekilde beraber çekersin. Özellikle use-case bazlı okumalarda etkilidir.

@EntityGraph

Repository method’u için hangi ilişkilerin yüklenmesini istediğini daha deklaratif ifade edebilirsin.

DTO projection

Entity graph yerine doğrudan ihtiyacın olan alanları response modeline yakın şekilde çekebilirsin.

JpaAccountRepository — fetch join / EntityGraph örnekleri
public interface JpaAccountRepository extends JpaRepository<JpaAccountEntity, String> {

    @Query("select a from JpaAccountEntity a left join fetch a.histories where a.accountNo = :accountNo")
    Optional<JpaAccountEntity> findByAccountNoWithHistory(String accountNo);

    @EntityGraph(attributePaths = {"customer", "histories"})
    Optional<JpaAccountEntity> findDetailedByAccountNo(String accountNo);
}
M05’ten M09’a bağlantı: pagination

Modül 05’te Pageable/Page REST contract’ını kurduk; burada aynı contract gerçek JpaRepository.findAll(Pageable) sorgusuna bağlanır. Bu uygulama ileri çalışmadır ve sonraki lab için prerequisite değildir.

@Transactional doğru kullanımı

@Transactional bir “veritabanı işlemi başlat” annotation’ı gibi görünür, ama aslında bir business boundary ifadesidir. “Bu method içindeki iş tek bir bütün olarak başarılı mı olmalı?” sorusunun cevabıdır.

Nereye koymalıyız?

Genel pratik: transaction boundary service katmanında olur. Controller HTTP detayını yönetir, repository veri erişimini yapar, service ise iş akışını ve transaction sınırını belirler. Para transferi gibi bir işte hem gönderen hesabın düşmesi hem alan hesabın artması aynı transaction içinde olmalıdır.

TransferService.java — transaction boundary
@Service
public class TransferService {

    private final AccountRepository accountRepository;
    private final AuditService auditService;

    public TransferService(AccountRepository accountRepository,
                           AuditService auditService) {
        this.accountRepository = accountRepository;
        this.auditService = auditService;
    }

    @Transactional
    public void transfer(String fromIban, String toIban, BigDecimal amount) {
        Account from = accountRepository.findByIban(fromIban).orElseThrow();
        Account to = accountRepository.findByIban(toIban).orElseThrow();

        from.withdraw(amount);
        to.deposit(amount);

        auditService.recordTransferAttempt(fromIban, toIban, amount);
    }
}

Proxy mantığı burada da geçerli

AOP modülünde gördüğümüz proxy mantığı burada tekrar karşımıza çıkar. @Transactional davranışı Spring proxy’si üzerinden uygulanır. Bu yüzden aynı class içinde this.someTransactionalMethod() çağrısı yaptığında transaction beklendiği gibi çalışmayabilir.

Üretim notu

Transaction içinde uzun süren external API çağrıları yapmak risklidir. DB connection ve lock süresi uzayabilir. External servis yavaşlarsa transaction da gereksiz yere açık kalır. Bu konuyu bir sonraki modüldeki External API bölümüne bağlayacağız.

Transaction propagation seviyeleri

Propagation, mevcut bir transaction varken çağrılan başka bir method’un o transaction’a katılıp katılmayacağını belirler. Bu bir enum ezberi değil; iş kuralı kararıdır.

En basit soru

TransferService.transfer() transaction içinde çalışıyor. İçeriden AuditService.saveAudit() çağrılırsa audit aynı transaction’a mı katılsın, yoksa bağımsız mı commit olsun?

REQUIRED

Varsa mevcut transaction’a katılır, yoksa yeni transaction açar. Default davranıştır ve çoğu service method’u için doğaldır.

REQUIRES_NEW

Mevcut transaction’ı askıya alır ve bağımsız yeni transaction açar. Audit/outbox gibi bazı durumlarda bilinçli kullanılır.

NESTED

Aynı physical transaction içinde savepoint mantığı sağlar. Kısmi rollback ihtiyacı varsa anlamlıdır; altyapı desteği önemlidir.

SUPPORTS

Transaction varsa katılır, yoksa transaction’sız çalışır. Genelde read/helper akışlarında görülür.

MANDATORY

Transaction olmak zorundadır. Yoksa hata verir. “Bu method transaction dışında anlamlı değil” demenin yoludur.

NOT_SUPPORTED / NEVER

Transaction istemeyen akışlar için kullanılır. NOT_SUPPORTED mevcut transaction’ı askıya alır; NEVER transaction varsa hata verir.

REQUIRED vs REQUIRES_NEW: MiniBank audit kararı

Transfer başarısız olursa audit kaydı da geri alınsın mı? Cevap “evet” ise REQUIRED doğal olabilir. Cevap “transfer rollback olsa bile deneme kaydı kalsın” ise REQUIRES_NEW düşünülebilir. Burada doğru cevap teknik değil, business kararıdır.

AuditService.java — bağımsız audit transaction
@Service
public class AuditService {

    private final AuditLogRepository auditLogRepository;

    public AuditService(AuditLogRepository auditLogRepository) {
        this.auditLogRepository = auditLogRepository;
    }

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void recordTransferAttempt(String fromIban, String toIban, BigDecimal amount) {
        auditLogRepository.save(AuditLog.transferAttempt(fromIban, toIban, amount));
    }
}

Rollback-only ve UnexpectedRollbackException

Bazı durumlarda iç method aynı transaction’a katılır ve hata oluştuğunda transaction rollback-only işaretlenir. Sen exception’ı yakalasan bile transaction artık commit edilemez hale gelmiş olabilir. Bu durumda dış method sonunda commit beklerken rollback gerçekleşebilir.

Önemli mesaj

Exception’ı catch etmek her zaman transaction’ı kurtarmaz. Transaction rollback-only olduysa commit aşamasında yine rollback davranışı görebilirsin.

readOnly, timeout, isolation kısa farkları

readOnly

Bu transaction’ın okuma ağırlıklı olduğunu ifade eder. Query niyetini netleştirir ve bazı altyapılarda optimizasyon sağlayabilir.

timeout

Transaction’ın ne kadar süre açık kalabileceğini sınırlar. Uzun süren işlemlerde kaynak tüketimini kontrol etmeye yardımcı olur.

isolation

Aynı anda çalışan transaction’ların birbirinin verisini nasıl göreceğini belirler. Her yerde yükseltmek performans maliyeti doğurabilir.

Lock stratejileri: aynı hesaba iki paralel transfer

Transaction “bu iş ya tamamen olsun ya hiç olmasın” garantisini verir; ama tek başına “aynı veriyi aynı anda güncelleyen iki işlem birbirini bozmasın” garantisini vermez. Bu ikinci soru eşzamanlılık (concurrency) ve lock konusudur ve MiniBank gibi para hareketi olan sistemlerde göz ardı edilemez.

Senaryo: lost update / race condition

Bir hesabın bakiyesi 100 TL olsun. Aynı anda iki transfer bu hesaptan 50’şer TL çekmeye çalışsın. İkisi de neredeyse aynı anda bakiyeyi 100 olarak okur, kendi hesabını yapar (100 - 50 = 50) ve 50 yazar. Sonuçta hesapta 50 TL kalır; oysa toplam 100 TL çekildiği için 0 olmalıydı. 50 TL boşluktan ortaya çıkmıştır. Buna lost update (kaybolan güncelleme) ya da race condition denir: doğru sonuç, iki işlemin hangi sırayla ve ne kadar iç içe çalıştığına bağlı hale gelmiştir.

01
İki işlem okur
Her ikisi de balance = 100 görür.
02
İkisi de hesaplar
100 - 50 = 50.
03
İkisi de yazar
Her ikisi de balance = 50 yazar.
04
Sonuç yanlış
100 çekildi ama bakiye 50 kaldı.

Bu problemi @Transactional tek başına çözmez, çünkü her iki işlem de geçerli bir transaction içinde, kendi açısından tutarlı çalışıyor. Eksik olan şey, “bu satırı ben güncellerken başkası araya girmesin” garantisidir. İki temel yaklaşım vardır: optimistic lock ve pessimistic lock.

Optimistic lock: @Version ile çakışmayı yakalama

Optimistic lock, “çakışma nadiren olur, o yüzden baştan kilitlemeyelim; ama olursa fark edelim” yaklaşımıdır. Entity’ye bir @Version alanı eklersin. JPA her güncellemede bu versiyonu kontrol eder: okuduğun versiyon ile yazmaya çalıştığın andaki versiyon farklıysa, demek ki araya başka bir işlem girmiş ve OptimisticLockException fırlatılır. Kimse satırı kilitlemez; çakışma sadece commit anında tespit edilir.

JpaAccountEntity.java — @Version alanı eklemek
@Entity
@Table(name = "jpa_accounts")
public class JpaAccountEntity {

    @Id
    private String accountNo;

    private BigDecimal balance;

    // Optimistic lock için versiyon alanı.
    // JPA her update'te versiyonu kontrol eder ve bir artırır.
    @Version
    private long version;

    // ... mevcut alanlar ve metotlar
}

Çakışma yakalandığında genelde işlemi baştan denemek (retry) mantıklıdır: hesabın güncel halini tekrar oku, işlemi tekrar uygula, tekrar commit etmeyi dene. Çünkü çakışma genelde geçicidir; ikinci denemede karşı işlem çoktan bitmiş olur.

TransferService — OptimisticLockException ve retry
public void transferWithRetry(String fromAccountNo, String toAccountNo, BigDecimal amount) {
    int attempts = 0;
    while (true) {
        try {
            transfer(fromAccountNo, toAccountNo, amount); // @Transactional
            return;
        } catch (OptimisticLockException ex) {
            if (++attempts >= 3) {
                throw ex; // çakışma ısrarcı: pes et ve yukarı bildir
            }
            // kısa bekle ve tekrar dene
        }
    }
}

Pessimistic lock: satırı baştan kilitlemek

Pessimistic lock, “çakışma olacak, o yüzden satırı en baştan kilitleyelim” yaklaşımıdır. İlgili satırı okurken veritabanı düzeyinde kilitlersin; bu da arka planda SELECT ... FOR UPDATE sorgusuna dönüşür. İkinci işlem aynı satıra dokunmak istediğinde, ilk işlem commit edip kilidi bırakana kadar bekler. Böylece iki işlem gerçekten sıraya girer, lost update imkansız hale gelir.

JpaAccountRepository — PESSIMISTIC_WRITE ile satır kilitleme
public interface JpaAccountRepository extends JpaRepository<JpaAccountEntity, String> {

    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("select a from JpaAccountEntity a where a.accountNo = :accountNo")
    Optional<JpaAccountEntity> findByAccountNoForUpdate(@Param("accountNo") String accountNo);
}

Bu metot bir @Transactional service içinden çağrıldığında, kilit transaction commit olana kadar tutulur. Transfer akışında gönderen hesabı bu metotla okursan, aynı hesaptan eş zamanlı başka bir transfer denemesi senin işlemin bitmesini bekler.

Hangisi ne zaman?

Optimistic (düşük çakışma)

Aynı satırın eş zamanlı güncellenme olasılığı düşükse optimistic uygundur. Çakışma nadir olduğu için retry maliyeti ucuzdur ve hiçbir satır gereksiz yere kilitlenmez. Çoğu CRUD ekranı bu profile uyar.

Pessimistic (yüksek çakışma)

Aynı satıra sürekli ve eş zamanlı yazılıyorsa, kritik bölüm kısaysa pessimistic daha öngörülebilir olur. Ama satır kilitlendiği için deadlock ve uzun bekleme riski doğar; kilit süresini kısa tutmak şarttır.

Üretim notu

Lock’un en sinsi maliyeti süredir. Pessimistic lock’ta satır, transaction commit olana kadar kilitli kalır; transaction ne kadar uzarsa kilit o kadar uzun tutulur ve bekleyen işlemler birikir. Bu yüzden “transaction içinde uzun süren işlem yapma” uyarısı burada iki katına çıkar ve bir sonraki Modül 10’daki external API çağrısı konusuyla doğrudan kesişir: bir kilidi tutarken yavaş bir dış servisi beklemek, sadece kendi işlemini değil, aynı hesabı bekleyen diğer transferleri de kilitlemek demektir. İki işlem birbirinin kilitlediği satırları ters sırada beklerse deadlock oluşur; bunu azaltmanın klasik yolu hesapları her zaman aynı sırada (örneğin accountNo sırasıyla) kilitlemektir. Pessimistic lock’ta ayrıca bir lock timeout belirlemek, sonsuza kadar beklemeyi engeller.

MiniBank Case 09 — JPA Relations, N+1 Optimization & Transfer Audit Propagation

Bu case’de MiniBank veri modelini gerçekçi hale getiriyoruz: customer-account-history ilişkilerini kuruyor, history endpointinde N+1 davranışını kontrollü şekilde gözlemliyor ve transfer audit kaydının transaction davranışını netleştiriyoruz. Görev listesi, doğrulama komutları, kod adımları ve kabul kriterleri Lab Dashboard'da.

Başlangıç
lab-09-start
Tamamlanmış
lab-09-complete
Checkpoint kuralı

Lab sırasında geride kalırsan lab-09-complete branch'ine geçip herkesle aynı checkpoint'ten devam edebilirsin.

Bu modülden akılda kalması gerekenler

1

JPA kolaylık sağlar ama SQL etkisini saklamaz.

Repository kodu temiz görünebilir ama arka planda üretilen SQL’i okumadan performans kararını doğrulayamazsın.

2

Lazy/eager seçimi use-case ile ilgilidir.

Her ilişkiyi eager yapmak çözüm değil; ihtiyaç duyulan veriyi kontrollü query ile çekmek daha sağlıklı yaklaşımdır.

3

N+1 production problemidir.

Test datasında görünmeyen küçük tekrarlar, gerçek veride yüzlerce query’ye dönüşebilir.

4

@Transactional business boundary ifade eder.

Transaction sınırı service katmanında, işin atomikliği düşünülerek belirlenmelidir.

5

Propagation teknik değil, iş kararıdır.

Audit kaydı transfer rollback olsa da kalsın mı? Bu sorunun cevabı REQUIRED mı REQUIRES_NEW mi seçeceğini belirler.

5 soru ile hızlı kontrol

1. Entity ile DTO arasındaki temel fark nedir?

Entity persistence modelidir ve veritabanı yapısına yakındır. DTO ise API contract veya use-case çıktısıdır. Entity’yi doğrudan dışarı vermek lazy loading, serialization ve contract problemleri doğurabilir.

2. N+1 problemi nedir?

Bir liste için önce 1 ana query, sonra listedeki her kayıt için ayrıca query atılmasıdır. Örneğin 100 account için 1 + 100 query oluşması N+1 problemidir.

3. @Transactional genelde hangi katmanda kullanılmalıdır?

Genellikle service katmanında kullanılır. Çünkü transaction boundary HTTP değil, business operation sınırıdır.

4. REQUIRED ve REQUIRES_NEW farkı nedir?

REQUIRED mevcut transaction varsa ona katılır, yoksa yeni açar. REQUIRES_NEW mevcut transaction’dan bağımsız yeni bir transaction açar.

5. Transaction içinde external API çağırmak neden risklidir?

DB connection ve lock’lar açık kalabilir. External servis yavaş veya hatalıysa transaction gereksiz uzar ve sistem kaynakları baskı altına girebilir.