Spring_08) Interface 다형성 활용으로 코드 결합도 낮추기
2025. 11. 5. 08:24

학습 목표

  • 인터페이스의 개념과 존재 이유를 이해한다
  • 느슨한 결합도와 다형성의 실질적 가치를 파악한다
  • Service 계층에서 인터페이스를 분리하는 이유를 학습한다
  • 실제 프로젝트 코드를 통해 인터페이스 활용 패턴을 분석한다

<인터페이스의 개념>

Spring Boot 프로젝트를 개발하다 보면 BoardService 인터페이스와 BoardServiceImpl 구현체처럼 인터페이스와 구현체를 분리하는 패턴을 자주 접하게 된다. 왜 굳이 인터페이스를 만들어야 할까. 구현체만으로도 충분히 동작하는데 말이다.

인터페이스는 클래스가 구현해야 하는 메서드들의 시그니처를 정의하는 일종의 계약서이다. 무엇을 해야 하는가를 정의하고, 어떻게 할 것인가는 구현체에서 결정한다. 이는 단순한 코드 스타일이 아니라, 유지보수성과 테스트 용이성을 높이는 중요한 설계 원칙이다.

- 실제 프로젝트의 인터페이스

프로젝트의 BoardService 인터페이스를 살펴보면 게시판 기능에 필요한 모든 메서드가 정의되어 있다.

public interface BoardService {
    // 기본 게시판 기능
    int selectAllBoardCount();
    ArrayList<Board> selectAllBoard(PageInfo pi);
    int selectSearchBoardCount(String condition, String keyword);
    ArrayList<Board> selectSearchBoard(PageInfo pi, String condition, String keyword);

    // 게시글 상세보기
    Board selectBoardByBoardNo(int boardNo);
    Attachment selectAttachment(int boardNo);
    int increaseCount(int boardNo);

    // 게시글 작성/수정/삭제
    int insertBoard(Board b, Attachment at);
    int updateBoard(Board b, Attachment at);
    int deleteBoard(int boardNo);

    // 댓글 기능
    int insertReply(Reply r);
    ArrayList<Reply> selectReplyByBoardNo(int boardNo);
    int deleteReply(int replyNo);
}

이 인터페이스를 구현하는 BoardServiceImpl 클래스는 실제 비즈니스 로직을 담당한다.

@Service
public class BoardServiceImpl implements BoardService {
    private final BoardMapper boardMapper;

    public BoardServiceImpl(BoardMapper boardMapper) {
        this.boardMapper = boardMapper;
    }

    // 인터페이스에 정의된 메서드들을 실제로 구현
}

<인터페이스를 사용하는 이유>

- 느슨한 결합도 달성

인터페이스를 사용하는 가장 중요한 이유는 느슨한 결합도를 달성하기 위함이다. 구현체에 의존하지 않고 인터페이스에 의존하게 되면, 구체적인 구현 방식의 변경이 다른 클래스에 영향을 미치지 않는다.

BoardController를 살펴보면 BoardService 인터페이스에 의존하고 있음을 알 수 있다.

@Controller
public class BoardController {
    private final BoardService boardService;

    public BoardController(BoardService boardService) {
        this.boardService = boardService;
    }
}

만약 인터페이스 없이 구현체를 직접 사용한다면 다음과 같은 형태가 된다.

// 인터페이스 없이 사용하는 경우
@Controller
public class BoardController {
    private final BoardServiceImpl boardService;  // 구체 클래스에 직접 의존

    public BoardController(BoardServiceImpl boardService) {
        this.boardService = boardService;
    }
}

이 경우 BoardControllerBoardServiceImpl에 강하게 결합되어, 구현체를 변경하거나 테스트용 Mock 객체를 주입하기 어렵다. 반면 인터페이스를 사용하면 구현체 변경 시 Controller 코드를 수정할 필요가 없고, 테스트 시 Mock 객체로 쉽게 대체할 수 있으며, 여러 구현체를 만들어 선택적으로 사용할 수 있다.

- 다형성의 활용

