| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 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 |
- hexagonal architecture
- Transaction
- transactional
- Layered Architecture
- Spring
- Spring Data JPA
- JDBC
- 실무
- Adapter
- Hexagonal
- JPA
- simplejpaRepository
- springboot
- Today
- Total
Ezcho
[Spring] Hexagonal vs Layerd Architecture (1) 본문
https://github.com/Ezcho/hex-user-boilerplate.git
GitHub - Ezcho/hex-user-boilerplate: User service based on hexagonal architecture
User service based on hexagonal architecture. Contribute to Ezcho/hex-user-boilerplate development by creating an account on GitHub.
github.com
Hexagonal Architecture + SpringSecurity를 활용한 유저 CRUD
최근 스타트업에서 개발을 하며 느꼈던게.. REST API를 적용하더라도 어플리케이션 및 서버의 복잡도가 늘어나는 추세였다. 더이상 Java와 DB하나로, 모든것들을 커버할 수 없고 외부 데이터들에 많이 의존하는 경향이다. ServerSide Rendering이 대세라지만.. 여전히 서버에서 처리해줘야하는 기능이 있는경우 서버규모가 커질 수 밖에 없는 것 같다.
진짜 기능은 별거 없는데 코드가 쓸때없이 너무 길었다.
또, 코드가 단순이 긴 것은 상관없지만 코드블럭간의 명확한 역할과 구조가 있어야하는데.. 그렇지 못했다.
특히 Service Layer가 너무 심했다. 이유를 생각해보면.. SpringBoot하나에 AI서버, 관리자Web, Flutter, RDMBS와 NOSQL DB도 붙었다. FireBase와의 연동도 있었고..
Service 하나에 그 짓을 다 하고있었으니 결과는 뻔하다. 코드는 100줄, 200줄, 물론 시간을 되돌리면 더 이쁘게 작성할 수 있지만.. 이미 돌아가는 코드를 다시 들여다보는게 쉽지않다.
머릿속에 구조가 다 정리되어 있어 크게 문제는 없었지만, 조금만 더 규모가 커졌다면 한계를 보지않았을까 싶다.
기능 하나 수정하는데 DTO변경하고 Response에 대한 응답처리를 다시 해주고… JPQL 을 사용하는경우, JPQL 문도 다시 생각해서 작성해야했다.
DTO는 원래 단순 데이터 전달용이어야 하는데, 종종 비즈니스와 직접 연결되다 보니 변경이 생기면 도미노처럼 터지고.. 서비스 로직 수정하고 테스트코드도 다 수정해야하고..
어쨌든.. 보다 구조화되고 “비즈니스로직” 에만 집중된 아키텍쳐들 살펴보다. 위 구조에 대해 공부해보면 좋을 것 같다는 생각을 했다.
또한 Hexagonal Architecture 공부를 위해 기존 Layered Architecture로 구성된, 프로젝트를 마이그레이션 해보기로 하였다.
우선 Hexagonal Architecture가 필요한 이유를 알아보았다.
Hexagonal Architecture를 알아야 하는 이유
코드가 커질수록 '유지보수 지옥'을 피하려면…
- 처음엔 Layered 구조가 간단하고 좋다.
- 그런데 기능이 많아지고 의존성 얽히면 “스파게티 코드”로 변한다.
- Service 레이어에 비즈니스 로직 + 트랜잭션 + 외부 호출 + 예외 처리 다 몰려와서 "스파게티 코드"로 변한다.
- Hexagonal은 책임이 명확히 나뉘어 있고, 도메인 로직은 순수하게 보존됩니다.
- 기능 추가/수정 시 어디만 건드리면 되는지 명확함.
졸업작품으로 설계한 프로젝트에서 Service에 작성된 함수 하나를 살펴봤다.
public LeaderBoardEntity createLeaderBoard(LeaderBoardRequest dto, String userIndex) {
// 1. 유저 조회
UserEntity user = userService.getLoginUserByLoginId(userIndex);
// 2. 유효성 검사
if (dto.getScore() <= 0 || dto.getTime() <= 0 || dto.getCode() == null) {
throw new IllegalArgumentException("유효하지 않은 점수, 시간 또는 코드입니다.");
}
// 3. 중복 제출 방지
if (leaderBoardRepository.existsByUserAndCode(user, dto.getCode())) {
throw new IllegalStateException("이미 제출한 코드입니다.");
}
// 4. 현재 최고 점수 가져오기
LeaderBoardEntity topScoreEntry = leaderBoardRepository.findTopByOrderByScoreDesc();
boolean isFirstPlace = topScoreEntry == null || dto.getScore() >= topScoreEntry.getScore();
// 5. 엔티티 구성
LeaderBoardEntity leaderBoard = new LeaderBoardEntity();
leaderBoard.setScore(dto.getScore());
leaderBoard.setTime(dto.getTime());
leaderBoard.setUser(user);
leaderBoard.setEmail(user.getEmail());
leaderBoard.setNickname(user.getNickname());
CodeEntity codeEntity = new CodeEntity();
codeEntity.setCode(dto.getCode());
codeEntity.setLeaderBoard(leaderBoard);
leaderBoard.setCodeEntity(codeEntity);
// 6. 저장
leaderBoardRepository.save(leaderBoard);
codeRepository.save(codeEntity);
// 7. 로그
log.info("리더보드 등록 완료: 사용자={}, 점수={}, 1등 여부={}", user.getLoginId(), dto.getScore(), isFirstPlace);
// 8. 1등이면 이메일 발송
if (isFirstPlace) {
try {
mailService.sendSimpleEmail(
user.getEmail(),
"🎉 1등 달성 축하드립니다!",
user.getNickname() + "님, 현재 리더보드 1등입니다! 점수: " + dto.getScore()
);
} catch (Exception e) {
log.warn("1등 이메일 전송 실패: {}", e.getMessage());
}
}
// 9. 비동기 FCM 알림 전송
CompletableFuture.runAsync(() -> {
try {
Message message = Message.builder()
.setToken(user.getFcmToken())
.setNotification(Notification.builder()
.setTitle("리더보드 등록!")
.setBody(user.getNickname() + "님이 " + dto.getScore() + "점을 기록했습니다!")
.build())
.build();
String response = FirebaseMessaging.getInstance().send(message);
log.info("FCM 전송 성공: {}", response);
} catch (FirebaseMessagingException e) {
log.error("FCM 전송 실패: {}", e.getMessage());
}
});
return leaderBoard;
}
기존 Hexagonal의 개념에 근거하면 LeaderBoardService가 너무 많은 책임을 가진다는 결론이 나온다.
- UserEntity 조회및 검증
- 두개의 Entity 생성
- Entity 관계 설정: CodeEntity와 LeaderBoardEntity간의 관계를 설정한다.
- 직접 Repository로 저장, 여기서 Spring Data JPA를 사용함을 알 수 있다.
++
- FCM 발송을 위한 메시지 작성
- 1등 검증 및 이메일 전송등
물론 아래 두개의 기능들은 여러 함수들을 사용해 간소화 할 수 있었다… 만
그럼 어떻게 설계해야하는가.?
Hexagonal Architecture
┌────────────────────┐
│ 외부 시스템들 │
│ (DB, Web, Kafka 등) │
└────────┬───────────┘
│
┌───────▼────────┐
│ Adapter │ ← WebController, JpaRepository, 외부 API 구현체 등
└───────┬────────┘
│
┌───────▼────────┐
│ Port │ ← 인터페이스 (ex: LoadUserPort)
└───────┬────────┘
│
┌───────▼────────┐
│ Application │ ← UseCase (ex: CreateUserUseCase)
└───────┬────────┘
│
┌───────▼────────┐
│ Domain │ ← 순수 로직 (ex: User, LeaderBoard 등)
└────────────────┘
위와 같은 방식으로 구현하면된다고 한다. 다른 그림을 확인해보자.

