목차
비즈니스 로직에 DB 코드가 엉킨다
사용자를 찾아서 “활성 상태인지” 확인하는 서비스 메서드를 보자. 하는 일은 딱 한 줄, 활성 여부를 검사하는 것이다.
public User findActiveUser(Long id) {
String sql = "SELECT id, email, active FROM users WHERE id = ?";
try (Connection conn = dataSource.getConnection();
PreparedStatement ps = conn.prepareStatement(sql)) {
ps.setLong(1, id);
ResultSet rs = ps.executeQuery();
if (rs.next()) {
User user = new User(rs.getLong("id"), rs.getString("email"), rs.getBoolean("active"));
if (!user.isActive()) { // ← 진짜 하고 싶은 일은 이 한 줄
throw new IllegalStateException("비활성 사용자");
}
return user;
}
throw new NoSuchElementException();
} catch (SQLException e) {
throw new RuntimeException(e);
}
}정작 중요한 한 줄이 커넥션·SQL·ResultSet·예외 처리에 파묻혀 있다. 그리고 사용자를 다루는 다른 메서드마다 이 뒤치다꺼리가 또 반복된다.
문제는 읽기 어렵다는 것만이 아니다.
- 테스트하려면 진짜 DB가 있어야 한다. “비활성 사용자를 거부하는가”만 확인하고 싶은데, 그러려면 DB를 띄우고 데이터를 넣어야 한다.
- 저장 방식을 바꾸면 로직까지 헤집는다. DB를 다른 것으로 바꾸거나 쿼리를 손보려면, 비즈니스 로직이 든 이 메서드를 건드려야 한다.
비즈니스 로직과 데이터 접근이 한 덩어리로 엉켜 있는 게 원인이다.
Repository는 데이터 접근을 인터페이스 뒤로 숨긴다
해결의 방향은 하나다. 데이터 접근을 인터페이스 뒤로 숨긴다. 부르는 쪽은 “사용자를 아이디로 달라”고만 하고, 그게 어떻게 오는지는 모른다.
서비스는 UserRepository라는 인터페이스만 안다. 그 뒤에 DB가 있는지, 파일이 있는지, 메모리 맵이 있는지는 서비스의 관심 밖이다. 마치 도메인 객체를 담아둔 컬렉션에서 꺼내 쓰는 것처럼 보인다. “저장소에서 사용자를 찾는다”까지가 서비스가 아는 전부다.
저장소는 컬렉션처럼 보인다
“Repository”라는 이름에 뜻이 있다. 이 패턴이 온 도메인 주도 설계(DDD)에서 repository는 도메인 객체를 담아둔 컬렉션처럼 다루자는 것이다.
리스트에서 원소를 꺼내고 넣듯, 저장소에서 사용자를 findById로 꺼내고 save로 넣는다. 부르는 쪽은 그게 메모리 위의 리스트인지 DB 테이블인지 신경 쓰지 않는다. 컬렉션을 쓰듯 쓴다.
그래서 **이 패턴의 정의는 “인터페이스로 갈랐다”가 아니라 “컬렉션처럼 보이게 했다”**이다. 순서가 중요하다. 인터페이스와 구현을 가르는 건 어떤 코드에나 쓰는 일반적인 기법이고, 그중 저장소를 도메인 객체가 담긴 컬렉션으로 보이게 만든 것이 Repository다.
이 구분이 실제로 쓰이는 자리가 있다. 데이터 접근을 담당하는 객체를 흔히 DAO라고도 부르는데, DAO는 보통 테이블 하나에 하나씩 두고 그 테이블을 읽고 쓰는 일을 맡는다. 이름과 메서드에 테이블 냄새가 남아도 이상하지 않다. 반면 Repository는 테이블이 아니라 도메인이 다루는 덩어리 단위로 둔다. 주문과 주문 항목이 늘 함께 다뤄진다면 OrderItemRepository를 따로 두는 대신 OrderRepository 하나가 그 덩어리를 통째로 넣고 꺼낸다. 그래서 Repository의 개수는 테이블 수가 아니라 도메인의 덩어리 수를 따라간다.
이 관점이 왜 중요하냐면, 인터페이스를 설계하는 손을 잡아주기 때문이다. 저장소를 “쿼리를 실행하는 것”으로 보면 인터페이스에 executeQuery 같은 SQL 냄새가 배지만, “객체를 담고 꺼내는 컬렉션”으로 보면 findById·save처럼 도메인의 말이 된다. 뒤에서 보겠지만, 이 차이가 패턴이 값을 하느냐를 가른다.
인터페이스와 구현을 가른다
인터페이스는 도메인의 말로 쓴다. “무엇을” 할지만 적고, “어떻게”는 비운다.
public interface UserRepository {
User findById(Long id);
void save(User user);
}앞에서 서비스에 엉켜 있던 DB 코드는 구현으로 옮겨간다.
public class JdbcUserRepository implements UserRepository {
private final DataSource dataSource;
// 생성자 생략
@Override
public User findById(Long id) {
String sql = "SELECT id, email, active FROM users WHERE id = ?";
// 커넥션 · SQL · ResultSet 매핑이 전부 여기로 들어온다
// ...
}
@Override
public void save(User user) { /* INSERT 또는 UPDATE */ }
}그러면 서비스는 자기 일만 남는다.
public class UserService {
private final UserRepository users; // 인터페이스에만 의존한다
public UserService(UserRepository users) {
this.users = users;
}
public User findActiveUser(Long id) {
User user = users.findById(id); // 어떻게 가져오는지는 모른다
if (!user.isActive()) {
throw new IllegalStateException("비활성 사용자");
}
return user;
}
}처음에 파묻혀 있던 그 한 줄이 이제 메서드의 전부다. 커넥션도 SQL도 안 보인다. 서비스는 UserRepository가 인터페이스라는 것만 알지, 그 뒤에 뭐가 있는지 모른다.
서비스가 구현(JdbcUserRepository)이 아니라 인터페이스(UserRepository)에 의존하는 것이 핵심이다. 구현은 생성자로 받아 끼운다. 누가 어떤 구현을 넣어주는지는 이 글의 관심 밖이고(Spring을 쓴다면 컨테이너가 한다), 지금 볼 것은 서비스가 인터페이스만 본다는 사실 하나다.
구현을 갈아끼워도 호출부는 그대로다
인터페이스로 갈라두면 무엇이 좋은가. 구현을 통째로 바꿔도 서비스는 한 줄도 안 바뀐다.
테스트용으로 DB 없이 메모리에만 담는 구현을 하나 만들어 보자.
public class InMemoryUserRepository implements UserRepository {
private final Map<Long, User> store = new HashMap<>();
@Override
public User findById(Long id) {
User user = store.get(id);
if (user == null) throw new NoSuchElementException();
return user;
}
@Override
public void save(User user) {
store.put(user.getId(), user);
}
}같은 UserRepository 인터페이스를 구현했으니, 서비스 입장에선 둘이 구별되지 않는다. 운영에선 JdbcUserRepository를, 테스트에선 InMemoryUserRepository를 넣으면 된다.
UserService 코드는 두 경우에 완전히 똑같다. 무엇을 넣어주느냐만 다르다.
다만 “코드가 안 바뀐다”와 “동작이 같다”는 다른 말이다. 위 두 구현만 해도 결이 다르다. InMemoryUserRepository는 Map에 든 그 객체를 그대로 돌려주니, 서비스가 꺼낸 객체를 고치면 save를 안 불러도 저장소에 반영된다. JdbcUserRepository는 매번 ResultSet에서 새 객체를 만드니 save를 빠뜨리면 아무 일도 안 일어난다. save를 빠뜨린 코드가 테스트는 통과하고 운영에서만 조용히 안 저장되는 것이다.
인터페이스가 감춰주지 못하는 것도 있다. 어디까지가 한 트랜잭션인지, 신규 객체의 ID를 누가 언제 만드는지 같은 것들이다. 위 메모리 구현은 store.put(user.getId(), user)로 저장하는데, ID가 아직 없는 새 객체를 넣으면 null을 키로 삼아 에러 없이 들어가고 다시는 못 찾는다. 실제 DB 구현이라면 그 ID를 DB가 만들어준다. 트랜잭션 경계 이야기는 Unit of Work에 있다.
실무: 테스트가 쉬워진다
이 갈아끼우기가 실무에서 제일 크게 값을 하는 곳이 테스트다.
맨 처음 문제에서, “비활성 사용자를 거부하는가”를 확인하려면 DB가 필요했다. 이제는 아니다.
@Test
void 비활성_사용자는_거부된다() {
var repo = new InMemoryUserRepository();
repo.save(new User(1L, "kim@example.com", false)); // active = false
var service = new UserService(repo);
assertThrows(IllegalStateException.class,
() -> service.findActiveUser(1L));
}DB도, 커넥션도, SQL도 없다. 메모리 구현을 넣고 원하는 데이터를 세팅한 뒤, 비즈니스 로직만 검증한다. 빠르고, 매번 같은 결과가 나온다.
이게 가능한 건 서비스가 인터페이스에만 의존하기 때문이다. 데이터 접근을 뒤로 숨긴 대가가 여기서 돌아온다. 테스트하고 싶은 로직에서 DB를 떼어낼 수 있다.
대신 이 테스트가 확인해주는 범위는 딱 거기까지다. 메모리 구현은 유니크 제약도, 외래 키도, 트랜잭션 격리도 흉내 내지 않는다. 정렬이나 NULL 취급이 DB와 다를 수도 있다. 그래서 이 테스트가 통과해도 쿼리가 맞는지는 아무것도 증명되지 않는다. 실무에서는 둘을 갈라서 쓴다. 업무 규칙은 이렇게 메모리 구현으로 빠르게 확인하고, 저장이 실제로 되는지는 진짜 DB를 띄워 따로 확인한다.
Spring Data는 구현까지 만들어준다
지금까지 구현을 손으로 썼다. 그런데 Spring Data JPA를 쓰면 그 구현을 안 쓴다. 인터페이스만 선언하면 된다.
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByEmail(String email); // 메서드 이름이 곧 쿼리다
}구현 클래스가 없는데도 이 인터페이스는 동작한다. Spring이 뜰 때 인터페이스를 보고 구현체를 만들어 빈으로 등록하기 때문이다. findByEmail은 이름을 해석해 SELECT ... WHERE email = ?을 만들어준다.
그러면 이 글에서 본 것이 쓸모없어지나. 반대다. 인터페이스만 남기고 구현을 감춘다는 이 패턴의 모양이, 프레임워크 차원에서 기본값이 된 것이다. 우리가 손으로 하던 일을 Spring이 대신할 뿐, 서비스가 인터페이스만 본다는 사실은 그대로다.
이때 인터페이스에 무엇을 담을지가 더 중요해진다. JpaRepository를 상속하는 순간 findAll·delete 같은 메서드 수십 개가 딸려 들어오고, 그중엔 이 도메인에서 쓰면 안 되는 것도 섞여 있다. 필요한 메서드만 직접 선언한 인터페이스를 두고 그 뒤에서 Spring Data를 쓰는 방식도 이래서 쓴다.
추상화가 새면 못 갈아끼운다
지금까지의 값어치에는 조건이 하나 붙어 있었다. 인터페이스가 “어떻게”를 정말로 감출 때만 성립한다.
인터페이스가 도메인의 말(findById·save)이 아니라 저장 방식의 세부를 밖으로 흘리면 추상화가 샌다. 예를 들어 이런 메서드가 인터페이스에 있다고 하자.
List<User> findByRawSql(String whereClause); // SQL 조각을 밖에서 받는다부르는 쪽이 SQL을 알아야 하니, 서비스가 다시 저장 방식에 묶인다. 그리고 이러면 구현을 갈아끼우기 어려워진다. InMemoryUserRepository는 whereClause라는 SQL 조각을 해석할 방법이 없다. 메모리 구현을 만들려면 SQL 해석기를 짜야 하는 셈이다.
이건 알아보기 쉬운 예다. 실무에서 실제로 새는 건 대개 프레임워크가 준 타입이다.
Page<User> findByActive(boolean active, Pageable pageable); // Spring Data 타입Page와 Pageable은 Spring Data의 것이다. 이게 인터페이스에 들어오면 부르는 쪽도 그 타입을 알아야 하니, 도메인이 특정 프레임워크에 묶인다. 나중에 저장 기술을 바꾸려면 서비스까지 따라 바뀐다. 페이징 자체가 나쁘다는 말이 아니라, 어디까지를 도메인의 말로 남길지 정해두고 쓰라는 뜻이다.
그래서 규칙은 하나다. 인터페이스는 “무엇을”만 말하고 “어떻게”는 감춘다. 쿼리 문자열, 쿼리 빌더, 저장 기술에 딸린 타입이 인터페이스에 새어 나오는 순간, 숨겨서 얻은 값이 도로 사라진다.
정리
| Repository란 | 저장소를 도메인 객체가 담긴 컬렉션처럼 다루게 만드는 패턴 |
| 왜 쓰나 | 비즈니스 로직에서 커넥션·SQL을 걷어내 로직만 남긴다 |
| 어떻게 | 도메인 말로 된 인터페이스 + 그 뒤의 구현. 서비스는 인터페이스에만 의존 |
| 단위 | 테이블 하나가 아니라 도메인이 함께 다루는 덩어리 하나 |
| 값어치 | 구현을 갈아끼워도 호출부가 안 바뀐다 - 특히 DB 없이 업무 규칙을 테스트할 수 있다 |
| 한계 | 트랜잭션 경계·ID 생성까지 감춰주지는 않는다. 쿼리가 맞는지는 진짜 DB로 따로 본다 |
| 깨지는 조건 | 인터페이스가 저장 방식이나 프레임워크 타입을 밖으로 흘리면 추상화가 샌다 |
서비스가 “어떻게 저장하는지”를 잊어버리게 만드는 것. 그게 Repository 패턴이 하는 일이다. 그렇게 남은 서비스가 무엇을 맡는 층인지는 서비스 계층에 있다.