콘텐츠 대표 이미지 - ⚡ Dapper 마이크로 ORM으로 날아오르는 데이터베이스 성능 최적화 완전정복

⚡ Dapper 마이크로 ORM으로 날아오르는 데이터베이스 성능 최적화 완전정복

C# 개발자를 위한 고성능 데이터 액세스 실전 가이드

안녕, 친구! 😊 오늘은 C# 개발자라면 꼭 알아야 할 Dapper에 대해 이야기해보려고 해.
혹시 Entity Framework 쓰다가 "왜 이렇게 느리지?" 하고 답답했던 적 있어?
아니면 ADO.NET으로 코딩하다가 "이거 너무 복잡한데..." 하고 한숨 쉬었던 경험은?

바로 그런 고민들을 해결해주는 게 Dapper야!
Stack Overflow 팀이 만든 이 마이크로 ORM은 성능과 편의성 사이의 완벽한 균형점을 찾아냈어.
실제로 Stack Overflow 같은 초대형 트래픽 사이트에서도 검증된 기술이지. 🚀

Dapper 성능 비교 Entity Framework 100ms Full ORM 자동 매핑 변경 추적 지연 로딩 Dapper 25ms Micro ORM 빠른 매핑 최소 오버헤드 SQL 제어 ADO.NET 20ms Raw SQL 수동 매핑 복잡한 코드 4배 1.25배 성능과 편의성의 최적 균형점

🎯 Dapper가 뭐길래 이렇게 난리야?

먼저 Dapper가 정확히 뭔지부터 알아보자.
Dapper는 .NET용 마이크로 ORM(Object-Relational Mapper)이야.
"마이크로"라는 단어가 붙은 이유는 딱 필요한 기능만 제공하기 때문이지.

🔍 ORM이 뭐냐고?

ORM은 Object-Relational Mapping의 약자야.
쉽게 말하면 데이터베이스의 테이블을 C# 객체로 변환해주는 기술이지.
SQL 쿼리 결과를 일일이 손으로 객체에 담지 않아도 자동으로 해준다는 거야!

Dapper의 핵심 철학은 이거야:
"SQL은 개발자가 직접 작성하되, 객체 매핑은 자동으로 해주자!"

이게 왜 중요하냐면, Entity Framework 같은 Full ORM은 모든 걸 자동화하려다 보니
때로는 비효율적인 SQL을 생성하거든.
반면 ADO.NET은 성능은 좋지만 코드가 너무 장황하고 반복적이야.

⚡ 실제 성능 수치를 보자!

Stack Overflow 팀의 벤치마크 결과:
- ADO.NET (Raw): 47ms (500회 쿼리 실행)
- Dapper: 49ms (거의 동일!)
- Entity Framework: 124ms (2.5배 느림)

보다시피 Dapper는 Raw ADO.NET과 거의 동일한 성능을 내면서도
코드는 훨씬 깔끔하고 유지보수하기 쉬워! 🎉

🚀 Dapper 시작하기 - 설치부터 첫 쿼리까지

자, 이제 실전으로 들어가볼까?
Dapper를 시작하는 건 정말 간단해. NuGet 패키지 하나만 설치하면 끝이야!

💡 설치 방법

Visual Studio의 패키지 관리자 콘솔에서:

Install-Package Dapper

또는 .NET CLI에서:

dotnet add package Dapper

설치가 끝났으면 이제 첫 번째 쿼리를 실행해보자!
가장 기본적인 SELECT 쿼리부터 시작할게.

using Dapper;
using System.Data.SqlClient;

// 데이터베이스 연결
using var connection = new SqlConnection(connectionString);

// 간단한 쿼리 실행
var users = connection.Query<User>(
    "SELECT Id, Name, Email FROM Users WHERE IsActive = @IsActive",
    new { IsActive = true }
);

foreach (var user in users)
{
    Console.WriteLine($"{user.Name} - {user.Email}");
}

어때? 엄청 간단하지? 😊
Query<T> 메서드 하나로 SQL 실행부터 객체 매핑까지 다 끝났어!

✅ 여기서 주목할 점!

1. 파라미터 바인딩: @IsActive 같은 파라미터를 익명 객체로 전달
2. 자동 매핑: SQL 결과가 자동으로 User 객체로 변환됨
3. 타입 안정성: 제네릭을 사용해서 컴파일 타임에 타입 체크
4. SQL 인젝션 방지: 파라미터 바인딩으로 자동 보호

🎨 다양한 쿼리 패턴 마스터하기

Dapper는 여러 가지 쿼리 패턴을 지원해.
상황에 맞는 메서드를 선택하면 코드가 훨씬 깔끔해지지!

단일 객체 조회 - QueryFirstOrDefault

하나의 레코드만 가져올 때는 QueryFirstOrDefault를 써!

var user = connection.QueryFirstOrDefault<User>(
    "SELECT * FROM Users WHERE Id = @Id",
    new { Id = 123 }
);

if (user != null)
{
    Console.WriteLine($"찾았다! {user.Name}");
}
else
{
    Console.WriteLine("사용자를 찾을 수 없어요 😢");
}

QueryFirst와 QueryFirstOrDefault의 차이는?
- QueryFirst: 결과가 없으면 예외 발생 💥
- QueryFirstOrDefault: 결과가 없으면 null 반환 (더 안전!)

단일 값 조회 - ExecuteScalar

COUNT나 SUM 같은 집계 함수 결과를 가져올 때 유용해!

var userCount = connection.ExecuteScalar<int>(
    "SELECT COUNT(*) FROM Users WHERE IsActive = @IsActive",
    new { IsActive = true }
);

Console.WriteLine($"활성 사용자: {userCount}명");

데이터 수정 - Execute

INSERT, UPDATE, DELETE 같은 명령은 Execute 메서드를 사용해!

// INSERT
var affectedRows = connection.Execute(
    @"INSERT INTO Users (Name, Email, CreatedAt) 
      VALUES (@Name, @Email, @CreatedAt)",
    new { 
        Name = "홍길동", 
        Email = "hong@example.com", 
        CreatedAt = DateTime.Now 
    }
);

// UPDATE
var updated = connection.Execute(
    "UPDATE Users SET Email = @Email WHERE Id = @Id",
    new { Email = "newemail@example.com", Id = 123 }
);

// DELETE
var deleted = connection.Execute(
    "DELETE FROM Users WHERE Id = @Id",
    new { Id = 123 }
);

💡 Pro Tip: 반환값 활용하기

Execute 메서드는 영향받은 행의 개수를 반환해.
이걸 활용하면 작업이 성공했는지 쉽게 확인할 수 있어!

if (affectedRows > 0)
{
    Console.WriteLine("저장 성공! ✅");
}
else
{
    Console.WriteLine("저장 실패... ❌");
}

🔥 고급 기능으로 레벨업하기