위 그림을 보면 Adapter 처럼, 다양한 외부 서비스들을 연동할 수 있는 느낌이 들었다.
- Rest API를 사용하는 Client
- 이벤트 프로시저
- 이메일 서버
- ORM DB
- 또 다른 REST API Server등
Layerd vs Hexagonal
코드샘플을 보고 이해를 해보자.
1. Layerd Architecture
// User.java (Entity)
@Entity
public class User {
@Id @GeneratedValue
private Long id;
private String name;
// getters/setters
}
// UserRepository.java
public interface UserRepository extends JpaRepository<User, Long> {}
// UserService.java
@Service
public class UserService {
private final UserRepository repository;
public UserService(UserRepository repository) {
this.repository = repository;
}
public List<User> getAllUsers() {
return repository.findAll();
}
}
// UserController.java
@RestController
@RequestMapping("/users")
public class UserController {
private final UserService service;
public UserController(UserService service) {
this.service = service;
}
@GetMapping
public List<User> getUsers() {
return service.getAllUsers();
}
}
Flow가 일방적이고, 각 계층별 역할이 명확하다.
너무 명확해서 문제다.
특히 DataBase만 사용하는 RestAPI형태는, 그리고 인증/인가가 간단하게 붙으면 너무 좋은 구조인 것 같다.
2. Hexagonal Architecture
Hexagonal은 철저한 도메인 중심이고 입/출력이 명확하게 구분된다.
- Domain 중심
- 입력(Driving Port), 출력(Driven Port)을 명확하게 구분
- 의존성은 외부가 내부에 의존하지 않고, 내부(Domain)가 외부(Infra)에 의존하지 않음
// User.java (Domain Entity)
public class User {
private Long id;
private String name;
// constructors, getters, setters
}
// UserRepository.java (Port - Interface)
public interface UserRepository {
List<User> findAll();
}
// UserService.java (Application Service)
public class UserService {
private final UserRepository userRepository;
public UserService(UserRepository userRepository) {
this.userRepository = userRepository;
}S
public List<User> getAllUsers() {
return userRepository.findAll();
}
}
// UserJpaEntity.java (JPA Entity for Infra Layer)
@Entity
public class UserJpaEntity {
@Id @GeneratedValue
private Long id;
private String name;
// getters/setters
}
// UserJpaRepository.java (Spring Data)
public interface UserJpaRepository extends JpaRepository<UserJpaEntity, Long> {}
// UserRepositoryAdapter.java (Adapter - Infra to Port)
@Repository
public class UserRepositoryAdapter implements UserRepository {
private final UserJpaRepository jpaRepository;
public UserRepositoryAdapter(UserJpaRepository jpaRepository) {
this.jpaRepository = jpaRepository;
}
@Override
public List<User> findAll() {
return jpaRepository.findAll().stream()
.map(e -> new User(e.getId(), e.getName()))
.collect(Collectors.toList());
}
}
// UserController.java (Adapter - Web)
@RestController
@RequestMapping("/users")
public class UserController {
private final UserService service;
public UserController(UserRepository repository) {
this.service = new UserService(repository);
}
@GetMapping
public List<User> getUsers() {
return service.getAllUsers();
}
}
코드를 살펴보면,
도메인 엔티티에는 변화가 없다. User 엔티티에 JPA를 연동하고 안하고의 차이가 있는 것 같은데…
코드의 흐름을 보고 이해한 바는
- Get요청을 보내서 전체 유저를 조회했을 때.
- Adapter인 컨트롤러가 Service를 호출한다.(여기까진 같다.)
- Service에서는 마찬가지로 Repository의 method 를 call한다.
- 사실 Layerd와 구조가 어느정도 비슷한것 같아서 AI와 이야기를 나누었다.
질문/답변
질문:
즉 Adapter는 기존 Layerd의 Controller이고, Application Core Service는 기존 Layerd의 Service이지만. 기존 User 모델에서 JPA문법을 통합하여 사용했던 것 과 다르게, 여기서는 도메인에 대한 모델과 실제 DB에 저장되는 모델을 구분해서 사용하고. JPA 의 메서드들 또한 직접 오버라이드 해서, 구현체로 표현하고.직접 인터페이스를 정의해 Service레이어에서 호출할 때 모르도록 하는건가?
ChatGPT의 말:
너가 지금 말한 게 바로 Hexagonal Architecture의 핵심 철학을 정확히 짚은 거야.
정리해주신 걸 기준으로, 하나씩 확신을 주는 설명을 붙여줄게.
✅ 다시 정리한 너의 말 + 설명
1. ✅ "Adapter는 기존 Layered의 Controller 역할"
→ 맞아, Hexagonal에서 Controller는 Driving Adapter 역할이야.
하지만 Layered에선 Controller → Service에만 의존한다면,
Hexagonal에선 Controller가 입력 포트(도메인 Service)를 호출한다는 점에서 의존성 방향은 같지만 역할의 인식이 달라.
- Layered: 그냥 계층 중 하나
- Hexagonal: 도메인 바깥에서 들어오는 모든 통로의 어댑터
2. ✅ "Application Core의 Service는 기존 Layered의 Service지만 역할이 달라진다"
→ 완전 맞아.
- Layered의 Service는 종종 비즈니스 로직 + 외부 기술까지 같이 품고 있어
- 예: JPA UserRepository 직접 호출
- Hexagonal의 Service는 외부 기술을 아예 몰라
- 외부 기능은 인터페이스(Port)로만 연결되고, 실제 구현은 어댑터가 맡음
즉, 역할이 단순화되면서 더 명확해지고, 책임이 분리돼.
3. ✅ "도메인 모델(User)과 DB Entity(UserJpaEntity)를 구분해서 사용"
→ 정확해!!
- 도메인 모델 User는 비즈니스 규칙에 집중
- JPA Entity UserJpaEntity는 DB 저장을 위한 기술적인 구조
이렇게 분리하면:
도메인 모델DB 모델 (JPA Entity)| 핵심 로직 표현 | 영속성 및 ORM 목적 |
| 외부 기술 모름 | JPA에 의존 |
| 테스트 쉬움 | 테스트 어려움 |
| 변화 적음 | DB 스키마 따라 자주 변경 |
4. ✅ "JPA의 메서드들은 Adapter에서 직접 오버라이드해서 구현한다"
→ 이게 핵심이야.
Hexagonal에선 JpaRepository는 인프라니까, 직접 도메인에 끌어들이지 않아.
public class UserRepositoryAdapter implements UserRepository {
private final UserJpaRepository jpa;
public List<User> findAll() {
return jpa.findAll().stream()
.map(entity -> new User(entity.getId(), entity.getName()))
.collect(Collectors.toList());
}
}
- 이 구현체(Adapter)가 Port(UserRepository)를 구현하고
- 이 포트를 Application Core(UserService)에서 호출
- → 따라서 Service는 "이게 JPA인지, API인지, 메모리인지" 몰라도 동작 가능
5. ✅ "직접 정의한 인터페이스로 외부 기술을 감추고, 도메인에서는 오직 그 인터페이스만 보도록 한다"
→ 이게 진짜 핵심이야.
Hexagonal이 말하는 의존성 역전(inversion of control), 경계 분리(boundary), 순수성(purity) 다 여기에 있음.
🔁 전체 요약하면
개념Layered ArchitectureHexagonal Architecture| 도메인 모델 | User = JPA Entity | User != JPA Entity (분리됨) |
| 외부 기술 의존 | Service에서 JPA 직접 사용 | Port/Adapter로 완전히 분리 |
| JPA Repository | 직접 호출 (userRepository.findAll()) | Adapter에서 구현 후 주입 |
| 테스트 용이성 | 상대적으로 낮음 | 도메인만 테스트 가능 |
| 관심사 분리 | 모호함 (Service가 다 함) | 명확함 (Service는 순수 로직만) |
너 지금 Hexagonal을 이해했다고 봐도 될 정도야.
이제 이걸 프로젝트에 적용하면서 컨트롤러는 어디까지 알고, 서비스는 무엇을 모르고, 어댑터는 어떤 로직만 다룰지를 조금씩 나눠서 설계하면 진짜 탄탄한 구조 나올 거야.
위 질문을 하고 얻은 결론은.
- Adapter와 Domain의 명확한 코드분리이다.
- 각 Layer별로 in과 out이 분명해야한다.
- 반드시 interface로 구현된 메서드를 사용한다. 정도..?
조금 더 정리해보았다.
Hexagonal Architecture 설계 원칙
1. Adapter와 Domain의 명확한코드 분리
- 외부 기술은 전부 Adapter에, 비즈니스 로직은 Domain에
- 서로의 의존성이 domain → adapter로 향하는 일은 없어야 한다.
2. Layer별로 in과 out이 분명해야한다.
- 어디서 들어오고 어디로 나가는가 를 명확하게 해야한다.
- 입력
- port.in, adapter.in 등으로 패키지 명시
- 외부 요청을 도메인에 전달
- 출력
- port.out, adapter.out 등으로 패키지 명시
- 도메인에서 외부 기술을 호출
이 방식을 통해서 adapter의 교체를 유연하게 할 수 있다. Rest → Kafka
3. 반드시 interface를 통한 간접 의존을 사용한다.
해당부분은 OOP 기본 원칙이기도 해서, 중요하지 않다고 생각했지만.
@Service
public class UserService {
@Autowired
private UserJpaRepository userRepository; // JPA에 직접 의존
}
사실 잘 쓰고 있지 않았다. 위와같이 느슨하게 결합 해버리면.. 뭐 어쨌든 돌아가니까.
하지만 아래와 같이 구현하면 Domain이 JPA인걸 숨길 수 있다.
public class GetMembersService implements GetMembersQuery {
private final MemberRepository memberRepository;
public List<Member> getAllMembers() {
return memberRepository.findAll();
}
}
4. Domain에는 Service, Repository, Entity 사용하지 않는다.
- 도메인은 순수 Java객체로 유지되어야 한다.
- 필요 시 Configuration 어노테이션으로 Bean에 등록한다.
코드 작성
아래 원칙을 기반으로 코드를 작성해보았다.
우선 전반적인 프로젝트의 구조는 아래와 같다.

