
입사 후 처음으로 맡은 온보딩 태스크는 클린 아키텍처로 작성된 레거시 프로젝트를 리팩토링하는 작업이었다.
AI 에이전트와 깐부를 맺은 나는 기존 구조를 파악하는 데 큰 자신감이 있었다.

결론부터 말하면 개망했다.
단순히 파일 구조를 파악하는 것과 비즈니스 로직의 전체 흐름을 이해하는 것은 전혀 다른 문제였다. 이에 근본적인 이해가 필요하겠다 생각했고, 내것으로 만들기 위한 과정에서 포스팅을 작성하게 되었다.

클린 아키텍처를 검색하면 나오는 것들이 있다. 동그라미 여러 개로 된 그 유명한 그림, Dependency Rule, Entities/Use Cases/Interface Adapters/Frameworks & Drivers... 처음 보는 사람 입장에선 뭔 소린지 감이 안 잡힌다.
이번 포스팅에서는 그 어려운 용어들을 최대한 배제하고, "왜 이 구조가 실무에서 필요한가"와 "써보면서 느낀점은 어떤가" 를 위주로 서술하고자 한다.
1. 늘 친했던 3-Layered Architecture
다양한 프로젝트를 거치며 백엔드를 설계할 일이 여러 번 있었는데, 프로젝트 복잡도와 관계없이 늘 이 구조를 기반으로 시작했다.
Controller → Service → Repository (DB)
NestJS + TypeORM 기준으로 보면 보통 이런식인데…
// member.entity.ts
@Entity()
export class Member {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ default: 0 })
overdueFee: number;
@Column({ default: 'ACTIVE' })
status: string;
}
// member.service.ts
@Injectable()
export class MemberService {
constructor(
@InjectRepository(Member)
private readonly memberRepo: Repository<Member>,
) {}
async borrow(memberId: string, bookId: string) {
const member = await this.memberRepo.findOne({ where: { id: memberId } });
if (!member) throw new Error('Not found');
if (member.status !== 'ACTIVE') throw new Error('Cannot borrow');
if (member.overdueFee > 0) throw new Error('Cannot borrow');
// ... 대~충 대출 처리 로직
}
}
직관적이고 빠르다. 그런데 코드가 쌓일수록 이런 상황이 반복된다.
- 비즈니스 로직이 Service에 몰린다.
member.status !== 'ACTIVE'판단이 여기저기 복붙된다. - ORM Entity가 도메인을 지배한다. DB 스키마가 바뀌면 Service도 연쇄적으로 흔들린다.
- 테스트가 어렵다. Service 단위 테스트에 DB 연결이 따라온다.
모든 문제의 근본 원인은 하나다. 비즈니스 규칙(여기서는 대출 가능 여부 결정 방법)이 외부 기술(DB, ORM 라이브러리)에 의존하고 있다.
2. 클린 아키텍처가 말하려는 것
여기서 한 가지 짚고 넘어갈 게 있다. 클린 아키텍처를 검색하면 특정 디렉토리 구조나 레이어 이름에 대한 설명이 많이 나오는데, 클린아키텍처가 원래 강조한 건 그 구조 자체가 아니다.
비즈니스 정책(Policy)은 외부 기술로부터 독립되어야 한다.
프레임워크, DB, UI, 외부 API. 이것들은 전부 "세부 구현 사항"이다. 비즈니스 규칙은 이 세부 사항들이 어떻게 바뀌든 흔들리지 않아야 한다는 것이 클린 아키텍처의 출발점이자 핵심이다.
즉, 클린아키텍처는 Entity, Usecase, Infrastructre 레이어들이 있고, 각 레이어는 어떤 역할을… 이런 개념이 아니고, 비즈니스 정책을 어떻게 해서 외부 기술과 독립을 시킬까? 에 집중하는 모든 행위가 곧 클린아키텍처를 의미한다.
그래서 아래에 서술되는 예제코드들은 “이 구조가 곧 클린아키텍처다!” 가 아니라 클린아키텍처를 하기위한 하나의 예시 정도로 봐주면 좋을 것 같다.
Presentation → Application → Domain
Infrastructure → (Domain 인터페이스를 구현)
바깥(Infrastructure, Presentation)은 안쪽(Domain)을 알지만, 안쪽(Domain)은 바깥을 전혀 모른다. MongoDB가 뭔지, NestJS가 뭔지, Domain은 관심 없다.
아직 무슨말인지 알것 같으면서도 뭔 소린가 싶다. 조금 더 알아보자.
레이어별 역할
| 레이어 | 역할 | 예시 |
| Domain | 비즈니스 정책, 규칙, Repository 인터페이스 | 순수 TypeScript 클래스 |
| Application | Usecase 오케스트레이션 | "도서 대출" 시나리오 처리 |
| Infrastructure | DB 구현체, 외부 API | ORM, 스케줄러 |
| Presentation | HTTP 요청/응답 | NestJS Controller |
그리고 이 구조를 NestJS로 구현하다 보면 자연스럽게 Rich Domain Model(엔티티가 단순 데이터 홀더가 아니라 직접 비즈니스 메서드를 가지는 방식)이 함께 따라온다.
엄밀히 말하면 이건 “복잡해진 도메인을 도메인 모델로 표현해서 그 안에서 관리하자!”에서 출발한 DDD(Domain-Driven Design)의 개념이기도 한데, 아직 DDD에 눈을 뜨지 못한 필자의 얄팍한 지식으로 인해 이 글에서는 그 경계를 엄격하게 나누지 않고 최대한 실무적인 관점에서 유리한 지점을 위주로 접근 해보자.
3. 엔티티 설계: 비즈니스 정책 담당자
3-Layered에서 Entity는 "DB 테이블의 매핑 객체"였다. 클린 아키텍처에서 순수 Entity는 오로지 비즈니스 정책만을 담는 객체다.
지금부터 당신은 도서 대출 서비스를 담당하는 백엔드 개발자다. 도서 대출을 진행하기 위해 사용자(Member)는 다음과 같은 정책을 따른다.
- 연체료가 남아있으면 대출 불가
- 이미 3권 대출 중이면 불가
- 정지 상태(
SUSPENDED)면 불가 - 연체료가 1,000원 이상 쌓이면 자동 정지
- 연체료를 모두 납부하면 자동 복구

