인터페이스 기반 리포지토리 설계
인터페이스에 의존하면 구현체를 자유롭게 교체할 수 있다는 것이 실제로 어떤 의미인지, 단계별 예시와 함께 정리합니다.
출발점
김영한 강사님이 MemberRepository를 작성할 때 이런 말씀을 하셨다.
“이걸 인터페이스로 구현하면 좋겠죠. 그렇게하면 나중에 여러분의 구현체로 DB의 회원을 관리할 수도 있고, 메모리의 회원을 관리할 수도 있고 뭐 이렇게 할 수 있으니까요?”
이 말의 핵심은 “인터페이스에 의존하면 구현체를 자유롭게 바꿀 수 있다”는 것이다.
이것은 객체지향 설계의 핵심 원칙인 의존성 역전 원칙(DIP)과 개방-폐쇄 원칙(OCP)을 따르는 방식이다.
설계 단계별 예시
1단계: 인터페이스 정의
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
package hello.login.domain.member;
import java.util.List;
import java.util.Optional;
/**
* 회원 리포지토리 인터페이스
* 구체적인 저장 방식(메모리, DB, 파일 등)에 무관한 추상화
*/
public interface MemberRepository {
Member save(Member member);
Optional<Member> findById(Long id);
Optional<Member> findByLoginId(String loginId);
List<Member> findAll();
void clearStore();
}
2단계: 메모리 기반 구현체 (학습/개발용)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
package hello.login.domain.member;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Repository;
import java.util.*;
@Slf4j
@Repository
public class MemoryMemberRepository implements MemberRepository {
private static Map<Long, Member> store = new HashMap<>();
private static long sequence = 0L;
@Override
public Member save(Member member) {
member.setId(++sequence);
store.put(member.getId(), member);
log.info("회원 저장 완료 (메모리): {}", member);
return member;
}
@Override
public Optional<Member> findById(Long id) {
return Optional.ofNullable(store.get(id));
}
@Override
public Optional<Member> findByLoginId(String loginId) {
return store.values().stream()
.filter(m -> m.getLoginId().equals(loginId))
.findFirst();
}
@Override
public List<Member> findAll() {
return new ArrayList<>(store.values());
}
@Override
public void clearStore() {
store.clear();
}
}
특징:
- HashMap을 사용한 인메모리 저장
- 애플리케이션 재시작 시 데이터가 소실됨
- 빠른 개발 및 테스트에 유리
- 별도의 DB 설정이 필요 없음
3단계: DB 기반 구현체 (실제 운영용)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
package hello.login.domain.member;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Repository;
import javax.sql.DataSource;
import java.sql.*;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
@Slf4j
// @Repository // 실제로 사용할 때만 활성화
@RequiredArgsConstructor
public class JdbcMemberRepository implements MemberRepository {
private final DataSource dataSource;
@Override
public Member save(Member member) {
String sql = "INSERT INTO member(login_id, name, password) VALUES (?, ?, ?)";
try (Connection con = dataSource.getConnection();
PreparedStatement pstmt = con.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) {
pstmt.setString(1, member.getLoginId());
pstmt.setString(2, member.getName());
pstmt.setString(3, member.getPassword());
pstmt.executeUpdate();
ResultSet rs = pstmt.getGeneratedKeys();
if (rs.next()) {
member.setId(rs.getLong(1));
}
log.info("회원 저장 완료 (DB): {}", member);
return member;
} catch (SQLException e) {
throw new RuntimeException(e);
}
}
@Override
public Optional<Member> findById(Long id) {
String sql = "SELECT * FROM member WHERE id = ?";
try (Connection con = dataSource.getConnection();
PreparedStatement pstmt = con.prepareStatement(sql)) {
pstmt.setLong(1, id);
ResultSet rs = pstmt.executeQuery();
if (rs.next()) {
Member member = new Member();
member.setId(rs.getLong("id"));
member.setLoginId(rs.getString("login_id"));
member.setName(rs.getString("name"));
member.setPassword(rs.getString("password"));
return Optional.of(member);
}
return Optional.empty();
} catch (SQLException e) {
throw new RuntimeException(e);
}
}
@Override
public Optional<Member> findByLoginId(String loginId) {
String sql = "SELECT * FROM member WHERE login_id = ?";
try (Connection con = dataSource.getConnection();
PreparedStatement pstmt = con.prepareStatement(sql)) {
pstmt.setString(1, loginId);
ResultSet rs = pstmt.executeQuery();
if (rs.next()) {
Member member = new Member();
member.setId(rs.getLong("id"));
member.setLoginId(rs.getString("login_id"));
member.setName(rs.getString("name"));
member.setPassword(rs.getString("password"));
return Optional.of(member);
}
return Optional.empty();
} catch (SQLException e) {
throw new RuntimeException(e);
}
}
@Override
public List<Member> findAll() {
String sql = "SELECT * FROM member";
List<Member> members = new ArrayList<>();
try (Connection con = dataSource.getConnection();
PreparedStatement pstmt = con.prepareStatement(sql)) {
ResultSet rs = pstmt.executeQuery();
while (rs.next()) {
Member member = new Member();
member.setId(rs.getLong("id"));
member.setLoginId(rs.getString("login_id"));
member.setName(rs.getString("name"));
member.setPassword(rs.getString("password"));
members.add(member);
}
return members;
} catch (SQLException e) {
throw new RuntimeException(e);
}
}
@Override
public void clearStore() {
String sql = "DELETE FROM member";
try (Connection con = dataSource.getConnection();
PreparedStatement pstmt = con.prepareStatement(sql)) {
pstmt.executeUpdate();
} catch (SQLException e) {
throw new RuntimeException(e);
}
}
}
특징:
- 실제 데이터베이스에 저장
- 데이터 영속성 보장
- 트랜잭션 처리 가능
- 동시성 제어 가능
4단계: 서비스 계층에서 사용
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
package hello.login.domain.member;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class MemberService {
// 핵심: 인터페이스 타입으로 의존한다.
// 구체적인 구현체(MemoryMemberRepository 또는 JdbcMemberRepository)를
// 알 필요가 없다.
private final MemberRepository memberRepository;
public Long join(Member member) {
validateDuplicateMember(member);
Member savedMember = memberRepository.save(member);
return savedMember.getId();
}
public Member login(String loginId, String password) {
return memberRepository.findByLoginId(loginId)
.filter(m -> m.getPassword().equals(password))
.orElse(null);
}
private void validateDuplicateMember(Member member) {
memberRepository.findByLoginId(member.getLoginId())
.ifPresent(m -> {
throw new IllegalStateException("이미 존재하는 회원입니다.");
});
}
}
이 설계가 주는 이점
구현체 교체가 간단하다
1
2
3
4
5
6
7
8
9
10
11
12
@Configuration
public class RepositoryConfig {
@Bean
public MemberRepository memberRepository() {
// 환경에 따라 바꾸기만 하면 됨
// return new MemoryMemberRepository(); // 개발 환경
return new JdbcMemberRepository(dataSource()); // 운영 환경
// MemberService 코드는 전혀 수정할 필요 없음
}
}
테스트가 쉬워진다
1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Test
void 로그인_테스트() {
// Mock 객체로 쉽게 교체 가능
MemberRepository mockRepository = mock(MemberRepository.class);
MemberService service = new MemberService(mockRepository);
// given
when(mockRepository.findByLoginId("testId"))
.thenReturn(Optional.of(testMember));
// when & then
Member result = service.login("testId", "password");
assertThat(result).isNotNull();
}
OCP(개방-폐쇄 원칙) 준수
확장에는 열려있고, 수정에는 닫혀있는 구조가 된다.
1
2
3
4
5
6
7
8
9
MemberService (변경 없음)
↓ 의존
MemberRepository (인터페이스)
↑ 구현
├── MemoryMemberRepository (메모리 저장)
├── JdbcMemberRepository (JDBC 사용)
├── JpaMemberRepository (JPA 사용) ← 추가 가능
├── MyBatisMemberRepository (MyBatis 사용) ← 추가 가능
└── RedisMemberRepository (Redis 사용) ← 추가 가능
새로운 구현체를 추가해도 MemberService 코드는 전혀 수정하지 않아도 된다.
실무 적용 시나리오
시나리오 1: 개발에서 운영으로 전환
1
2
3
4
5
6
7
8
9
// 개발 단계: 빠른 개발을 위해 메모리 사용
@Profile("dev")
@Repository
public class MemoryMemberRepository implements MemberRepository { ... }
// 운영 단계: 실제 DB 사용
@Profile("prod")
@Repository
public class JdbcMemberRepository implements MemberRepository { ... }
1
2
3
4
5
# application-dev.properties
spring.profiles.active=dev
# application-prod.properties
spring.profiles.active=prod
시나리오 2: 성능 개선을 위한 캐시 추가
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 기존 DB Repository를 감싸는 캐시 레이어
@Primary
@Repository
@RequiredArgsConstructor
public class CachedMemberRepository implements MemberRepository {
private final JdbcMemberRepository jdbcRepository;
private final Map<Long, Member> cache = new ConcurrentHashMap<>();
@Override
public Optional<Member> findById(Long id) {
// 캐시 먼저 확인
if (cache.containsKey(id)) {
return Optional.of(cache.get(id));
}
// 캐시 미스면 DB 조회 후 캐시에 저장
Optional<Member> member = jdbcRepository.findById(id);
member.ifPresent(m -> cache.put(id, m));
return member;
}
// 나머지 메서드들...
}
시나리오 3: 멀티 DB 환경
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// 읽기 전용 DB
@Qualifier("reader")
@Repository
public class ReadOnlyMemberRepository implements MemberRepository { ... }
// 쓰기 전용 DB
@Qualifier("writer")
@Repository
public class WriteOnlyMemberRepository implements MemberRepository { ... }
// 서비스에서 선택적 사용
@Service
public class MemberService {
private final MemberRepository readerRepo;
private final MemberRepository writerRepo;
public MemberService(
@Qualifier("reader") MemberRepository readerRepo,
@Qualifier("writer") MemberRepository writerRepo) {
this.readerRepo = readerRepo;
this.writerRepo = writerRepo;
}
}
정리
인터페이스 = 계약(Contract). “이런 기능들을 제공해야 한다”는 약속이다.
구현체 = 계약의 실제 이행 방법. 메모리, DB, 파일, 네트워크 등 다양한 방식으로 구현할 수 있다.
사용하는 쪽(Service, Controller) = 계약만 알면 된다. 구체적인 구현 방법은 몰라도 되고, 나중에 구현체가 바뀌어도 코드를 수정할 필요가 없다.
이것이 바로 유연한 설계의 핵심이다.
This post is licensed under CC BY 4.0 by the author.