기본은 마스터했으니 이제 진짜 파워풀한 기능들을 알아보자!
이것들만 잘 써도 Dapper 고수 소리 들을 수 있어. 😎

다중 결과 처리 - QueryMultiple

하나의 쿼리로 여러 결과셋을 가져올 수 있어!
데이터베이스 왕복을 줄여서 성능이 확 올라가지. 🚀

using var multi = connection.QueryMultiple(@"
    SELECT * FROM Users WHERE Id = @UserId;
    SELECT * FROM Orders WHERE UserId = @UserId;
    SELECT * FROM Reviews WHERE UserId = @UserId;
", new { UserId = 123 });

var user = multi.ReadFirstOrDefault<User>();
var orders = multi.Read<Order>().ToList();
var reviews = multi.Read<Review>().ToList();

Console.WriteLine($"{user.Name}님의 주문 {orders.Count}건, 리뷰 {reviews.Count}건");

이렇게 하면 데이터베이스에 3번 갈 걸 1번으로 줄일 수 있어!
특히 네트워크 지연이 있는 환경에서는 엄청난 성능 향상을 가져와. 💪

⚡ 성능 비교

개별 쿼리 3번 실행: 150ms (네트워크 지연 각 50ms)
QueryMultiple 1번 실행: 55ms (네트워크 지연 50ms + 쿼리 5ms)
약 3배 빠름!

일괄 처리 - Batch Operations

여러 개의 데이터를 한 번에 처리할 때 진가를 발휘해!

var users = new List<User>
{
    new User { Name = "김철수", Email = "kim@example.com" },
    new User { Name = "이영희", Email = "lee@example.com" },
    new User { Name = "박민수", Email = "park@example.com" }
};

// 한 번에 여러 행 INSERT!
var inserted = connection.Execute(
    "INSERT INTO Users (Name, Email) VALUES (@Name, @Email)",
    users
);

Console.WriteLine($"{inserted}명의 사용자가 추가되었습니다!");

이 방식의 장점은 뭐냐면:
1. 코드가 간결: 반복문 필요 없음
2. 성능 우수: 내부적으로 최적화됨
3. 트랜잭션 관리 용이: 전체가 하나의 단위로 처리됨

동적 객체 활용 - Dynamic Mapping

때로는 클래스를 만들기 귀찮을 때가 있잖아?
그럴 땐 dynamic을 사용하면 돼!

var results = connection.Query(
    "SELECT Name, Email, COUNT(*) as OrderCount FROM Users u " +
    "JOIN Orders o ON u.Id = o.UserId " +
    "GROUP BY Name, Email"
);

foreach (var row in results)
{
    // 동적으로 속성에 접근!
    Console.WriteLine($"{row.Name}: {row.OrderCount}건 주문");
}

⚠️ 주의사항

동적 타입은 편리하지만 단점도 있어:
- 컴파일 타임 타입 체크 불가
- IntelliSense 지원 안 됨
- 런타임 오류 가능성 증가

프로토타이핑이나 일회성 스크립트에는 좋지만,
프로덕션 코드에서는 가급적 강타입 클래스를 사용하는 게 좋아!

Dapper 최적화 전략 연결 관리 Connection Pool 쿼리 최적화 Buffered vs Unbuffered 비동기 처리 Async/Await 핵심 원칙 적절한 연결 관리 + 효율적인 쿼리 + 비동기 처리 = 최고의 성능

⚡ 성능 최적화 핵심 전략

Dapper가 빠르다고 해서 아무렇게나 써도 되는 건 아니야.
제대로 된 최적화 전략을 알아야 진짜 고성능을 뽑아낼 수 있지! 🎯

연결 관리의 기술

데이터베이스 연결은 비싼 리소스야.
제대로 관리하지 않으면 성능이 확 떨어져!

✨ 베스트 프랙티스: using 문 활용

// 좋은 예 ✅
using (var connection = new SqlConnection(connectionString))
{
    var users = connection.Query<User>("SELECT * FROM Users");
    // 자동으로 연결이 닫힘!
}

// 더 좋은 예 (C# 8.0+) ✅
using var connection = new SqlConnection(connectionString);
var users = connection.Query<User>("SELECT * FROM Users");
// 스코프 끝에서 자동으로 닫힘!

Connection Pooling은 자동으로 처리되니까 걱정 마!
연결 문자열에 특별한 설정이 없으면 기본적으로 활성화되어 있어.

💡 Connection Pooling 설정

Server=myServer;Database=myDB;User Id=myUser;Password=myPass;
Min Pool Size=5;Max Pool Size=100;Pooling=true;

- Min Pool Size: 최소 유지 연결 수
- Max Pool Size: 최대 연결 수
- Pooling: 풀링 활성화 여부

Buffered vs Unbuffered 쿼리

Dapper의 Query 메서드는 기본적으로 buffered 모드로 동작해.
이게 무슨 뜻이냐면, 모든 결과를 메모리에 한 번에 로드한다는 거야.

// Buffered (기본값) - 모든 데이터를 메모리에 로드
var users = connection.Query<User>(
    "SELECT * FROM Users",
    buffered: true  // 기본값이라 생략 가능
).ToList();

// Unbuffered - 필요할 때마다 하나씩 읽음
var users = connection.Query<User>(
    "SELECT * FROM Users",
    buffered: false
);

foreach (var user in users)
{
    // 이 시점에 실제로 데이터를 읽어옴
    ProcessUser(user);
}

🔍 언제 뭘 써야 할까?

Buffered 모드 (기본)
✅ 결과셋이 작을 때
✅ 여러 번 반복해서 접근할 때
✅ 연결을 빨리 닫아야 할 때
❌ 대용량 데이터 처리 시 메모리 부족 위험

Unbuffered 모드
✅ 대용량 데이터 처리
✅ 스트리밍 방식 처리
✅ 메모리 절약이 중요할 때
❌ 연결이 오래 유지됨
❌ 한 번만 순회 가능

비동기 프로그래밍으로 확장성 높이기

요즘 웹 애플리케이션에서 비동기 처리는 필수야!
Dapper는 모든 주요 메서드의 비동기 버전을 제공해. 🚀

// 동기 버전
var users = connection.Query<User>("SELECT * FROM Users");

// 비동기 버전
var users = await connection.QueryAsync<User>("SELECT * FROM Users");

// 다른 비동기 메서드들
var user = await connection.QueryFirstOrDefaultAsync<User>(
    "SELECT * FROM Users WHERE Id = @Id", 
    new { Id = 123 }
);

var count = await connection.ExecuteScalarAsync<int>(
    "SELECT COUNT(*) FROM Users"
);

var affected = await connection.ExecuteAsync(
    "UPDATE Users SET LastLogin = @Now WHERE Id = @Id",
    new { Now = DateTime.Now, Id = 123 }
);

✅ 비동기 사용의 이점

1. 스레드 효율성: I/O 대기 중 스레드가 다른 작업 처리 가능
2. 확장성 향상: 동시 요청 처리 능력 증가
3. 응답성 개선: UI가 멈추지 않음 (데스크톱 앱의 경우)

특히 ASP.NET Core 같은 웹 프레임워크에서는
비동기 메서드 사용이 거의 필수라고 봐야 해!

재능넷 같은 플랫폼에서 실시간으로 많은 사용자 요청을 처리할 때,
비동기 프로그래밍은 서버 리소스를 효율적으로 사용하게 해줘. 💪

🎭 복잡한 매핑 시나리오 정복하기

실제 프로젝트에서는 단순한 1:1 매핑만으로는 부족할 때가 많아.
조인 쿼리나 복잡한 객체 구조를 다뤄야 할 때가 있지!

One-to-Many 관계 매핑

예를 들어 사용자와 주문의 관계를 생각해보자.
한 사용자는 여러 개의 주문을 가질 수 있어.

public class User
{
    public int Id { get; set; }
    public string Name { get; set; }
    public List<Order> Orders { get; set; } = new();
}

public class Order
{
    public int Id { get; set; }
    public int UserId { get; set; }
    public decimal Amount { get; set; }
    public DateTime OrderDate { get; set; }
}

// 쿼리 실행
var sql = @"
    SELECT u.*, o.*
    FROM Users u
    LEFT JOIN Orders o ON u.Id = o.UserId
    WHERE u.Id = @UserId";

var userDictionary = new Dictionary<int, User>();

var result = connection.Query<User, Order, User>(
    sql,
    (user, order) =>
    {
        if (!userDictionary.TryGetValue(user.Id, out var userEntry))
        {
            userEntry = user;
            userDictionary.Add(user.Id, userEntry);
        }
        
        if (order != null)
        {
            userEntry.Orders.Add(order);
        }
        
        return userEntry;
    },
    new { UserId = 123 },
    splitOn: "Id"  // Order의 Id 컬럼에서 분리
).Distinct().FirstOrDefault();

이 코드가 하는 일을 단계별로 설명하면:
1. User와 Order를 조인한 결과를 가져옴
2. 각 행을 User와 Order 객체로 분리
3. Dictionary로 중복된 User를 제거
4. Order를 해당 User의 Orders 리스트에 추가

💡 splitOn 파라미터의 비밀

splitOn은 어디서부터 다음 객체가 시작되는지 알려줘.
기본값은 "Id"인데, 컬럼 이름이 다르면 명시적으로 지정해야 해!

// Order의 첫 컬럼이 OrderId라면
splitOn: "OrderId"

// 여러 객체를 매핑할 때
splitOn: "OrderId,ProductId"

Multi-Mapping으로 복잡한 구조 처리

3개 이상의 테이블을 조인할 때도 Dapper는 우아하게 처리할 수 있어!

public class Order
{
    public int Id { get; set; }
    public User User { get; set; }
    public Product Product { get; set; }
    public int Quantity { get; set; }
}

var sql = @"
    SELECT o.*, u.*, p.*
    FROM Orders o
    INNER JOIN Users u ON o.UserId = u.Id
    INNER JOIN Products p ON o.ProductId = p.Id
    WHERE o.Id = @OrderId";

var order = connection.Query<Order, User, Product, Order>(
    sql,
    (order, user, product) =>
    {
        order.User = user;
        order.Product = product;
        return order;
    },
    new { OrderId = 456 },
    splitOn: "Id,Id"  // User의 Id, Product의 Id
).FirstOrDefault();

보다시피 Dapper는 최대 7개까지의 타입을 동시에 매핑할 수 있어!
Query<T1, T2, T3, T4, T5, T6, T7, TReturn> 이런 식으로 말이야.

🛡️ 트랜잭션과 에러 처리

데이터베이스 작업에서 트랜잭션은 정말 중요해!
여러 작업이 모두 성공하거나 모두 실패해야 할 때 필수지.

트랜잭션 기본 사용법

using var connection = new SqlConnection(connectionString);
connection.Open();

using var transaction = connection.BeginTransaction();

try
{
    // 첫 번째 작업: 계좌에서 출금
    connection.Execute(
        "UPDATE Accounts SET Balance = Balance - @Amount WHERE Id = @FromId",
        new { Amount = 1000, FromId = 1 },
        transaction: transaction
    );
    
    // 두 번째 작업: 다른 계좌에 입금
    connection.Execute(
        "UPDATE Accounts SET Balance = Balance + @Amount WHERE Id = @ToId",
        new { Amount = 1000, ToId = 2 },
        transaction: transaction
    );
    
    // 세 번째 작업: 거래 내역 기록
    connection.Execute(
        @"INSERT INTO Transactions (FromId, ToId, Amount, TransDate) 
          VALUES (@FromId, @ToId, @Amount, @TransDate)",
        new { FromId = 1, ToId = 2, Amount = 1000, TransDate = DateTime.Now },
        transaction: transaction
    );
    
    // 모든 작업이 성공하면 커밋!
    transaction.Commit();
    Console.WriteLine("이체 완료! ✅");
}
catch (Exception ex)
{
    // 하나라도 실패하면 모두 롤백!
    transaction.Rollback();
    Console.WriteLine($"이체 실패! 롤백됨: {ex.Message} ❌");
    throw;
}

⚠️ 트랜잭션 사용 시 주의사항

1. 연결을 명시적으로 열어야 함: connection.Open()
2. 모든 쿼리에 transaction 파라미터 전달: 안 그러면 트랜잭션 밖에서 실행됨!
3. 트랜잭션은 짧게 유지: 오래 걸리면 락(Lock) 문제 발생
4. 예외 처리 필수: 롤백 로직 반드시 구현

격리 수준(Isolation Level) 설정

트랜잭션의 격리 수준을 조절해서 성능과 일관성의 균형을 맞출 수 있어!

using var transaction = connection.BeginTransaction(
    IsolationLevel.ReadCommitted  // 기본값
);

// 다른 격리 수준들
// IsolationLevel.ReadUncommitted  - 가장 빠르지만 Dirty Read 가능
// IsolationLevel.ReadCommitted    - 커밋된 데이터만 읽음 (기본)
// IsolationLevel.RepeatableRead   - 같은 데이터를 여러 번 읽어도 동일
// IsolationLevel.Serializable     - 가장 안전하지만 느림
// IsolationLevel.Snapshot         - MVCC 방식 (SQL Server)

🔍 격리 수준 선택 가이드

ReadCommitted (기본): 대부분의 경우 이걸로 충분해
ReadUncommitted: 대시보드나 통계 같은 정확도가 덜 중요한 읽기
RepeatableRead: 같은 데이터를 여러 번 읽는 복잡한 비즈니스 로직
Serializable: 금융 거래 같은 절대적 일관성이 필요한 경우
Snapshot: 읽기 성능이 중요하고 SQL Server를 쓸 때

🎪 실전 패턴과 베스트 프랙티스

이론은 충분히 배웠으니 이제 실전에서 바로 쓸 수 있는
검증된 패턴들을 알아보자! 💼

Repository 패턴 구현

Dapper를 사용할 때 가장 많이 쓰이는 패턴이야.
데이터 액세스 로직을 깔끔하게 분리할 수 있어!

public interface IUserRepository
{
    Task<User> GetByIdAsync(int id);
    Task<IEnumerable<User>> GetAllAsync();
    Task<int> CreateAsync(User user);
    Task<bool> UpdateAsync(User user);
    Task<bool> DeleteAsync(int id);
}

public class UserRepository : IUserRepository
{
    private readonly string _connectionString;
    
    public UserRepository(string connectionString)
    {
        _connectionString = connectionString;
    }
    
    public async Task<User> GetByIdAsync(int id)
    {
        using var connection = new SqlConnection(_connectionString);
        return await connection.QueryFirstOrDefaultAsync<User>(
            "SELECT * FROM Users WHERE Id = @Id",
            new { Id = id }
        );
    }
    
    public async Task<IEnumerable<User>> GetAllAsync()
    {
        using var connection = new SqlConnection(_connectionString);
        return await connection.QueryAsync<User>(
            "SELECT * FROM Users ORDER BY Name"
        );
    }
    
    public async Task<int> CreateAsync(User user)
    {
        using var connection = new SqlConnection(_connectionString);
        return await connection.ExecuteScalarAsync<int>(
            @"INSERT INTO Users (Name, Email, CreatedAt) 
              VALUES (@Name, @Email, @CreatedAt);
              SELECT CAST(SCOPE_IDENTITY() as int)",
            user
        );
    }
    
    public async Task<bool> UpdateAsync(User user)
    {
        using var connection = new SqlConnection(_connectionString);
        var affected = await connection.ExecuteAsync(
            @"UPDATE Users 
              SET Name = @Name, Email = @Email 
              WHERE Id = @Id",
            user
        );
        return affected > 0;
    }
    
    public async Task<bool> DeleteAsync(int id)
    {
        using var connection = new SqlConnection(_connectionString);
        var affected = await connection.ExecuteAsync(
            "DELETE FROM Users WHERE Id = @Id",
            new { Id = id }
        );
        return affected > 0;
    }
}

이렇게 Repository 패턴을 쓰면:
✅ 테스트하기 쉬워짐 (Mock 객체 사용 가능)
✅ 비즈니스 로직과 데이터 액세스 분리
✅ 데이터베이스 변경 시 영향 범위 최소화
✅ 코드 재사용성 증가

Unit of Work 패턴으로 트랜잭션 관리

여러 Repository를 하나의 트랜잭션으로 묶어야 할 때 유용해!

public interface IUnitOfWork : IDisposable
{
    IUserRepository Users { get; }
    IOrderRepository Orders { get; }
    void BeginTransaction();
    void Commit();
    void Rollback();
}

public class UnitOfWork : IUnitOfWork
{
    private readonly SqlConnection _connection;
    private SqlTransaction _transaction;
    
    public IUserRepository Users { get; }
    public IOrderRepository Orders { get; }
    
    public UnitOfWork(string connectionString)
    {
        _connection = new SqlConnection(connectionString);
        _connection.Open();
        
        Users = new UserRepository(_connection);
        Orders = new OrderRepository(_connection);
    }
    
    public void BeginTransaction()
    {
        _transaction = _connection.BeginTransaction();
    }
    
    public void Commit()
    {
        _transaction?.Commit();
        _transaction?.Dispose();
        _transaction = null;
    }
    
    public void Rollback()
    {
        _transaction?.Rollback();
        _transaction?.Dispose();
        _transaction = null;
    }
    
    public void Dispose()
    {
        _transaction?.Dispose();
        _connection?.Dispose();
    }
}

// 사용 예시
using var uow = new UnitOfWork(connectionString);
try
{
    uow.BeginTransaction();
    
    var user = await uow.Users.CreateAsync(newUser);
    await uow.Orders.CreateAsync(new Order { UserId = user.Id });
    
    uow.Commit();
}
catch
{
    uow.Rollback();
    throw;
}

SQL 쿼리 관리 전략

SQL 문자열을 코드에 직접 박아넣으면 관리가 힘들어져.
몇 가지 좋은 방법들을 소개할게!

// 방법 1: 상수로 관리
public static class UserQueries
{
    public const string GetById = "SELECT * FROM Users WHERE Id = @Id";
    public const string GetAll = "SELECT * FROM Users ORDER BY Name";
    public const string Insert = @"
        INSERT INTO Users (Name, Email, CreatedAt) 
        VALUES (@Name, @Email, @CreatedAt);
        SELECT CAST(SCOPE_IDENTITY() as int)";
}

// 방법 2: 리소스 파일 사용
// UserQueries.resx 파일에 저장하고
var sql = Resources.UserQueries.GetById;

// 방법 3: 별도 SQL 파일 (임베디드 리소스)
// Queries/Users/GetById.sql 파일을 만들고
var assembly = Assembly.GetExecutingAssembly();
using var stream = assembly.GetManifestResourceStream("MyApp.Queries.Users.GetById.sql");
using var reader = new StreamReader(stream);
var sql = reader.ReadToEnd();

💡 추천하는 방법

개인적으로는 상수 클래스 방식을 추천해!
- 간단하고 직관적
- IntelliSense 지원
- 컴파일 타임 체크
- 별도 파일 관리 불필요

다만 SQL이 정말 길고 복잡하다면
별도 .sql 파일로 관리하는 것도 좋은 선택이야.

🔧 고급 최적화 테크닉

이제 진짜 고수들만 아는 최적화 비법을 공개할게!
이것들만 알아도 성능이 눈에 띄게 향상될 거야. 🚀

컴파일된 쿼리 캐싱

Dapper는 내부적으로 쿼리를 캐싱하지만,
같은 쿼리를 반복 실행할 때는 직접 캐싱하는 게 더 빨라!

public class UserRepository
{
    // 쿼리를 미리 컴파일해서 저장
    private static readonly CommandDefinition GetByIdCommand = 
        new CommandDefinition(
            "SELECT * FROM Users WHERE Id = @Id",
            flags: CommandFlags.None
        );
    
    public async Task<User> GetByIdAsync(int id)
    {
        using var connection = new SqlConnection(_connectionString);
        
        // 캐싱된 쿼리 사용
        return await connection.QueryFirstOrDefaultAsync<User>(
            GetByIdCommand.CommandText,
            new { Id = id }
        );
    }
}

Bulk Insert 최적화

대량의 데이터를 INSERT할 때는 특별한 기법이 필요해!

// 방법 1: Dapper의 기본 배치 처리
var users = GetLargeUserList(); // 10,000개의 사용자
await connection.ExecuteAsync(
    "INSERT INTO Users (Name, Email) VALUES (@Name, @Email)",
    users
);

// 방법 2: SqlBulkCopy 사용 (더 빠름!)
public async Task BulkInsertAsync(IEnumerable<User> users)
{
    var dataTable = new DataTable();
    dataTable.Columns.Add("Name", typeof(string));
    dataTable.Columns.Add("Email", typeof(string));
    
    foreach (var user in users)
    {
        dataTable.Rows.Add(user.Name, user.Email);
    }
    
    using var connection = new SqlConnection(_connectionString);
    await connection.OpenAsync();
    
    using var bulkCopy = new SqlBulkCopy(connection)
    {
        DestinationTableName = "Users",
        BatchSize = 1000,
        BulkCopyTimeout = 300
    };
    
    await bulkCopy.WriteToServerAsync(dataTable);
}

⚡ 성능 비교 (10,000개 INSERT)

개별 INSERT: 약 45초 😱
Dapper 배치: 약 8초 😊
SqlBulkCopy: 약 1초 🚀

대량 데이터 처리에서는 SqlBulkCopy가 압도적이야!

메모리 최적화 - 스트리밍 처리

수백만 건의 데이터를 처리할 때는 메모리가 문제가 될 수 있어.
이럴 때는 스트리밍 방식을 사용해!

public async Task ProcessLargeDatasetAsync()
{
    using var connection = new SqlConnection(_connectionString);
    await connection.OpenAsync();
    
    // Unbuffered 모드로 스트리밍
    var users = connection.Query<User>(
        "SELECT * FROM Users",
        buffered: false  // 중요!
    );
    
    // 한 번에 하나씩 처리
    foreach (var user in users)
    {
        await ProcessUserAsync(user);
        
        // 메모리 정리 (선택적)
        if (ShouldCollectGarbage())
        {
            GC.Collect();
            GC.WaitForPendingFinalizers();
        }
    }
}

// 또는 비동기 스트리밍 (C# 8.0+)
public async IAsyncEnumerable<User> StreamUsersAsync()
{
    using var connection = new SqlConnection(_connectionString);
    await connection.OpenAsync();
    
    var users = connection.Query<User>(
        "SELECT * FROM Users",
        buffered: false
    );
    
    foreach (var user in users)
    {
        yield return user;
    }
}

// 사용
await foreach (var user in StreamUsersAsync())
{
    await ProcessUserAsync(user);
}

연결 문자열 최적화

연결 문자열 설정만 잘해도 성능이 달라져!

// 최적화된 연결 문자열 예시
var connectionString = new SqlConnectionStringBuilder
{
    DataSource = "myserver.database.windows.net",
    InitialCatalog = "mydb",
    UserID = "myuser",
    Password = "mypassword",
    
    // 성능 관련 설정
    MinPoolSize = 5,              // 최소 연결 풀 크기
    MaxPoolSize = 100,            // 최대 연결 풀 크기
    Pooling = true,               // 연결 풀링 활성화
    MultipleActiveResultSets = true,  // MARS 활성화
    
    // 타임아웃 설정
    ConnectTimeout = 30,          // 연결 타임아웃 (초)
    CommandTimeout = 300,         // 명령 타임아웃 (초)
    
    // 보안 설정
    Encrypt = true,               // 암호화 사용
    TrustServerCertificate = false,
    
    // 성능 향상
    ApplicationIntent = ApplicationIntent.ReadWrite,
    LoadBalanceTimeout = 0,
    
}.ConnectionString;

🔍 주요 설정 설명

MinPoolSize / MaxPoolSize: 적절한 풀 크기 설정이 중요해.
너무 작으면 연결 부족, 너무 크면 리소스 낭비!

MultipleActiveResultSets (MARS): 하나의 연결로 여러 쿼리 동시 실행.
QueryMultiple 사용 시 유용하지만 약간의 오버헤드 있음.

ConnectTimeout: 네트워크가 불안정한 환경에서는 늘려줘야 해.

Dapper 사용 시 피해야 할 실수들 연결 관리 실수 1. using 문 미사용 연결이 닫히지 않아 리소스 누수 발생 2. 연결 재사용 시도 Connection Pool이 알아서 처리함 3. 트랜잭션 미전달 쿼리마다 transaction 파라미터 필수 쿼리 작성 실수 1. N+1 쿼리 문제 반복문 안에서 쿼리 실행 금지 2. SELECT * 남발 필요한 컬럼만 명시적으로 선택 3. 인덱스 미활용 WHERE 절에 인덱스 컬럼 사용 성능 관련 실수 1. 동기 메서드 사용 웹앱에서는 반드시 Async 메서드 사용 2. Buffered 모드 오용 대용량 데이터는 Unbuffered 사용 3. 예외 처리 부족 DB 오류는 반드시 적절히 처리 이 실수들만 피해도 90% 성공!

🎓 실전 프로젝트 구조 예시

이론은 충분히 배웠으니 이제 실제 프로젝트에서
어떻게 구조를 잡아야 하는지 알아보자! 🏗️

프로젝트 레이어 구조

MyProject/
├── MyProject.Domain/              # 도메인 모델
│   ├── Entities/
│   │   ├── User.cs
│   │   ├── Order.cs
│   │   └── Product.cs
│   └── Interfaces/
│       ├── IUserRepository.cs
│       └── IOrderRepository.cs
│
├── MyProject.Infrastructure/      # 데이터 액세스
│   ├── Repositories/
│   │   ├── UserRepository.cs
│   │   └── OrderRepository.cs
│   ├── UnitOfWork/
│   │   └── UnitOfWork.cs
│   └── Queries/
│       ├── UserQueries.cs
│       └── OrderQueries.cs
│
├── MyProject.Application/         # 비즈니스 로직
│   ├── Services/
│   │   ├── UserService.cs
│   │   └── OrderService.cs
│   └── DTOs/
│       ├── UserDto.cs
│       └── OrderDto.cs
│
└── MyProject.WebApi/              # API 레이어
    ├── Controllers/
    │   ├── UsersController.cs
    │   └── OrdersController.cs
    └── appsettings.json

의존성 주입 설정 (ASP.NET Core)

// Program.cs 또는 Startup.cs
public class Program
{
    public static void Main(string[] args)
    {
        var builder = WebApplication.CreateBuilder(args);
        
        // 연결 문자열 가져오기
        var connectionString = builder.Configuration
            .GetConnectionString("DefaultConnection");
        
        // Repository 등록
        builder.Services.AddScoped<IUserRepository>(sp => 
            new UserRepository(connectionString));
        builder.Services.AddScoped<IOrderRepository>(sp => 
            new OrderRepository(connectionString));
        
        // Unit of Work 등록
        builder.Services.AddScoped<IUnitOfWork>(sp => 
            new UnitOfWork(connectionString));
        
        // Service 등록
        builder.Services.AddScoped<IUserService, UserService>();
        builder.Services.AddScoped<IOrderService, OrderService>();
        
        builder.Services.AddControllers();
        builder.Services.AddEndpointsApiExplorer();
        builder.Services.AddSwaggerGen();
        
        var app = builder.Build();
        
        if (app.Environment.IsDevelopment())
        {
            app.UseSwagger();
            app.UseSwaggerUI();
        }
        
        app.UseHttpsRedirection();
        app.UseAuthorization();
        app.MapControllers();
        
        app.Run();
    }
}

컨트롤러 구현 예시

[ApiController]
[Route("api/[controller]")]
public class UsersController : ControllerBase
{
    private readonly IUserService _userService;
    private readonly ILogger<UsersController> _logger;
    
    public UsersController(
        IUserService userService,
        ILogger<UsersController> logger)
    {
        _userService = userService;
        _logger = logger;
    }
    
    [HttpGet]
    public async Task<ActionResult<IEnumerable<UserDto>>> GetAll()
    {
        try
        {
            var users = await _userService.GetAllUsersAsync();
            return Ok(users);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "사용자 목록 조회 실패");
            return StatusCode(500, "서버 오류가 발생했습니다.");
        }
    }
    
    [HttpGet("{id}")]
    public async Task<ActionResult<UserDto>> GetById(int id)
    {
        try
        {
            var user = await _userService.GetUserByIdAsync(id);
            
            if (user == null)
                return NotFound($"사용자 ID {id}를 찾을 수 없습니다.");
            
            return Ok(user);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "사용자 조회 실패: {UserId}", id);
            return StatusCode(500, "서버 오류가 발생했습니다.");
        }
    }
    
    [HttpPost]
    public async Task<ActionResult<UserDto>> Create([FromBody] CreateUserDto dto)
    {
        try
        {
            var userId = await _userService.CreateUserAsync(dto);
            var user = await _userService.GetUserByIdAsync(userId);
            
            return CreatedAtAction(
                nameof(GetById), 
                new { id = userId }, 
                user
            );
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "사용자 생성 실패");
            return StatusCode(500, "서버 오류가 발생했습니다.");
        }
    }
    
    [HttpPut("{id}")]
    public async Task<ActionResult> Update(int id, [FromBody] UpdateUserDto dto)
    {
        try
        {
            var success = await _userService.UpdateUserAsync(id, dto);
            
            if (!success)
                return NotFound($"사용자 ID {id}를 찾을 수 없습니다.");
            
            return NoContent();
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "사용자 수정 실패: {UserId}", id);
            return StatusCode(500, "서버 오류가 발생했습니다.");
        }
    }
    
    [HttpDelete("{id}")]
    public async Task<ActionResult> Delete(int id)
    {
        try
        {
            var success = await _userService.DeleteUserAsync(id);
            
            if (!success)
                return NotFound($"사용자 ID {id}를 찾을 수 없습니다.");
            
            return NoContent();
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "사용자 삭제 실패: {UserId}", id);
            return StatusCode(500, "서버 오류가 발생했습니다.");
        }
    }
}