인터페이스를 통해 여러 구현체를 만들고, 상황에 따라 다른 구현체를 사용할 수 있다. 예를 들어 기본 구현체, 캐시를 사용하는 구현체, 성능 최적화된 구현체를 각각 만들어 필요에 따라 선택할 수 있다.

// 기본 구현체
@Service
public class BoardServiceImpl implements BoardService {
    // 기본 로직
}

// 캐시를 사용하는 구현체
@Service("cachedBoardService")
public class CachedBoardServiceImpl implements BoardService {
    // 캐시를 활용한 로직
}

필요에 따라 @Qualifier를 사용하여 원하는 구현체를 선택할 수 있다.

@Controller
public class BoardController {
    private final BoardService boardService;

    public BoardController(@Qualifier("cachedBoardService") BoardService boardService) {
        this.boardService = boardService;
    }
}

- 테스트 용이성 향상

인터페이스를 사용하면 단위 테스트 작성이 훨씬 쉬워진다. 실제 데이터베이스 연결 없이 Mock 객체를 사용하여 테스트할 수 있다.

@SpringBootTest
class BoardControllerTest {

    @MockBean
    private BoardService boardService;  // 인터페이스이므로 Mock 객체 생성이 쉬움

    @Autowired
    private BoardController boardController;

    @Test
    void testListBoard() {
        // Given
        List<Board> mockList = new ArrayList<>();
        when(boardService.selectAllBoard(any())).thenReturn(mockList);

        // When
        ModelAndView mv = boardController.listBoard(1, new ModelAndView());

        // Then
        assertNotNull(mv);
    }
}

인터페이스가 없다면 실제 데이터베이스 연결이나 복잡한 설정이 필요한 통합 테스트만 가능하다. 이는 테스트 실행 시간을 늘리고, 외부 의존성으로 인해 테스트가 불안정해질 수 있다.

- 계약의 명확화

인터페이스는 클래스가 제공해야 하는 기능을 명확하게 정의한다. MemberService 인터페이스를 보면 회원 관리 시스템이 어떤 기능을 제공해야 하는지 한눈에 알 수 있다.

public interface MemberService {
    Member getMemberById(String memberId);
    int getMemberCountById(String memberId);
    int addMember(Member member);
    int updateMember(Member member);
    int updatePwd(String memberId, String newPwd);
    int deleteMember(String memberId);
}

이 인터페이스를 보면 구현체를 확인하지 않아도 API 계약을 이해할 수 있다. 회원 조회, 등록, 수정, 비밀번호 변경, 삭제 기능이 필요하다는 것이 명확하다.

- 유지보수성 향상

구현체의 내부 로직이 변경되어도 인터페이스가 유지되면, 해당 인터페이스를 사용하는 다른 클래스들은 수정할 필요가 없다.

@Override
@Transactional
public int insertBoard(Board b, Attachment at) {
    int result = boardMapper.insertBoard(b);

    if(at != null) {
        result *= boardMapper.insertAttachment(at);
    }

    return result;
}

나중에 insertBoard 메서드의 내부 로직을 변경하거나 최적화하더라도, 인터페이스의 시그니처는 그대로 유지되므로 BoardController는 전혀 수정할 필요가 없다. 메서드 이름, 매개변수, 반환 타입이 변경되지 않는 한 호출하는 쪽에서는 변경 사항을 인지할 필요가 없다.


<실제 프로젝트의 계층 구조>

- Controller에서 Service 인터페이스 사용

프로젝트의 계층 구조는 Controller → Service Interface → Service Impl → Mapper 순서로 구성된다.

@Controller
public class BoardController {
    private final BoardService boardService;

    public BoardController(BoardService boardService) {
        this.boardService = boardService;
    }

    @GetMapping("list.bo")
    public ModelAndView listBoard(@RequestParam(value = "cpage", required = false) Integer cpage, ModelAndView mv){
        int currentPage = cpage != null ? cpage : 1;
        int listCount = boardService.selectAllBoardCount();
        PageInfo pi = new PageInfo(currentPage, listCount, 5, 5);
        ArrayList<Board> list = boardService.selectAllBoard(pi);
        mv.addObject("list", list);
        mv.addObject("pi", pi);
        mv.setViewName("board/listView");
        return mv;
    }
}