기존 방식이라면 이 판단들이 MemberService 곳곳에 흩어진다.
하지만 이번에는 이러한 정책들을 모두 Entity 안에 넣어보자.
// src/domain/member/member.entity.ts
export class Member {
private _activeLoanCount: number;
private _overdueFee: number;
private _status: MemberStatus;
private static readonly MAX_LOAN_COUNT = 3;
private static readonly SUSPEND_FEE_THRESHOLD = 1000;
canBorrow(): boolean {
return (
this._status === 'ACTIVE' &&
this._overdueFee === 0 &&
this._activeLoanCount < Member.MAX_LOAN_COUNT
);
}
applyOverdueFee(fee: number): void {
this._overdueFee += fee;
if (this._overdueFee >= Member.SUSPEND_FEE_THRESHOLD) {
this._status = 'SUSPENDED';
}
}
payFee(amount: number): void {
this._overdueFee = Math.max(0, this._overdueFee - amount);
if (this._overdueFee === 0 && this._status === 'SUSPENDED') {
this._status = 'ACTIVE';
}
}
}
Entity를 떠올리면 하면 항상 붙던 ORM 데코레이터가 없다. @Entity(), @Column() 없이 순수 TypeScript 클래스다.
비즈니스 정책이 Entity 안에 모여있다는 건 생각보다 큰 차이를 만든다. "연체료가 있으면 대출 불가" 규칙이 바뀌면 Member.canBorrow() 하나만 고치면 끝난다. 3-Layered에서는 어디를 건드려야 하는지 파악하는 것부터가 일이다.
Repository는 인터페이스로만
Domain Layer의 Repository는 인터페이스만 존재한다. 구현체가 없다.
// src/domain/member/member.repository.ts
export interface MemberRepository {
findById(id: string): Promise<Member | null>;
save(member: Member): Promise<Member>;
}
export const MEMBER_REPOSITORY = 'MEMBER_REPOSITORY';
Domain은 이 인터페이스를 기반으로 데이터를 주고받는다. 그 뒤에 MongoDB가 있는지 PostgreSQL이 있는지는 모르도록 유지하는 것이다. 재미있게도 이부분은 클린 아키텍처의 의존성 역전 원칙(DIP)을 구현하는 방식임과 동시에 그 유명한 헥사고날 아키텍처에서 말하는 Port/Mapper 개념과도 상당 부분 겹친다.
실제 구현 코드는 헥사고날과 거의 유사하며 무엇을 초점으로 두고 설명하느냐에 따라 설명이 달라지는 주제로 보인다.
💡 일단은 너무 어렵게 생각하지 말자.
"이거 헥사고날 아니에요?" 라고 물으면 반은 맞다. "이거 DDD 아니에요?"도 반은 맞다. 이름 싸움은 나중에 해도 되니까, 본 포스팅에서는 클린아키텍처를 씹고 뜯고 맛보고 즐기는데에 최선을 다해보자.
4. UseCase: 그거 나 잘 모름, 엔티티한테 물어보셈
UseCase는 "이 시스템이 무엇을 할 수 있는가"를 시나리오 단위로 명시하는 계층이다. 즉, 이 대출 이라는 행동이 어떤 흐름으로 실행될것인지 오케스트레이션을 하는것이다.
// src/application/loan/borrow-book.usecase.ts
@Injectable()
export class BorrowBookUseCase {
constructor(
@Inject(MEMBER_REPOSITORY) // 여기서도 MemberRepository는 그냥 인터페이스로 호출.
private readonly memberRepository: MemberRepository,
@Inject(BOOK_REPOSITORY) // 주입할 실제 구현체는 별도로 구현해두고,
private readonly bookRepository: BookRepository,
@Inject(LOAN_REPOSITORY) // 나중에 module에서 어떤 구현체를 사용할것인지 선택하면 된다.
private readonly loanRepository: LoanRepository,
) {}
async execute(command: BorrowBookCommand): Promise<BorrowBookResult> {
const member = await this.memberRepository.findById(command.memberId);
if (!member) throw new NotFoundError('Member not found');
// 대출 가능 여부는 Member 엔티티에게 묻는다.
// member의 내부 상태를 직접 까보지 않는다.
if (!member.canBorrow()) {
throw new BusinessRuleError('Member cannot borrow');
}
// member확인이 끝났다면 이제 book을 확인해보자.
const book = await this.bookRepository.findById(command.bookId);
if (!book) throw new NotFoundError('Book not found');
if (!book.isAvailableForBorrow()) {
throw new BusinessRuleError('Book is not available');
}
// 정상적인 상태로 확인이 끝났다면
// book을 "대여중" 상태로 업데이트하고, loan을 새롭게 저장하고, member의 상태를 변화(대여중인 책의 수 증가) 하는 것을 모두 Entity에게 메서드 형태로 시킨다.
book.markAsBorrowed();
await this.bookRepository.save(book);
const loan = Loan.create(command.memberId, command.bookId);
const savedLoan = await this.loanRepository.save(loan);
member.incrementLoanCount();
await this.memberRepository.save(member);
return { loanId: savedLoan.id, dueDate: savedLoan.dueDate };
}
}
UseCase가 하는 일은 명확하다. 필요한 객체를 가져오고, 엔티티 메서드로 상태를 바꾸고, 저장한다. 판단은 엔티티에게 위임한다.
UseCase는 if문 안에서 member 객체의 _overdueFee가 얼마인지, _status가 뭔지 직접 일일이 들여다보지 않는다. 그냥 "빌릴 수 있는 상태 맞음?" 하고 묻는다.
UseCase의 또 다른 장점은 파일 이름만 봐도 이 시스템이 무슨 일을 하는지 보인다는 것이다.
BorrowBookUseCase
ReturnBookUseCase
ExtendLoanUseCase
PayOverdueFeeUseCase
Service 클래스 하나에 메서드가 열개 스무개씩 몰리는 광경과는 사뭇 다르다.
근데 이렇게 하면 유즈케이스가 많은 프로젝트에서는 UseCase 파일이 너무 많아지지않을까...?