adapter와 domain으로 패키지를 명확하게 나누고, 각 레이어에 대해 in과 out을 명확히했다.
이후 GET요청으로 Member Model 전체를 구성하는 코드를 작성해보았다.
1. Adapter.in
MemberController
Controller는 RestClient로부터 Data를 받아오는 역할입니다. 이에 adapter.in에 위치해야합니다.
이후 해당 코드에서는 service의 getAllMembers를 Call하고 있습니다.
//adapter/in/MemberController
package com.seoultech.codemos.adapter.in.web;
import com.seoultech.codemos.domain.model.Member;
import com.seoultech.codemos.domain.port.in.GetMembersQuery;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
@RequestMapping("/members")
public class MemberController {
private final GetMembersQuery service;
public MemberController(GetMembersQuery service) {
this.service = service;
}
@GetMapping
public List<Member> getMembers() {
return service.getAllMembers();
}
}
2. domain.port.in
GetMemberQuery
Adapter와 Domain의 명확한 분리를 위해 GetMembersQuery 인터페이스에서, getAllMembers() 메서드를 정의한다.
사실 이 부분까지는 LayerdPattern에서 Service구현체의 Impl을 정의하는것과 동일하다.
package com.seoultech.codemos.domain.port.in;
import com.seoultech.codemos.domain.model.Member;
import java.util.List;
public interface GetMembersQuery {
List<Member> getAllMembers();
}
3. domain.model
Member
domain 딴에서 사용되는 모델을 살펴보자.
일반적인 Entity클래스를 작성하나, Hexagonal원칙에 따라 @Entity 어노테이션을 사용하지않음으로써,
JPA ORM 으로써의 역할을 하지 않게 해야한다.
package com.seoultech.codemos.domain.model;
public class Member {
private final Long id;
private final String name;
public Member(Long id, String name) {
this.id = id;
this.name = name;
}
// getters
public Long getId() { return id; }
public String getName() { return name; }
}
4. domain.service
GetMemberService
Service 어노테이션을 작성하지 않았다. 그 이유는…
- Hexagonal Architecture는, Domain Level에서 Java바닐라 코드작성을 지향하고있다.
- 이렇게 가져가야 Hexagonal의 철학을 자유롭기 때문이다.
- @Service 는 SpringFramework에 종속된 Annotation이다.
- 이걸 붙이면 도메인이 Spring에 의존하게된다.
- 도메인이 외부 기술에 의존하게된다는것은. 원칙에 위배된다.
- domain → adapter로 향하는 일은 없어야 한다고 위에서 명시했다.
package com.seoultech.codemos.domain.service;
import com.seoultech.codemos.domain.model.Member;
import com.seoultech.codemos.domain.port.in.GetMembersQuery;
import com.seoultech.codemos.domain.port.out.MemberRepository;
import org.springframework.stereotype.Service;
import java.util.List;
public class GetMembersService implements GetMembersQuery {
private final MemberRepository repository;
public GetMembersService(MemberRepository repository) {
this.repository = repository;
}
@Override
public List<Member> getAllMembers() {
return repository.findAll();
}
}
이렇게 적으면 스프링에서 코드를 돌릴 수 없지 않나..?
그래서 아래와 같이 Bean + Configuration을 사용한 수동 주입을 해주어야 한다.
Config
위와 같은 관계 정립을 해주어야 한다.
Bean Annotation을 등록해준다는것은, 제어권은 개발자에 있다는것을 알 수 있을것이다.
위 표현을 통해 Domain의 완전한 독립을 이루는것이다.
package com.seoultech.codemos.config;
import com.seoultech.codemos.domain.port.in.GetMembersQuery;
import com.seoultech.codemos.domain.port.out.MemberRepository;
import com.seoultech.codemos.domain.service.GetMembersService;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class BeanConfig {
@Bean
public GetMembersQuery getMembersQuery(MemberRepository repository) {
return new GetMembersService(repository);
}
}
5. domain.port.out
MemberRepository
findAll()은 JPA에서 서빙하는 메서드이다. 하지만, domain레이어는 Hexagonal의 규칙에 따라서 떠한 외부 기능과의 의존관계가 설정되면 안된다.
JPA는 인프라이기 때문이다.
어떤 것도 상속, 제약을 받지않는 인터페이스를 생성해서 findAll 메서드만 정의해주도록 했다.
package com.seoultech.codemos.domain.port.out;
import com.seoultech.codemos.domain.model.Member;
import java.util.List;
public interface MemberRepository {
List<Member> findAll();
}
6. adapter.out.presistence
MemberJpaEntity
중요하다.
Domain 레벨의 엔티티가 SpringDataJPA와 관계를 맺지 않았다.
그래서 Adapter레벨에서 사용하기위한 아래와 같이 ORM을 위한 엔티티를 하나 더 설계해준다.
해당레이어는 JPA ORM을 위한 클래스이다. 그래서 DB테이블이 아래 클래스를 기반으로 생성된다.
package com.seoultech.codemos.adapter.out.persistence;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
import lombok.Getter;
import lombok.Setter;
@Entity
@Getter
@Setter
public class MemberJpaEntity {
@Id
@GeneratedValue
private Long id;
private String name;
}
MemberJpaRepository
또한 위 클래스를 적용한 Spring Data Jpa를 사용하기위해서, JpaRepository를 상속받는 인터페이스를 작성해야하기 때문에, 아래와같이 작성하였다.
Layered 의 Repository와 동일한 기능을 한다.
package com.seoultech.codemos.adapter.out.persistence;
import org.springframework.data.jpa.repository.JpaRepository;
public interface MemberJpaRepository extends JpaRepository<MemberJpaEntity, Long> {}
MemberRepositoryAdapter
Interface의 MemberRepository의 구현체이다.
Hexagonal의 규칙에 따라 Domain레이어에서 findAll()메서드를 직접 호출 할 수 없기 때문,
외부기능인 JPA를 사용하기때문에, Adapter레이어에서 코드가 작성되어야 하고. findAll()메서드를 반드시 구현하기위해서, JPA의 findAll()을가져와서 사용한다.
package com.seoultech.codemos.adapter.out.persistence;
import com.seoultech.codemos.domain.model.Member;
import com.seoultech.codemos.domain.port.out.MemberRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.stream.Collectors;
@Repository
public class MemberRepositoryAdapter implements MemberRepository {
private final MemberJpaRepository jpaRepository;
public MemberRepositoryAdapter(MemberJpaRepository jpaRepository) {
this.jpaRepository = jpaRepository;
}
@Override
public List<Member> findAll() {
return jpaRepository.findAll().stream()
.map(e -> new Member(e.getId(), e.getName()))
.collect(Collectors.toList());
}
}
요약
다시한번 호출 흐름에 대해서 요약해보면,
- Adapter.in
- Layered의 컨트롤러 역할이다.
- 위 예제에서는 Spring의 RestController를 사용했다.
- Domain.port.in
- Layered의 Impl 서비스 인터페이스와 유사한 기능을 한다.
- Controller에서는 Service를 직접호출하지않는다. 인터페이스를 사용해 Adapter와 Domain을 완전히 분리시켰다.
- Service
- 핵심 비즈니스로직이다. 해당 레이어에서는 어떤 의존관계도 설정되어서는 안되고, 기본 Java코드만을 활용해 작성된다.
- 만약 Spring에서 동작하는경우 Bean 어노테이션을 사용해 수동 의존 관계 추가를 해주어야 한다.
- Service어노테이션을 사용하면 Hexagonal원칙에 위배된다.
- Domain.port.out
- Service에서 호출될 코드이다.
- Domain레이어 이므로 마찬가지로 인터페이스형식으로 어떤 Adapter를 연결할 지 정한다.
- Adatper.out
- 위 예제에서는 DB접근을 위해 JPA기능을 위한 클래스들을 작성했다.
- JPA Entity
- JPA Repository를 상속받는 인터페이스
- JPA 기능을 수행하며, Domain.out 인터페이스의 구현체
- SMTP나, 아니면 또 다른 Data들을 얻어오기위해 상황에 맞는 설계가 필요하다.
- 위 예제에서는 DB접근을 위해 JPA기능을 위한 클래스들을 작성했다.
결론
기존 Layerd Architecture를 사용하는 사람들도, 코드가 구조화 되었다고 느끼겠지만.
객체지향 프로그래밍을 지향하는 자바개발자의 입장으로 Hexagonal은 아주 멋진 코드구조라고 느꼈다.
DB와 연결하고 값만 반환해주는 RESTAPI서버라면 Layered Architecture를 사용하는것이 유리할 지 모르겠으나, 실무환경에서는 점점 더 변화하는 사용자의 요구사항에 유연하게 대응하려면
Hexagonal은 혁신적인 구조일것이다.
아래는 처음 제공했던 레거시 코드를 Hexagonal스타일의 Service로 다시 작성해보았다.
@RequiredArgsConstructor
public class LeaderBoardServiceImpl implements LeaderBoardUseCase {
private final UserQueryPort userQueryPort;
private final LeaderBoardSavePort leaderBoardSavePort;
private final NotificationPort notificationPort;
@Override
public LeaderBoardEntity registerLeaderBoard(LeaderBoardRequest dto, String userIndex) {
UserEntity user = userQueryPort.getUserByLoginId(userIndex);
validateRequest(dto);
if (leaderBoardSavePort.existsByUserAndCode(user, dto.getCode())) {
throw new IllegalStateException("이미 제출한 코드입니다.");
}
LeaderBoardEntity leaderBoard = new LeaderBoardEntity();
leaderBoard.setScore(dto.getScore());
leaderBoard.setTime(dto.getTime());
leaderBoard.setUser(user);
leaderBoard.setEmail(user.getEmail());
leaderBoard.setNickname(user.getNickname());
CodeEntity codeEntity = new CodeEntity();
codeEntity.setCode(dto.getCode());
codeEntity.setLeaderBoard(leaderBoard);
leaderBoard.setCodeEntity(codeEntity);
leaderBoardSavePort.saveLeaderBoard(leaderBoard);
leaderBoardSavePort.saveCode(codeEntity);
boolean isFirstPlace = leaderBoardSavePort.findTopScore()
.map(top -> dto.getScore() >= top.getScore())
.orElse(true);
if (isFirstPlace) {
notificationPort.sendFirstPlaceEmail(user, dto.getScore());
}
notificationPort.sendFcmNotification(user, dto.getScore());
return leaderBoard;
}
private void validateRequest(LeaderBoardRequest dto) {
if (dto.getScore() <= 0 || dto.getTime() <= 0 || dto.getCode() == null) {
throw new IllegalArgumentException("유효하지 않은 리더보드 정보입니다.");
}
}
}
다음 글에서는 Hexagonal적용을 통해 TestCode작성의 유연성및 유지보수의 유연성의 관점에서 작성해보겠다.
'BE' 카테고리의 다른 글
| 오디오 업로드 데모 API, 이렇게 뚫린다 — 게이트웨이 개발 전략 (0) | 2025.08.27 |
|---|---|
| [Spring] SpringBoot (1) | 2024.01.02 |