Controller는 Service 인터페이스를 통해 비즈니스 로직을 호출한다. 어떤 구현체가 주입되는지는 Controller가 알 필요가 없다. Spring Container가 적절한 구현체를 찾아 주입해준다.

- Service 인터페이스와 구현체 분리

Service 계층은 인터페이스와 구현체로 분리되어 있다. 인터페이스는 제공할 기능을 정의하고, 구현체는 실제 로직을 담당한다.

public interface BoardService {
    int selectAllBoardCount();
    ArrayList<Board> selectAllBoard(PageInfo pi);
    int insertBoard(Board b, Attachment at);
    int updateBoard(Board b, Attachment at);
    int deleteBoard(int boardNo);
}
@Service
public class BoardServiceImpl implements BoardService {
    private final BoardMapper boardMapper;

    public BoardServiceImpl(BoardMapper boardMapper) {
        this.boardMapper = boardMapper;
    }

    @Override
    public int selectAllBoardCount() {
        return boardMapper.selectAllBoardCount();
    }

    @Override
    public ArrayList<Board> selectAllBoard(PageInfo pi) {
        return boardMapper.selectAllBoard(pi);
    }
}

이렇게 계층별로 인터페이스를 분리하면 각 계층의 책임이 명확해지고, 계층 간 의존성이 느슨해지며, 테스트와 유지보수가 쉬워진다.

- Spring 의존성 주입과의 시너지

Spring의 의존성 주입은 인터페이스 기반으로 동작하도록 설계되었다. Spring Container는 BoardService 타입의 Bean을 찾아서 주입한다.

@Service
public class BoardServiceImpl implements BoardService {
    private final BoardMapper boardMapper;

    public BoardServiceImpl(BoardMapper boardMapper) {
        this.boardMapper = boardMapper;
    }
}

BoardServiceImplBoardService를 구현하고 @Service로 등록되어 있으므로, Spring은 자동으로 이 구현체를 BoardService 타입이 필요한 곳에 주입한다. 만약 인터페이스가 없다면 구체 클래스에 강하게 결합되어, 다른 구현체로 교체하거나 테스트하기 어렵다.


<인터페이스 사용 시나리오 비교>

- 구현체 변경이 필요한 경우

인터페이스를 사용하면 구현체를 변경할 때 호출하는 쪽의 코드를 수정할 필요가 없다.

인터페이스 사용 시:

// 기존 구현체
@Service
public class BoardServiceImpl implements BoardService {
    // 기본 로직
}

// 새로운 구현체로 교체
@Service
public class OptimizedBoardServiceImpl implements BoardService {
    // 최적화된 로직
}

// Controller는 변경 불필요
@Controller
public class BoardController {
    private final BoardService boardService;  // 여전히 인터페이스에 의존
}

인터페이스 없이 사용 시:

// 기존 구현체
@Service
public class BoardServiceImpl {
    // 기본 로직
}

// Controller
@Controller
public class BoardController {
    private final BoardServiceImpl boardService;  // 구체 클래스에 의존

    // 새로운 구현체로 변경하려면 Controller 코드를 수정해야 함
}

인터페이스가 없으면 구현체를 변경할 때마다 Controller를 포함한 모든 의존하는 클래스를 수정해야 한다. 프로젝트 규모가 커질수록 이러한 변경의 파급 효과는 기하급수적으로 증가한다.

- 단위 테스트 작성

인터페이스를 사용하면 Mock 객체를 쉽게 생성하여 단위 테스트를 작성할 수 있다.

인터페이스 사용 시:

@SpringBootTest
class BoardControllerTest {
    @MockBean
    private BoardService boardService;  // Mock 객체로 쉽게 대체

    @Test
    void test() {
        when(boardService.selectAllBoardCount()).thenReturn(10);
        // 데이터베이스 연결 없이 테스트 가능
    }
}