5. Infrastructure: Repository 구현체와 DI 조립
드디어 우리가 사랑하는 ORM이 나왔다.

위 UseCase는 Repository 인터페이스들에만 의존했다.
그래서 실제로 무슨 쿼리를 날릴지는 Infrastructure Layer의 구현체가 담당한다.
// src/infrastructure/repositories/mongo-member.repository.ts
@Injectable()
export class MongoMemberRepository implements MemberRepository {
async save(member: Member): Promise<Member> {
const props = member.toProps(); // 도메인 객체 → 평범한 데이터
const doc = await this.memberModel
.findOneAndUpdate({ memberId: props.id }, { ...props }, { upsert: true, new: true })
.exec();
return this.toDomain(doc!); // DB 문서 → 도메인 객체
}
private toDomain(doc: MemberDocument): Member {
return new Member({ id: doc.memberId, overdueFee: doc.overdueFee, /* ... */ });
}
}
toDomain() / toProps()가 핵심이다. 도메인 엔티티와 DB 스키마는 서로를 모른다. Repository가 둘 사이의 번역을 전담한다.
어떤 구현체를 사용할지는 NestJS 모듈에서 결정한다. (Usecase 예제의 생성자 참고)
@Module({
providers: [
{ provide: MEMBER_REPOSITORY, useClass: MongoMemberRepository },
{ provide: LOAN_REPOSITORY, useClass: MongoLoanRepository },
BorrowBookUseCase,
ReturnBookUseCase,
],
controllers: [LoanController],
})
export class LoanModule {}
MongoMemberRepository를 PostgresMemberRepository로 바꾸고 싶으면 여기 한 줄만 수정하면 된다. UseCase와 Domain은 건드릴 필요가 없다.
또한, MongoDB 기반 Repository 구현체 사용하다가, Postgres 기반으로 바꾸고 싶다면 Postgres에 맞는 Repository 구현체를 만들고 여기만 바꿔주면 그대로 동작할수있다는 장점이 있다.
다만 조금 현실적인 얘기를 해보자면, "Repository 구현체만 바꾸면 DB를 갈아끼울 수 있다"는 표현을 너무 만능처럼 받아들이면 안된다. 실제로 RDB에서 MongoDB로 이전하면 쿼리 모델, 트랜잭션 처리 방식, 관계 로딩 전략 등이 전부 달라지기 때문에 그렇게 딸깍 수준은 아닐 확률이 높다.
다만 클린 아키텍처가 주는 것은 단순히 "DB를 쉽게 갈아끼울 수 있음"이 아니라, “인프라 변경의 영향이 Domain과 Application까지 전파되는 것을 막아주는 장치”라는 점을 확고히 하면 된다.
6. 트랜잭션: 클린 아키텍처가 소심해지는 순간
실무에서 클린 아키텍처를 적용하다 보면, 레이어 많은 것보다 훨씬 더 머리를 아프게 하는 문제가 하나 있다. 바로 트랜잭션 경계다.