🧪 테스트 전략

좋은 코드는 테스트 가능한 코드야!
Dapper를 사용한 코드를 어떻게 테스트하는지 알아보자. 🔬

통합 테스트 (Integration Test)

public class UserRepositoryIntegrationTests : IDisposable
{
    private readonly SqlConnection _connection;
    private readonly UserRepository _repository;
    
    public UserRepositoryIntegrationTests()
    {
        // 테스트용 데이터베이스 연결
        var connectionString = "Server=localhost;Database=TestDB;...";
        _connection = new SqlConnection(connectionString);
        _connection.Open();
        
        _repository = new UserRepository(connectionString);
        
        // 테스트 데이터 초기화
        SetupTestData();
    }
    
    [Fact]
    public async Task GetByIdAsync_ExistingUser_ReturnsUser()
    {
        // Arrange
        var userId = 1;
        
        // Act
        var user = await _repository.GetByIdAsync(userId);
        
        // Assert
        Assert.NotNull(user);
        Assert.Equal(userId, user.Id);
        Assert.NotEmpty(user.Name);
    }
    
    [Fact]
    public async Task CreateAsync_ValidUser_ReturnsNewId()
    {
        // Arrange
        var newUser = new User
        {
            Name = "테스트 사용자",
            Email = "test@example.com",
            CreatedAt = DateTime.Now
        };
        
        // Act
        var userId = await _repository.CreateAsync(newUser);
        
        // Assert
        Assert.True(userId > 0);
        
        // 실제로 생성되었는지 확인
        var created = await _repository.GetByIdAsync(userId);
        Assert.NotNull(created);
        Assert.Equal(newUser.Name, created.Name);
    }
    