인터페이스 없이 사용 시:

@SpringBootTest
class BoardControllerTest {
    @MockBean
    private BoardServiceImpl boardService;  // 구체 클래스 Mock

    // 문제: 실제 구현체의 변경이 테스트에 영향을 줄 수 있음
    // 문제: 모든 의존성을 설정해야 함
}

인터페이스가 없으면 구체 클래스의 모든 의존성을 설정해야 하고, 구현체의 변경이 테스트에 직접적인 영향을 미친다. 이는 테스트의 독립성을 해치고 유지보수를 어렵게 만든다.


<인터페이스를 사용하지 않는 경우>

모든 경우에 인터페이스가 필요한 것은 아니다. 다음과 같은 상황에서는 인터페이스 없이도 충분하다.

첫 번째, 구현체가 하나이고 변경될 가능성이 거의 없는 경우이다. 단순한 데이터 변환이나 포맷팅 같은 기능은 인터페이스가 불필요할 수 있다.

두 번째, 간단한 유틸리티 클래스의 경우이다. 날짜 포맷팅, 문자열 처리 같은 정적 메서드만 제공하는 유틸리티 클래스는 인터페이스가 의미가 없다.

세 번째, 인터페이스가 단순히 구현체를 복사한 형태일 때이다. 불필요한 추상화는 오히려 코드를 복잡하게 만든다.

하지만 Service 계층 같은 경우는 대부분 인터페이스를 사용하는 것이 좋다. 비즈니스 로직 변경이 빈번하고, 테스트 작성이 중요하며, 여러 구현체가 필요할 가능성이 있기 때문이다.


<실전 활용 팁>

- 인터페이스 네이밍 규칙

프로젝트에서는 일관된 네이밍 규칙을 사용한다. 인터페이스는 BoardService, MemberService처럼 명사형으로 작성하고, 구현체는 BoardServiceImpl, MemberServiceImpl처럼 Impl 접미사를 붙인다.

이러한 네이밍 규칙은 코드를 읽는 사람이 인터페이스와 구현체를 쉽게 구분할 수 있게 해준다. 또한 Spring의 컴포넌트 스캔이 자동으로 Bean을 등록할 때도 명확한 네이밍이 도움이 된다.

- 인터페이스 메서드 추가 시 주의사항

인터페이스에 새로운 메서드를 추가하면, 모든 구현체에서 해당 메서드를 구현해야 한다. 이는 인터페이스 변경의 파급 효과가 크다는 것을 의미한다.

public interface BoardService {
    // 기존 메서드들
    int newMethod();  // 새로 추가하면 모든 구현체에서 구현 필요
}

Java 8 이상에서는 default 메서드를 사용하여 기본 구현을 제공할 수 있다.

public interface BoardService {
    default int newMethod() {
        // 기본 구현
        return 0;
    }
}

default 메서드를 사용하면 기존 구현체를 수정하지 않고도 새로운 기능을 추가할 수 있다. 하지만 default 메서드는 신중하게 사용해야 한다. 인터페이스에 구현 로직이 들어가는 것은 인터페이스의 원래 목적과 거리가 있기 때문이다.

- Spring AOP와 인터페이스

Spring은 인터페이스 기반 프록시를 생성한다. @Transactional, @Cacheable 같은 AOP 기능을 사용할 때 인터페이스가 있으면 더 효율적이다.

@Override
@Transactional
public int insertBoard(Board b, Attachment at) {
    int result = boardMapper.insertBoard(b);

    if(at != null) {
        result *= boardMapper.insertAttachment(at);
    }

    return result;
}

@Transactional이 제대로 동작하려면 Spring이 프록시를 생성해야 하는데, 인터페이스가 있으면 JDK 동적 프록시를 사용하여 더 효율적이다. 인터페이스가 없으면 CGLIB 프록시를 사용해야 하는데, 이는 바이트코드 조작이 필요하여 상대적으로 무겁다.