BorrowBookUseCase를 예로 들면 bookRepository.save(), loanRepository.save(), memberRepository.save()가 순서대로 호출된다. 중간에 에러가 나면? 세 저장소 중 일부만 반영된 채로 남는다.
class BorrowBookUseCase {
constructor(
private bookRepository: BookRepository,
private loanRepository: LoanRepository,
private memberRepository: MemberRepository,
) {}
async execute(memberId: string, bookId: string): Promise<void> {
const member = await this.memberRepository.findById(memberId);
const book = await this.bookRepository.findById(bookId);
// 도메인 로직
member.borrow(book); // 대출 한도 체크 등
book.markAsUnavailable(); // 재고 차감
const loan = Loan.create(member, book);
// ⚠️ 세 save()가 각각 독립 트랜잭션으로 열림
await this.bookRepository.save(book); // TX 1 commit ✅
await this.loanRepository.save(loan); // TX 2 commit ✅
await this.memberRepository.save(member); // TX 3 💥 에러!
}
}
보통은 EntityManager와 같이 트랜잭션을 관리할 수 있는 의존성을 Service에서 직접 다루는데, UseCase가 Repository 인터페이스에만 의존하는 구조에서는 이렇게 할수가 없다.
당장 이 문제 자체에 대한 해결방법으로는
- 한 트랜잭션으로 다 해결하려 하지 말고 이벤트 방식으로 바꿔버리거나
- 이러한 케이스에 대한 별도 인터페이스를 만들거나
- 그냥 이 케이스에 한해서만 UseCase가 EntityManager를 직접 주입받아 트랜잭션 처리
정도가 있다.
어떤 방법을 택할것인지는 각 방안들의 트레이드오프를 따져보고 현 상황에 가장 적절한걸 고르면 되겠지만... 세번째와 같은 케이스는 많아질수록 클린아키텍처의 장점이 옅어진다.
나는 이번 사내 프로젝트에서 딱 하나의 도메인에 있어서 세번째 방법을 사용했다. 별도로 이벤트 아키텍처를 도입할만큼 확장성을 고려하지 않아도 되는 동시 접속자가 한정된 형태의 사내 백오피스 시스템이었기에 괜찮다고 판단했다.