    [Fact]
    public async Task UpdateAsync_ExistingUser_ReturnsTrue()
    {
        // Arrange
        var user = await _repository.GetByIdAsync(1);
        user.Name = "수정된 이름";
        
        // Act
        var result = await _repository.UpdateAsync(user);
        
        // Assert
        Assert.True(result);
        
        // 실제로 수정되었는지 확인
        var updated = await _repository.GetByIdAsync(1);
        Assert.Equal("수정된 이름", updated.Name);
    }
    
    private void SetupTestData()
    {
        // 테스트 데이터 삽입
        _connection.Execute(@"
            TRUNCATE TABLE Users;
            INSERT INTO Users (Id, Name, Email, CreatedAt) 
            VALUES (1, '홍길동', 'hong@example.com', GETDATE())
        ");
    }
    
    public void Dispose()
    {
        // 테스트 데이터 정리
        _connection.Execute("TRUNCATE TABLE Users");
        _connection.Dispose();
    }
}

단위 테스트 (Unit Test) - Mock 사용

public class UserServiceTests
{
    private readonly Mock<IUserRepository> _mockRepository;
    private readonly UserService _service;
    
    public UserServiceTests()
    {
        _mockRepository = new Mock<IUserRepository>();
        _service = new UserService(_mockRepository.Object);
    }
    