트랜잭션 관리는 비즈니스 로직에서 매우 중요한 부분이다. 게시글 삽입과 첨부파일 삽입이 하나의 트랜잭션으로 묶여 있어, 둘 중 하나라도 실패하면 전체가 롤백된다. 이러한 원자성 보장은 데이터 무결성을 유지하는 핵심 메커니즘이다.


<MemberService 분석>

프로젝트의 MemberService를 분석하면 인터페이스 활용 패턴을 더 명확히 이해할 수 있다.

- MemberService 인터페이스

public interface MemberService {
    Member getMemberById(String memberId);
    int getMemberCountById(String memberId);
    int addMember(Member member);
    int updateMember(Member member);
    int updatePwd(String memberId, String newPwd);
    int deleteMember(String memberId);
}

회원 관리에 필요한 핵심 기능들이 정의되어 있다. 조회, 중복 체크, 등록, 수정, 비밀번호 변경, 삭제 기능이 명확히 드러난다.

- MemberServiceImpl 구현체

@Service
public class MemberServiceImpl implements MemberService {

    private final MemberMapper memberMapper;
    private final BCryptPasswordEncoder bCryptPasswordEncoder;

    @Autowired
    public MemberServiceImpl(MemberMapper memberMapper, BCryptPasswordEncoder bCryptPasswordEncoder) {
        this.memberMapper = memberMapper;
        this.bCryptPasswordEncoder = bCryptPasswordEncoder;
    }

    @Override
    public Member getMemberById(String memberId) {
        return memberMapper.getMemberById(memberId);
    }

    // 나머지 메서드 구현
}

구현체는 실제 데이터베이스 접근을 위해 MemberMapper를 사용하고, 비밀번호 암호화를 위해 BCryptPasswordEncoder를 사용한다. 이러한 의존성은 생성자 주입으로 관리된다.

- MemberController에서의 사용

@Controller
public class MemberController {
    private final MemberService memberService;
    private final BCryptPasswordEncoder bCryptPasswordEncoder;

    @Autowired
    public MemberController(MemberService memberService, BCryptPasswordEncoder bCryptPasswordEncoder) {
        this.memberService = memberService;
        this.bCryptPasswordEncoder = bCryptPasswordEncoder;
    }
}

Controller는 MemberService 인터페이스에 의존하고 있어, MemberServiceImpl의 내부 구현이 변경되어도 영향을 받지 않는다. 예를 들어 나중에 Redis 캐시를 추가한 CachedMemberServiceImpl을 만들더라도 Controller는 수정할 필요가 없다.


정리 및 요약

인터페이스를 사용하는 이유는 크게 다섯 가지로 정리할 수 있다. 느슨한 결합도를 통해 구현체 변경의 영향을 최소화하고, 다형성을 활용하여 여러 구현체를 상황에 따라 선택할 수 있으며, Mock 객체를 사용한 단위 테스트가 용이하고, 제공해야 하는 기능을 명확히 정의하며, 구현체 변경 시 영향 범위를 최소화한다.

실제 프로젝트에서 BoardService, MemberService 같은 Service 계층은 거의 항상 인터페이스를 사용한다. 이것은 단순한 코드 스타일이 아니라, 유지보수성과 테스트 용이성을 높이는 중요한 설계 원칙이다.

처음에는 인터페이스를 만드는 것이 번거로울 수 있다. 하나의 기능을 위해 인터페이스와 구현체 두 개의 파일을 만들어야 하기 때문이다. 하지만 프로젝트가 커지고 복잡해질수록 인터페이스의 가치가 더욱 빛을 발한다. 구현체를 변경하거나 테스트를 작성할 때, 인터페이스가 없었다면 수정해야 할 코드의 양은 기하급수적으로 증가한다.

인터페이스는 코드의 유연성을 높이고, 변경에 강한 구조를 만드는 핵심 도구이다. Spring의 의존성 주입, AOP, 트랜잭션 관리 같은 기능들도 모두 인터페이스 기반으로 설계되어 있다. 인터페이스를 이해하고 올바르게 활용하는 것은 Spring 프레임워크를 효과적으로 사용하는 첫걸음이다.

전체 코드 : GitHub 아이콘