결국 "Repository 추상화"와 "트랜잭션 경계"를 어떻게 함께 설계할 것인가는 클린 아키텍처를 진지하게 적용할 때 반드시 마주치는 숙제다. 이게 현실에서 실제로 가장 골치 아픈 부분이라는 점은 분명히 짚고 싶었다.
7. "전부 다 분리"가 능사는 아니다
이 아키텍처를 처음 배우면 빠지기 쉬운 함정이 있다.
모든 API에 UseCase 만들기
모든 엔티티 rich domain model로 설계하기
Repository 인터페이스 전부 추상화하기
mapper 500개 탄생
개발 속도 박살
단순 CRUD API에 이 모든 계층을 강제로 끼워 넣으면, 코드량만 늘고 얻는 이점이 없다. 아키텍처 규칙만을 지키기 위한 아무런 목적없는 행위가 된다는 말이다.

그래서 현실적으로 자주 쓰이는 접근이 선택적 적용이다.
비즈니스 정책이 복잡하거나, 상태 전이가 엄격하게 관리되어야 하거나, 핵심 정책이라 오래 유지될 도메인만 순수 TypeScript 엔티티로 분리한다. 나머지 단순 CRUD 위주의 도메인은 기존 3-Layered 방식 그대로 둔다. 두 방식이 같은 프로젝트 안에 공존하되, 디렉토리로 명확히 구분한다.
처음에는 "일관성이 없는 거 아닌가?" 싶다. 근데 막상 해보면, 고작 일관성을 지키려고 단순한 API에도 UseCase를 억지로 만드는 것보다, 복잡도에 따라 적절한 구조를 고르는 것이 실제로 더 나은 결과를 만들 때도 있다는 것이다.
8. 요약
장점
① 비즈니스 정책이 한 곳에 모인다
그냥 단순히 가독성이 좋아지는 수준을 넘어서 애플리케이션이 복잡해질수록 이 장점은 빛을 발한다.
예컨대 "대출 가능 여부" 규칙이 바뀌면 Member.canBorrow() 하나만 수정하면 된다는 믿음이 생긴다.
만약 이러한 규칙이 Service에 산재되어 있는 3-Layered라면 관련 Service를 전부 뒤져야 했다.
(하나라도 빠트리면 비즈니스 정책의 버그로 이어질수 있다. 심지어 테스트코드조차 못잡는 Silent한 버그가 터지면 당신의 퇴근시간도 동시에 리미트가 터질수 있다.)
② DB 없이 도메인 로직 테스트가 가능하다
it('연체료가 1원이라도 있으면 대출 불가', () => {
const member = new Member({ status: 'ACTIVE', overdueFee: 100, activeLoanCount: 0 });
expect(member.canBorrow()).toBe(false);
});
NestJS도, DB도, 아무것도 필요 없이 엔티티(정책)과 관련된 테스트가 가능하다. (그 Mock 떡칠에 뚱보같던 테스트가 2줄컷이 될수있다?!)
③ 인프라 변경의 영향을 격리할 수 있다
DB 구현체를 바꿔도 Domain과 Application은 건드리지 않아도 된다. (단, 앞에서 얘기했듯 완전히 자유로운 건 아니다.)
잘 맞는 환경
| 조건 | 이유 |
| 비즈니스 정책이 복잡하고 자주 바뀐다 | 정책이 한 곳에 모여있으면 변경 비용이 낮아진다 |
| 도메인 로직 단위 테스트가 중요하다 | DB 없이 빠르게 정책을 검증할 수 있다 |
| 장기적으로 유지보수해야 하는 프로젝트다 | 초기 비용은 높지만 나중에 빛을 발한다 |
도메인 정책이 매우 복잡한 서비스거나, 대규모 애플리케이션일수록 장기적인 유지보수 관점에서 강력해진다.
단점
① 파일이 많다
Entity, Repository 인터페이스, UseCase, Repository 구현체, Mapper, DTO, 모듈 설정. CRUD 하나를 만들어도 이 세트가 다 따라온다.
② 트랜잭션 관리가 까다롭다
앞에서 자세히 다뤘다. 레이어 많은 거 자체보다 이게 더 현실적인 난이도다.
③ Mapper 코드가 늘어난다
ORM은 매핑을 자동으로 해주지만, 이 구조에서는 Repository가 직접 작성해야 한다. 도메인 모델이 복잡할수록 이 코드도 늘어난다.
부담이 되는 환경
| 조건 | 이유 |
| 초기 MVP, 빠른 프로토타입 | 초기 개발 속도가 느려진다 (신경쓸게 많음) |
| 단순 CRUD 중심 서비스 | 비즈니스 정책이 간단하면 엔티티 분리의 이점이 없다 |
| 소규모 팀, 단기 프로젝트 | 러닝커브 대비 효과가 적다 |
단순 게시판 만드는데 이 짓을 해두는 것은 매우 비효율적인 행위라고 생각한다. 평소에 강박증이 있거나 책상에 물건이 가지런히 놓여있지않으면 심히 불안한 사람들은 반드시 모든 프로젝트에 클린아키텍처를 적용하기를 바란다.
클린 아키텍처는 "좋은 아키텍처"가 아니라 "특정 문제를 해결하기 위한 아키텍처"다. 즉, 비즈니스 정책이 프레임워크나 DB 같은 세부 구현에 오염되어서는 안 된다.
디렉토리 구조를 예쁘게 나누는 것, 레이어 이름을 맞게 붙이는 것은 그 목표를 달성하기 위한 수단이지 목적이 아니다. 어떤 부분에서 정책과 외부 의존성을 분리하는 것이 진짜 값을 하는지를 판단하는 눈을 갖추는 것.
그게 클린 아키텍처를 "적용한다"는 것의 본질에 더 가깝다고 생각한다.
느낀점
요즘 바이브코딩이 대세다. 아니, 대세를 넘어서 AI Agent를 적극적으로 활용한 구현이 높은 생산성을 추구하는 조직의 표준처럼 자리잡고 있다.
구현을 AI에게 위임하고, 인간은 설계-검증-피드백 루프에 집중하는 방향으로 빠르게 재편되고 있다. 심지어 그 검증과 피드백 루프마저도 자동화하려는 시도들이 활발하다. Harness, Hermes 같은 도구들이 그 예시다.
그 과정에서 솔직히 회의감이 들었다.
그동안 내가 생각하는 좋은 코드의 정의는 "이해하고, 관리하고, 수정하기 쉬운 코드" 라고 믿어왔는데, 그 세 가지를 요즘은 개발자가 직접 안 하기 때문이다.