    [Fact]
    public async Task GetUserByIdAsync_ExistingUser_ReturnsUserDto()
    {
        // Arrange
        var userId = 1;
        var expectedUser = new User
        {
            Id = userId,
            Name = "홍길동",
            Email = "hong@example.com"
        };
        
        _mockRepository
            .Setup(r => r.GetByIdAsync(userId))
            .ReturnsAsync(expectedUser);
        
        // Act
        var result = await _service.GetUserByIdAsync(userId);
        
        // Assert
        Assert.NotNull(result);
        Assert.Equal(expectedUser.Id, result.Id);
        Assert.Equal(expectedUser.Name, result.Name);
        
        // Repository가 정확히 한 번 호출되었는지 확인
        _mockRepository.Verify(
            r => r.GetByIdAsync(userId), 
            Times.Once
        );
    }
    
    [Fact]
    public async Task CreateUserAsync_ValidDto_ReturnsNewUserId()
    {
        // Arrange
        var dto = new CreateUserDto
        {
            Name = "새 사용자",
            Email = "new@example.com"
        };
        
        var expectedUserId = 123;
        
        _mockRepository
            .Setup(r => r.CreateAsync(It.IsAny<User>()))
            .ReturnsAsync(expectedUserId);
        
        // Act
        var result = await _service.CreateUserAsync(dto);
        
        // Assert
        Assert.Equal(expectedUserId, result);
        
        // 올바른 데이터로 호출되었는지 확인
        _mockRepository.Verify(
            r => r.CreateAsync(It.Is<User>(u => 
                u.Name == dto.Name && 
                u.Email == dto.Email
            )),
            Times.Once
        );
    }
}

💡 테스트 전략 추천

통합 테스트: Repository 레이어
- 실제 데이터베이스 사용
- SQL 쿼리 검증
- 데이터 무결성 확인

단위 테스트: Service 레이어
- Mock 객체 사용
- 비즈니스 로직 검증
- 빠른 실행 속도

🔍 모니터링과 디버깅

프로덕션 환경에서는 모니터링이 정말 중요해!
문제가 생겼을 때 빠르게 찾아내고 해결할 수 있어야 하거든. 🔎

SQL 쿼리 로깅

public class LoggingUserRepository : IUserRepository
{
    private readonly IUserRepository _innerRepository;
    private readonly ILogger<LoggingUserRepository> _logger;
    