근데 조금 더 생각해보니까, 이 문제가 생각보다 단순하지 않다.
LLM이 코드를 잘 짠다고 해서, 클린한 코드가 LLM에게도 더 다루기 쉬운 코드인지는 별개의 문제다. LLM은 기존에 인간이 작성한 산출물들을 기반으로 학습했기 때문에 어느 정도는 겹치겠지만, 인간이 "이해하기 쉽다"고 느끼는 구조와 모델이 "컨텍스트로 처리하기 좋은" 구조가 완전히 일치한다는 보장은 없기 때문이다. LLM이 발전할수록 이 간극은 더 커질것이 자명하다.
(정확한것은 관련된 논문이나 벤치마크를 찾아볼 필요가 있을듯하나, 스파게티 코드가 LLM에게 더 좋다는 말이 아니라 LLM특성상 추상화/분리/간접 참조가 많은 코드에서 필요한 문맥을 놓치기 쉽고, 오히려 중복/명시성/가까운 문맥이 도움이 되는 경우가 있지 않을까? 라는 생각이다.)
결국 지금 시점에서 클린 아키텍처를 배우는 이유는, "구현을 더욱 잘(건강하게) 하기 위해서"보다 "AI가 만들어낸 결과물을 제대로 검증하고, 피드백을 줄 수 있는 눈을 갖기 위해서" 에 더 가까워지고 있는 것 같다.
비즈니스 정책이 제대로 격리되어 있는지, 이 변경이 어디까지 영향을 주는지, 이 구조가 장기적으로 유지보수 가능한지. 이 판단들은 여전히 인간의 몫이고, 그 판단을 제대로 하려면 결국 좋은 코드가 무엇이고 기대효과가 무엇인지는 알아야 한다는 강한 확신이 들었다.

점차 구현 레이어에서의 역할이 옅어지는 대신 판단 레이어에 특화된 엔지니어가 되는 것. 어쩌면 그게 지금 시점에서 가장 내가 목표삼아야 하는 방향이지 않을까?
'Web' 카테고리의 다른 글
| NestJS Guards 잘 쓴다고 소문나는 법 💀 (Feat. Nest Execution Context) (3) | 2025.08.26 |
|---|---|
| TypeORM 의 Date String 반환 이슈 (0) | 2025.06.11 |
| 자바 vs 노드 당신의 선택은?! (8) | 2025.01.18 |
| NestJS + TypeORM + Testcontainers 를 사용한 통합 테스트 DB환경 구축하기 (2) | 2025.01.17 |
| 코딩테스트 준비를 위한 Java 입출력 정리 (1) | 2024.05.23 |