    public LoggingUserRepository(
        IUserRepository innerRepository,
        ILogger<LoggingUserRepository> logger)
    {
        _innerRepository = innerRepository;
        _logger = logger;
    }
    
    public async Task<User> GetByIdAsync(int id)
    {
        var stopwatch = Stopwatch.StartNew();
        
        try
        {
            _logger.LogInformation(
                "쿼리 시작: GetByIdAsync, UserId: {UserId}", 
                id
            );
            
            var result = await _innerRepository.GetByIdAsync(id);
            
            stopwatch.Stop();
            
            _logger.LogInformation(
                "쿼리 완료: GetByIdAsync, UserId: {UserId}, " +
                "실행시간: {ElapsedMs}ms, 결과: {Found}",
                id,
                stopwatch.ElapsedMilliseconds,
                result != null ? "Found" : "NotFound"
            );
            
            return result;
        }
        catch (Exception ex)
        {
            stopwatch.Stop();
            
            _logger.LogError(
                ex,
                "쿼리 실패: GetByIdAsync, UserId: {UserId}, " +
                "실행시간: {ElapsedMs}ms",
                id,
                stopwatch.ElapsedMilliseconds
            );
            
            throw;
        }
    }
    
    // 다른 메서드들도 비슷하게 구현...
}

성능 메트릭 수집

public class PerformanceMetrics
{
    private static readonly ConcurrentDictionary<string, QueryMetric> 
        _metrics = new();
    
    public static void RecordQuery(
        string queryName, 
        long elapsedMs, 
        bool success)
    {
        var metric = _metrics.GetOrAdd(
            queryName, 
            _ => new QueryMetric()
        );
        
        metric.TotalCalls++;
        metric.TotalElapsedMs += elapsedMs;
        
        if (success)
            metric.SuccessfulCalls++;
        else
            metric.FailedCalls++;
        
        if (elapsedMs > metric.MaxElapsedMs)
            metric.MaxElapsedMs = elapsedMs;
        
        if (elapsedMs < metric.MinElapsedMs || metric.MinElapsedMs == 0)
            metric.MinElapsedMs = elapsedMs;
    }
    
    public static Dictionary<string, QueryMetric> GetMetrics()
    {
        return _metrics.ToDictionary(
            kvp => kvp.Key, 
            kvp => kvp.Value
        );
    }
    
    public static void Reset()
    {
        _metrics.Clear();
    }
}

public class QueryMetric
{
    public long TotalCalls { get; set; }
    public long SuccessfulCalls { get; set; }
    public long FailedCalls { get; set; }
    public long TotalElapsedMs { get; set; }
    public long MinElapsedMs { get; set; }
    public long MaxElapsedMs { get; set; }
    
    public double AverageElapsedMs => 
        TotalCalls > 0 ? (double)TotalElapsedMs / TotalCalls : 0;
    
    public double SuccessRate => 
        TotalCalls > 0 ? (double)SuccessfulCalls / TotalCalls * 100 : 0;
}

// 사용 예시
var stopwatch = Stopwatch.StartNew();
try
{
    var result = await _repository.GetByIdAsync(id);
    stopwatch.Stop();
    
    PerformanceMetrics.RecordQuery(
        "GetByIdAsync", 
        stopwatch.ElapsedMilliseconds, 
        true
    );
}
catch
{
    stopwatch.Stop();
    
    PerformanceMetrics.RecordQuery(
        "GetByIdAsync", 
        stopwatch.ElapsedMilliseconds, 
        false
    );
    
    throw;
}

헬스 체크 엔드포인트

[ApiController]
[Route("api/[controller]")]
public class HealthController : ControllerBase
{
    private readonly string _connectionString;
    
    public HealthController(IConfiguration configuration)
    {
        _connectionString = configuration
            .GetConnectionString("DefaultConnection");
    }
    
    [HttpGet]
    public async Task<ActionResult> Get()
    {
        var health = new
        {
            Status = "Healthy",
            Timestamp = DateTime.UtcNow,
            Database = await CheckDatabaseAsync(),
            Metrics = PerformanceMetrics.GetMetrics()
        };
        
        return Ok(health);
    }
    
    private async Task<object> CheckDatabaseAsync()
    {
        var stopwatch = Stopwatch.StartNew();
        
        try
        {
            using var connection = new SqlConnection(_connectionString);
            await connection.OpenAsync();
            
            var result = await connection.ExecuteScalarAsync<int>(
                "SELECT 1"
            );
            
            stopwatch.Stop();
            
            return new
            {
                Status = "Connected",
                ResponseTime = $"{stopwatch.ElapsedMilliseconds}ms"
            };
        }
        catch (Exception ex)
        {
            stopwatch.Stop();
            
            return new
            {
                Status = "Error",
                Message = ex.Message,
                ResponseTime = $"{stopwatch.ElapsedMilliseconds}ms"
            };
        }
    }
}

🌟 실전 팁과 트릭

마지막으로 실무에서 정말 유용한 팁들을 공유할게!
이것들은 공식 문서에도 잘 안 나와있는 노하우들이야. 😉

✨ Tip 1: 동적 WHERE 절 구성

public async Task<IEnumerable<User>> SearchUsersAsync(
    string name = null,
    string email = null,
    bool? isActive = null)
{
    var sql = "SELECT * FROM Users WHERE 1=1";
    var parameters = new DynamicParameters();
    
    if (!string.IsNullOrEmpty(name))
    {
        sql += " AND Name LIKE @Name";
        parameters.Add("Name", $"%{name}%");
    }
    
    if (!string.IsNullOrEmpty(email))
    {
        sql += " AND Email LIKE @Email";
        parameters.Add("Email", $"%{email}%");
    }
    
    if (isActive.HasValue)
    {
        sql += " AND IsActive = @IsActive";
        parameters.Add("IsActive", isActive.Value);
    }
    
    using var connection = new SqlConnection(_connectionString);
    return await connection.QueryAsync<User>(sql, parameters);
}

✨ Tip 2: 페이징 구현

public async Task<PagedResult<User>> GetUsersPagedAsync(
    int pageNumber = 1,
    int pageSize = 20)
{
    var sql = @"
        SELECT COUNT(*) FROM Users;
        
        SELECT * FROM Users
        ORDER BY Id
        OFFSET @Offset ROWS
        FETCH NEXT @PageSize ROWS ONLY";
    
    using var connection = new SqlConnection(_connectionString);
    using var multi = await connection.QueryMultipleAsync(
        sql,
        new
        {
            Offset = (pageNumber - 1) * pageSize,
            PageSize = pageSize
        }
    );
    
    var totalCount = await multi.ReadFirstAsync<int>();
    var items = (await multi.ReadAsync<User>()).ToList();
    
    return new PagedResult<User>
    {
        Items = items,
        TotalCount = totalCount,
        PageNumber = pageNumber,
        PageSize = pageSize,
        TotalPages = (int)Math.Ceiling(totalCount / (double)pageSize)
    };
}

public class PagedResult<T>
{
    public List<T> Items { get; set; }
    public int TotalCount { get; set; }
    public int PageNumber { get; set; }
    public int PageSize { get; set; }
    public int TotalPages { get; set; }
    public bool HasPreviousPage => PageNumber > 1;
    public bool HasNextPage => PageNumber < TotalPages;
}

✨ Tip 3: Stored Procedure 호출

public async Task<IEnumerable<User>> GetActiveUsersAsync()
{
    using var connection = new SqlConnection(_connectionString);
    
    return await connection.QueryAsync<User>(
        "sp_GetActiveUsers",
        commandType: CommandType.StoredProcedure
    );
}

public async Task<int> CreateUserWithProcedureAsync(User user)
{
    using var connection = new SqlConnection(_connectionString);
    
    var parameters = new DynamicParameters();
    parameters.Add("@Name", user.Name);
    parameters.Add("@Email", user.Email);
    parameters.Add("@UserId", dbType: DbType.Int32, direction: ParameterDirection.Output);
    
    await connection.ExecuteAsync(
        "sp_CreateUser",
        parameters,
        commandType: CommandType.StoredProcedure
    );
    
    return parameters.Get<int>("@UserId");
}

✨ Tip 4: 커스텀 타입 핸들러

// JSON 컬럼을 객체로 자동 변환
public class JsonTypeHandler<T> : SqlMapper.TypeHandler<T>
{
    public override T Parse(object value)
    {
        return JsonSerializer.Deserialize<T>(value.ToString());
    }
    
    public override void SetValue(
        IDbDataParameter parameter, 
        T value)
    {
        parameter.Value = JsonSerializer.Serialize(value);
    }
}

// 등록
SqlMapper.AddTypeHandler(new JsonTypeHandler<UserSettings>());

// 사용
public class User
{
    public int Id { get; set; }
    public string Name { get; set; }
    public UserSettings Settings { get; set; }  // JSON 컬럼
}

// 이제 자동으로 변환됨!
var user = await connection.QueryFirstAsync<User>(
    "SELECT Id, Name, Settings FROM Users WHERE Id = @Id",
    new { Id = 123 }
);

💡 Pro Tip: 재능넷에서 활용하기

재능넷 같은 플랫폼을 개발할 때 Dapper는 정말 유용해!
특히 이런 시나리오에서 빛을 발하지:

1. 실시간 검색: 빠른 쿼리 실행으로 즉각적인 결과 제공
2. 대시보드: 복잡한 집계 쿼리를 효율적으로 처리
3. 리포트 생성: 대용량 데이터를 스트리밍 방식으로 처리
4. API 성능: 낮은 오버헤드로 높은 처리량 달성

🎯 마무리하며

자, 여기까지 Dapper의 모든 것을 함께 알아봤어! 🎉
처음에는 복잡해 보였을 수도 있지만, 하나씩 따라하다 보면
어느새 Dapper 마스터가 되어 있을 거야!

🎓 핵심 요약

1. Dapper의 장점
- ADO.NET에 가까운 성능
- 간결하고 직관적인 API
- SQL에 대한 완전한 제어
- 가벼운 라이브러리 (단일 파일)

2. 언제 사용하면 좋을까?
- 성능이 중요한 프로젝트
- 복잡한 SQL 쿼리가 많은 경우
- 레거시 데이터베이스와 작업할 때
- 마이크로서비스 아키텍처

3. 주의할 점
- SQL 인젝션 방지 (파라미터 바인딩 필수)
- 연결 관리 (using 문 사용)
- 트랜잭션 처리 (적절한 격리 수준)
- 예외 처리 (데이터베이스 오류 대응)

Dapper는 정말 강력한 도구야.
하지만 도구는 도구일 뿐, 결국 중요한 건 어떻게 사용하느냐지!

이 글에서 배운 내용들을 실제 프로젝트에 적용해보면서
자신만의 노하우를 쌓아가길 바라! 💪

재능넷에서도 이런 고성능 데이터베이스 액세스 기술이
많은 사용자들에게 빠르고 안정적인 서비스를 제공하는 데
큰 역할을 하고 있어.

✅ 다음 단계

1. 실습: 간단한 CRUD 애플리케이션 만들어보기
2. 성능 측정: Entity Framework와 비교해보기
3. 패턴 적용: Repository와 Unit of Work 구현하기
4. 최적화: 실제 프로젝트에 적용하며 튜닝하기

가장 중요한 건 직접 해보는 것!
이론만 알고 있으면 소용없어. 지금 바로 코딩을 시작해봐! 🚀

궁금한 점이 있거나 더 알고 싶은 내용이 있다면
언제든지 재능넷 커뮤니티에서 질문하고 공유해줘!
함께 성장하는 개발자가 되자! 😊

Happy Coding with Dapper! 🎉

댓글 작성

이 글에 대한 여러분의 생각을 들려주세요

댓글 0
댓글을 불러오는 중입니다.

아직 댓글이 없습니다.